Merge branch 'MDL-53500-master' of git://github.com/cameron1729/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 5 Apr 2016 04:22:42 +0000 (12:22 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 5 Apr 2016 04:22:42 +0000 (12:22 +0800)
114 files changed:
.gitattributes [new file with mode: 0644]
admin/settings.php
admin/tests/behat/filter_users.feature
admin/tool/messageinbound/classes/manager.php
admin/tool/monitor/tests/behat/disabled.feature
auth/mnet/auth.php
auth/mnet/upgrade.txt [new file with mode: 0644]
cache/classes/config.php
cohort/tests/behat/access_visible_cohorts.feature
cohort/tests/behat/add_cohort.feature
cohort/tests/behat/behat_cohort.php
cohort/tests/behat/view_cohorts.feature
config-dist.php
group/lib.php
install/lang/da/admin.php
install/lang/da/install.php
install/lang/mk/moodle.php
install/lang/pt/error.php
install/lang/pt/install.php
lang/en/cache.php
lang/en/group.php
lang/en/search.php
lib/amd/build/form-autocomplete.min.js
lib/amd/src/form-autocomplete.js
lib/behat/form_field/behat_form_select.php
lib/classes/event/grouping_group_assigned.php [new file with mode: 0644]
lib/classes/event/grouping_group_unassigned.php [new file with mode: 0644]
lib/db/caches.php
lib/db/events.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/clean.js
lib/grade/grade_category.php
lib/modinfolib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/pagelib.php
message/ajax.php
mod/assign/classes/search/activity.php
mod/assign/tests/search_test.php [new file with mode: 0644]
mod/data/edit.php
mod/data/lib.php
mod/data/tests/behat/add_entries.feature
mod/data/tests/search_test.php
mod/feedback/db/access.php
mod/feedback/db/upgrade.php
mod/feedback/import.php
mod/feedback/item/captcha/lib.php
mod/feedback/item/feedback_item_form_class.php
mod/feedback/item/info/lib.php
mod/feedback/item/label/label_form.php
mod/feedback/item/label/lib.php
mod/feedback/item/multichoice/lib.php
mod/feedback/item/multichoicerated/lib.php
mod/feedback/item/numeric/lib.php
mod/feedback/item/numeric/numeric_form.php
mod/feedback/item/textarea/lib.php
mod/feedback/item/textfield/lib.php
mod/feedback/item/textfield/textfield_form.php
mod/feedback/lang/en/feedback.php
mod/feedback/lib.php
mod/feedback/tests/behat/multichoice.feature [new file with mode: 0644]
mod/feedback/tests/behat/question_types.feature
mod/feedback/version.php
mod/folder/classes/search/activity.php
mod/folder/tests/search_test.php [new file with mode: 0644]
mod/forum/classes/output/email/renderer.php
mod/forum/classes/search/post.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/settings.php
mod/forum/tests/mail_test.php
mod/forum/tests/search_test.php
mod/glossary/classes/search/entry.php
mod/lti/locallib.php
mod/lti/tests/locallib_test.php
mod/page/classes/search/activity.php
mod/quiz/classes/structure.php
mod/quiz/db/upgrade.php
mod/quiz/tests/structure_test.php
mod/quiz/version.php
mod/resource/classes/search/activity.php
mod/resource/tests/search_test.php [new file with mode: 0644]
mod/scorm/datamodels/scorm_13.js
mod/scorm/datamodels/scorm_13lib.php
mod/url/classes/search/activity.php
report/search/index.php
repository/s3/README_MOODLE.txt
repository/s3/S3.php
repository/s3/lib.php
repository/s3/thirdpartylibs.xml
repository/s3/version.php
search/classes/area/base.php
search/classes/area/base_activity.php
search/classes/area/base_mod.php
search/classes/document.php
search/classes/engine.php
search/classes/manager.php
search/classes/observer.php [deleted file]
search/engine/solr/classes/document.php
search/engine/solr/classes/engine.php
search/engine/solr/classes/schema.php
search/engine/solr/lang/en/search_solr.php
search/engine/solr/settings.php
search/engine/solr/tests/engine_test.php
search/templates/result.mustache
search/tests/fixtures/mock_search_area.php
search/tests/fixtures/mock_search_engine.php
theme/base/style/search.css
theme/bootstrapbase/less/moodle/search.less
theme/bootstrapbase/style/moodle.css
user/lib.php
version.php

diff --git a/.gitattributes b/.gitattributes
new file mode 100644 (file)
index 0000000..fb39fe0
--- /dev/null
@@ -0,0 +1,4 @@
+**/yui/build/** -diff
+**/amd/build/** -diff
+theme/bootstrapbase/style/editor.css -diff
+theme/bootstrapbase/style/moodle.css -diff
index 76584b3..fda2359 100644 (file)
@@ -20,7 +20,11 @@ $adminroot = admin_get_root(); // need all settings
 $settingspage = $adminroot->locate($section, true);
 
 if (empty($settingspage) or !($settingspage instanceof admin_settingpage)) {
-    print_error('sectionerror', 'admin', "$CFG->wwwroot/$CFG->admin/");
+    if (moodle_needs_upgrading()) {
+        redirect(new moodle_url('/admin/index.php'));
+    } else {
+        print_error('sectionerror', 'admin', "$CFG->wwwroot/$CFG->admin/");
+    }
     die;
 }
 
index 8feb542..f8b930d 100644 (file)
@@ -27,7 +27,6 @@ Feature: An administrator can filter user accounts by role, cohort and other pro
     And I add "User Three (three@example.com)" user to "CH1" cohort members
     And I follow "Browse list of users"
 
-  @javascript
   Scenario: Filter user accounts by role and cohort
     When I set the following fields to these values:
       | courserole_rl | Student |
@@ -51,7 +50,6 @@ Feature: An administrator can filter user accounts by role, cohort and other pro
     And I should see "User Three"
     And I should see "User Four"
 
-  @javascript
   Scenario: Filter user accounts by confirm and authentication method
     When I set the following fields to these values:
       | Confirmed | No |
index 25762a1..8bb57ac 100644 (file)
@@ -100,6 +100,7 @@ class manager {
             'password' => $CFG->messageinbound_hostpass,
             'hostspec' => $CFG->messageinbound_host,
             'secure'   => $CFG->messageinbound_hostssl,
+            'debug'    => empty($CFG->debugimap) ? null : fopen('php://stderr', 'w'),
         );
 
         $this->client = new \Horde_Imap_Client_Socket($configuration);
index 24dbea6..01c7619 100644 (file)
@@ -1,10 +1,9 @@
-@javascript @tool @tool_monitor
+@tool @tool_monitor
 Feature: Enable/disable managment of the event monitor
   In order to manage event monitoring
   As an admin
   I need to enable/disable it
 
-  @javascript
   Scenario: Tool is disabled by default.
     Given I log in as "admin"
     When I navigate to "Event monitoring rules" node in "Site administration > Reports"
index 6490a7f..11ddba2 100644 (file)
@@ -302,7 +302,7 @@ class auth_plugin_mnet extends auth_plugin_base {
             $remoteuser->firstaccess = 0;
             $remoteuser->confirmed = 1;
 
-            $remoteuser->id = $DB->insert_record('user', $remoteuser);
+            $remoteuser->id = user_create_user($remoteuser, false);
             $firsttime = true;
             $localuser = $remoteuser;
         }
diff --git a/auth/mnet/upgrade.txt b/auth/mnet/upgrade.txt
new file mode 100644 (file)
index 0000000..0a3128e
--- /dev/null
@@ -0,0 +1,6 @@
+This files describes API changes in auth_mnet code.
+
+=== 3.1 ===
+
+* Users now are created through user_create_user function which, apart from inserting the user in the database and generating
+  a user_created event, is applying the site default preferences if they are not set.
index 5fdf2b9..53d0f1e 100644 (file)
@@ -330,7 +330,11 @@ class cache_config {
         if (!file_exists($cachefile)) {
             throw new cache_exception('Default cache config could not be found. It should have already been created by now.');
         }
-        include($cachefile);
+
+        if (!include($cachefile)) {
+            throw new cache_exception('Unable to load the cache configuration file');
+        }
+
         if (!is_array($configuration)) {
             throw new cache_exception('Invalid cache configuration file');
         }
index c777c86..8f5dc51 100644 (file)
@@ -44,12 +44,11 @@ Feature: Access visible and hidden cohorts
       | user    | course | role           |
       | teacher | C1     | editingteacher |
 
-  @javascript
   Scenario: Teacher can see visible cohorts defined in the above contexts
     When I log in as "teacher"
     And I follow "Course 1"
     And I navigate to "Enrolment methods" node in "Course administration > Users"
-    And I set the field "Add method" to "Cohort sync"
+    And I select "Cohort sync" from the "Add method" singleselect
     Then the "Cohort" select box should contain "Cohort in category 1"
     And the "Cohort" select box should contain "System cohort"
     And the "Cohort" select box should not contain "Cohort hidden in category 1"
@@ -71,13 +70,12 @@ Feature: Access visible and hidden cohorts
     And the "Select members from cohort" select box should not contain "Cohort empty in category 1"
     And the "Select members from cohort" select box should not contain "System empty cohort"
 
-  @javascript
   Scenario: System manager can see all cohorts defined in the above contexts
     When I log in as "user1"
     And I am on site homepage
     And I follow "Course 1"
     And I navigate to "Enrolment methods" node in "Course administration > Users"
-    And I set the field "Add method" to "Cohort sync"
+    And I select "Cohort sync" from the "Add method" singleselect
     Then the "Cohort" select box should contain "Cohort in category 1"
     And the "Cohort" select box should contain "System cohort"
     And the "Cohort" select box should contain "Cohort hidden in category 1"
@@ -99,13 +97,12 @@ Feature: Access visible and hidden cohorts
     And the "Select members from cohort" select box should not contain "Cohort empty in category 1"
     And the "Select members from cohort" select box should not contain "System empty cohort"
 
-  @javascript
   Scenario: Category manager can see all cohorts defined in his category and visible cohorts defined above
     When I log in as "user2"
     And I am on site homepage
     And I follow "Course 1"
     And I navigate to "Enrolment methods" node in "Course administration > Users"
-    And I set the field "Add method" to "Cohort sync"
+    And I select "Cohort sync" from the "Add method" singleselect
     Then the "Cohort" select box should contain "Cohort in category 1"
     And the "Cohort" select box should contain "System cohort"
     And the "Cohort" select box should contain "Cohort hidden in category 1"
index e6983b2..7d8ccdb 100644 (file)
@@ -21,7 +21,6 @@ Feature: Add cohorts of users
       | Description | Test cohort description |
     And I press "Save changes"
 
-  @javascript
   Scenario: Add a cohort
     When I follow "Cohorts"
     Then I should see "Test cohort name"
@@ -29,7 +28,6 @@ Feature: Add cohorts of users
     And I should see "Test cohort description"
     And I should see "Created manually"
 
-  @javascript
   Scenario: Add users to a cohort selecting them from the system users list
     When I add "First User (first@example.com)" user to "333" cohort members
     And I add "Second User (second@example.com)" user to "333" cohort members
@@ -39,7 +37,6 @@ Feature: Add cohorts of users
     And the "Current users" select box should contain "Second User (second@example.com)"
     And the "Current users" select box should not contain "Forth User (forth@example.com)"
 
-  @javascript
   Scenario: Add users to a cohort using a bulk user action
     When I follow "Bulk user actions"
     And I set the field "Available" to "Third User"
index e1fd621..6a18092 100644 (file)
@@ -59,29 +59,16 @@ class behat_cohort extends behat_base {
         if (!$this->getSession()->getPage()->find('css', 'input#cohort_search_q')) {
 
             // With JS enabled we should expand a few tree nodes.
-            if ($this->running_javascript()) {
-                $parentnodes = get_string('administrationsite') . ' > ' .
-                    get_string('users', 'admin') . ' > ' .
-                    get_string('accounts', 'admin');
-                $steps = array_merge(
-                    array(
-                        new Given('I am on homepage'),
-                        new Given('I navigate to "' . get_string('cohorts', 'cohort') . '" node in "' . $parentnodes . '"')
-                    ),
-                    $steps
-                );
-
-            } else {
-                // JS disabled.
-                $steps = array_merge(
-                    array(
-                        new Given('I am on homepage'),
-                        new Given('I follow "' . get_string('administrationsite') . '" node'),
-                        new Given('I follow "' . get_string('cohorts', 'cohort') . '"')
-                    ),
-                    $steps
-                );
-            }
+            $parentnodes = get_string('administrationsite') . ' > ' .
+                get_string('users', 'admin') . ' > ' .
+                get_string('accounts', 'admin');
+            $steps = array_merge(
+                array(
+                    new Given('I am on homepage'),
+                    new Given('I navigate to "' . get_string('cohorts', 'cohort') . '" node in "' . $parentnodes . '"')
+                ),
+                $steps
+            );
         }
 
         return $steps;
index b585272..8e7a742 100644 (file)
@@ -27,7 +27,6 @@ Feature: View cohort list
       | user1 | manager | System       |           |
       | user2 | manager | Category     | CAT1      |
 
-  @javascript
   Scenario: Admin can see system cohorts and all cohorts
     When I log in as "admin"
     And I navigate to "Cohorts" node in "Site administration > Users > Accounts"
@@ -40,7 +39,6 @@ Feature: View cohort list
     And I should see "Cohort in category 3"
     And I log out
 
-  @javascript
   Scenario: Manager can see system cohorts and all cohorts
     When I log in as "user1"
     And I navigate to "Cohorts" node in "Site administration > Users > Accounts"
@@ -53,7 +51,6 @@ Feature: View cohort list
     And I should see "Cohort in category 3"
     And I log out
 
-  @javascript
   Scenario: Manager in category can see cohorts in the category
     When I log in as "user2"
     And I follow "Courses"
index cdf3de9..f4167a5 100644 (file)
@@ -563,6 +563,9 @@ $CFG->admin = 'admin';
 // Prevent theme caching
 // $CFG->themedesignermode = true; // NOT FOR PRODUCTION SERVERS!
 //
+// Enable verbose debug information during fetching of email messages from IMAP server.
+// $CFG->debugimap = true;
+//
 // Prevent JS caching
 // $CFG->cachejs = false; // NOT FOR PRODUCTION SERVERS!
 //
index e0c0e4b..3f8de4e 100644 (file)
@@ -837,12 +837,21 @@ function groups_assign_grouping($groupingid, $groupid, $timeadded = null, $inval
     }
     $DB->insert_record('groupings_groups', $assign);
 
+    $courseid = $DB->get_field('groupings', 'courseid', array('id' => $groupingid));
     if ($invalidatecache) {
         // Invalidate the grouping cache for the course
-        $courseid = $DB->get_field('groupings', 'courseid', array('id' => $groupingid));
         cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($courseid));
     }
 
+    // Trigger event.
+    $params = array(
+        'context' => context_course::instance($courseid),
+        'objectid' => $groupingid,
+        'other' => array('groupid' => $groupid)
+    );
+    $event = \core\event\grouping_group_assigned::create($params);
+    $event->trigger();
+
     return true;
 }
 
@@ -858,12 +867,21 @@ function groups_unassign_grouping($groupingid, $groupid, $invalidatecache = true
     global $DB;
     $DB->delete_records('groupings_groups', array('groupingid'=>$groupingid, 'groupid'=>$groupid));
 
+    $courseid = $DB->get_field('groupings', 'courseid', array('id' => $groupingid));
     if ($invalidatecache) {
         // Invalidate the grouping cache for the course
-        $courseid = $DB->get_field('groupings', 'courseid', array('id' => $groupingid));
         cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($courseid));
     }
 
+    // Trigger event.
+    $params = array(
+        'context' => context_course::instance($courseid),
+        'objectid' => $groupingid,
+        'other' => array('groupid' => $groupid)
+    );
+    $event = \core\event\grouping_group_unassigned::create($params);
+    $event->trigger();
+
     return true;
 }
 
index 1b24a4e..cd95381 100644 (file)
@@ -40,3 +40,4 @@ $string['cliunknowoption'] = 'Ukendte options: {$a} Brug venligst --help option.
 $string['cliyesnoprompt'] = 'j (betyder ja) og n (betyder nej)';
 $string['environmentrequireinstall'] = 'skal være installeret og aktiveret';
 $string['environmentrequireversion'] = 'minimumskravet er version {$a->needed} og du kører {$a->current}';
+$string['upgradekeyset'] = 'Opgraderingsnøgle (efterlad blank for ikke at angive denne)';
index 6e43152..9fe5de6 100644 (file)
@@ -34,6 +34,7 @@ $string['admindirname'] = 'Admin-mappe';
 $string['availablelangs'] = 'Tilgængelige sprogpakker';
 $string['chooselanguagehead'] = 'Vælg et sprog';
 $string['chooselanguagesub'] = 'Vælg et sprog til brug under installationen. Dette sprog vil også blive brugt som standardsprog på webstedet, men det kan altid ændres til et andet sprog.';
+$string['clialreadyconfigured'] = 'Konfigurationsfilen config.php eksisterer allerede. Benyt venigst admin/cli/install_database.php til at installere Moodle for dette site.';
 $string['clialreadyinstalled'] = 'Filen config.php eksisterer allerede, brug venligst admin/cli/upgrade.php hvis du ønsker at opgradere dette websted.';
 $string['cliinstallheader'] = 'Moodle {$a} kommandolinje-installationsprogram';
 $string['databasehost'] = 'Databasevært';
index 713ae26..cbc1c37 100644 (file)
@@ -31,5 +31,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = 'Јазик';
+$string['moodlelogo'] = 'Moodle лого';
 $string['next'] = 'Следно';
 $string['previous'] = 'Претходно';
+$string['reload'] = 'Вчитај повторно';
index b5835d4..6d8e8d7 100644 (file)
@@ -44,7 +44,7 @@ $string['dmlexceptiononinstall'] = '<p>Ocorreu um erro de base de dados [{$a->er
 $string['downloadedfilecheckfailed'] = 'A verificação do ficheiro descarregado falhou.';
 $string['invalidmd5'] = 'A variável de verificação está errada - tente novamente.';
 $string['missingrequiredfield'] = 'Um dos campos obrigatórios está em falta';
-$string['remotedownloaderror'] = 'O download do componente para o servidor falhou. Verifique as configurações do proxy. A instalação da extensão cURL do PHP é muito recomendada.<br /><br />Terá de fazer manualmente o download do ficheiro <a href="{$a->url}">{$a->url}</a>, copiá-lo para a pasta "{$a->dest}" no seu servidor e descompactá-lo';
+$string['remotedownloaderror'] = 'O descarregamento do componente para o servidor falhou. Verifique as configurações do proxy. A instalação da extensão cURL do PHP é muito recomendada.<br /><br />Terá de fazer manualmente o descarregamento do ficheiro <a href="{$a->url}">{$a->url}</a>, copiá-lo para a pasta "{$a->dest}" no seu servidor e descompactá-lo';
 $string['wrongdestpath'] = 'Caminho de destino errado';
 $string['wrongsourcebase'] = 'Base do URL de origem errada';
 $string['wrongzipfilename'] = 'Nome de ficheiro ZIP errado';
index b23c0f2..23c2662 100644 (file)
@@ -48,7 +48,7 @@ $string['environmenthead'] = 'A verificar sistema...';
 $string['environmentsub2'] = 'Cada nova versão do Moodle tem pré-requisitos mínimos relativamente à versão do PHP e extensões necessárias para o seu correto funcionamento. Estes pré-requisitos são verificados sempre que o Moodle é instalado ou atualizado. Contacte o administrador do servidor caso seja necessário atualizar a versão do PHP ou instalar novas extensões.';
 $string['errorsinenvironment'] = 'A verificação do sistema falhou!';
 $string['installation'] = 'Instalação';
-$string['langdownloaderror'] = 'Não foi possível instalar o idioma <b>{$a}</b> por falha no download. O processo de instalação continuará em Inglês.';
+$string['langdownloaderror'] = 'Não foi possível instalar o idioma <b>{$a}</b> por falha no descarregamento. O processo de instalação continuará em Inglês.';
 $string['memorylimithelp'] = '<p>O limite de memória para o PHP definido atualmente no servidor é <b>{$a}</b>.</p><p>Um número elevado de módulos em utilização ou de utilizadores registados pode fazer com que o Moodle apresente problemas de falta de memória.</p><p>É recomendado que o PHP seja configurado com um limite de memória de pelo menos 40MB. Esta configuração pode ser definida de diversas formas:</p><ol><li>Compilação do PHP com o parâmetro <b>--enable-memory-limit</b>. Esta definição permitirá ao próprio Moodle definir o valor a utilizar.</li><li>Alteração do parâmetro <b>memory_limit</b> no ficheiro de configuração do PHP para um valor igual ou superior a 40MB.</li><li>Criação de um ficheiro <b>.htaccess</b> na raiz da pasta do Moodle com a linha <b>php_value memory_limit 40M</b><p>ATENÇÃO: Em alguns servidores esta configuração impedirá o funcionamento de <b>todas</b> as páginas PHP. Nestes casos, não poderá ser utilizado o ficheiro <b>.htaccess</b>.</p></li></ol>';
 $string['paths'] = 'Caminhos';
 $string['pathserrcreatedataroot'] = 'O programa de instalação não conseguiu criar a pasta de dados <b>{$a->dataroot}</b>.';
index 327da65..8ebb54e 100644 (file)
@@ -59,7 +59,6 @@ $string['cachedef_plugin_functions'] = 'Plugins available callbacks';
 $string['cachedef_plugin_manager'] = 'Plugin info manager';
 $string['cachedef_questiondata'] = 'Question definitions';
 $string['cachedef_repositories'] = 'Repositories instances data';
-$string['cachedef_search_results'] = 'Search results user data';
 $string['cachedef_grade_categories'] = 'Grade category queries';
 $string['cachedef_string'] = 'Language string cache';
 $string['cachedef_tags'] = 'Tags collections and areas';
index 464ee74..227f500 100644 (file)
@@ -76,6 +76,8 @@ $string['eventgroupmemberremoved'] = 'Group member removed';
 $string['eventgroupupdated'] = 'Group updated';
 $string['eventgroupingcreated'] = 'Grouping created';
 $string['eventgroupingdeleted'] = 'Grouping deleted';
+$string['eventgroupinggroupassigned'] = 'Group assigned to grouping';
+$string['eventgroupinggroupunassigned'] = 'Group unassigned from grouping';
 $string['eventgroupingupdated'] = 'Grouping updated';
 $string['existingmembers'] = 'Existing members: {$a}';
 $string['filtergroups'] = 'Filter groups by:';
index 49d741a..b1e9455 100644 (file)
@@ -64,9 +64,12 @@ $string['incourse'] = 'in course {$a}';
 $string['index'] = 'Index';
 $string['invalidindexerror'] = 'Index directory either contains an invalid index, or nothing at all.';
 $string['ittook'] = 'It took';
+$string['matchingfile'] = 'Matched from file <span class="filename">{$a}</span>';
+$string['matchingfiles'] = 'Matched from files:';
 $string['next'] = 'Next';
 $string['noindexmessage'] = 'Admin: There appears to be no search index. Please';
 $string['noresults'] = 'No results';
+$string['notitle'] = 'No title';
 $string['normalsearch'] = 'Normal search';
 $string['openedon'] = 'opened on';
 $string['optimize'] = 'Optimize';
index d024ddb..76fd2b9 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 474708e..ba27f86 100644 (file)
@@ -111,6 +111,9 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         }).fail(notification.exception);
         // Because this function get's called after changing the selection, this is a good place
         // to trigger a change notification.
+        if (typeof M.core_formchangechecker != 'undefined') {
+            M.core_formchangechecker.set_form_changed();
+        }
         originalSelect.change();
     };
 
index 880d061..20c3a6b 100644 (file)
@@ -76,7 +76,12 @@ class behat_form_select extends behat_form_field {
             $browser = \Moodle\BehatExtension\Driver\MoodleSelenium2Driver::getBrowser();
             if (!$singleselect && ($browser == 'phantomjs')) {
                 $script = "Syn.trigger('change', {}, {{ELEMENT}})";
-                $this->session->getDriver()->triggerSynScript($this->field->getXpath(), $script);
+                try {
+                    $this->session->getDriver()->triggerSynScript($this->field->getXpath(), $script);
+                } catch (Exception $e) {
+                    // No need to do anything if element has been removed by JS.
+                    // This is possible when inline editing element is used.
+                }
             }
             $this->session->wait(behat_base::TIMEOUT * 1000, behat_base::PAGE_READY_JS);
         }
diff --git a/lib/classes/event/grouping_group_assigned.php b/lib/classes/event/grouping_group_assigned.php
new file mode 100644 (file)
index 0000000..5deb9bd
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Group assigned to grouping event.
+ *
+ * @package    core
+ * @copyright  2016 Vadim Dvorovenko
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Group assigned to grouping event class.
+ *
+ * @package    core
+ * @since      Moodle 3.1
+ * @copyright  2016 Vadim Dvorovenko
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grouping_group_assigned extends base {
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' assigned the group with id '{$this->other['groupid']}'" .
+                " to the grouping with id '$this->objectid'.";
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventgroupinggroupassigned', 'group');
+    }
+
+    /**
+     * Get URL related to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/group/assign.php', array('id' => $this->objectid));
+    }
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+        $this->data['objecttable'] = 'groupings';
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to its new value in the new course.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'groupings', 'restore' => 'group');
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to its new value in the new course.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        $othermapped = array();
+        $othermapped['groupid'] = array('db' => 'groups', 'restore' => 'group');
+        return $othermapped;
+    }
+}
diff --git a/lib/classes/event/grouping_group_unassigned.php b/lib/classes/event/grouping_group_unassigned.php
new file mode 100644 (file)
index 0000000..6fe209e
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Group unassigned from grouping event.
+ *
+ * @package    core
+ * @copyright  2016 Vadim Dvorovenko
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Group unassigned from grouping event class.
+ *
+ * @package    core
+ * @since      Moodle 3.1
+ * @copyright  2016 Vadim Dvorovenko
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grouping_group_unassigned extends base {
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' unassigned the group with id '{$this->other['groupid']}'" .
+                " from the grouping with id '$this->objectid'.";
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventgroupinggroupunassigned', 'group');
+    }
+
+    /**
+     * Get URL related to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/group/assign.php', array('id' => $this->objectid));
+    }
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+        $this->data['objecttable'] = 'groupings';
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to its new value in the new course.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'groupings', 'restore' => 'group');
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to its new value in the new course.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        $othermapped = array();
+        $othermapped['groupid'] = array('db' => 'groups', 'restore' => 'group');
+        return $othermapped;
+    }
+}
index 6dbdc1e..09902dd 100644 (file)
@@ -263,18 +263,13 @@ $definitions = array(
         'staticacceleration' => true,
     ),
 
-    // Caches search results.
-    'search_results' => array(
-        'mode' => cache_store::MODE_SESSION,
-        'simplekeys' => true,
-        'staticacceleration' => true,
-        'staticaccelerationsize' => 3
-    ),
-
-    // Grade categories. Stored at request level as invalidation is very aggressive.
+    // Grade categories. Stored at session level as invalidation is very aggressive.
     'grade_categories' => array(
-        'mode' => cache_store::MODE_REQUEST,
+        'mode' => cache_store::MODE_SESSION,
         'simplekeys' => true,
+        'invalidationevents' => array(
+            'changesingradecategories',
+        )
     ),
 
     // Store temporary tables information.
index 26f1dc3..a9ccfc0 100644 (file)
@@ -54,10 +54,6 @@ $observers = array(
         'eventname'   => '\core\event\user_updated',
         'callback'    => 'core_badges_observer::profile_criteria_review',
     ),
-    array(
-        'eventname'   => '\core\event\search_indexed',
-        'callback'    => 'core_search_observer::invalidate_caches',
-    ),
 );
 
 // List of all events triggered by Moodle can be found using Events list report.
index d5ed2a6..4efa43a 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js differ
index 6d77779..b64c044 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js differ
index d15b0a7..3b9fe61 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js differ
index 0c37c58..5b86018 100644 (file)
@@ -290,6 +290,8 @@ EditorClean.prototype = {
             {regex: /(<[^>]*?style\s*?=\s*?")([^>"]*)(")/gi, replace: function(match, group1, group2, group3) {
                     // Remove MSO-blah, MSO:blah style attributes.
                     group2 = group2.replace(/(?:^|;)[\s]*MSO[-:](?:&[\w]*;|[^;"])*/gi,"");
+                    // Remove backgroud color style.
+                    group2 = group2.replace(/background-color:.*?;/gi,"");
                     return group1 + group2 + group3;
                 }},
             // Get all class attributes so we can work on them.
index 13d15c4..2723f70 100644 (file)
@@ -2691,7 +2691,6 @@ class grade_category extends grade_object {
      * @return void
      */
     public static function clean_record_set() {
-        $cache = cache::make('core', 'grade_categories');
-        $cache->purge();
+        cache_helper::purge_by_event('changesingradecategories');
     }
 }
index ff0a4f7..23aeb8e 100644 (file)
@@ -250,7 +250,7 @@ class course_modinfo {
         $modnames = get_module_types_names($plural);
         $modnamesused = array();
         foreach ($this->get_cms() as $cmid => $mod) {
-            if (isset($modnames[$mod->modname]) && $mod->uservisible) {
+            if (!isset($modnamesused[$mod->modname]) && isset($modnames[$mod->modname]) && $mod->uservisible) {
                 $modnamesused[$mod->modname] = $modnames[$mod->modname];
             }
         }
index b44b279..47de940 100644 (file)
@@ -2459,7 +2459,7 @@ class paging_bar implements renderable {
 
             $pagenum = $this->page + 1;
 
-            if ($pagenum != $displaypage) {
+            if ($pagenum != $lastpage) {
                 $this->nextlink = html_writer::link(new moodle_url($this->baseurl, array($this->pagevar=>$pagenum)), get_string('next'), array('class'=>'next'));
             }
         }
index c20a16c..c8e75b5 100644 (file)
@@ -3000,7 +3000,7 @@ EOD;
             }
 
             if (!empty($pagingbar->lastlink)) {
-                $output .= ' ...' . $pagingbar->lastlink . ' ';
+                $output .= ' ... ' . $pagingbar->lastlink . ' ';
             }
 
             if (!empty($pagingbar->nextlink)) {
@@ -3377,12 +3377,16 @@ EOD;
                                 array('class' => 'iconsmall')
                             ) . $value->title;
                         }
+
                         $al = new action_menu_link_secondary(
                             $value->url,
                             $pix,
                             $value->title,
                             array('class' => 'icon')
                         );
+                        if (!empty($value->titleidentifier)) {
+                            $al->attributes['data-title'] = $value->titleidentifier;
+                        }
                         $am->add($al);
                         break;
                 }
index 6c2cd9c..33466f5 100644 (file)
@@ -443,10 +443,15 @@ class moodle_page {
      * @return context the main context to which this page belongs.
      */
     protected function magic_get_context() {
+        global $CFG;
         if (is_null($this->_context)) {
             if (CLI_SCRIPT or NO_MOODLE_COOKIES) {
                 // Cli scripts work in system context, do not annoy devs with debug info.
                 // Very few scripts do not use cookies, we can safely use system as default context there.
+            } else if (AJAX_SCRIPT && $CFG->debugdeveloper) {
+                // Throw exception inside AJAX script in developer mode, otherwise the debugging message may be missed.
+                throw new coding_exception('$PAGE->context was not set. You may have forgotten '
+                    .'to call require_login() or $PAGE->set_context()');
             } else {
                 debugging('Coding problem: $PAGE->context was not set. You may have forgotten '
                     .'to call require_login() or $PAGE->set_context(). The page may not display '
index b4362d6..7a4bb65 100644 (file)
@@ -39,6 +39,7 @@ if (empty($CFG->messaging)) {
     throw new moodle_exception('disabled', 'core_message');
 }
 
+$PAGE->set_context(null);
 require_sesskey();
 $action = optional_param('action', null, PARAM_ALPHA);
 $response = null;
index 1964312..c7adafc 100644 (file)
@@ -26,6 +26,8 @@ namespace mod_assign\search;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . '/mod/assign/locallib.php');
+
 /**
  * Search area for mod_assign activities.
  *
@@ -34,4 +36,32 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\area\base_activity {
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Add the attached description files.
+     *
+     * @param document $document The current document
+     * @return null
+     */
+    public function attach_files($document) {
+        $fs = get_file_storage();
+
+        $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid'));
+        $context = \context_module::instance($cm->id);
+
+        $files = $fs->get_area_files($context->id, 'mod_assign', ASSIGN_INTROATTACHMENT_FILEAREA, 0,
+                'sortorder DESC, id ASC', false);
+
+        foreach ($files as $file) {
+            $document->add_stored_file($file);
+        }
+    }
 }
diff --git a/mod/assign/tests/search_test.php b/mod/assign/tests/search_test.php
new file mode 100644 (file)
index 0000000..ad312f6
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Assign search unit tests.
+ *
+ * @package     mod_assign
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+require_once($CFG->dirroot . '/mod/assign/locallib.php');
+
+/**
+ * Provides the unit tests for forum search.
+ *
+ * @package     mod_assign
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_assign_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $assignareaid = null;
+
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->assignareaid = \core_search\manager::generate_areaid('mod_assign', 'activity');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Test for assign file attachments.
+     *
+     * @return void
+     */
+    public function test_attach_files() {
+        global $USER;
+
+        $this->setAdminUser();
+        // Setup test data.
+        $course = $this->getDataGenerator()->create_course();
+
+        $fs = get_file_storage();
+        $usercontext = context_user::instance($USER->id);
+
+        $record = new stdClass();
+        $record->course = $course->id;
+
+        $assign = $this->getDataGenerator()->create_module('assign', $record);
+        $context = context_module::instance($assign->cmid);
+
+        // Attach the main file. We put them in the draft area, create_module will move them.
+        $filerecord = array(
+            'contextid' => $context->id,
+            'component' => 'mod_assign',
+            'filearea'  => ASSIGN_INTROATTACHMENT_FILEAREA,
+            'itemid'    => 0,
+            'filepath'  => '/'
+        );
+
+        // Attach 4 files.
+        for ($i = 1; $i <= 4; $i++) {
+            $filerecord['filename'] = 'myfile'.$i;
+            $fs->create_file_from_string($filerecord, 'Test assign file '.$i);
+        }
+
+        // And a fifth in a sub-folder.
+        $filerecord['filename'] = 'myfile5';
+        $filerecord['filepath'] = '/subfolder/';
+        $fs->create_file_from_string($filerecord, 'Test assign file 5');
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->assignareaid);
+        $this->assertInstanceOf('\mod_assign\search\activity', $searcharea);
+
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $doc = $searcharea->get_document($record);
+            $searcharea->attach_files($doc);
+            $files = $doc->get_files();
+
+            // Assign should return all files attached.
+            $this->assertCount(5, $files);
+
+            // We don't know the order, so get all the names, then sort, then check.
+            $filenames = array();
+            foreach ($files as $file) {
+                $filenames[] = $file->get_filename();
+            }
+            sort($filenames);
+
+            for ($i = 1; $i <= 5; $i++) {
+                $this->assertEquals('myfile'.$i, $filenames[($i - 1)]);
+            }
+
+            $nrecords++;
+        }
+
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(1, $nrecords);
+    }
+
+}
index df09006..132f600 100644 (file)
@@ -268,6 +268,9 @@ if ($datarecord = data_submitted() and confirm_sesskey()) {
                     'rid' => $recordid,
                 ));
                 redirect($viewurl);
+            } else if (!empty($datarecord->saveandadd)) {
+                // User has clicked "Save and add another". Reset all of the fields.
+                $datarecord = null;
             }
         }
     }
@@ -354,7 +357,7 @@ if ($rid) {
     echo '&nbsp;<input type="submit" name="cancel" value="'.get_string('cancel').'" onclick="javascript:history.go(-1)" />';
 } else {
     if ((!$data->maxentries) || has_capability('mod/data:manageentries', $context) || (data_numentries($data) < ($data->maxentries - 1))) {
-        echo '&nbsp;<input type="submit" value="'.get_string('saveandadd','data').'" />';
+        echo '&nbsp;<input type="submit" name="saveandadd" value="' . get_string('saveandadd', 'data') . '" />';
     }
 }
 echo '</div>';
index a85601c..8882776 100644 (file)
@@ -3654,6 +3654,11 @@ function data_get_all_recordids($dataid, $selectdata = '', $params = null) {
  * @return array $recordids   An array of record ids.
  */
 function data_get_advance_search_ids($recordids, $searcharray, $dataid) {
+    // Check to see if we have any record IDs.
+    if (empty($recordids)) {
+        // Send back an empty search.
+        return array();
+    }
     $searchcriteria = array_keys($searcharray);
     // Loop through and reduce the IDs one search criteria at a time.
     foreach ($searchcriteria as $key) {
index 2e9ad77..496bbdb 100644 (file)
@@ -52,6 +52,7 @@ Feature: Users can add entries to database activities
     And I add an entry to "Test database name" database with:
       | Test field name | Student second entry |
     And I press "Save and add another"
+    And the field "Test field name" does not match value "Student second entry"
     And I add an entry to "Test database name" database with:
       | Test field name | Student third entry |
     And I press "Save and view"
index f2bd376..1e54d95 100644 (file)
@@ -189,6 +189,9 @@ class data_advanced_search_sql_test extends advanced_testcase {
      * extra parameters. $alias is the field alias used in the sql query and $commaid
      * is a comma seperated string of record IDs.
      *
+     * Test 3.1: This tests that if no recordids are provided (In a situation where a search is done on an empty database)
+     * That an empty array is returned.
+     *
      * Test 4: data_get_advanced_search_sql provides an array which contains an sql string to be used for displaying records
      * to the user when they use the advanced search criteria and the parameters that go with the sql statement. This test
      * takes that information and does a search on the database, returning a record.
@@ -217,6 +220,10 @@ class data_advanced_search_sql_test extends advanced_testcase {
         $newrecordids = data_get_advance_search_ids($recordids, $this->recordsearcharray, $this->recorddata->id);
         $this->assertEquals($this->datarecordset, $newrecordids);
 
+        // Test 3.1
+        $resultrecordids = data_get_advance_search_ids(array(), $this->recordsearcharray, $this->recorddata->id);
+        $this->assertEmpty($resultrecordids);
+
         // Test 4
         $sortorder = 'ORDER BY r.timecreated ASC , r.id ASC';
         $html = data_get_advanced_search_sql('0', $this->recorddata, $newrecordids, '', $sortorder);
index 30c309a..ccf8639 100644 (file)
@@ -44,6 +44,7 @@ $capabilities = array(
         'contextlevel' => CONTEXT_MODULE,
         'archetypes' => array(
             'guest' => CAP_ALLOW,
+            'frontpage' => CAP_ALLOW,
             'student' => CAP_ALLOW,
             'teacher' => CAP_ALLOW,
             'editingteacher' => CAP_ALLOW,
@@ -58,6 +59,7 @@ $capabilities = array(
         'captype' => 'write',
         'contextlevel' => CONTEXT_MODULE,
         'archetypes' => array(
+            'frontpage' => CAP_ALLOW,
             'student' => CAP_ALLOW
         )
     ),
index 6733a34..6bcb644 100644 (file)
@@ -37,7 +37,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 function xmldb_feedback_upgrade($oldversion) {
-    global $CFG;
+    global $CFG, $DB;
 
     // Moodle v2.8.0 release upgrade line.
     // Put any upgrade step following this.
@@ -48,5 +48,29 @@ function xmldb_feedback_upgrade($oldversion) {
     // Moodle v3.0.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2016031600) {
+        // Remove labels from all 'captcha' and 'label' items.
+        $DB->execute('UPDATE {feedback_item} SET label = ? WHERE typ = ? OR typ = ?',
+                array('', 'captcha', 'label'));
+
+        // Data savepoint reached.
+        upgrade_mod_savepoint(true, 2016031600, 'feedback');
+    }
+
+    if ($oldversion < 2016040100) {
+
+        // In order to keep the previous "Analysis" results unchanged,
+        // set all multiple-answer multiplechoice questions as "Do not analyse empty submits"="Yes"
+        // because prior to this date this setting did not work.
+
+        $sql = "UPDATE {feedback_item} SET options = " . $DB->sql_concat('?', 'options') .
+                " WHERE typ = ? AND presentation LIKE ? AND options NOT LIKE ?";
+        $params = array('i', 'multichoice', 'c%', '%i%');
+        $DB->execute($sql, $params);
+
+        // Feedback savepoint reached.
+        upgrade_mod_savepoint(true, 2016040100, 'feedback');
+    }
+
     return true;
 }
index 083ba26..1339afd 100644 (file)
@@ -208,6 +208,10 @@ function feedback_import_loaded_data(&$data, $feedbackid) {
         $newitem->typ = $typ;
         $newitem->name = trim($item['#']['ITEMTEXT'][0]['#']);
         $newitem->label = trim($item['#']['ITEMLABEL'][0]['#']);
+        if ($typ === 'captcha' || $typ === 'label') {
+            $newitem->label = '';
+            $newitem->name = '';
+        }
         $newitem->options = trim($item['#']['OPTIONS'][0]['#']);
         $newitem->presentation = trim($item['#']['PRESENTATION'][0]['#']);
         //check old types of radio, check, and so on
index 88847ef..66c2431 100644 (file)
@@ -55,7 +55,7 @@ class feedback_item_captcha extends feedback_item_base {
         $this->item->feedback = $feedback->id;
         $this->item->template = 0;
         $this->item->name = get_string('captcha', 'feedback');
-        $this->item->label = get_string('captcha', 'feedback');
+        $this->item->label = '';
         $this->item->presentation = '';
         $this->item->typ = $this->type;
         $this->item->hasvalue = $this->get_hasvalue();
@@ -139,8 +139,10 @@ class feedback_item_captcha extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name.$requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
 
     }
@@ -192,8 +194,10 @@ class feedback_item_captcha extends feedback_item_base {
 
             //print the question and label
             echo '<div class="feedback_item_label_'.$align.'">';
-            echo '('.$item->label.') ';
-            echo format_text($item->name.$requiredmark, true, false, false);
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
             $inputname = 'name="'.$item->typ.'_'.$item->id.'"';
             echo '<input type="hidden" value="'.$USER->sesskey.'" '.$inputname.' />';
             echo '</div>';
@@ -259,26 +263,19 @@ class feedback_item_captcha extends feedback_item_base {
      * @return void
      */
     public function print_item_show_value($item, $value = '') {
-        global $DB;
+        global $OUTPUT;
 
         $align = right_to_left() ? 'right' : 'left';
 
-        $cmid = 0;
-        $feedbackid = $item->feedback;
-        if ($feedbackid > 0) {
-            $feedback = $DB->get_record('feedback', array('id'=>$feedbackid));
-            if ($cm = get_coursemodule_from_instance("feedback", $feedback->id, $feedback->course)) {
-                $cmid = $cm->id;
-            }
-        }
-
         $requiredmark = '<img class="req" title="'.get_string('requiredelement', 'form').'" alt="'.
             get_string('requiredelement', 'form').'" src="'.$OUTPUT->pix_url('req') .'" />';
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name.$requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
     }
 
index e44ec33..f9b16d8 100644 (file)
@@ -76,7 +76,7 @@ abstract class feedback_item_form extends moodleform {
         $mform->setType('template', PARAM_INT);
 
         $mform->setType('name', PARAM_RAW);
-        $mform->setType('label', PARAM_ALPHANUM);
+        $mform->setType('label', PARAM_NOTAGS);
 
         $mform->addElement('hidden', 'typ', $this->type);
         $mform->setType('typ', PARAM_ALPHA);
index de56497..7583b79 100644 (file)
@@ -142,7 +142,7 @@ class feedback_item_info extends feedback_item_base {
         if (!isset($value->value)) {
             return '';
         }
-        return userdate($value->value);
+        return $item->presentation == 1 ? userdate($value->value) : $value->value;
     }
 
     public function print_analysed($item, $itemnr = '', $groupid = false, $courseid = false) {
@@ -150,7 +150,11 @@ class feedback_item_info extends feedback_item_base {
         $data = $analysed_item->data;
         if (is_array($data)) {
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$item->name;
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo format_text($item->name, FORMAT_HTML, array('noclean' => true, 'para' => false));
             echo '</th></tr>';
             $sizeofdata = count($data);
             for ($i = 0; $i < $sizeofdata; $i++) {
@@ -241,12 +245,14 @@ class feedback_item_info extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name.$requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -330,7 +336,7 @@ class feedback_item_info extends feedback_item_base {
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<span class="'.$highlight.'">';
-            echo format_text($item->name.$requiredmark, true, false, false);
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</span>';
         echo '</div>';
 
@@ -363,8 +369,10 @@ class feedback_item_info extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-            echo '('.$item->label.') ';
-            echo format_text($item->name . $requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
 
         //print the presentation
index b015cd1..3509908 100644 (file)
@@ -35,7 +35,7 @@ class feedback_label_form extends feedback_item_form {
         $mform->setType('required', PARAM_INT);
         $mform->addElement('hidden', 'name', 'label');
         $mform->setType('template', PARAM_ALPHA);
-        $mform->addElement('hidden', 'label', '-');
+        $mform->addElement('hidden', 'label', '');
         $mform->setType('label', PARAM_ALPHA);
 
         $mform->addElement('header', 'general', get_string($this->type, 'feedback'));
index 6b6d449..d4ca8e0 100644 (file)
@@ -184,7 +184,7 @@ class feedback_item_label extends feedback_item_base {
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
index c8df4aa..aa30d8e 100644 (file)
@@ -231,7 +231,11 @@ class feedback_item_multichoice extends feedback_item_base {
         if ($analysed_item) {
             $itemname = $analysed_item[1];
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$itemname;
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo $itemname;
             echo '</th></tr>';
 
             $analysed_vals = $analysed_item[2];
@@ -320,12 +324,14 @@ class feedback_item_multichoice extends feedback_item_base {
         if ($info->subtype == 'd') {
             echo '<label for="'. $item->typ . '_' . $item->id .'">';
         }
-        echo '('.$item->label.') ';
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
         echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -364,7 +370,7 @@ class feedback_item_multichoice extends feedback_item_base {
             </span>
             <span class="feedback_item_radiolabel_<?php echo $hv.'_'.$align;?>">
                 <label for="<?php echo $item->typ . '_' . $item->id.'_xxx';?>">
-                    <?php print_string('not_selected', 'feedback');?>&nbsp;
+                    <?php print_string('not_selected', 'feedback');?>
                 </label>
             </span>
         </li>
@@ -425,7 +431,7 @@ class feedback_item_multichoice extends feedback_item_base {
         echo '<div class="feedback_item_label_'.$align.'">';
         if ($info->subtype == 'd') {
             echo '<label for="'. $inputname .'">';
-            echo format_text($item->name.$requiredmark, true, false, false);
+            echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
             if ($highlightrequire AND $item->required AND (count($values) == 0 OR $values[0] == '' OR $values[0] == 0)) {
                 echo '<br class="error"><span id="id_error_'.$inputname.'" class="error"> '.get_string('err_required', 'form').
                     '</span><br id="id_error_break_'.$inputname.'" class="error" >';
@@ -474,7 +480,7 @@ class feedback_item_multichoice extends feedback_item_base {
                 </span>
                 <span class="feedback_item_radiolabel_<?php echo $hv.'_'.$align;?>">
                     <label for="<?php echo $item->typ.'_'.$item->id.'_xxx';?>">
-                        <?php print_string('not_selected', 'feedback');?>&nbsp;
+                        <?php print_string('not_selected', 'feedback');?>
                     </label>
                 </span>
             </li>
@@ -536,7 +542,9 @@ class feedback_item_multichoice extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-        echo '('.$item->label.') ';
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
         echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
 
@@ -578,7 +586,7 @@ class feedback_item_multichoice extends feedback_item_base {
             return true;
         }
 
-        if (empty($value) OR !is_array($value) OR $value[0] == '' OR $value[0] == 0) {
+        if (empty($value) OR !is_array($value) OR !array_filter($value)) {
             return false;
         }
 
@@ -588,7 +596,7 @@ class feedback_item_multichoice extends feedback_item_base {
     public function create_value($data) {
         $vallist = $data;
         if (is_array($vallist)) {
-            $vallist = array_unique($vallist);
+            $vallist = array_unique(array_filter($vallist));
         }
         return trim($this->item_array_to_string($vallist));
     }
@@ -667,6 +675,9 @@ class feedback_item_multichoice extends feedback_item_base {
         if (!is_array($value)) {
             return $value;
         }
+        if (empty($value)) {
+            return '0';
+        }
         $retval = '';
         $arrvals = array_values($value);
         $arrvals = clean_param_array($arrvals, PARAM_INT);  //prevent sql-injection
@@ -742,6 +753,8 @@ class feedback_item_multichoice extends feedback_item_base {
 
         $index = 1;
         $checked = '';
+        $inputname = $item->typ. '_' . $item->id;
+        echo '<input type="hidden" name="'.$inputname.'[]" value="0" />';
         foreach ($presentation as $check) {
             foreach ($values as $val) {
                 if ($val == $index) {
@@ -751,7 +764,6 @@ class feedback_item_multichoice extends feedback_item_base {
                     $checked = '';
                 }
             }
-            $inputname = $item->typ. '_' . $item->id;
             $inputid = $item->typ. '_' . $item->id.'_'.$index;
         ?>
             <li class="feedback_item_check_<?php echo $hv.'_'.$align;?>">
index 79c2078..0eff14e 100644 (file)
@@ -206,7 +206,11 @@ class feedback_item_multichoicerated extends feedback_item_base {
         $analysed_item = $this->get_analysed($item, $groupid, $courseid);
         if ($analysed_item) {
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$analysed_item[1];
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo $analysed_item[1];
             echo '</th></tr>';
             $analysed_vals = $analysed_item[2];
             $pixnr = 0;
@@ -305,12 +309,14 @@ class feedback_item_multichoicerated extends feedback_item_base {
         if ($info->subtype == 'd') {
             echo '<label for="'. $item->typ . '_' . $item->id .'">';
         }
-        echo '('.$item->label.') ';
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
         echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -356,7 +362,7 @@ class feedback_item_multichoicerated extends feedback_item_base {
         echo '<div class="feedback_item_label_'.$align.'">';
         if ($info->subtype == 'd') {
             echo '<label for="'. $inputname .'">';
-            echo format_text($item->name.$requiredmark, true, false, false);
+            echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
             if ($highlightrequire AND $item->required AND intval($value) <= 0) {
                 echo '<br class="error"><span id="id_error_'.$inputname.'" class="error"> '.get_string('err_required', 'form').
                     '</span><br id="id_error_break_'.$inputname.'" class="error" >';
@@ -403,8 +409,10 @@ class feedback_item_multichoicerated extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-            echo '('.$item->label.') ';
-            echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
 
         //print the presentation
index fadd246..9bbacea 100644 (file)
@@ -19,21 +19,12 @@ require_once($CFG->dirroot.'/mod/feedback/item/feedback_item_class.php');
 
 class feedback_item_numeric extends feedback_item_base {
     protected $type = "numeric";
-    public $sep_dec, $sep_thous;
     private $commonparams;
     private $item_form;
     private $item;
 
     public function init() {
-        $this->sep_dec = get_string('separator_decimal', 'feedback');
-        if (substr($this->sep_dec, 0, 2) == '[[') {
-            $this->sep_dec = FEEDBACK_DECIMAL;
-        }
 
-        $this->sep_thous = get_string('separator_thousand', 'feedback');
-        if (substr($this->sep_thous, 0, 2) == '[[') {
-            $this->sep_thous = FEEDBACK_THOUSAND;
-        }
     }
 
     public function build_editform($item, $feedback, $cm) {
@@ -58,17 +49,13 @@ class feedback_item_numeric extends feedback_item_base {
 
         $range_from_to = explode('|', $item->presentation);
         if (isset($range_from_to[0]) AND is_numeric($range_from_to[0])) {
-            $range_from = str_replace(FEEDBACK_DECIMAL,
-                                $this->sep_dec,
-                                floatval($range_from_to[0]));
+            $range_from = $this->format_float($range_from_to[0]);
         } else {
             $range_from = '-';
         }
 
         if (isset($range_from_to[1]) AND is_numeric($range_from_to[1])) {
-            $range_to = str_replace(FEEDBACK_DECIMAL,
-                                $this->sep_dec,
-                                floatval($range_from_to[1]));
+            $range_to = $this->format_float($range_from_to[1]);
         } else {
             $range_to = '-';
         }
@@ -152,7 +139,7 @@ class feedback_item_numeric extends feedback_item_base {
                     $counter++;
                 }
             }
-            $avg = $counter > 0 ? $avg / $counter : 0;
+            $avg = $counter > 0 ? $avg / $counter : null;
             $analysed->data = $data;
             $analysed->avg = $avg;
         }
@@ -173,19 +160,23 @@ class feedback_item_numeric extends feedback_item_base {
 
         if (isset($values->data) AND is_array($values->data)) {
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$item->name;
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo format_text($item->name, FORMAT_HTML, array('noclean' => true, 'para' => false));
             echo '</th></tr>';
 
             foreach ($values->data as $value) {
                 echo '<tr><td colspan="2" valign="top" align="left">';
-                echo '-&nbsp;&nbsp;'.number_format($value, 2, $this->sep_dec, $this->sep_thous);
+                echo '-&nbsp;&nbsp;'.$this->format_float($value);
                 echo '</td></tr>';
             }
 
             if (isset($values->avg)) {
-                $avg = number_format($values->avg, 2, $this->sep_dec, $this->sep_thous);
+                $avg = format_float($values->avg, 2);
             } else {
-                $avg = number_format(0, 2, $this->sep_dec, $this->sep_thous);
+                $avg = '-';
             }
             echo '<tr><td align="left" colspan="2"><b>';
             echo get_string('average', 'feedback').': '.$avg;
@@ -204,16 +195,23 @@ class feedback_item_numeric extends feedback_item_base {
         $data = $analysed_item->data;
         if (is_array($data)) {
 
-            //mittelwert anzeigen
+            // Export average.
             $worksheet->write_string($row_offset,
                                      2,
                                      get_string('average', 'feedback'),
                                      $xls_formats->value_bold);
 
-            $worksheet->write_number($row_offset + 1,
-                                     2,
-                                     $analysed_item->avg,
-                                     $xls_formats->value_bold);
+            if (isset($analysed_item->avg)) {
+                $worksheet->write_number($row_offset + 1,
+                                         2,
+                                         $analysed_item->avg,
+                                         $xls_formats->value_bold);
+            } else {
+                $worksheet->write_string($row_offset + 1,
+                                         2,
+                                         '',
+                                         $xls_formats->value_bold);
+            }
             $row_offset++;
         }
         $row_offset++;
@@ -241,14 +239,14 @@ class feedback_item_numeric extends feedback_item_base {
         if (isset($range_from_to[0]) AND is_numeric($range_from_to[0])) {
             $range_from = floatval($range_from_to[0]);
         } else {
-            $range_from = 0;
+            $range_from = '-';
         }
 
         //get the max-value
         if (isset($range_from_to[1]) AND is_numeric($range_from_to[1])) {
             $range_to = floatval($range_from_to[1]);
         } else {
-            $range_to = 0;
+            $range_to = '-';
         }
 
         $requiredmark = ($item->required == 1) ? $strrequiredmark : '';
@@ -256,13 +254,15 @@ class feedback_item_numeric extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name . $requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             $params = array('id'=>$item->dependitem);
             if ($dependitem = $DB->get_record('feedback_item', $params)) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -270,17 +270,17 @@ class feedback_item_numeric extends feedback_item_base {
         switch(true) {
             case ($range_from === '-' AND is_numeric($range_to)):
                 echo ' ('.get_string('maximal', 'feedback').
-                        ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                        ': '.$this->format_float($range_to).')';
                 break;
             case (is_numeric($range_from) AND $range_to === '-'):
                 echo ' ('.get_string('minimal', 'feedback').
-                        ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).')';
+                        ': '.$this->format_float($range_from).')';
                 break;
             case ($range_from === '-' AND $range_to === '-'):
                 break;
             default:
-                echo ' ('.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).
-                        ' - '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                echo ' ('.$this->format_float($range_from).
+                        ' - '.$this->format_float($range_to).')';
                 break;
         }
         echo '</span>';
@@ -301,6 +301,22 @@ class feedback_item_numeric extends feedback_item_base {
         echo '</div>';
     }
 
+    /**
+     * Prints the float nicely in the localized format
+     *
+     * Similar to format_float() but automatically calculates the number of decimal places
+     *
+     * @param float $value The float to print
+     * @return string
+     */
+    protected function format_float($value) {
+        if (!is_numeric($value)) {
+            return null;
+        }
+        $decimal = is_int($value) ? 0 : strlen(substr(strrchr($value, '.'), 1));
+        return format_float($value, $decimal);
+    }
+
     /**
      * print the item at the complete-page of feedback
      *
@@ -323,14 +339,14 @@ class feedback_item_numeric extends feedback_item_base {
         if (isset($range_from_to[0]) AND is_numeric($range_from_to[0])) {
             $range_from = floatval($range_from_to[0]);
         } else {
-            $range_from = 0;
+            $range_from = '-';
         }
 
         //get the max-value
         if (isset($range_from_to[1]) AND is_numeric($range_from_to[1])) {
             $range_to = floatval($range_from_to[1]);
         } else {
-            $range_to = 0;
+            $range_to = '-';
         }
 
         $requiredmark = ($item->required == 1) ? $strrequiredmark : '';
@@ -339,22 +355,22 @@ class feedback_item_numeric extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-        echo format_text($item->name . $requiredmark, true, false, false);
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '<span class="feedback_item_numinfo">';
         switch(true) {
             case ($range_from === '-' AND is_numeric($range_to)):
                 echo ' ('.get_string('maximal', 'feedback').
-                        ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                        ': '.$this->format_float($range_to).')';
                 break;
             case (is_numeric($range_from) AND $range_to === '-'):
                 echo ' ('.get_string('minimal', 'feedback').
-                        ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).')';
+                        ': '.$this->format_float($range_from).')';
                 break;
             case ($range_from === '-' AND $range_to === '-'):
                 break;
             default:
-                echo ' ('.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).
-                        ' - '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                echo ' ('.$this->format_float($range_from).
+                        ' - '.$this->format_float($range_to).')';
                 break;
         }
         echo '</span>';
@@ -399,34 +415,36 @@ class feedback_item_numeric extends feedback_item_base {
         if (isset($range_from_to[0]) AND is_numeric($range_from_to[0])) {
             $range_from = floatval($range_from_to[0]);
         } else {
-            $range_from = 0;
+            $range_from = '-';
         }
         //get the max-value
         if (isset($range_from_to[1]) AND is_numeric($range_from_to[1])) {
             $range_to = floatval($range_from_to[1]);
         } else {
-            $range_to = 0;
+            $range_to = '-';
         }
         $requiredmark = ($item->required == 1) ? $strrequiredmark : '';
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name . $requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         switch(true) {
             case ($range_from === '-' AND is_numeric($range_to)):
                 echo ' ('.get_string('maximal', 'feedback').
-                    ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                    ': '.$this->format_float($range_to).')';
                 break;
             case (is_numeric($range_from) AND $range_to === '-'):
                 echo ' ('.get_string('minimal', 'feedback').
-                    ': '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).')';
+                    ': '.$this->format_float($range_from).')';
                 break;
             case ($range_from === '-' AND $range_to === '-'):
                 break;
             default:
-                echo ' ('.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_from).
-                    ' - '.str_replace(FEEDBACK_DECIMAL, $this->sep_dec, $range_to).')';
+                echo ' ('.$this->format_float($range_from).
+                    ' - '.$this->format_float($range_to).')';
                 break;
         }
         echo '</div>';
@@ -435,7 +453,7 @@ class feedback_item_numeric extends feedback_item_base {
         echo '<div class="feedback_item_presentation_'.$align.'">';
         echo $OUTPUT->box_start('generalbox boxalign'.$align);
         if (is_numeric($value)) {
-            $str_num_value = number_format($value, 2, $this->sep_dec, $this->sep_thous);
+            $str_num_value = $this->format_float($value);
         } else {
             $str_num_value = '&nbsp;';
         }
@@ -445,7 +463,7 @@ class feedback_item_numeric extends feedback_item_base {
     }
 
     public function check_value($value, $item) {
-        $value = str_replace($this->sep_dec, FEEDBACK_DECIMAL, $value);
+        $value = unformat_float($value, true);
         //if the item is not required, so the check is true if no value is given
         if ((!isset($value) OR $value == '') AND $item->required != 1) {
             return true;
@@ -491,7 +509,7 @@ class feedback_item_numeric extends feedback_item_base {
     }
 
     public function create_value($data) {
-        $data = str_replace($this->sep_dec, FEEDBACK_DECIMAL, $data);
+        $data = unformat_float($data, true);
 
         if (is_numeric($data)) {
             $data = floatval($data);
@@ -512,14 +530,14 @@ class feedback_item_numeric extends feedback_item_base {
     }
 
     public function get_presentation($data) {
-        $num1 = str_replace($this->sep_dec, FEEDBACK_DECIMAL, $data->numericrangefrom);
+        $num1 = unformat_float($data->numericrangefrom, true);
         if (is_numeric($num1)) {
             $num1 = floatval($num1);
         } else {
             $num1 = '-';
         }
 
-        $num2 = str_replace($this->sep_dec, FEEDBACK_DECIMAL, $data->numericrangeto);
+        $num2 = unformat_float($data->numericrangeto, true);
         if (is_numeric($num2)) {
             $num2 = floatval($num2);
         } else {
@@ -546,11 +564,11 @@ class feedback_item_numeric extends feedback_item_base {
     }
 
     public function value_type() {
-        return PARAM_FLOAT;
+        return PARAM_TEXT;
     }
 
     public function clean_input_value($value) {
-        $value = str_replace($this->sep_dec, FEEDBACK_DECIMAL, $value);
+        $value = unformat_float($value, true);
         if (!is_numeric($value)) {
             if ($value == '') {
                 return null; //an empty string should be null
@@ -558,6 +576,6 @@ class feedback_item_numeric extends feedback_item_base {
                 return clean_param($value, PARAM_TEXT); //we have to know the value if it is wrong
             }
         }
-        return clean_param($value, $this->value_type());
+        return clean_param($value, PARAM_FLOAT);
     }
 }
index 449d1b7..67ca019 100644 (file)
@@ -44,13 +44,13 @@ class feedback_numeric_form extends feedback_item_form {
                             'rangefrom',
                             get_string('numeric_range_from', 'feedback'),
                             array('size'=>10, 'maxlength'=>10));
-        $mform->setType('rangefrom', PARAM_INT);
+        $mform->setType('rangefrom', PARAM_RAW);
 
         $mform->addElement('text',
                             'rangeto',
                             get_string('numeric_range_to', 'feedback'),
                             array('size'=>10, 'maxlength'=>10));
-        $mform->setType('rangeto', PARAM_INT);
+        $mform->setType('rangeto', PARAM_RAW);
 
         parent::definition();
         $this->set_data($item);
@@ -62,19 +62,13 @@ class feedback_numeric_form extends feedback_item_form {
             return false;
         }
 
-        $itemobj = new feedback_item_numeric();
-
-        $num1 = str_replace($itemobj->sep_dec, FEEDBACK_DECIMAL, $item->rangefrom);
-        if (is_numeric($num1)) {
-            $num1 = floatval($num1);
-        } else {
+        $num1 = unformat_float($item->rangefrom, true);
+        if ($num1 === false || $num1 === null) {
             $num1 = '-';
         }
 
-        $num2 = str_replace($itemobj->sep_dec, FEEDBACK_DECIMAL, $item->rangeto);
-        if (is_numeric($num2)) {
-            $num2 = floatval($num2);
-        } else {
+        $num2 = unformat_float($item->rangeto, true);
+        if ($num2 === false || $num2 === null) {
             $num2 = '-';
         }
 
index 66d76ff..bfb2f0c 100644 (file)
@@ -151,7 +151,11 @@ class feedback_item_textarea extends feedback_item_base {
         $values = feedback_get_group_values($item, $groupid, $courseid);
         if ($values) {
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$item->name;
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo format_text($item->name, FORMAT_HTML, array('noclean' => true, 'para' => false));
             echo '</th></tr>';
             foreach ($values as $value) {
                 echo '<tr>';
@@ -208,12 +212,14 @@ class feedback_item_textarea extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name.$requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -254,7 +260,7 @@ class feedback_item_textarea extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-            echo format_text($item->name . $requiredmark, true, false, false);
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($highlightrequire AND $item->required AND strval($value) == '') {
             echo '<br class="error"><span id="id_error_'.$inputname.'" class="error"> '.get_string('err_required', 'form').
                 '</span><br id="id_error_break_'.$inputname.'" class="error" >';
@@ -294,8 +300,10 @@ class feedback_item_textarea extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-            echo '('.$item->label.') ';
-            echo format_text($item->name . $requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
 
         //print the presentation
index 77027c1..bcc1d89 100644 (file)
@@ -55,7 +55,7 @@ class feedback_item_textfield extends feedback_item_base {
             $itemsize = 30;
         }
 
-        $itemlength = isset($size_and_length[1]) ? $size_and_length[1] : 5;
+        $itemlength = isset($size_and_length[1]) ? $size_and_length[1] : 255;
 
         $item->itemsize = $itemsize;
         $item->itemmaxlength = $itemlength;
@@ -147,7 +147,11 @@ class feedback_item_textfield extends feedback_item_base {
         $values = feedback_get_group_values($item, $groupid, $courseid);
         if ($values) {
             echo '<tr><th colspan="2" align="left">';
-            echo $itemnr.'&nbsp;('.$item->label.') '.$item->name;
+            echo $itemnr . ' ';
+            if (strval($item->label) !== '') {
+                echo '('. format_string($item->label).') ';
+            }
+            echo format_text($item->name, FORMAT_HTML, array('noclean' => true, 'para' => false));
             echo '</th></tr>';
             foreach ($values as $value) {
                 echo '<tr><td colspan="2" valign="top" align="left">';
@@ -198,12 +202,14 @@ class feedback_item_textfield extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-        echo '('.$item->label.') ';
-        echo format_text($item->name.$requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($item->dependitem) {
             if ($dependitem = $DB->get_record('feedback_item', array('id'=>$item->dependitem))) {
                 echo ' <span class="feedback_depend">';
-                echo '('.$dependitem->label.'-&gt;'.$item->dependvalue.')';
+                echo '('.format_string($dependitem->label).'-&gt;'.$item->dependvalue.')';
                 echo '</span>';
             }
         }
@@ -245,7 +251,7 @@ class feedback_item_textfield extends feedback_item_base {
         $inputname = $item->typ . '_' . $item->id;
         echo '<div class="feedback_item_label_'.$align.'">';
         echo '<label for="'. $inputname .'">';
-            echo format_text($item->name.$requiredmark, true, false, false);
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         if ($highlightrequire AND $item->required AND strval($value) == '') {
             echo '<br class="error"><span id="id_error_'.$inputname.'" class="error"> '.get_string('err_required', 'form').
                 '</span><br id="id_error_break_'.$inputname.'" class="error" >';
@@ -285,8 +291,10 @@ class feedback_item_textfield extends feedback_item_base {
 
         //print the question and label
         echo '<div class="feedback_item_label_'.$align.'">';
-            echo '('.$item->label.') ';
-            echo format_text($item->name . $requiredmark, true, false, false);
+        if (strval($item->label) !== '') {
+            echo '('. format_string($item->label).') ';
+        }
+        echo format_text($item->name . $requiredmark, FORMAT_HTML, array('noclean' => true, 'para' => false));
         echo '</div>';
         echo $OUTPUT->box_start('generalbox boxalign'.$align);
         echo $value ? $value : '&nbsp;';
index 9361dff..a042bd2 100644 (file)
@@ -44,10 +44,11 @@ class feedback_textfield_form extends feedback_item_form {
                             get_string('textfield_size', 'feedback').'&nbsp;',
                             array_slice(range(0, 255), 5, 255, true));
 
-        $mform->addElement('select',
+        $mform->addElement('text',
                             'itemmaxlength',
-                            get_string('textfield_maxlength', 'feedback').'&nbsp;',
-                            array_slice(range(0, 255), 5, 255, true));
+                            get_string('textfield_maxlength', 'feedback'));
+        $mform->setType('itemmaxlength', PARAM_INT);
+        $mform->addRule('itemmaxlength', null, 'numeric', null, 'client');
 
         parent::definition();
         $this->set_data($item);
index c05b93a..79fb95f 100644 (file)
@@ -156,7 +156,8 @@ $string['mapcourses_help'] = 'Once you have selected the relevant course(s) from
 you can associate them with this feedback using map course(s). Multiple courses may be selected by holding down the Apple or Ctrl key whilst clicking on the course names. A course may be disassociated from a feedback at any time.';
 $string['mappedcourses'] = 'Mapped courses';
 $string['max_args_exceeded'] = 'Max 6 arguments can be handled, too many arguments for';
-$string['maximal'] = 'maximal';
+$string['minimal'] = 'minimum';
+$string['maximal'] = 'maximum';
 $string['messageprovider:message'] = 'Feedback reminder';
 $string['messageprovider:submission'] = 'Feedback notifications';
 $string['mode'] = 'Mode';
index f28d769..df09db1 100644 (file)
@@ -1450,7 +1450,9 @@ function feedback_get_depend_candidates_for_item($feedback, $item) {
     }
     //adding the choose-option
     foreach ($feedbackitems as $key => $val) {
-        $dependitems[$key] = $val;
+        if (trim(strval($val)) !== '') {
+            $dependitems[$key] = format_string($val);
+        }
     }
     return $dependitems;
 }
diff --git a/mod/feedback/tests/behat/multichoice.feature b/mod/feedback/tests/behat/multichoice.feature
new file mode 100644 (file)
index 0000000..6ddfaf3
--- /dev/null
@@ -0,0 +1,424 @@
+@mod @mod_feedback
+Feature: Testing multichoice questions in feedback
+  In order to create feedbacks
+  As a teacher
+  I need to be able to create different types of multichoice questions
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname |
+      | teacher1 | Teacher   | 1        |
+      | student1 | Student   | 1        |
+      | student2 | Student   | 2        |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+    And I log in as "admin"
+    And I navigate to "Manage activities" node in "Site administration > Plugins > Activity modules"
+    And I click on "Show" "link" in the "Feedback" "table_row"
+    And I log out
+    And the following "activities" exist:
+      | activity   | name                | course | idnumber    |
+      | feedback   | Learning experience | C1     | feedback0   |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Edit questions"
+
+  @javascript
+  Scenario: Non-rated single-answer multiple choice questions in feedback
+    When I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 1 |
+      | Label            | multichoice1                |
+      | Multiple choice type | Multiple choice - single answer |
+      | Multiple choice values | option a\noption b\noption c  |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the first page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 2 |
+      | Label            | multichoice2                |
+      | Multiple choice type | Multiple choice - single answer |
+      | Multiple choice values | option d\noption e\noption f  |
+      | Required | 1 |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the second page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 3 |
+      | Label            | multichoice3                |
+      | Multiple choice type | Multiple choice - single answer |
+      | Multiple choice values | option g\noption h\noption i  |
+      | Hide the "Not selected" option | Yes                   |
+      | Dependence item                | multichoice2          |
+      | Dependence value               | option d              |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the third page of the feedback |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    # Examine the first page, select nothing, go to the next page
+    Then the following fields match these values:
+      | Not selected | 1 |
+      | option a     | 0 |
+      | option b     | 0 |
+      | option c     | 0 |
+    And "Previous page" "button" should not exist
+    And "Submit your answers" "button" should not exist
+    And I should see "this is the first page of the feedback"
+    And I should not see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # Examine the second page, select nothing, try to go to the next page
+    And I should see "Not selected"
+    And the following fields match these values:
+      | Not selected | 1 |
+      | option d     | 0 |
+      | option e     | 0 |
+      | option f     | 0 |
+    And "Previous page" "button" should exist
+    And "Submit your answers" "button" should not exist
+    And I should not see "this is the first page of the feedback"
+    And I should see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # We are still on the second page because the field is required
+    And I should see "Saving failed because missing or false values" in the ".error" "css_element"
+    And I should see "this is the second page of the feedback"
+    And I set the field "option e" to "1"
+    And the following fields match these values:
+      | Not selected | 0 |
+      | option d     | 0 |
+      | option e     | 1 |
+      | option f     | 0 |
+    And I press "Next page"
+    # Now we are on the third page, element is not shown because of wrong dependency.
+    And I should see "this is the third page of the feedback"
+    And I should not see "this is a multiple choice 3"
+    # Go back, check that values are preserved and change the option to enable dependency.
+    And I press "Previous page"
+    And the following fields match these values:
+      | Not selected | 0 |
+      | option d     | 0 |
+      | option e     | 1 |
+      | option f     | 0 |
+    And I set the field "option d" to "1"
+    And I press "Next page"
+    # Now third page contains all items.
+    And I should see "this is a multiple choice 3"
+    And I should see "this is the third page of the feedback"
+    And I should not see "Not selected"
+    And the following fields match these values:
+      | option g     | 0 |
+      | option h     | 0 |
+      | option i     | 0 |
+    And "Previous page" "button" should exist
+    And "Next page" "button" should not exist
+    And "Submit your answers" "button" should exist
+    And I set the field "option i" to "1"
+    And I press "Submit your answers"
+    And I log out
+    # Student 2 tries to trick - he answers the third question and then
+    # goes back and changes dependency question. Analysis should not show this answer!
+    And I log in as "student2"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    And I set the field "option a" to "1"
+    And I press "Next page"
+    And I set the field "option d" to "1"
+    And I press "Next page"
+    And I set the field "option g" to "1"
+    And I press "Previous page"
+    And I set the field "option f" to "1"
+    And I press "Next page"
+    And I press "Submit your answers"
+    And I log out
+    # Login as teacher and check analysis
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Analysis"
+    And I should see "Submitted answers: 2"
+    And I should see "Questions: 3"
+    And I should see "1 (50.00 %)" in the "option a:" "table_row"
+    And I should not see "%" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I should see "1 (50.00 %)" in the "option d:" "table_row"
+    And I should not see "%" in the "option e:" "table_row"
+    And I should see "1 (50.00 %)" in the "option f:" "table_row"
+    And I should not see "%" in the "option g:" "table_row"
+    And I should not see "%" in the "option h:" "table_row"
+    And I should see "1 (100.00 %)" in the "option i:" "table_row"
+    # Change the settings so we don't analyse empty submits
+    And I follow "Edit questions"
+    And I click on "Edit question" "link" in the "//li[contains(@class, 'feedback_itemlist') and contains(.,'multichoice1')]" "xpath_element"
+    And I set the field "Do not analyse empty submits" to "Yes"
+    And I press "Save changes to question"
+    And I follow "Analysis"
+    And I should see "1 (100.00 %)" in the "option a:" "table_row"
+    And I should not see "%" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I log out
+
+  Scenario: Non-rated multiple-answers multiple choice questions in feedback
+    # Create a feedback with three pages, required and dependent questions.
+    When I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 1 |
+      | Label            | multichoice1                |
+      | Multiple choice type | Multiple choice - multiple answers |
+      | Multiple choice values | option a\noption b\noption c  |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the first page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 2 |
+      | Label            | multichoice2                |
+      | Multiple choice type | Multiple choice - multiple answers |
+      | Multiple choice values | option d\noption e\noption f  |
+      | Required | 1 |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the second page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 3 |
+      | Label            | multichoice3                |
+      | Multiple choice type | Multiple choice - multiple answers |
+      | Multiple choice values | option g\noption h\noption i  |
+      | Dependence item                | multichoice2          |
+      | Dependence value               | option d              |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the third page of the feedback |
+    And I log out
+    # Login as the first student.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    # Examine the first page, select nothing, go to the next page
+    And I should not see "Not selected"
+    And the following fields match these values:
+      | option a     | 0 |
+      | option b     | 0 |
+      | option c     | 0 |
+    And "Previous page" "button" should not exist
+    And "Submit your answers" "button" should not exist
+    And I should see "this is the first page of the feedback"
+    And I should not see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # Examine the second page, select nothing, try to go to the next page
+    Then I should not see "Not selected"
+    And the following fields match these values:
+      | option d     | 0 |
+      | option e     | 0 |
+      | option f     | 0 |
+    And "Previous page" "button" should exist
+    And "Submit your answers" "button" should not exist
+    And I should not see "this is the first page of the feedback"
+    And I should see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # We are still on the second page because the field is required
+    And I should see "Saving failed because missing or false values" in the ".error" "css_element"
+    And I should see "this is the second page of the feedback"
+    And I set the field "option e" to "1"
+    And I set the field "option f" to "1"
+    And the following fields match these values:
+      | option d     | 0 |
+      | option e     | 1 |
+      | option f     | 1 |
+    And I press "Next page"
+    # Now we are on the third page, element is not shown because of wrong dependency.
+    And I should see "this is the third page of the feedback"
+    And I should not see "this is a multiple choice 3"
+    # Go back, check that values are preserved and change the option to enable dependency.
+    And I press "Previous page"
+    And the following fields match these values:
+      | option d     | 0 |
+      | option e     | 1 |
+      | option f     | 1 |
+    And I set the field "option d" to "1"
+    And I set the field "option e" to "0"
+    And I press "Next page"
+    # Now third page contains all items.
+    And I should see "this is a multiple choice 3"
+    And I should see "this is the third page of the feedback"
+    And the following fields match these values:
+      | option g     | 0 |
+      | option h     | 0 |
+      | option i     | 0 |
+    And "Previous page" "button" should exist
+    And "Next page" "button" should not exist
+    And "Submit your answers" "button" should exist
+    And I set the field "option i" to "1"
+    And I press "Submit your answers"
+    And I log out
+    # Student 2 tries to trick - he answers the third question and then
+    # goes back and changes dependency question. Analysis should not show this answer!
+    And I log in as "student2"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    And I set the field "option a" to "1"
+    And I set the field "option b" to "1"
+    And I press "Next page"
+    And I set the field "option d" to "1"
+    And I press "Next page"
+    And I set the field "option g" to "1"
+    And I press "Previous page"
+    And I set the field "option d" to "0"
+    And I set the field "option f" to "1"
+    And I press "Next page"
+    And I press "Submit your answers"
+    And I log out
+    # Login as teacher and check analysis
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Analysis"
+    And I should see "Submitted answers: 2"
+    And I should see "Questions: 3"
+    And I should see "1 (50.00 %)" in the "option a:" "table_row"
+    And I should see "1 (50.00 %)" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I should see "1 (50.00 %)" in the "option d:" "table_row"
+    And I should not see "%" in the "option e:" "table_row"
+    And I should see "2 (100.00 %)" in the "option f:" "table_row"
+    And I should not see "%" in the "option g:" "table_row"
+    And I should not see "%" in the "option h:" "table_row"
+    And I should see "1 (100.00 %)" in the "option i:" "table_row"
+    # Change the settings so we don't analyse empty submits
+    And I follow "Edit questions"
+    And I click on "Edit question" "link" in the "//li[contains(@class, 'feedback_itemlist') and contains(.,'multichoice1')]" "xpath_element"
+    And I set the field "Do not analyse empty submits" to "Yes"
+    And I press "Save changes to question"
+    And I follow "Analysis"
+    And I should see "1 (100.00 %)" in the "option a:" "table_row"
+    And I should see "1 (100.00 %)" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I log out
+
+  Scenario: Non-rated single-answer dropdown multiple choice questions in feedback
+    When I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 1 |
+      | Label            | multichoice1                |
+      | Multiple choice type | Multiple choice - single answer allowed (dropdownlist) |
+      | Multiple choice values | option a\noption b\noption c  |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the first page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 2 |
+      | Label            | multichoice2                |
+      | Multiple choice type | Multiple choice - single answer allowed (dropdownlist) |
+      | Multiple choice values | option d\noption e\noption f  |
+      | Required | 1 |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the second page of the feedback |
+    And I select "Add a page break" from the "Add question" singleselect
+    And I add a "Multiple choice" question to the feedback with:
+      | Question         | this is a multiple choice 3 |
+      | Label            | multichoice3                |
+      | Multiple choice type | Multiple choice - single answer allowed (dropdownlist) |
+      | Multiple choice values | option g\noption h\noption i  |
+      | Dependence item                | multichoice2          |
+      | Dependence value               | option d              |
+    And I add a "Label" question to the feedback with:
+      | Contents | this is the third page of the feedback |
+    And I log out
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    # Examine the first page, select nothing, go to the next page
+    Then the following fields match these values:
+      | this is a multiple choice 1 | 0 |
+    And "Previous page" "button" should not exist
+    And "Submit your answers" "button" should not exist
+    And I should see "this is the first page of the feedback"
+    And I should not see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # Examine the second page, select nothing, try to go to the next page
+    And the following fields match these values:
+      | this is a multiple choice 2 | 0 |
+    And "Previous page" "button" should exist
+    And "Submit your answers" "button" should not exist
+    And I should not see "this is the first page of the feedback"
+    And I should see "this is the second page of the feedback"
+    And I should not see "this is the third page of the feedback"
+    And I press "Next page"
+    # We are still on the second page because the field is required
+    And I should see "Saving failed because missing or false values" in the ".error" "css_element"
+    And I should see "this is the second page of the feedback"
+    And I set the field "this is a multiple choice 2" to "option e"
+    And I press "Next page"
+    # Now we are on the third page, element is not shown because of wrong dependency.
+    And I should see "this is the third page of the feedback"
+    And I should not see "this is a multiple choice 3"
+    # Go back, check that values are preserved and change the option to enable dependency.
+    And I press "Previous page"
+    And the following fields match these values:
+      | this is a multiple choice 2 | option e |
+    And I set the field "this is a multiple choice 2" to "option d"
+    And I press "Next page"
+    # Now third page contains all items.
+    And I should see "this is the third page of the feedback"
+    And the following fields match these values:
+      | this is a multiple choice 3 | 0 |
+    And "Previous page" "button" should exist
+    And "Next page" "button" should not exist
+    And "Submit your answers" "button" should exist
+    And I set the field "this is a multiple choice 3" to "option i"
+    And I press "Submit your answers"
+    And I log out
+    # Student 2 tries to trick - he answers the third question and then
+    # goes back and changes dependency question. Analysis should not show this answer!
+    And I log in as "student2"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Answer the questions..."
+    And I set the field "this is a multiple choice 1" to "option a"
+    And I press "Next page"
+    And I set the field "this is a multiple choice 2" to "option d"
+    And I press "Next page"
+    And I set the field "this is a multiple choice 3" to "option g"
+    And I press "Previous page"
+    And I set the field "this is a multiple choice 2" to "option f"
+    And I press "Next page"
+    And I press "Submit your answers"
+    And I log out
+    # Login as teacher and check analysis
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Learning experience"
+    And I follow "Analysis"
+    And I should see "Submitted answers: 2"
+    And I should see "Questions: 3"
+    And I should see "1 (50.00 %)" in the "option a:" "table_row"
+    And I should not see "%" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I should see "1 (50.00 %)" in the "option d:" "table_row"
+    And I should not see "%" in the "option e:" "table_row"
+    And I should see "1 (50.00 %)" in the "option f:" "table_row"
+    And I should not see "%" in the "option g:" "table_row"
+    And I should not see "%" in the "option h:" "table_row"
+    And I should see "1 (100.00 %)" in the "option i:" "table_row"
+    # Change the settings so we don't analyse empty submits
+    And I follow "Edit questions"
+    And I click on "Edit question" "link" in the "//li[contains(@class, 'feedback_itemlist') and contains(.,'multichoice1')]" "xpath_element"
+    And I set the field "Do not analyse empty submits" to "Yes"
+    And I press "Save changes to question"
+    And I follow "Analysis"
+    And I should see "1 (100.00 %)" in the "option a:" "table_row"
+    And I should not see "%" in the "option b:" "table_row"
+    And I should not see "%" in the "option c:" "table_row"
+    And I log out
index d6dcbd6..0f506cc 100644 (file)
@@ -62,6 +62,7 @@ Feature: Test creating different types of feedback questions
     And I add a "Numeric answer" question to the feedback with:
       | Question               | this is a numeric answer |
       | Label                  | numeric                  |
+      | Range from             | 0                        |
       | Range to               | 100                      |
     And I add a "Short text answer" question to the feedback with:
       | Question               | this is a short text answer |
@@ -124,8 +125,8 @@ Feature: Test creating different types of feedback questions
     And I should see "1 (50.00 %)" in the "option l (1):" "table_row"
     And I should see "1 (50.00 %)" in the "option m (5):" "table_row"
     And I should see "Average: 3.00" in the "(multichoice4)" "table"
-    And I should see "35.00" in the "(numeric)" "table"
-    And I should see "71.00" in the "(numeric)" "table"
+    And I should see "35" in the "(numeric)" "table"
+    And I should see "71" in the "(numeric)" "table"
     And I should see "Average: 53.00" in the "(numeric)" "table"
     And I should see "no way" in the "(shorttext)" "table"
     And I should see "hello" in the "(shorttext)" "table"
index 567bc93..4dc648a 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2015111600;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2016040100;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2015111000;    // Requires this Moodle version
 $plugin->component = 'mod_feedback';   // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;
index 160dc93..0cf3218 100644 (file)
@@ -34,4 +34,31 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\area\base_activity {
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Add all the folder files to the index.
+     *
+     * @param document $document The current document
+     * @return null
+     */
+    public function attach_files($document) {
+        $fs = get_file_storage();
+
+        $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid'));
+        $context = \context_module::instance($cm->id);
+
+        $files = $fs->get_area_files($context->id, 'mod_folder', 'content', 0, 'sortorder DESC, id ASC', false);
+
+        foreach ($files as $file) {
+            $document->add_stored_file($file);
+        }
+    }
 }
diff --git a/mod/folder/tests/search_test.php b/mod/folder/tests/search_test.php
new file mode 100644 (file)
index 0000000..0c9f751
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Folder search unit tests.
+ *
+ * @package     mod_folder
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+
+/**
+ * Provides the unit tests for forum search.
+ *
+ * @package     mod_folder
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_folder_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $folderareaid = null;
+
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->folderareaid = \core_search\manager::generate_areaid('mod_folder', 'activity');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Test for folder file attachments.
+     *
+     * @return void
+     */
+    public function test_attach_files() {
+        global $USER;
+
+        $this->setAdminUser();
+        // Setup test data.
+        $course = $this->getDataGenerator()->create_course();
+
+        $fs = get_file_storage();
+        $usercontext = context_user::instance($USER->id);
+
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->files = file_get_unused_draft_itemid();
+
+        // Attach the main file. We put them in the draft area, create_module will move them.
+        $filerecord = array(
+            'contextid' => $usercontext->id,
+            'component' => 'user',
+            'filearea'  => 'draft',
+            'itemid'    => $record->files,
+            'filepath'  => '/'
+        );
+
+        // Attach 4 files.
+        for ($i = 1; $i <= 4; $i++) {
+            $filerecord['filename'] = 'myfile'.$i;
+            $fs->create_file_from_string($filerecord, 'Test folder file '.$i);
+        }
+
+        // And a fifth in a sub-folder.
+        $filerecord['filename'] = 'myfile5';
+        $filerecord['filepath'] = '/subfolder/';
+        $fs->create_file_from_string($filerecord, 'Test folder file 5');
+
+        $this->getDataGenerator()->create_module('folder', $record);
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->folderareaid);
+        $this->assertInstanceOf('\mod_folder\search\activity', $searcharea);
+
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $doc = $searcharea->get_document($record);
+            $searcharea->attach_files($doc);
+            $files = $doc->get_files();
+
+            // Folder should return all files attached.
+            $this->assertCount(5, $files);
+
+            // We don't know the order, so get all the names, then sort, then check.
+            $filenames = array();
+            foreach ($files as $file) {
+                $filenames[] = $file->get_filename();
+            }
+            sort($filenames);
+
+            for ($i = 1; $i <= 5; $i++) {
+                $this->assertEquals('myfile'.$i, $filenames[($i - 1)]);
+            }
+
+            $nrecords++;
+        }
+
+        // If there would be an error/failure in the foreach above the recordset would be closed on shutdown.
+        $recordset->close();
+        $this->assertEquals(1, $nrecords);
+    }
+
+}
index 62c83df..10a19e4 100644 (file)
@@ -53,9 +53,12 @@ class renderer extends \mod_forum_renderer {
      * @return string
      */
     public function format_message_text($cm, $post) {
+        $message = file_rewrite_pluginfile_urls($post->message, 'pluginfile.php',
+            \context_module::instance($cm->id)->id,
+            'mod_forum', 'post', $post->id);
         $options = new \stdClass();
         $options->para = true;
-        return format_text($post->message, $post->messageformat, $options);
+        return format_text($message, $post->messageformat, $options);
     }
 
     /**
index 8729aab..5836723 100644 (file)
@@ -65,7 +65,7 @@ class post extends \core_search\area\base_mod {
                   FROM {forum_posts} fp
                   JOIN {forum_discussions} fd ON fd.id = fp.discussion
                   JOIN {forum} f ON f.id = fd.forum
-                WHERE fp.modified >= ? ORDER BY fp.modified ASC';
+                 WHERE fp.modified >= ? ORDER BY fp.modified ASC';
         return $DB->get_recordset_sql($sql, array($modifiedfrom));
     }
 
@@ -73,9 +73,10 @@ class post extends \core_search\area\base_mod {
      * Returns the document associated with this post id.
      *
      * @param stdClass $record Post info.
+     * @param array    $options
      * @return \core_search\document
      */
-    public function get_document($record) {
+    public function get_document($record, $options = array()) {
 
         try {
             $cm = $this->get_cm('forum', $record->forumid, $record->courseid);
@@ -96,15 +97,62 @@ class post extends \core_search\area\base_mod {
         $doc->set('title', $record->subject);
         $doc->set('content', content_to_text($record->message, $record->messageformat));
         $doc->set('contextid', $context->id);
-        $doc->set('type', \core_search\manager::TYPE_TEXT);
         $doc->set('courseid', $record->courseid);
         $doc->set('userid', $record->userid);
         $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
         $doc->set('modified', $record->modified);
 
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $record->created)) {
+            // If the document was created after the last index time, it must be new.
+            $doc->set_is_new(true);
+        }
+
         return $doc;
     }
 
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Add the forum post attachments.
+     *
+     * @param document $document The current document
+     * @return null
+     */
+    public function attach_files($document) {
+        global $DB;
+
+        $postid = $document->get('itemid');
+
+        try {
+            $post = $this->get_post($postid);
+        } catch (\dml_missing_record_exception $e) {
+            unset($this->postsdata[$postid]);
+            debugging('Could not get record to attach files to '.$document->get('id'), DEBUG_DEVELOPER);
+            return;
+        }
+
+        // Because this is used during indexing, we don't want to cache posts. Would result in memory leak.
+        unset($this->postsdata[$postid]);
+
+        $cm = $this->get_cm('forum', $post->forum, $document->get('courseid'));
+        $context = \context_module::instance($cm->id);
+
+        // Get the files and attach them.
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($context->id, 'mod_forum', 'attachment', $postid, "filename", false);
+        foreach ($files as $file) {
+            $document->add_stored_file($file);
+        }
+    }
+
     /**
      * Whether the user can access the document or not.
      *
index fe31125..7fd36ae 100644 (file)
@@ -94,7 +94,7 @@ $string['configcleanreadtime'] = 'The hour of the day to clean old posts from th
 $string['configdigestmailtime'] = 'People who choose to have emails sent to them in digest form will be emailed the digest daily. This setting controls which time of day the daily mail will be sent (the next cron that runs after this hour will send it).';
 $string['configdisplaymode'] = 'The default display mode for discussions if one isn\'t set.';
 $string['configenablerssfeeds'] = 'This switch will enable the possibility of RSS feeds for all forums.  You will still need to turn feeds on manually in the settings for each forum.';
-$string['configenabletimedposts'] = 'Set to \'yes\' if you want to allow setting of display periods when posting a new forum discussion (Experimental as not yet fully tested)';
+$string['configenabletimedposts'] = 'Set to \'yes\' if you want to allow setting of display periods when posting a new forum discussion.';
 $string['configlongpost'] = 'Any post over this length (in characters not including HTML) is considered long. Posts displayed on the site front page, social format course pages, or user profiles are shortened to a natural break somewhere between the forum_shortpost and forum_longpost values.';
 $string['configmanydiscussions'] = 'Maximum number of discussions shown in a forum per page';
 $string['configmaxattachments'] = 'Default maximum number of attachments allowed per post.';
index a1e9069..5aec1ba 100644 (file)
@@ -826,8 +826,8 @@ function forum_cron() {
                 // MS Outlook / Office uses poorly documented and non standard headers, including
                 // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc.
                 $a->subject = $discussion->name;
-                $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
-                $userfrom->customheaders[] = "Thread-Topic: $postsubject";
+                $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
+                $userfrom->customheaders[] = "Thread-Topic: $threadtopic";
                 $userfrom->customheaders[] = "Thread-Index: " . substr($rootid, 1, 28);
 
                 // Send the post now!
index 3290444..d2980f1 100644 (file)
@@ -133,6 +133,6 @@ if ($ADMIN->fulltree) {
     }
 
     $settings->add(new admin_setting_configcheckbox('forum_enabletimedposts', get_string('timedposts', 'forum'),
-                       get_string('configenabletimedposts', 'forum'), 0));
+                       get_string('configenabletimedposts', 'forum'), 1));
 }
 
index d979a4f..4c88100 100644 (file)
@@ -184,6 +184,8 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Add a post to the discussion.
         $record = new stdClass();
         $record->course = $forum->course;
+        $strre = get_string('re', 'forum');
+        $record->subject = $strre . ' ' . $discussion->subject;
         $record->userid = $author->id;
         $record->forum = $forum->id;
         $record->discussion = $discussion->id;
@@ -834,6 +836,33 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $this->assertEquals($expectedsubject, $message->subject);
     }
 
+    /**
+     * Test inital email and reply email subjects
+     */
+    public function test_subjects() {
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_FORCESUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        list($author) = $this->helper_create_users($course, 1);
+        list($commenter) = $this->helper_create_users($course, 1);
+
+        $strre = get_string('re', 'forum');
+
+        // New posts should not have Re: in the subject.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $messages = $this->helper_run_cron_check_count($post, 2);
+        $this->assertNotContains($strre, $messages[0]->subject);
+
+        // Replies should have Re: in the subject.
+        $reply = $this->helper_post_to_discussion($forum, $discussion, $commenter);
+        $messages = $this->helper_run_cron_check_count($reply, 2);
+        $this->assertContains($strre, $messages[0]->subject);
+    }
+
     /**
      * dataProvider for test_forum_post_email_templates().
      */
@@ -916,6 +945,24 @@ class mod_forum_mail_testcase extends advanced_testcase {
             'Hello Moodle&', 'Welcome to Moodle&');
         $textcases['Text mail with ampersands everywhere'] = array('data' => $newcase);
 
+        // Text+image message i.e. @@PLUGINFILE@@ token handling.
+        $newcase = $base;
+        $newcase['forums'][0]['forumposts'][0]['name'] = 'Text and image';
+        $newcase['forums'][0]['forumposts'][0]['message'] = 'Welcome to Moodle, '
+            .'@@PLUGINFILE@@/Screen%20Shot%202016-03-22%20at%205.54.36%20AM%20%281%29.png !';
+        $newcase['expectations'][0]['subject'] = '.*101.*Text and image';
+        $newcase['expectations'][0]['contents'] = array(
+            '~{$a',
+            '~&(amp|lt|gt|quot|\#039);(?!course)',
+            'Attachment example.txt:\n' .
+            'http://www.example.com/moodle/pluginfile.php/\d*/mod_forum/attachment/\d*/example.txt\n',
+            'Text and image', 'Moodle Forum',
+            'Welcome to Moodle, *\n.*'
+                .'http://www.example.com/moodle/pluginfile.php/\d+/mod_forum/post/\d+/'
+                .'Screen%20Shot%202016-03-22%20at%205\.54\.36%20AM%20%281%29\.png *\n.*!',
+            'Love Moodle', '1\d1');
+        $textcases['Text mail with text+image message i.e. @@PLUGINFILE@@ token handling'] = array('data' => $newcase);
+
         // Now the html cases.
         $htmlcases = array();
 
@@ -944,6 +991,25 @@ class mod_forum_mail_testcase extends advanced_testcase {
             '>Welcome.*Moodle\'"&gt;&amp;', '>Love Moodle&\#039;&quot;&gt;&amp;', '>101\'"&gt;&amp');
         $htmlcases['HTML mail with quotes, gt, lt and ampersand  everywhere'] = array('data' => $newcase);
 
+        // Text+image message i.e. @@PLUGINFILE@@ token handling.
+        $newcase = $htmlbase;
+        $newcase['forums'][0]['forumposts'][0]['name'] = 'HTML text and image';
+        $newcase['forums'][0]['forumposts'][0]['message'] = '<p>Welcome to Moodle, '
+            .'<img src="@@PLUGINFILE@@/Screen%20Shot%202016-03-22%20at%205.54.36%20AM%20%281%29.png"'
+            .' alt="" width="200" height="393" class="img-responsive" />!</p>';
+        $newcase['expectations'][0]['subject'] = '.*101.*HTML text and image';
+        $newcase['expectations'][0]['contents'] = array(
+            '~{\$a',
+            '~&(amp|lt|gt|quot|\#039);(?!course)',
+            '<div class="attachments">( *\n *)?<a href',
+            '<div class="subject">\n.*HTML text and image', '>Moodle Forum',
+            '<p>Welcome to Moodle, '
+                .'<img src="http://www.example.com/moodle/pluginfile.php/\d+/mod_forum/post/\d+/'
+                .'Screen%20Shot%202016-03-22%20at%205\.54\.36%20AM%20%281%29\.png"'
+                .' alt="" width="200" height="393" class="img-responsive" />!</p>',
+            '>Love Moodle', '>1\d1');
+        $htmlcases['HTML mail with text+image message i.e. @@PLUGINFILE@@ token handling'] = array('data' => $newcase);
+
         return $textcases + $htmlcases;
     }
 
index 6dc5033..c5bc5ab 100644 (file)
@@ -267,4 +267,107 @@ class mod_forum_search_testcase extends advanced_testcase {
         $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($discussion1reply1->id));
         $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($discussion2reply1->id));
     }
+
+    /**
+     * Test for post attachments.
+     *
+     * @return void
+     */
+    public function test_attach_files() {
+        global $DB;
+
+        $fs = get_file_storage();
+
+        // Returns the instance as long as the area is supported.
+        $searcharea = \core_search\manager::get_search_area($this->forumpostareaid);
+        $this->assertInstanceOf('\mod_forum\search\post', $searcharea);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        $course1 = self::getDataGenerator()->create_course();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student');
+
+        $record = new stdClass();
+        $record->course = $course1->id;
+
+        $forum1 = self::getDataGenerator()->create_module('forum', $record);
+
+        // Create discussion1.
+        $record = new stdClass();
+        $record->course = $course1->id;
+        $record->userid = $user1->id;
+        $record->forum = $forum1->id;
+        $record->message = 'discussion';
+        $record->attachemt = 1;
+        $discussion1 = self::getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+
+        // Attach 2 file to the discussion post.
+        $post = $DB->get_record('forum_posts', array('discussion' => $discussion1->id));
+        $filerecord = array(
+            'contextid' => context_module::instance($forum1->cmid)->id,
+            'component' => 'mod_forum',
+            'filearea'  => 'attachment',
+            'itemid'    => $post->id,
+            'filepath'  => '/',
+            'filename'  => 'myfile1'
+        );
+        $file1 = $fs->create_file_from_string($filerecord, 'Some contents 1');
+        $filerecord['filename'] = 'myfile2';
+        $file2 = $fs->create_file_from_string($filerecord, 'Some contents 2');
+
+        // Create post1 in discussion1.
+        $record = new stdClass();
+        $record->discussion = $discussion1->id;
+        $record->parent = $discussion1->firstpost;
+        $record->userid = $user2->id;
+        $record->message = 'post2';
+        $record->attachemt = 1;
+        $discussion1reply1 = self::getDataGenerator()->get_plugin_generator('mod_forum')->create_post($record);
+
+        $filerecord['itemid'] = $discussion1reply1->id;
+        $filerecord['filename'] = 'myfile3';
+        $file3 = $fs->create_file_from_string($filerecord, 'Some contents 3');
+
+        // Create post2 in discussion1.
+        $record = new stdClass();
+        $record->discussion = $discussion1->id;
+        $record->parent = $discussion1->firstpost;
+        $record->userid = $user2->id;
+        $record->message = 'post3';
+        $discussion1reply2 = self::getDataGenerator()->get_plugin_generator('mod_forum')->create_post($record);
+
+        // Now get all the posts and see if they have the right files attached.
+        $searcharea = \core_search\manager::get_search_area($this->forumpostareaid);
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $doc = $searcharea->get_document($record);
+            $searcharea->attach_files($doc);
+            $files = $doc->get_files();
+            // Now check that each doc has the right files on it.
+            switch ($doc->get('itemid')) {
+                case ($post->id):
+                    $this->assertCount(2, $files);
+                    $this->assertEquals($file1->get_id(), $files[$file1->get_id()]->get_id());
+                    $this->assertEquals($file2->get_id(), $files[$file2->get_id()]->get_id());
+                    break;
+                case ($discussion1reply1->id):
+                    $this->assertCount(1, $files);
+                    $this->assertEquals($file3->get_id(), $files[$file3->get_id()]->get_id());
+                    break;
+                case ($discussion1reply2->id):
+                    $this->assertCount(0, $files);
+                    break;
+                default:
+                    $this->fail('Unexpected post returned');
+                    break;
+            }
+            $nrecords++;
+        }
+        $recordset->close();
+        $this->assertEquals(3, $nrecords);
+    }
 }
index 32397db..40e223f 100644 (file)
@@ -61,9 +61,10 @@ class entry extends \core_search\area\base_mod {
      * Returns the documents associated with this glossary entry id.
      *
      * @param stdClass $entry glossary entry.
+     * @param array    $options
      * @return \core_search\document
      */
-    public function get_document($entry) {
+    public function get_document($entry, $options = array()) {
         global $DB;
 
         $keywords = array();
@@ -92,12 +93,17 @@ class entry extends \core_search\area\base_mod {
         $doc->set('title', $entry->concept);
         $doc->set('content', content_to_text($entry->definition, $entry->definitionformat));
         $doc->set('contextid', $context->id);
-        $doc->set('type', \core_search\manager::TYPE_TEXT);
         $doc->set('courseid', $entry->course);
         $doc->set('userid', $entry->userid);
         $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
         $doc->set('modified', $entry->timemodified);
 
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $entry->timecreated)) {
+            // If the document was created after the last index time, it must be new.
+            $doc->set_is_new(true);
+        }
+
         // Adding keywords as extra info.
         if ($keywords) {
             $doc->set('description1', implode(' ' , $keywords));
index 52a04f7..19578c9 100644 (file)
@@ -1404,10 +1404,8 @@ function lti_prepare_type_for_save($type, $config) {
         $type->secureicon = $config->lti_secureicon;
     }
 
-    if (isset($config->lti_forcessl)) {
-        $type->forcessl = !empty($config->lti_forcessl) ? $config->lti_forcessl : 0;
-        $config->lti_forcessl = $type->forcessl;
-    }
+    $type->forcessl = !empty($config->lti_forcessl) ? $config->lti_forcessl : 0;
+    $config->lti_forcessl = $type->forcessl;
 
     $type->timemodified = time();
 
index 350e5aa..50c785b 100644 (file)
@@ -264,4 +264,32 @@ class mod_lti_locallib_testcase extends advanced_testcase {
 
         $this->assertEquals($ncount, $rncount, 'All newline characters should be a combination of \r\n');
     }
+
+    /**
+     * Tests lti_prepare_type_for_save's handling of the "Force SSL" configuration.
+     */
+    public function test_lti_prepare_type_for_save_forcessl() {
+        $type = new stdClass();
+        $config = new stdClass();
+
+        // Try when the forcessl config property is not set.
+        lti_prepare_type_for_save($type, $config);
+        $this->assertObjectHasAttribute('lti_forcessl', $config);
+        $this->assertEquals(0, $config->lti_forcessl);
+        $this->assertEquals(0, $type->forcessl);
+
+        // Try when forcessl config property is set.
+        $config->lti_forcessl = 1;
+        lti_prepare_type_for_save($type, $config);
+        $this->assertObjectHasAttribute('lti_forcessl', $config);
+        $this->assertEquals(1, $config->lti_forcessl);
+        $this->assertEquals(1, $type->forcessl);
+
+        // Try when forcessl config property is set to 0.
+        $config->lti_forcessl = 0;
+        lti_prepare_type_for_save($type, $config);
+        $this->assertObjectHasAttribute('lti_forcessl', $config);
+        $this->assertEquals(0, $config->lti_forcessl);
+        $this->assertEquals(0, $type->forcessl);
+    }
 }
index d38fe93..c37a393 100644 (file)
@@ -43,9 +43,10 @@ class activity extends \core_search\area\base_activity {
      * description field is not.
      *
      * @param stdClass $record
+     * @param array    $options
      * @return \core_search\document
      */
-    public function get_document($record) {
+    public function get_document($record, $options = array()) {
 
         try {
             $cm = $this->get_cm($this->get_module_name(), $record->id, $record->course);
@@ -66,7 +67,6 @@ class activity extends \core_search\area\base_activity {
         $doc->set('title', $record->name);
         $doc->set('content', content_to_text($record->content, $record->contentformat));
         $doc->set('contextid', $context->id);
-        $doc->set('type', \core_search\manager::TYPE_TEXT);
         $doc->set('courseid', $record->course);
         $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
         $doc->set('modified', $record->timemodified);
index a9c4fe2..ba7f3a3 100644 (file)
@@ -708,6 +708,17 @@ class structure {
             $moveafterslotnumber = (int) $this->slots[$idmoveafter]->slot;
         }
 
+        // If the action came in as moving a slot to itself, normalise this to
+        // moving the slot to after the previous slot.
+        if ($moveafterslotnumber == $movingslotnumber) {
+            $moveafterslotnumber = $moveafterslotnumber - 1;
+        }
+
+        $followingslotnumber = $moveafterslotnumber + 1;
+        if ($followingslotnumber == $movingslotnumber) {
+            $followingslotnumber += 1;
+        }
+
         // Check the target page number is OK.
         if ($page == 0) {
             $page = 1;
@@ -716,16 +727,10 @@ class structure {
                 $page < 1) {
             throw new \coding_exception('The target page number is too small.');
         } else if (!$this->is_last_slot_in_quiz($moveafterslotnumber) &&
-                $page > $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
+                $page > $this->get_page_number_for_slot($followingslotnumber)) {
             throw new \coding_exception('The target page number is too large.');
         }
 
-        // If the action came in as moving a slot to itself, normalise this to
-        // moving the slot to after the previosu slot.
-        if ($moveafterslotnumber == $movingslotnumber) {
-            $moveafterslotnumber = $moveafterslotnumber - 1;
-        }
-
         // Work out how things are being moved.
         $slotreorder = array();
         if ($moveafterslotnumber > $movingslotnumber) {
@@ -768,10 +773,12 @@ class structure {
                 $headingmoveafter = $movingslotnumber;
                 $headingmovebefore = $movingslotnumber + 2;
                 $headingmovedirection = -1;
-            } else {
+            } else if ($page < $movingslot->page) {
                 $headingmoveafter = $movingslotnumber - 1;
                 $headingmovebefore = $movingslotnumber + 1;
                 $headingmovedirection = 1;
+            } else {
+                return; // Nothing to do.
             }
         }
 
index c8590e3..d56585e 100644 (file)
@@ -165,5 +165,30 @@ function xmldb_quiz_upgrade($oldversion) {
     // Moodle v3.0.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2016032600) {
+        // Update quiz_sections to repair quizzes what were broken by MDL-53507.
+        $problemquizzes = $DB->get_records_sql("
+                SELECT quizid, MIN(firstslot) AS firstsectionfirstslot
+                FROM {quiz_sections}
+                GROUP BY quizid
+                HAVING MIN(firstslot) > 1");
+
+        if ($problemquizzes) {
+            $pbar = new progress_bar('upgradequizfirstsection', 500, true);
+            $total = count($problemquizzes);
+            $done = 0;
+            foreach ($problemquizzes as $problemquiz) {
+                $DB->set_field('quiz_sections', 'firstslot', 1,
+                        array('quizid' => $problemquiz->quizid,
+                        'firstslot' => $problemquiz->firstsectionfirstslot));
+                $done += 1;
+                $pbar->update($done, $total, "Fixing quiz layouts - {$done}/{$total}.");
+            }
+        }
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2016032600, 'quiz');
+    }
+
     return true;
 }
index 6210f81..a8e39f8 100644 (file)
@@ -515,7 +515,7 @@ class mod_quiz_structure_testcase extends advanced_testcase {
             ), $structure);
     }
 
-    public function test_move_slot_to_down_start_of_second_section() {
+    public function test_move_slot_down_to_start_of_second_section() {
         $quizobj = $this->create_test_quiz(array(
                 'Heading 1',
                 array('TF1', 1, 'truefalse'),
@@ -539,6 +539,63 @@ class mod_quiz_structure_testcase extends advanced_testcase {
             ), $structure);
     }
 
+    public function test_move_first_slot_down_to_start_of_page_2() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, 0, '2');
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_move_first_slot_to_same_place_on_page_1() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, 0, '1');
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_move_first_slot_to_before_page_1() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, 0, '');
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ), $structure);
+    }
+
     public function test_move_slot_up_to_start_of_second_section() {
         $quizobj = $this->create_test_quiz(array(
                 'Heading 1',
index 3889380..c70290a 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016032109;
+$plugin->version   = 2016032600;
 $plugin->requires  = 2015111000;
 $plugin->component = 'mod_quiz';
 $plugin->cron      = 60;
index 4f3031c..1b40ca5 100644 (file)
@@ -34,4 +34,34 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class activity extends \core_search\area\base_activity {
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return true;
+    }
+
+    /**
+     * Add the main file to the index.
+     *
+     * @param document $document The current document
+     * @return null
+     */
+    public function attach_files($document) {
+        $fs = get_file_storage();
+
+        $cm = $this->get_cm($this->get_module_name(), $document->get('itemid'), $document->get('courseid'));
+        $context = \context_module::instance($cm->id);
+
+        // Order by sortorder desc, the first is consided the main file.
+        $files = $fs->get_area_files($context->id, 'mod_resource', 'content', 0, 'sortorder DESC, id ASC', false);
+
+        $mainfile = $files ? reset($files) : null;
+        if ($mainfile && $mainfile->get_sortorder() > 0) {
+            $document->add_stored_file($mainfile);
+        }
+    }
+
 }
diff --git a/mod/resource/tests/search_test.php b/mod/resource/tests/search_test.php
new file mode 100644 (file)
index 0000000..b4b7d09
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Resource search unit tests.
+ *
+ * @package     mod_resource
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+
+/**
+ * Provides the unit tests for forum search.
+ *
+ * @package     mod_resource
+ * @category    test
+ * @copyright   2016 Eric Merrill {@link http://www.merrilldigital.com}
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_resource_search_testcase extends advanced_testcase {
+
+    /**
+     * @var string Area id
+     */
+    protected $resourceareaid = null;
+
+    public function setUp() {
+        $this->resetAfterTest(true);
+        set_config('enableglobalsearch', true);
+
+        $this->resourceareaid = \core_search\manager::generate_areaid('mod_resource', 'activity');
+
+        // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this.
+        $search = testable_core_search::instance();
+    }
+
+    /**
+     * Test for resource file attachments.
+     *
+     * @return void
+     */
+    public function test_attach_files() {
+        global $USER;
+
+        $this->setAdminUser();
+        // Setup test data.
+        $course = $this->getDataGenerator()->create_course();
+
+        $fs = get_file_storage();
+        $usercontext = context_user::instance($USER->id);
+
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->files = file_get_unused_draft_itemid();
+
+        // Attach the main file. We put them in the draft area, create_module will move them.
+        $filerecord = array(
+            'contextid' => $usercontext->id,
+            'component' => 'user',
+            'filearea'  => 'draft',
+            'itemid'    => $record->files,
+            'filepath'  => '/',
+            'filename'  => 'mainfile',
+            'sortorder' => 1
+        );
+        $fs->create_file_from_string($filerecord, 'Test resource file');
+
+        // Attach a second file that shouldn't be returned with the search doc.
+        $filerecord['filename'] = 'extrafile';
+        $filerecord['sortorder'] = 0;
+        $fs->create_file_from_string($filerecord, 'Test resource file 2');
+
+        $resource = $this->getDataGenerator()->create_module('resource', $record);
+
+        $searcharea = \core_search\manager::get_search_area($this->resourceareaid);
+        $this->assertInstanceOf('\mod_resource\search\activity', $searcharea);
+
+        $recordset = $searcharea->get_recordset_by_timestamp(0);
+        $nrecords = 0;
+        foreach ($recordset as $record) {
+            $doc = $searcharea->get_document($record);
+            $searcharea->attach_files($doc);
+            $files = $doc->get_files();
+
+            // Resources should only return their main file.
+            $this->assertCount(1, $files);
+            $file = reset($files);
+            $this->assertEquals('mainfile', $file->get_filename());
+
+            $nrecords++;
+        }
+
+        $recordset->close();
+        $this->assertEquals(1, $nrecords);
+    }
+
+}
index e0569fd..d735172 100644 (file)
@@ -185,7 +185,7 @@ function SCORMapi1_3(def, cmiobj, cmiint, cmicommentsuser, cmicommentslms, scorm
             'cmi.interactions.n.result':{'pattern':CMIIndex, 'format':CMIResult, 'mod':'rw'},
             'cmi.interactions.n.latency':{'pattern':CMIIndex, 'format':CMITimespan, 'mod':'rw'},
             'cmi.interactions.n.description':{'pattern':CMIIndex, 'format':CMILangString250, 'mod':'rw'},
-            'cmi.launch_data':{'defaultvalue':def[scoid]['cmi.exit'], 'mod':'r'},
+            'cmi.launch_data':{'defaultvalue':def[scoid]['cmi.launch_data'], 'mod':'r'},
             'cmi.learner_id':{'defaultvalue':def[scoid]['cmi.learner_id'], 'mod':'r'},
             'cmi.learner_name':{'defaultvalue':def[scoid]['cmi.learner_name'], 'mod':'r'},
             'cmi.learner_preference._children':{'defaultvalue':student_preference_children, 'mod':'r'},
index 0f79ace..71ee823 100644 (file)
@@ -1280,6 +1280,7 @@ function get_scorm_default (&$userdata, $scorm, $scoid, $attempt, $mode) {
     $def['cmi.suspend_data'] = scorm_isset($userdata, 'cmi.suspend_data');
     $def['cmi.time_limit_action'] = scorm_isset($userdata, 'timelimitaction');
     $def['cmi.total_time'] = scorm_isset($userdata, 'cmi.total_time', 'PT0H0M0S');
+    $def['cmi.launch_data'] = scorm_isset($userdata, 'datafromlms');
 
     return $def;
 }
index f9c876f..bc60f0e 100644 (file)
@@ -41,10 +41,11 @@ class activity extends \core_search\area\base_activity {
      * Overwrites base_activity to add the provided URL as description.
      *
      * @param stdClass $record
+     * @param array    $options
      * @return \core_search\document
      */
-    public function get_document($record) {
-        $doc = parent::get_document($record);
+    public function get_document($record, $options = array()) {
+        $doc = parent::get_document($record, $options);
         if (!$doc) {
             return false;
         }
index 64b435c..6bdbd0c 100644 (file)
@@ -64,10 +64,6 @@ if ($data = $mform->get_data()) {
 
         if (!empty($data->all) || $anydelete) {
             echo $OUTPUT->notification(get_string('deleted', 'report_search'), 'notifysuccess');
-
-            // Purge the cache.
-            $cache = \cache::make('core', 'search_results');
-            $cache->purge();
         }
     }
 
index b387765..4f44dc3 100644 (file)
@@ -3,7 +3,6 @@
 Amazon S3 PHP Class
 
 Cloned from git://github.com/tpyo/amazon-s3-php-class.git
-At commit 8413f6f70ad3bb79ae756958d4ba2238514b00af
 
 https://github.com/tpyo/amazon-s3-php-class
 http://undesigned.org.za/2007/10/22/amazon-s3-php-class
index 0b1564b..d8048c7 100644 (file)
@@ -32,7 +32,7 @@
 * Amazon S3 PHP class
 *
 * @link http://undesigned.org.za/2007/10/22/amazon-s3-php-class
-* @version 0.5.1-dev
+* @version 0.5.1
 */
 class S3
 {
@@ -56,7 +56,7 @@ class S3
         * @static
         */
        private static $__accessKey = null;
-       
+
        /**
         * AWS Secret Key
         *
@@ -65,7 +65,7 @@ class S3
         * @static
         */
        private static $__secretKey = null;
-       
+
        /**
         * SSL Client key
         *
@@ -74,7 +74,15 @@ class S3
         * @static
         */
        private static $__sslKey = null;
-       
+
+       /**
+        * Default delimiter to be used, for example while getBucket().
+        * @var string
+        * @access public
+        * @static
+        */
+       public static $defDelimiter = null;
+
        /**
         * AWS URI
         *
@@ -83,7 +91,7 @@ class S3
         * @static
         */
        public static $endpoint = 's3.amazonaws.com';
-       
+
        /**
         * Proxy information
         *
@@ -92,7 +100,7 @@ class S3
         * @static
         */
        public static $proxy = null;
-       
+
        /**
         * Connect using SSL?
         *
@@ -101,7 +109,7 @@ class S3
         * @static
         */
        public static $useSSL = false;
-       
+
        /**
         * Use SSL validation?
         *
@@ -110,7 +118,16 @@ class S3
         * @static
         */
        public static $useSSLValidation = true;
-       
+
+       /**
+        * Use SSL version
+        *
+        * @var const
+        * @access public
+        * @static
+        */
+       public static $useSSLVersion = CURL_SSLVERSION_TLSv1;
+
        /**
         * Use PHP exceptions?
         *
@@ -202,6 +219,7 @@ class S3
                self::$endpoint = $host;
        }
 
+
        /**
        * Set AWS access key and secret key
        *
@@ -416,6 +434,7 @@ class S3
                if ($marker !== null && $marker !== '') $rest->setParameter('marker', $marker);
                if ($maxKeys !== null && $maxKeys !== '') $rest->setParameter('max-keys', $maxKeys);
                if ($delimiter !== null && $delimiter !== '') $rest->setParameter('delimiter', $delimiter);
+               else if (!empty(self::$defDelimiter)) $rest->setParameter('delimiter', self::$defDelimiter);
                $response = $rest->getResponse();
                if ($response->error === false && $response->code !== 200)
                        $response->error = array('code' => $response->code, 'message' => 'Unexpected HTTP status');
@@ -561,6 +580,7 @@ class S3
                        self::__triggerError('S3::inputFile(): Unable to open input file: '.$file, __FILE__, __LINE__);
                        return false;
                }
+               clearstatcache(false, $file);
                return array('file' => $file, 'size' => filesize($file), 'md5sum' => $md5sum !== false ?
                (is_string($md5sum) ? $md5sum : base64_encode(md5_file($file, true))) : '');
        }
@@ -634,15 +654,18 @@ class S3
                if (isset($input['size']) && $input['size'] >= 0)
                        $rest->size = $input['size'];
                else {
-                       if (isset($input['file']))
+                       if (isset($input['file'])) {
+                               clearstatcache(false, $input['file']);
                                $rest->size = filesize($input['file']);
+                       }
                        elseif (isset($input['data']))
                                $rest->size = strlen($input['data']);
                }
 
                // Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
                if (is_array($requestHeaders))
-                       foreach ($requestHeaders as $h => $v) $rest->setHeader($h, $v);
+                       foreach ($requestHeaders as $h => $v)
+                               strpos($h, 'x-amz-') === 0 ? $rest->setAmzHeader($h, $v) : $rest->setHeader($h, $v);
                elseif (is_string($requestHeaders)) // Support for legacy contentType parameter
                        $input['type'] = $requestHeaders;
 
@@ -797,7 +820,8 @@ class S3
        {
                $rest = new S3Request('PUT', $bucket, $uri, self::$endpoint);
                $rest->setHeader('Content-Length', 0);
-               foreach ($requestHeaders as $h => $v) $rest->setHeader($h, $v);
+               foreach ($requestHeaders as $h => $v)
+                               strpos($h, 'x-amz-') === 0 ? $rest->setAmzHeader($h, $v) : $rest->setHeader($h, $v);
                foreach ($metaHeaders as $h => $v) $rest->setAmzHeader('x-amz-meta-'.$h, $v);
                if ($storageClass !== self::STORAGE_CLASS_STANDARD) // Storage class
                        $rest->setAmzHeader('x-amz-storage-class', $storageClass);
@@ -2117,6 +2141,9 @@ final class S3Request
 
                if (S3::$useSSL)
                {
+                       // Set protocol version
+                       curl_setopt($curl, CURLOPT_SSLVERSION, S3::$useSSLVersion);
+
                        // SSL Validation can now be optional for those with broken OpenSSL installations
                        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, S3::$useSSLValidation ? 2 : 0);
                        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, S3::$useSSLValidation ? 1 : 0);
@@ -2292,6 +2319,7 @@ final class S3Request
        private function __dnsBucketName($bucket)
        {
                if (strlen($bucket) > 63 || preg_match("/[^a-z0-9\.-]/", $bucket) > 0) return false;
+               if (S3::$useSSL && strstr($bucket, '.') !== false) return false;
                if (strstr($bucket, '-.') !== false) return false;
                if (strstr($bucket, '..') !== false) return false;
                if (!preg_match("/^[0-9a-z]/", $bucket)) return false;
index efb9909..ea87af0 100644 (file)
 require_once($CFG->dirroot . '/repository/lib.php');
 require_once($CFG->dirroot . '/repository/s3/S3.php');
 
+// This constant is not defined in php 5.4. Set it to avoid errors.
+if (!defined('CURL_SSLVERSION_TLSv1')) {
+    define('CURL_SSLVERSION_TLSv1', 1);
+}
+
 /**
  * This is a repository class used to browse Amazon S3 content.
  *
index f53ed95..04bb560 100644 (file)
@@ -4,7 +4,7 @@
     <location>S3.php</location>
     <name>S3</name>
     <license>BSD</license>
-    <version>0.5.1-dev</version>
+    <version>0.5.1</version>
     <licenseversion></licenseversion>
   </library>
 </libraries>
index df6d1fa..8cba240 100644 (file)
@@ -26,6 +26,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2015111600;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2015111601;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2015111000;        // Requires this Moodle version
 $plugin->component = 'repository_s3';   // Full name of the plugin (used for diagnostics)
index 0976bcf..78aa3b8 100644 (file)
@@ -185,6 +185,15 @@ abstract class base {
         return (bool)get_config($componentname, $varname . '_enabled');
     }
 
+    /**
+     * Returns true if this area uses file indexing.
+     *
+     * @return bool
+     */
+    public function uses_file_indexing() {
+        return false;
+    }
+
     /**
      * Returns a recordset ordered by modification date ASC.
      *
@@ -213,10 +222,25 @@ abstract class base {
      * Search areas should send plain text to the search engine, use the following function to convert any user
      * input data to plain text: {@link content_to_text}
      *
+     * Valid keys for the options array are:
+     *     indexfiles => File indexing is enabled if true.
+     *     lastindexedtime => The last time this area was indexed. 0 if never indexed.
+     *
      * @param \stdClass $record A record containing, at least, the indexed document id and a modified timestamp
+     * @param array     $options Options for document creation
      * @return \core_search\document
      */
-    abstract public function get_document($record);
+    abstract public function get_document($record, $options = array());
+
+    /**
+     * Add any files to the document that should be indexed.
+     *
+     * @param document $document The current document
+     * @return void
+     */
+    public function attach_files($document) {
+        return;
+    }
 
     /**
      * Can the current user see the document.
index 2b35049..18254fd 100644 (file)
@@ -43,6 +43,11 @@ abstract class base_activity extends base_mod {
      */
     const MODIFIED_FIELD_NAME = 'timemodified';
 
+    /**
+     * Activities with a time created field can overwrite this constant.
+     */
+    const CREATED_FIELD_NAME = '';
+
     /**
      * The context levels the search area is working on.
      * @var array
@@ -68,9 +73,10 @@ abstract class base_activity extends base_mod {
      * default ones, or to fill description optional fields with extra stuff.
      *
      * @param stdClass $record
+     * @param array    $options
      * @return \core_search\document
      */
-    public function get_document($record) {
+    public function get_document($record, $options = array()) {
 
         try {
             $cm = $this->get_cm($this->get_module_name(), $record->id, $record->course);
@@ -91,11 +97,19 @@ abstract class base_activity extends base_mod {
         $doc->set('title', $record->name);
         $doc->set('content', content_to_text($record->intro, $record->introformat));
         $doc->set('contextid', $context->id);
-        $doc->set('type', \core_search\manager::TYPE_TEXT);
         $doc->set('courseid', $record->course);
         $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
         $doc->set('modified', $record->{static::MODIFIED_FIELD_NAME});
 
+        // Check if this document should be considered new.
+        if (isset($options['lastindexedtime'])) {
+            $createdfield = static::CREATED_FIELD_NAME;
+            if (!empty($createdfield) && ($options['lastindexedtime'] < $record->{$createdfield})) {
+                // If the document was created after the last index time, it must be new.
+                $doc->set_is_new(true);
+            }
+        }
+
         return $doc;
     }
 
@@ -110,8 +124,8 @@ abstract class base_activity extends base_mod {
     public function check_access($id) {
         global $DB;
 
-        $activity = $this->get_activity($id);
         try {
+            $activity = $this->get_activity($id);
             $cminfo = $this->get_cm($this->get_module_name(), $activity->id, $activity->course);
             $cminfo->get_course_module_record();
         } catch (\dml_missing_record_exception $ex) {
index 10781ce..288cb1a 100644 (file)
@@ -63,13 +63,12 @@ abstract class base_mod extends base {
      * @return \cm_info
      */
     protected function get_cm($modulename, $instanceid, $courseid) {
-
         $modinfo = get_fast_modinfo($courseid);
 
         // Hopefully not many, they are indexed by cmid.
         $instances = $modinfo->get_instances_of($modulename);
         foreach ($instances as $cminfo) {
-            if ($cminfo->instance === $instanceid) {
+            if ($cminfo->instance == $instanceid) {
                 return $cminfo;
             }
         }
index 9d47ab9..985f85d 100644 (file)
@@ -68,6 +68,16 @@ class document implements \renderable, \templatable {
      */
     protected $contentitemid = null;
 
+    /**
+     * @var bool Should be set to true if document hasn't been indexed before. False if unknown.
+     */
+    protected $isnew = false;
+
+    /**
+     * @var \stored_file[] An array of stored files to attach to the document.
+     */
+    protected $files = array();
+
     /**
      * All required fields any doc should contain.
      *
@@ -91,14 +101,16 @@ class document implements \renderable, \templatable {
             'indexed' => true
         ),
         'title' => array(
-            'type' => 'string',
+            'type' => 'text',
             'stored' => true,
-            'indexed' => true
+            'indexed' => true,
+            'mainquery' => true
         ),
         'content' => array(
-            'type' => 'string',
+            'type' => 'text',
             'stored' => true,
-            'indexed' => true
+            'indexed' => true,
+            'mainquery' => true
         ),
         'contextid' => array(
             'type' => 'int',
@@ -118,7 +130,7 @@ class document implements \renderable, \templatable {
         'courseid' => array(
             'type' => 'int',
             'stored' => true,
-            'indexed' => false
+            'indexed' => true
         ),
         'owneruserid' => array(
             'type' => 'int',
@@ -145,20 +157,34 @@ class document implements \renderable, \templatable {
         'userid' => array(
             'type' => 'int',
             'stored' => true,
-            'indexed' => false
+            'indexed' => true
         ),
         'description1' => array(
-            'type' => 'string',
+            'type' => 'text',
             'stored' => true,
-            'indexed' => true
+            'indexed' => true,
+            'mainquery' => true
         ),
         'description2' => array(
-            'type' => 'string',
+            'type' => 'text',
             'stored' => true,
-            'indexed' => true
-        ),
+            'indexed' => true,
+            'mainquery' => true
+        )
     );
 
+    /**
+     * Any fields that are engine specifc. These are fields that are solely used by a search engine plugin
+     * for internal purposes.
+     *
+     * Field names should be prefixed with engine name to avoid potential conflict with core fields.
+     *
+     * Uses same format as fields above.
+     *
+     * @var array
+     */
+    protected static $enginefields = array();
+
     /**
      * We ensure that the document has a unique id across search areas.
      *
@@ -178,6 +204,40 @@ class document implements \renderable, \templatable {
         $this->data['itemid'] = intval($itemid);
     }
 
+    /**
+     * Add a stored file to the document.
+     *
+     * @param \stored_file|int $file The file to add, or file id.
+     * @return void
+     */
+    public function add_stored_file($file) {
+        if (is_numeric($file)) {
+            $this->files[$file] = $file;
+        } else {
+            $this->files[$file->get_id()] = $file;
+        }
+    }
+
+    /**
+     * Returns the array of attached files.
+     *
+     * @return \stored_file[]
+     */
+    public function get_files() {
+        // The files array can contain stored file ids, so we need to get instances if asked.
+        foreach ($this->files as $id => $listfile) {
+            if (is_numeric($listfile)) {
+                $fs = get_file_storage();
+
+                if ($file = $fs->get_file_by_id($id)) {
+                    $this->files[$id] = $file;
+                }
+            }
+        }
+
+        return $this->files;
+    }
+
     /**
      * Setter.
      *
@@ -197,6 +257,8 @@ class document implements \renderable, \templatable {
             $fielddata = static::$requiredfields[$fieldname];
         } else if (!empty(static::$optionalfields[$fieldname])) {
             $fielddata = static::$optionalfields[$fieldname];
+        } else if (!empty(static::$enginefields[$fieldname])) {
+            $fielddata = static::$enginefields[$fieldname];
         }
 
         if (empty($fielddata)) {
@@ -268,13 +330,31 @@ class document implements \renderable, \templatable {
         return (isset($this->data[$field]) || isset($this->extradata[$field]));
     }
 
+    /**
+     * Set if this is a new document. False if unknown.
+     *
+     * @param bool $new
+     */
+    public function set_is_new($new) {
+       $this->isnew = (bool)$new;
+    }
+
+    /**
+     * Returns if the document is new. False if unknown.
+     *
+     * @return bool
+     */
+    public function get_is_new() {
+       return $this->isnew;
+    }
+
     /**
      * Returns all default fields definitions.
      *
      * @return array
      */
     public static function get_default_fields_definition() {
-        return static::$requiredfields + static::$optionalfields;
+        return static::$requiredfields + static::$optionalfields + static::$enginefields;
     }
 
     /**
@@ -305,6 +385,19 @@ class document implements \renderable, \templatable {
         return $string;
     }
 
+    /**
+     * Formats a text value for the search engine.
+     *
+     * Search engines may overwrite this method to apply restrictions, like limiting the size.
+     * The default behaviour is just returning the string.
+     *
+     * @param string $text
+     * @return string
+     */
+    public static function format_text_for_engine($text) {
+        return $text;
+    }
+
     /**
      * Returns a timestamp from the value stored in the search engine.
      *
@@ -337,7 +430,7 @@ class document implements \renderable, \templatable {
      * @return void
      */
     public function set_data_from_engine($docdata) {
-        $fields = static::$requiredfields + static::$optionalfields;
+        $fields = static::$requiredfields + static::$optionalfields + static::$enginefields;
         foreach ($fields as $fieldname => $field) {
 
             // Optional params might not be there.
@@ -395,6 +488,8 @@ class document implements \renderable, \templatable {
      * @return array
      */
     public function export_for_engine() {
+        // Set any unset defaults.
+        $this->apply_defaults();
 
         // We don't want to affect the document instance.
         $data = $this->data;
@@ -411,12 +506,17 @@ class document implements \renderable, \templatable {
                 // Overwrite the timestamp with the engine dependant format.
                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
             } else if ($field['type'] === 'string') {
-                // Overwrite the timestamp with the engine dependant format.
+                // Overwrite the string with the engine dependant format.
                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
+            } else if ($field['type'] === 'text') {
+                // Overwrite the text with the engine dependant format.
+                $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
             }
+
         }
 
-        foreach (static::$optionalfields as $fieldname => $field) {
+        $fields = static::$optionalfields + static::$enginefields;
+        foreach ($fields as $fieldname => $field) {
             if (!isset($data[$fieldname])) {
                 continue;
             }
@@ -424,14 +524,29 @@ class document implements \renderable, \templatable {
                 // Overwrite the timestamp with the engine dependant format.
                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
             } else if ($field['type'] === 'string') {
-                // Overwrite the timestamp with the engine dependant format.
+                // Overwrite the string with the engine dependant format.
                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
+            } else if ($field['type'] === 'text') {
+                // Overwrite the text with the engine dependant format.
+                $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
             }
         }
 
         return $data;
     }
 
+    /**
+     * Apply any defaults to unset fields before export. Called after document building, but before export.
+     *
+     * Sub-classes of this should make sure to call parent::apply_defaults().
+     */
+    protected function apply_defaults() {
+        // Set the default type, TYPE_TEXT.
+        if (!isset($this->data['type'])) {
+            $this->data['type'] = manager::TYPE_TEXT;
+        }
+    }
+
     /**
      * Export the document data to be used as a template context.
      *
@@ -444,14 +559,14 @@ class document implements \renderable, \templatable {
      * @return array
      */
     public function export_for_template(\renderer_base $output) {
-
         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid'));
 
+        $title = $this->is_set('title') ? $this->format_text($this->get('title')) : '';
         $data = [
             'courseurl' => new \moodle_url('/course/view.php?id=' . $this->get('courseid')),
             'coursefullname' => format_string($this->get('coursefullname'), true, array('context' => $this->get('contextid'))),
             'modified' => userdate($this->get('modified')),
-            'title' => format_string($this->get('title'), true, array('context' => $this->get('contextid'))),
+            'title' => ($title !== '') ? $title : get_string('notitle', 'search'),
             'docurl' => $this->get_doc_url(),
             'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null,
             'contexturl' => $this->get_context_url(),
@@ -459,6 +574,22 @@ class document implements \renderable, \templatable {
             'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null,
         ];
 
+        // Now take any attached any files.
+        $files = $this->get_files();
+        if (!empty($files)) {
+            if (count($files) > 1) {
+                $filenames = array();
+                foreach ($files as $file) {
+                    $filenames[] = $file->get_filename();
+                }
+                $data['multiplefiles'] = true;
+                $data['filenames'] = $filenames;
+            } else {
+                $file = reset($files);
+                $data['filename'] = $file->get_filename();
+            }
+        }
+
         if ($this->is_set('userid')) {
             $data['userurl'] = new \moodle_url('/user/view.php', array('id' => $this->get('userid'), 'course' => $this->get('courseid')));
             $data['userfullname'] = format_string($this->get('userfullname'), true, array('context' => $this->get('contextid')));
index 6452345..b157d83 100644 (file)
@@ -313,6 +313,15 @@ abstract class engine {
         return $this->queryerror;
     }
 
+    /**
+     * Return true if file indexing is supported and enabled. False otherwise.
+     *
+     * @return bool
+     */
+    public function file_indexing_enabled() {
+        return false;
+    }
+
     /**
      * Clears the current query error value.
      *
@@ -334,10 +343,11 @@ abstract class engine {
     /**
      * Adds a document to the search engine.
      *
-     * @param array $doc
-     * @return void
+     * @param document $document
+     * @param bool     $fileindexing True if file indexing is to be used
+     * @return bool    False if the file was skipped or failed, true on success
      */
-    abstract function add_document($doc);
+    abstract function add_document($document, $fileindexing = false);
 
     /**
      * Executes the query on the engine.
index 53be704..1a84344 100644 (file)
@@ -42,6 +42,11 @@ class manager {
      */
     const TYPE_TEXT = 1;
 
+    /**
+     * @var int File contents.
+     */
+    const TYPE_FILE = 2;
+
     /**
      * @var int User can not access the document.
      */
@@ -397,17 +402,6 @@ class manager {
     public function search(\stdClass $formdata) {
         global $USER;
 
-        $cache = \cache::make('core', 'search_results');
-
-        // Generate a string from all query filters
-        // Not including $areascontext here, being a user cache it is not needed.
-        $querykey = $this->generate_query_key($formdata, $USER->id);
-
-        // Look for cached results before executing it.
-        if ($results = $cache->get($querykey)) {
-            return $results;
-        }
-
         // Clears previous query errors.
         $this->engine->clear_query_error();
 
@@ -419,40 +413,9 @@ class manager {
             $docs = $this->engine->execute_query($formdata, $areascontexts);
         }
 
-        // Cache results.
-        $cache->set($querykey, $docs);
-
         return $docs;
     }
 
-    /**
-     * We generate the key ourselves so MUC knows that it contains simplekeys.
-     *
-     * @param stdClass $formdata
-     * @return string
-     */
-    protected function generate_query_key($formdata) {
-        global $USER;
-
-        // Empty values by default (although q should always have a value).
-        $fields = array('q', 'title', 'areaid', 'timestart', 'timeend', 'page');
-
-        // Just in this function scope.
-        $params = clone $formdata;
-        foreach ($fields as $field) {
-            if (empty($params->{$field})) {
-                $params->{$field} = '';
-            }
-        }
-
-        // Although it is not likely, we prevent cache hits if available search areas change during the session.
-        $enabledareas = implode('-', array_keys(static::get_search_areas_list(true)));
-
-        return md5($params->q . 'userid=' . $USER->id . 'title=' . $params->title . 'areaid=' . $params->areaid .
-            'timestart=' . $params->timestart . 'timeend=' . $params->timeend . 'page=' . $params->page .
-            $enabledareas);
-    }
-
     /**
      * Merge separate index segments into one.
      */
@@ -498,33 +461,40 @@ class manager {
             $numdocsignored = 0;
             $lastindexeddoc = 0;
 
+            $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
+
             if ($fullindex === true) {
-                $prevtimestart = 0;
+                $referencestarttime = 0;
             } else {
-                $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
+                $referencestarttime = $prevtimestart;
             }
 
             // Getting the recordset from the area.
-            $recordset = $searcharea->get_recordset_by_timestamp($prevtimestart);
+            $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime);
 
             // Pass get_document as callback.
-            $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'));
+            $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing();
+            $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart);
+            $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'), $options);
             foreach ($iterator as $document) {
-
                 if (!$document instanceof \core_search\document) {
                     continue;
                 }
 
-                $docdata = $document->export_for_engine();
-                switch ($docdata['type']) {
-                    case static::TYPE_TEXT:
-                        $this->engine->add_document($docdata);
-                        $numdocs++;
-                        break;
-                    default:
-                        $numdocsignored++;
-                        $iterator->close();
-                        throw new \moodle_exception('doctypenotsupported', 'search');
+                if ($prevtimestart == 0) {
+                    // If we have never indexed this area before, it must be new.
+                    $document->set_is_new(true);
+                }
+
+                if ($fileindexing) {
+                    // Attach files if we are indexing.
+                    $searcharea->attach_files($document);
+                }
+
+                if ($this->engine->add_document($document, $fileindexing)) {
+                    $numdocs++;
+                } else {
+                    $numdocsignored++;
                 }
 
                 $lastindexeddoc = $document->get('modified');
diff --git a/search/classes/observer.php b/search/classes/observer.php
deleted file mode 100644 (file)
index f7e6d48..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Events observer for core_search purposes.
- *
- * @package    core_search
- * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Event observer for search.
- *
- * @package    core_search
- * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class core_search_observer {
-
-    /**
-     * Invalidates search results cache.
-     *
-     * Quite aggressive as search_results cache is per session.
-     *
-     * @param \core\event\search_indexed $event
-     * @return void
-     */
-    public static function invalidate_caches(\core\event\search_indexed $event) {
-        $cache = \cache::make('core', 'search_results');
-        $cache->purge();
-    }
-}
index 20c1f3e..f428dee 100644 (file)
@@ -33,6 +33,49 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class document extends \core_search\document {
+    /**
+     * Indicates the file contents were not indexed due to an error.
+     */
+    const INDEXED_FILE_ERROR = -1;
+
+    /**
+     * Indicates the file contents were not indexed due filtering/settings.
+     */
+    const INDEXED_FILE_FALSE = 0;
+
+    /**
+     * Indicates the file contents are indexed with the record.
+     */
+    const INDEXED_FILE_TRUE = 1;
+
+    /**
+     * Any fields that are engine specifc. These are fields that are solely used by a seach engine plugin
+     * for internal purposes.
+     *
+     * @var array
+     */
+    protected static $enginefields = array(
+        'solr_filegroupingid' => array(
+            'type' => 'string',
+            'stored' => true,
+            'indexed' => true
+        ),
+        'solr_fileid' => array(
+            'type' => 'string',
+            'stored' => true,
+            'indexed' => false
+        ),
+        'solr_filecontenthash' => array(
+            'type' => 'string',
+            'stored' => true,
+            'indexed' => false
+        ),
+        'solr_fileindexedcontent' => array(
+            'type' => 'int',
+            'stored' => true,
+            'indexed' => true
+        )
+    );
 
     /**
      * Formats the timestamp according to the search engine needs.
@@ -72,6 +115,80 @@ class document extends \core_search\document {
      * @return int
      */
     protected function get_text_format() {
-        return FORMAT_MARKDOWN;
+        return FORMAT_HTML;
+    }
+
+    /**
+     * Formats a text string coming from the search engine.
+     *
+     * @param  string $text Text to format
+     * @return string HTML text to be renderer
+     */
+    protected function format_text($text) {
+        // Since we allow output for highlighting, we need to encode html entities.
+        // This ensures plaintext html chars don't become valid html.
+        $out = s($text);
+
+        $startcount = 0;
+        $endcount = 0;
+
+        // Remove end/start pairs that span a few common seperation characters. Allows us to highlight phrases instead of words.
+        $regex = '|'.engine::HIGHLIGHT_END.'([ .,-]{0,3})'.engine::HIGHLIGHT_START.'|';
+        $out = preg_replace($regex, '$1', $out);
+
+        // Now replace our start and end highlight markers.
+        $out = str_replace(engine::HIGHLIGHT_START, '<span class="highlight">', $out, $startcount);
+        $out = str_replace(engine::HIGHLIGHT_END, '</span>', $out, $endcount);
+
+        // This makes sure any highlight tags are balanced, incase truncation or the highlight text contained our markers.
+        while ($startcount > $endcount) {
+            $out .= '</span>';
+            $endcount++;
+        }
+        while ($startcount < $endcount) {
+            $out = '<span class="highlight">' . $out;
+            $endcount++;
+        }
+
+        return parent::format_text($out);
+    }
+
+    /**
+     * Apply any defaults to unset fields before export. Called after document building, but before export.
+     *
+     * Sub-classes of this should make sure to call parent::apply_defaults().
+     */
+    protected function apply_defaults() {
+        parent::apply_defaults();
+
+        // We want to set the solr_filegroupingid to id if it isn't set.
+        if (!isset($this->data['solr_filegroupingid'])) {
+            $this->data['solr_filegroupingid'] = $this->data['id'];
+        }
+    }
+
+    /**
+     * Export the data for the given file in relation to this document.
+     *
+     * @param \stored_file $file The stored file we are talking about.
+     * @return array
+     */
+    public function export_file_for_engine($file) {
+        $data = $this->export_for_engine();
+
+        // Content is index in the main document.
+        unset($data['content']);
+        unset($data['description1']);
+        unset($data['description2']);
+
+        // Going to append the fileid to give it a unique id.
+        $data['id'] = $data['id'].'-solrfile'.$file->get_id();
+        $data['type'] = \core_search\manager::TYPE_FILE;
+        $data['solr_fileid'] = $file->get_id();
+        $data['solr_filecontenthash'] = $file->get_contenthash();
+        $data['solr_fileindexedcontent'] = self::INDEXED_FILE_TRUE;
+        $data['title'] = $file->get_filename();
+
+        return $data;
     }
 }
index cd2ccd8..4fe1f7d 100644 (file)
@@ -46,15 +46,30 @@ class engine extends \core_search\engine {
     const AUTOCOMMIT_WITHIN = 15000;
 
     /**
-     * @var int Highlighting fragsize.
+     * Highlighting fragsize. Slightly larger than output size (500) to allow for ... appending.
      */
-    const FRAG_SIZE = 500;
+    const FRAG_SIZE = 510;
+
+    /**
+     * Marker for the start of a highlight.
+     */
+    const HIGHLIGHT_START = '@@HI_S@@';
+
+    /**
+     * Marker for the end of a highlight.
+     */
+    const HIGHLIGHT_END = '@@HI_E@@';
 
     /**
      * @var \SolrClient
      */
     protected $client = null;
 
+    /**
+     * @var bool True if we should reuse SolrClients, false if not.
+     */
+    protected $cacheclient = true;
+
     /**
      * @var \curl Direct curl object.
      */
@@ -63,7 +78,22 @@ class engine extends \core_search\engine {
     /**
      * @var array Fields that can be highlighted.
      */
-    protected $highlightfields = array('content', 'description1', 'description2');
+    protected $highlightfields = array('title', 'content', 'description1', 'description2');
+
+    /**
+     * Initialises the search engine configuration.
+     *
+     * @return void
+     */
+    public function __construct() {
+        parent::__construct();
+
+        $curlversion = curl_version();
+        if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) {
+            // There is a flaw with curl 7.35.0 that causes problems with client reuse.
+            $this->cacheclient = false;
+        }
+    }
 
     /**
      * Prepares a Solr query, applies filters and executes it returning its results.
@@ -80,15 +110,20 @@ class engine extends \core_search\engine {
         $data = clone $filters;
 
         // If there is any problem we trigger the exception as soon as possible.
-        $this->client = $this->get_search_client();
+        $client = $this->get_search_client();
 
         $serverstatus = $this->is_server_ready();
         if ($serverstatus !== true) {
             throw new \core_search\engine_exception('engineserverstatus', 'search');
         }
 
-        $query = new \SolrQuery();
-        $this->set_query($query, $data->q);
+        $query = new \SolrDisMaxQuery();
+        $maxrows = \core_search\manager::MAX_RESULTS;
+        if ($this->file_indexing_enabled()) {
+            // When using file indexing and grouping, we are going to collapse results, so we want extra results.
+            $maxrows *= 2;
+        }
+        $this->set_query($query, $data->q, $maxrows);
         $this->add_fields($query);
 
         // Search filters applied, we don't cache these filters as we don't want to pollute the cache with tmp filters
@@ -140,7 +175,15 @@ class engine extends \core_search\engine {
         }
 
         try {
-            return $this->query_response($this->client->query($query));
+            if ($this->file_indexing_enabled()) {
+                // Now group records by solr_filegroupingid. Limit to 3 results per group.
+                $query->setGroup(true);
+                $query->setGroupLimit(3);
+                $query->addGroupField('solr_filegroupingid');
+                return $this->grouped_files_query_response($client->query($query));
+            } else {
+                return $this->query_response($client->query($query));
+            }
         } catch (\SolrClientException $ex) {
             debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
             $this->queryerror = $ex->getMessage();
@@ -155,10 +198,15 @@ class engine extends \core_search\engine {
 
     /**
      * Prepares a new query by setting the query, start offset and rows to return.
+     *
      * @param SolrQuery $query
-     * @param object $q Containing query and filters.
+     * @param object    $q Containing query and filters.
+     * @param null|int  $maxresults The number of results to limit. manager::MAX_RESULTS if not set.
      */
-    protected function set_query($query, $q) {
+    protected function set_query($query, $q, $maxresults = null) {
+        if (!is_numeric($maxresults)) {
+            $maxresults = \core_search\manager::MAX_RESULTS;
+        }
 
         // Set hightlighting.
         $query->setHighlight(true);
@@ -166,25 +214,36 @@ class engine extends \core_search\engine {
             $query->addHighlightField($field);
         }
         $query->setHighlightFragsize(static::FRAG_SIZE);
-        $query->setHighlightSimplePre('__');
-        $query->setHighlightSimplePost('__');
+        $query->setHighlightSimplePre(self::HIGHLIGHT_START);
+        $query->setHighlightSimplePost(self::HIGHLIGHT_END);
+        $query->setHighlightMergeContiguous(true);
 
         $query->setQuery($q);
 
         // A reasonable max.
-        $query->setRows(\core_search\manager::MAX_RESULTS);
+        $query->setRows($maxresults);
     }
 
     /**
      * Sets fields to be returned in the result.
      *
-     * @param SolrQuery $query object.
+     * @param SolrDisMaxQuery|SolrQuery $query object.
      */
     public function add_fields($query) {
         $documentclass = $this->get_document_classname();
-        $fields = array_keys($documentclass::get_default_fields_definition());
-        foreach ($fields as $field) {
-            $query->addField($field);
+        $fields = $documentclass::get_default_fields_definition();
+
+        $dismax = false;
+        if ($query instanceof SolrDisMaxQuery) {
+            $dismax = true;
+        }
+
+        foreach ($fields as $key => $field) {
+            $query->addField($key);
+            if ($dismax && !empty($field['mainquery'])) {
+                // Add fields the main query should be run against.
+                $query->addQueryField($key);
+            }
         }
     }
 
@@ -193,6 +252,11 @@ class engine extends \core_search\engine {
      * @param object $response containing results.
      */
     public function add_highlight_content($response) {
+        if (!isset($response->highlighting)) {
+            // There is no highlighting to add.
+            return;
+        }
+
         $highlightedobject = $response->highlighting;
         foreach ($response->response->docs as $doc) {
             $x = $doc->id;
@@ -291,6 +355,155 @@ class engine extends \core_search\engine {
         return $docs;
     }
 
+    /**
+     * Processes grouped file results into documents, with attached matching files.
+     *
+     * @param SolrQueryResponse $queryresponse The response returned from solr server
+     * @return array Final results to be displayed.
+     */
+    protected function grouped_files_query_response($queryresponse) {
+        $response = $queryresponse->getResponse();
+
+        // If we can't find the grouping, or there are no matches in the grouping, return empty.
+        if (!isset($response->grouped->solr_filegroupingid) || empty($response->grouped->solr_filegroupingid->matches)) {
+            return array();
+        }
+
+        $numgranted = 0;
+        $orderedids = array();
+        $completedocs = array();
+        $incompletedocs = array();
+
+        $highlightingobj = $response->highlighting;
+
+        // Each group represents a "master document".
+        $groups = $response->grouped->solr_filegroupingid->groups;
+        foreach ($groups as $group) {
+            $groupid = $group->groupValue;
+            $groupdocs = $group->doclist->docs;
+            $firstdoc = reset($groupdocs);
+
+            if (!$searcharea = $this->get_search_area($firstdoc->areaid)) {
+                // Well, this is a problem.
+                continue;
+            }
+
+            // Check for access.
+            $access = $searcharea->check_access($firstdoc->itemid);
+            switch ($access) {
+                case \core_search\manager::ACCESS_DELETED:
+                    // If deleted from Moodle, delete from index and then continue.
+                    $this->delete_by_id($firstdoc->id);
+                    continue 2;
+                    break;
+                case \core_search\manager::ACCESS_DENIED:
+                    // This means we should just skip for the current user.
+                    continue 2;
+                    break;
+            }
+            $numgranted++;
+
+            $maindoc = false;
+            $fileids = array();
+            // Seperate the main document and any files returned.
+            foreach ($groupdocs as $groupdoc) {
+                if ($groupdoc->id == $groupid) {
+                    $maindoc = $groupdoc;
+                } else if (isset($groupdoc->solr_fileid)) {
+                    $fileids[] = $groupdoc->solr_fileid;
+                }
+            }
+
+            // Store the id of this group, in order, for later merging.
+            $orderedids[] = $groupid;
+
+            if (!$maindoc) {
+                // We don't have the main doc, store what we know for later building.
+                $incompletedocs[$groupid] = $fileids;
+            } else {
+                if (isset($highlightingobj->$groupid)) {
+                    // Merge the highlighting for this doc.
+                    $this->merge_highlight_field_values($maindoc, $highlightingobj->$groupid);
+                }
+                $docdata = $this->standarize_solr_obj($maindoc);
+                $doc = $this->to_document($searcharea, $docdata);
+                // Now we need to attach the result files to the doc.
+                foreach ($fileids as $fileid) {
+                    $doc->add_stored_file($fileid);
+                }
+                $completedocs[$groupid] = $doc;
+            }
+
+            if ($numgranted >= \core_search\manager::MAX_RESULTS) {
+                // We have hit the max results, we will just ignore the rest.
+                break;
+            }
+        }
+
+        $incompletedocs = $this->get_missing_docs($incompletedocs);
+
+        $out = array();
+        // Now merge the complete and incomplete documents, in results order.
+        foreach ($orderedids as $docid) {
+            if (isset($completedocs[$docid])) {
+                $out[] = $completedocs[$docid];
+            } else if (isset($incompletedocs[$docid])) {
+                $out[] = $incompletedocs[$docid];
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Retreive any missing main documents and attach provided files.
+     *
+     * The missingdocs array should be an array, indexed by document id, of main documents we need to retrieve. The value
+     * associated to the key should be an array of stored_files or stored file ids to attach to the result document.
+     *
+     * Return array also indexed by document id.
+     *
+     * @param array() $missingdocs An array, indexed by document id, with arrays of files/ids to attach.
+     * @return document[]
+     */
+    protected function get_missing_docs($missingdocs) {
+        if (empty($missingdocs)) {
+            return array();
+        }
+
+        $docids = array_keys($missingdocs);
+
+        // Build a custom query that will get all the missing documents.
+        $query = new \SolrQuery();
+        $this->set_query($query, '*', count($docids));
+        $this->add_fields($query);
+        $query->addFilterQuery('{!cache=false}id:(' . implode(' OR ', $docids) . ')');
+
+        try {
+            $results = $this->query_response($this->get_search_client()->query($query));
+        } catch (\SolrClientException $ex) {
+            return array();
+        } catch (\SolrServerException $ex) {
+            return array();
+        }
+
+        $out = array();
+        foreach ($results as $result) {
+            $resultid = $result->get('id');
+            if (!isset($missingdocs[$resultid])) {
+                // We got a result we didn't expect. Skip it.
+                continue;
+            }
+            // Attach the files.
+            foreach ($missingdocs[$resultid] as $filedoc) {
+                $result->add_stored_file($filedoc);
+            }
+            $out[$resultid] = $result;
+        }
+
+        return $out;
+    }
+
     /**
      * Returns a standard php array from a \SolrObject instance.
      *
@@ -314,11 +527,32 @@ class engine extends \core_search\engine {
      *
      * This does not commit to the search engine.
      *
-     * @param array $doc
-     * @return void
+     * @param document $document
+     * @param bool     $fileindexing True if file indexing is to be used
+     * @return bool
      */
-    public function add_document($doc) {
+    public function add_document($document, $fileindexing = false) {
+        $docdata = $document->export_for_engine();
+
+        if (!$this->add_solr_document($docdata)) {
+            return false;
+        }
 
+        if ($fileindexing) {
+            // This will take care of updating all attached files in the index.
+            $this->process_document_files($document);
+        }
+
+        return true;
+    }
+
+    /**
+     * Adds a text document to the search engine.
+     *
+     * @param array $doc
+     * @return bool
+     */
+    protected function add_solr_document($doc) {
         $solrdoc = new \SolrInputDocument();
         foreach ($doc as $field => $value) {
             $solrdoc->addField($field, $value);
@@ -326,6 +560,7 @@ class engine extends \core_search\engine {
 
         try {
             $result = $this->get_search_client()->addDocument($solrdoc, true, static::AUTOCOMMIT_WITHIN);
+            return true;
         } catch (\SolrClientException $e) {
             debugging('Solr client error adding document with id ' . $doc['id'] . ': ' . $e->getMessage(), DEBUG_DEVELOPER);
         } catch (\SolrServerException $e) {
@@ -333,6 +568,295 @@ class engine extends \core_search\engine {
             $msg = strtok($e->getMessage(), "\n");
             debugging('Solr server error adding document with id ' . $doc['id'] . ': ' . $msg, DEBUG_DEVELOPER);
         }
+
+        return false;
+    }
+
+    /**
+     * Index files attached to the docuemnt, ensuring the index matches the current document files.
+     *
+     * For documents that aren't known to be new, we check the index for existing files.
+     * - New files we will add.
+     * - Existing and unchanged files we will skip.
+     * - File that are in the index but not on the document will be deleted from the index.
+     * - Files that have changed will be re-indexed.
+     *
+     * @param document $document
+     */
+    protected function process_document_files($document) {
+        if (!$this->file_indexing_enabled()) {
+            return;
+        }
+
+        // Maximum rows to process at a time.
+        $rows = 500;
+
+        // Get the attached files.
+        $files = $document->get_files();
+
+        // If this isn't a new document, we need to check the exiting indexed files.
+        if (!$document->get_is_new()) {
+            // We do this progressively, so we can handle lots of files cleanly.
+            list($numfound, $indexedfiles) = $this->get_indexed_files($document, 0, $rows);
+            $count = 0;
+            $idstodelete = array();
+
+            do {
+                // Go through each indexed file. We want to not index any stored and unchanged ones, delete any missing ones.
+                foreach ($indexedfiles as $indexedfile) {
+                    $fileid = $indexedfile->solr_fileid;
+
+                    if (isset($files[$fileid])) {
+                        // Check for changes that would mean we need to re-index the file. If so, just leave in $files.
+                        // Filelib does not guarantee time modified is updated, so we will check important values.
+                        if ($indexedfile->modified < $files[$fileid]->get_timemodified()) {
+                            continue;
+                        }
+                        if (strcmp($indexedfile->title, $files[$fileid]->get_filename()) !== 0) {
+                            continue;
+                        }
+                        if ($indexedfile->solr_filecontenthash != $files[$fileid]->get_contenthash()) {
+                            continue;
+                        }
+                        if ($indexedfile->solr_fileindexedcontent == document::INDEXED_FILE_FALSE &&
+                                $this->file_is_indexable($files[$fileid])) {
+                            // This means that the last time we indexed this file, filtering blocked it.
+                            // Current settings say it is indexable, so we will allow it to be indexed.
+                            continue;
+                        }
+
+                        // If the file is already indexed, we can just remove it from the files array and skip it.
+                        unset($files[$fileid]);
+                    } else {
+                        // This means we have found a file that is no longer attached, so we need to delete from the index.
+                        // We do it later, since this is progressive, and it could reorder results.
+                        $idstodelete[] = $indexedfile->id;
+                    }
+                }
+                $count += $rows;
+
+                if ($count < $numfound) {
+                    // If we haven't hit the total count yet, fetch the next batch.
+                    list($numfound, $indexedfiles) = $this->get_indexed_files($document, $count, $rows);
+                }
+
+            } while ($count < $numfound);
+
+            // Delete files that are no longer attached.
+            foreach ($idstodelete as $id) {
+                // We directly delete the item using the client, as the engine delete_by_id won't work on file docs.
+                $this->get_search_client()->deleteById($id);
+            }
+        }
+
+        // Now we can actually index all the remaining files.
+        foreach ($files as $file) {
+            $this->add_stored_file($document, $file);
+        }
+    }
+
+    /**
+     * Get the currently indexed files for a particular document, returns the total count, and a subset of files.
+     *
+     * @param document $document
+     * @param int      $start The row to start the results on. Zero indexed.
+     * @param int      $rows The number of rows to fetch
+     * @return array   A two element array, the first is the total number of availble results, the second is an array
+     *                 of documents for the current request.
+     */
+    protected function get_indexed_files($document, $start = 0, $rows = 500) {
+        // Build a custom query that will get any document files that are in our solr_filegroupingid.
+        $query = new \SolrQuery();
+
+        // We want to get all file records tied to a document.
+        // For efficiency, we are building our own, stripped down, query.
+        $query->setQuery('*');
+        $query->setRows($rows);
+        $query->setStart($start);
+        // We want a consistent sorting.
+        $query->addSortField('id');
+
+        // We only want the bare minimum of fields.
+        $query->addField('id');
+        $query->addField('modified');
+        $query->addField('title');
+        $query->addField('solr_fileid');
+        $query->addField('solr_filecontenthash');
+        $query->addField('solr_fileindexedcontent');
+
+        $query->addFilterQuery('{!cache=false}solr_filegroupingid:(' . $document->get('id') . ')');
+        $query->addFilterQuery('type:' . \core_search\manager::TYPE_FILE);
+
+        try {
+            $response = $this->get_search_client()->query($query);
+            $responsedoc = $response->getResponse();
+
+            if (empty($responsedoc->response->numFound)) {
+                return array(0, array());
+            }
+            $numfound = $responsedoc->response->numFound;
+
+            return array($numfound, $this->convert_file_results($responsedoc));
+        } catch (\SolrClientException $ex) {
+            debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
+            $this->queryerror = $ex->getMessage();
+            return array(0, array());
+        } catch (\SolrServerException $ex) {
+            debugging('Error executing the provided query: ' . $ex->getMessage(), DEBUG_DEVELOPER);
+            $this->queryerror = $ex->getMessage();
+            return array(0, array());
+        }
+    }
+
+    /**
+     * A very lightweight handler for getting information about already indexed files from a Solr response.
+     *
+     * @param SolrObject $responsedoc A Solr response document
+     * @return stdClass[] An array of objects that contain the basic information for file processing.
+     */
+    protected function convert_file_results($responsedoc) {
+        if (!$docs = $responsedoc->response->docs) {
+            return array();
+        }
+
+        $out = array();
+
+        foreach ($docs as $doc) {
+            // Copy the bare minimim needed info.
+            $result = new \stdClass();
+            $result->id = $doc->id;
+            $result->modified = document::import_time_from_engine($doc->modified);
+            $result->title = $doc->title;
+            $result->solr_fileid = $doc->solr_fileid;
+            $result->solr_filecontenthash = $doc->solr_filecontenthash;
+            $result->solr_fileindexedcontent = $doc->solr_fileindexedcontent;
+            $out[] = $result;
+        }
+
+        return $out;
+    }
+
+    /**
+     * Adds a file to the search engine.
+     *
+     * Notes about Solr and Tika indexing. We do not send the mime type, only the filename.
+     * Tika has much better content type detection than Moodle, and we will have many more doc failures
+     * if we try to send mime types.
+     *
+     * @param document $document
+     * @param \stored_file $storedfile
+     * @return void
+     */
+    protected function add_stored_file($document, $storedfile) {
+        $filedoc = $document->export_file_for_engine($storedfile);
+
+        if (!$this->file_is_indexable($storedfile)) {
+            // For files that we don't consider indexable, we will still place a reference in the search engine.
+            $filedoc['solr_fileindexedcontent'] = document::INDEXED_FILE_FALSE;
+            $this->add_solr_document($filedoc);
+            return;
+        }
+
+        $curl = $this->get_curl_object();
+
+        $url = $this->get_connection_url('/update/extract');
+
+        // This will prevent solr from automatically making fields for every tika output.
+        $url->param('uprefix', 'ignored_');
+
+        // These are common fields that matches the standard *_point dynamic field and causes an error.
+        $url->param('fmap.media_white_point', 'ignored_mwp');
+        $url->param('fmap.media_black_point', 'ignored_mbp');
+
+        // Copy each key to the url with literal.
+        // We place in a temp name then copy back to the true field, which prevents errors or Tika overwriting common field names.
+        foreach ($filedoc as $key => $value) {
+            // This will take any fields from tika that match our schema and discard them, so they don't overwrite ours.
+            $url->param('fmap.'.$key, 'ignored_'.$key);
+            // Place data in a tmp field.
+            $url->param('literal.mdltmp_'.$key, $value);
+            // Then move to the final field.
+            $url->param('fmap.mdltmp_'.$key, $key);
+        }
+
+        // This sets the true filename for Tika.
+        $url->param('resource.name', $storedfile->get_filename());
+
+        // A giant block of code that is really just error checking around the curl request.
+        try {
+            // Now actually do the request.
+            $result = $curl->post($url->out(false), array('myfile' => $storedfile));
+
+            $code = $curl->get_errno();
+            $info = $curl->get_info();
+
+            // Now error handling. It is just informational, since we aren't tracking per file/doc results.
+            if ($code != 0) {
+                // This means an internal cURL error occurred error is in result.
+                $message = 'Curl error '.$code.' while indexing file with document id '.$filedoc['id'].': '.$result.'.';
+                debugging($message, DEBUG_DEVELOPER);
+            } else if (isset($info['http_code']) && ($info['http_code'] !== 200)) {
+                // Unexpected HTTP response code.
+                $message = 'Error while indexing file with document id '.$filedoc['id'];
+                // Try to get error message out of msg or title if it exists.
+                if (preg_match('|<str [^>]*name="msg"[^>]*>(.*?)</str>|i', $result, $matches)) {
+                    $message .= ': '.$matches[1];
+                } else if (preg_match('|<title[^>]*>([^>]*)</title>|i', $result, $matches)) {
+                    $message .= ': '.$matches[1];
+                }
+                // This is a common error, happening whenever a file fails to index for any reason, so we will make it quieter.
+                if (CLI_SCRIPT && !PHPUNIT_TEST) {
+                    mtrace($message);
+                }
+            } else {
+                // Check for the expected status field.
+                if (preg_match('|<int [^>]*name="status"[^>]*>(\d*)</int>|i', $result, $matches)) {
+                    // Now check for the expected status of 0, if not, error.
+                    if ((int)$matches[1] !== 0) {
+                        $message = 'Unexpected Solr status code '.(int)$matches[1];
+                        $message .= ' while indexing file with document id '.$filedoc['id'].'.';
+                        debugging($message, DEBUG_DEVELOPER);
+                    } else {
+                        // The document was successfully indexed.
+                        return;
+                    }
+                } else {
+                    // We received an unprocessable response.
+                    $message = 'Unexpected Solr response while indexing file with document id '.$filedoc['id'].': ';
+                    $message .= strtok($result, "\n");
+                    debugging($message, DEBUG_DEVELOPER);
+                }
+            }
+        } catch (\Exception $e) {
+            // There was an error, but we are not tracking per-file success, so we just continue on.
+            debugging('Unknown exception while indexing file "'.$storedfile->get_filename().'".', DEBUG_DEVELOPER);
+        }
+
+        // If we get here, the document was not indexed due to an error. So we will index just the base info without the file.
+        $filedoc['solr_fileindexedcontent'] = document::INDEXED_FILE_ERROR;
+        $this->add_solr_document($filedoc);
+    }
+
+    /**
+     * Checks to see if a passed file is indexable.
+     *
+     * @param \stored_file $file The file to check
+     * @return bool True if the file can be indexed
+     */
+    protected function file_is_indexable($file) {
+        if (!empty($this->config->maxindexfilekb) && ($file->get_filesize() > ($this->config->maxindexfilekb * 1024))) {
+            // The file is too big to index.
+            return false;
+        }
+
+        $mime = $file->get_mimetype();
+
+        if ($mime == 'application/vnd.moodle.backup') {
+            // We don't index Moodle backup files. There is nothing usefully indexable in them.
+            return false;
+        }
+
+        return true;
     }
 
     /**
@@ -360,6 +884,15 @@ class engine extends \core_search\engine {
         return true;
     }
 
+    /**
+     * Return true if file indexing is supported and enabled. False otherwise.
+     *
+     * @return bool
+     */
+    public function file_indexing_enabled() {
+        return (bool)$this->config->fileindexing;
+    }
+
     /**
      * Defragments the index.
      *
@@ -376,7 +909,8 @@ class engine extends \core_search\engine {
      * @return void
      */
     public function delete_by_id($id) {
-        $this->get_search_client()->deleteById($id);
+        // We need to make sure we delete the item and all related files, which can be done with solr_filegroupingid.
+        $this->get_search_client()->deleteByQuery('solr_filegroupingid:' . $id);
         $this->commit();
     }
 
@@ -406,12 +940,12 @@ class engine extends \core_search\engine {
             return 'No solr configuration found';
         }
 
-        if (!$this->client = $this->get_search_client(false)) {
+        if (!$client = $this->get_search_client(false)) {
             return get_string('engineserverstatus', 'search');
         }
 
         try {
-            @$this->client->ping();
+            @$client->ping();
         } catch (\SolrClientException $ex) {
             return 'Solr client error: ' . $ex->getMessage();
         } catch (\SolrServerException $ex) {
@@ -441,6 +975,8 @@ class engine extends \core_search\engine {
     /**
      * Returns the solr client instance.
      *
+     * We don't reuse SolrClient if we are on libcurl 7.35.0, due to a bug in that version of curl.
+     *
      * @throws \core_search\engine_exception
      * @param bool $triggerexception
      * @return \SolrClient
@@ -467,13 +1003,17 @@ class engine extends \core_search\engine {
             'timeout' => !empty($this->config->server_timeout) ? $this->config->server_timeout : '30'
         );
 
-        $this->client = new \SolrClient($options);
+        $client = new \SolrClient($options);
 
-        if ($this->client === false && $triggerexception) {
+        if ($client === false && $triggerexception) {
             throw new \core_search\engine_exception('engineserverstatus', 'search');
         }
 
-        return $this->client;
+        if ($this->cacheclient) {
+            $this->client = $client;
+        }
+
+        return $client;
     }
 
     /**
index c68268f..63dfdd2 100644 (file)
@@ -162,7 +162,7 @@ class schema {
             $params = array(
                 'add-field' => array(
                     'name' => $fieldname,
-                    'type' => $data['type'],
+                    'type' => ($data['type'] === 'text' ? 'text_general' : $data['type']),
                     'stored' => $data['stored'],
                     'multiValued' => false,
                     'indexed' => $data['indexed']
@@ -228,7 +228,8 @@ class schema {
                         throw new \moodle_exception('errorcreatingschema', 'search_solr', '',
                             get_string('schemafieldautocreated', 'search_solr', $fieldname));
 
-                    } else if ($results->field->type !== $data['type'] ||
+                    } else if (($results->field->type !== $data['type'] &&
+                                ($data['type'] !== 'text' || $results->field->type !== 'text_general')) ||
                                 $results->field->multiValued !== false ||
                                 $results->field->indexed !== $data['indexed'] ||
                                 $results->field->stored !== $data['stored']) {
index 5633931..ca06662 100644 (file)
  */
 
 $string['connectionerror'] = 'The specified Solr server is not available or the specified index does not exist';
+$string['connectionsettings'] = 'Connection settings';
 $string['errorcreatingschema'] = 'Error creating the Solr schema: {$a}';
 $string['errorvalidatingschema'] = 'Error validating Solr schema, field {$a->fieldname} does not exist. Please <a href="{$a->setupurl}">follow this link</a> to setup the fields required by Moodle.';
 $string['extensionerror'] = 'The Apache Solr PHP extension is not installed. Please check the documentation.';
+$string['fileindexing'] = 'Enable file indexing';
+$string['fileindexing_help'] = 'If your Solr install supports it, this feature allows Moodle to send files to be indexed.';
+$string['fileindexsettings'] = 'File indexing settings';
+$string['maxindexfilekb'] = 'Maximum file size to index (kB)';
+$string['maxindexfilekb_help'] = 'Files larger than this number of kilobytes will be skipped for search indexing. 0 to index files of any size.';
 $string['missingconfig'] = 'Your Apache Solr server is not yet configured in Moodle.';
 $string['multivaluedfield'] = 'Field "{$a}" returned an array instead of a scalar, the field is probably defined in Solr with "Multivalued" to true, this means that Solr autocreated the field for you when you indexed data because you forgot to run search/engine/solr/cli/setup_schema.php. Please delete the current index, create a new one and run setup_schema.php before indexing data in Solr.';
 $string['nodatafromserver'] = 'No data from server';
index 32b409e..88ce5d8 100644 (file)
@@ -31,8 +31,10 @@ if ($ADMIN->fulltree) {
             $settings->add(new admin_setting_heading('search_solr_settings', '', get_string('extensionerror', 'search_solr')));
 
         } else {
-            $settings->add(new admin_setting_configtext('search_solr/server_hostname', new lang_string('solrserverhostname', 'search_solr'), new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_TEXT));
-            $settings->add(new admin_setting_configtext('search_solr/indexname', new lang_string('solrindexname', 'search_solr'), '', 'moodle', PARAM_TEXT));
+            $settings->add(new admin_setting_heading('search_solr_connection',
+                    new lang_string('connectionsettings', 'search_solr'), ''));
+            $settings->add(new admin_setting_configtext('search_solr/server_hostname', new lang_string('solrserverhostname', 'search_solr'), new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_HOST));
+            $settings->add(new admin_setting_configtext('search_solr/indexname', new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT));
             $settings->add(new admin_setting_configcheckbox('search_solr/secure', new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0));
 
             $secure = get_config('search_solr', 'secure');
@@ -46,6 +48,15 @@ if ($ADMIN->fulltree) {
             $settings->add(new admin_setting_configtext('search_solr/ssl_keypassword', new lang_string('solrsslkeypassword', 'search_solr'), new lang_string('solrsslkeypassword_desc', 'search_solr'), '', PARAM_RAW));
             $settings->add(new admin_setting_configtext('search_solr/ssl_cainfo', new lang_string('solrsslcainfo', 'search_solr'), new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW));
             $settings->add(new admin_setting_configtext('search_solr/ssl_capath', new lang_string('solrsslcapath', 'search_solr'), new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW));
+
+            $settings->add(new admin_setting_heading('search_solr_fileindexing',
+                    new lang_string('fileindexsettings', 'search_solr'), ''));
+            $settings->add(new admin_setting_configcheckbox('search_solr/fileindexing',
+                    new lang_string('fileindexing', 'search_solr'),
+                    new lang_string('fileindexing_help', 'search_solr'), 1));
+            $settings->add(new admin_setting_configtext('search_solr/maxindexfilekb',
+                    new lang_string('maxindexfilekb', 'search_solr'),
+                    new lang_string('maxindexfilekb_help', 'search_solr'), '20197152', PARAM_INT));
         }
     }
 }
index bc364eb..2e05935 100644 (file)
@@ -70,8 +70,8 @@ class search_solr_engine_testcase extends advanced_testcase {
             $this->markTestSkipped('Solr extension test server not set.');
         }
 
-        set_config('hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr');
-        set_config('port', TEST_SEARCH_SOLR_PORT, 'search_solr');
+        set_config('server_hostname', TEST_SEARCH_SOLR_HOSTNAME, 'search_solr');
+        set_config('server_port', TEST_SEARCH_SOLR_PORT, 'search_solr');
         set_config('indexname', TEST_SEARCH_SOLR_INDEXNAME, 'search_solr');
 
         if (defined('TEST_SEARCH_SOLR_USERNAME')) {
@@ -99,6 +99,10 @@ class search_solr_engine_testcase extends advanced_testcase {
             set_config('ssl_cainfo', TEST_SEARCH_SOLR_CAINFOCERT, 'search_solr');
         }
 
+        set_config('fileindexing', 1, 'search_solr');
+
+        // We are only test indexing small string files, so setting this as low as we can.
+        set_config('maxindexfilekb', 1, 'search_solr');
 
         // Inject search solr engine into the testable core search as we need to add the mock
         // search component to it.
@@ -161,8 +165,6 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assertEquals($USER->id, $results[0]->get('userid'));
         $this->assertEquals(\context_system::instance()->id, $results[0]->get('contextid'));
 
-        // Testing filters we don't purge cache in between assertions because cache key depends on the whole filters set
-        // and they are different.
         sleep(1);
         $beforeadding = time();
         sleep(1);
@@ -183,6 +185,13 @@ class search_solr_engine_testcase extends advanced_testcase {
         unset($querydata->timeend);
         $querydata->title = 'moodle/course:renameroles roleid 1';
         $this->assertCount(1, $this->search->search($querydata));
+
+        // Check that index contents get updated.
+        $DB->delete_records('role_capabilities', array('capability' => 'moodle/course:renameroles'));
+        $this->search->index(true);
+        unset($querydata->title);
+        $querydata->q = '*renameroles*';
+        $this->assertCount(0, $this->search->search($querydata));
     }
 
     public function test_delete() {
@@ -195,7 +204,6 @@ class search_solr_engine_testcase extends advanced_testcase {
 
         $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'role_capabilities');
         $this->search->delete_index($areaid);
-        cache_helper::purge_by_definition('core', 'search_results');
         $this->assertCount(0, $this->search->search($querydata));
     }
 
@@ -213,7 +221,7 @@ class search_solr_engine_testcase extends advanced_testcase {
 
         // Get the doc and insert the default doc.
         $doc = $area->get_document($record);
-        $engine->add_document($doc->export_for_engine());
+        $engine->add_document($doc);
 
         $users = array();
         $users[] = $this->getDataGenerator()->create_user();
@@ -225,9 +233,10 @@ class search_solr_engine_testcase extends advanced_testcase {
 
         // Now add a custom doc for each user.
         foreach ($users as $user) {
+            $doc = $area->get_document($record);
             $doc->set('id', $originalid.'-'.$user->id);
             $doc->set('owneruserid', $user->id);
-            $engine->add_document($doc->export_for_engine());
+            $engine->add_document($doc);
         }
 
         $engine->area_index_complete($area->get_area_id());
@@ -274,4 +283,223 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assertEquals(0, $results[0]->get('owneruserid'));
         $this->assertEquals($originalid, $results[0]->get('id'));
     }
+
+    public function test_highlight() {
+        global $PAGE;
+
+        $this->search->index();
+
+        $querydata = new stdClass();
+        $querydata->q = 'message';
+
+        $results = $this->search->search($querydata);
+        $this->assertCount(2, $results);
+
+        $result = reset($results);
+
+        $regex = '|'.\search_solr\engine::HIGHLIGHT_START.'message'.\search_solr\engine::HIGHLIGHT_END.'|';
+        $this->assertRegExp($regex, $result->get('content'));
+
+        $searchrenderer = $PAGE->get_renderer('core_search');
+        $exported = $result->export_for_template($searchrenderer);
+
+        $regex = '|<span class="highlight">message</span>|';
+        $this->assertRegExp($regex, $exported['content']);
+    }
+
+    public function test_index_file() {
+        // Very simple test.
+        $this->search->index();
+        $querydata = new stdClass();
+        $querydata->q = '"File contents"';
+
+        $this->assertCount(2, $this->search->search($querydata));
+    }
+
+    public function test_reindexing_files() {
+        // Get engine and area to work with.
+        $engine = $this->search->get_engine();
+        $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'role_capabilities');
+        $area = \core_search\manager::get_search_area($areaid);
+
+        // Get a single record to make a doc from.
+        $recordset = $area->get_recordset_by_timestamp(0);
+        $record = $recordset->current();
+        $recordset->close();
+
+        $doc = $area->get_document($record);
+
+        // Now we are going to make some files.
+        $fs = get_file_storage();
+        $syscontext = \context_system::instance();
+
+        $files = array();
+        $filerecord = array(
+            'contextid' => $syscontext->id,
+            'component' => 'core',
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+        );
+
+        // We make enough so that we pass the 500 files threashold. That is the boundary when getting files.
+        $boundary = 500;
+        $top = (int)($boundary * 1.1);
+        for ($i = 0; $i < $top; $i++) {
+            $filerecord['filename']  = 'searchfile'.$i;
+            $file = $fs->create_file_from_string($filerecord, 'Some FileContents'.$i);
+            $doc->add_stored_file($file);
+            $files[] = $file;
+        }
+
+        // Add the doc with lots of files, then commit.
+        $engine->add_document($doc, true);
+        $engine->area_index_complete($area->get_area_id());
+
+        // Indexes we are going to check. 0 means we will delete, 1 means we will keep.
+        $checkfiles = array(
+            0 => 0,                        // Check the begining of the set.
+            1 => 1,
+            2 => 0,
+            ($top - 3) => 0,               // Check the end of the set.
+            ($top - 2) => 1,
+            ($top - 1) => 0,
+            ($boundary - 2) => 0,          // Check at the boundary between fetch groups.
+            ($boundary - 1) => 0,
+            $boundary => 0,
+            ($boundary + 1) => 0,
+            ((int)($boundary * 0.5)) => 1, // Make sure we keep some middle ones.
+            ((int)($boundary * 1.05)) => 1
+        );
+
+        $querydata = new stdClass();
+
+        // First, check that all the files are currently there.
+        foreach ($checkfiles as $key => $unused) {
+            $querydata->q = 'FileContents'.$key;
+            $this->assertCount(1, $this->search->search($querydata));
+            $querydata->q = 'searchfile'.$key;
+            $this->assertCount(1, $this->search->search($querydata));
+        }
+
+        // Remove the files we want removed from the files array.
+        foreach ($checkfiles as $key => $keep) {
+            if (!$keep) {
+                unset($files[$key]);
+            }
+        }
+
+        // And make us a new file to add.
+        $filerecord['filename']  = 'searchfileNew';
+        $files[] = $fs->create_file_from_string($filerecord, 'Some FileContentsNew');
+        $checkfiles['New'] = 1;
+
+        $doc = $area->get_document($record);
+        foreach($files as $file) {
+            $doc->add_stored_file($file);
+        }
+
+        // Reindex the document with the changed files.
+        $engine->add_document($doc, true);
+        $engine->area_index_complete($area->get_area_id());
+
+        // Go through our check array, and see if the file is there or not.
+        foreach ($checkfiles as $key => $keep) {
+            $querydata->q = 'FileContents'.$key;
+            $this->assertCount($keep, $this->search->search($querydata));
+            $querydata->q = 'searchfile'.$key;
+            $this->assertCount($keep, $this->search->search($querydata));
+        }
+
+        // Now check that we get one result when we search from something in all of them.
+        $querydata->q = 'Some';
+        $this->assertCount(1, $this->search->search($querydata));
+    }
+
+    public function test_index_filtered_file() {
+        // Get engine and area to work with.
+        $engine = $this->search->get_engine();
+        $areaid = \core_search\manager::generate_areaid('core_mocksearch', 'role_capabilities');
+        $area = \core_search\manager::get_search_area($areaid);
+
+        // Get a single record to make a doc from.
+        $recordset = $area->get_recordset_by_timestamp(0);
+        $record = $recordset->current();
+        $recordset->close();
+
+        $doc = $area->get_document($record);
+
+        // Now we are going to make some files.
+        $fs = get_file_storage();
+        $syscontext = \context_system::instance();
+
+        $files = array();
+        $filerecord = array(
+            'contextid' => $syscontext->id,
+            'component' => 'core',
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => 'largefile'
+        );
+
+        // We need to make a file greater than 1kB in size, which is the lowest filter size.
+        $contents = 'Some LargeFindContent to find.';
+        for ($i = 0; $i < 200; $i++) {
+            $contents .= ' The quick brown fox jumps over the lazy dog.';
+        }
+
+        $this->assertGreaterThan(1024, strlen($contents));
+
+        $file = $fs->create_file_from_string($filerecord, $contents);
+        $doc->add_stored_file($file);
+
+        $filerecord['filename'] = 'smallfile';
+        $file = $fs->create_file_from_string($filerecord, 'Some SmallFindContent to find.');
+        $doc->add_stored_file($file);
+
+        $engine->add_document($doc, true);
+        $engine->area_index_complete($area->get_area_id());
+
+        $querydata = new stdClass();
+        // We shouldn't be able to find the large file contents.
+        $querydata->q = 'LargeFindContent';
+        $this->assertCount(0, $this->search->search($querydata));
+
+        // But we should be able to find the filename.
+        $querydata->q = 'largefile';
+        $this->assertCount(1, $this->search->search($querydata));
+
+        // We should be able to find the small file contents.
+        $querydata->q = 'SmallFindContent';
+        $this->assertCount(1, $this->search->search($querydata));
+
+        // And we should be able to find the filename.
+        $querydata->q = 'smallfile';
+        $this->assertCount(1, $this->search->search($querydata));
+    }
+
+    public function test_delete_by_id() {
+        // First get files in the index.
+        $this->search->index();
+        $engine = $this->search->get_engine();
+
+        $querydata = new stdClass();
+
+        // Then search to make sure they are there.
+        $querydata->q = '"File contents"';
+        $results = $this->search->search($querydata);
+        $this->assertCount(2, $results);
+
+        $first = reset($results);
+        $deleteid = $first->get('id');
+
+        $engine->delete_by_id($deleteid);
+
+        // Check that we don't get a result for it anymore.
+        $results = $this->search->search($querydata);
+        $this->assertCount(1, $results);
+        $result = reset($results);
+        $this->assertNotEquals($deleteid, $result->get('id'));
+    }
 }
index 6d39ae8..f28c00a 100644 (file)
@@ -38,6 +38,9 @@
     * userfullname
     * description1
     * description2
+    * filename
+    * multiplefiles
+    * filenames
 
     Example context (json):
     {
         "contexturl": "https://example.com/mod/example/view.php?id=2"
         "userurl": "https://example.com/user/profile.php?id=3",
         "userfullname": "Example User Full Name",
+        "multiplefiles": true,
+        "filenames":
+        {
+            "file1.txt",
+            "file2.txt"
+        }
     }
 }}
 <div class="result">
     <h4 class="result-title">
-        <a href="{{{docurl}}}">{{title}}</a>
+        <a href="{{{docurl}}}">{{{title}}}</a>
     </h4>
     {{#content}}
         <div class="result-content">{{{content}}}</div>
     {{#description2}}
         <div class="result-content">{{{description2}}}</div>
     {{/description2}}
+    {{#filename}}
+        <div class="result-content-filename">
+            {{#str}}matchingfile, search, {{filename}}{{/str}}
+        </div>
+    {{/filename}}
+    {{#multiplefiles}}
+        <div class="result-content-filenames">
+            {{#str}}matchingfiles, search{{/str}}<br>
+            <ul class="list">
+            {{#filenames}}
+                <li><span class="filename">{{.}}</span></li>
+            {{/filenames}}
+            </ul>
+        </div>
+    {{/multiplefiles}}
     <div class="result-context-info">
         <a href="{{{contexturl}}}">{{#str}}viewresultincontext, search{{/str}}</a> -
         <a href="{{{courseurl}}}">{{#str}}incourse, search, {{coursefullname}}{{/str}}</a>