MDL-53508 search: Improve highlighting and move to text fields
[moodle.git] / search / classes / document.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Document representation.
19  *
20  * @package    core_search
21  * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_search;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Represents a document to index.
31  *
32  * Note that, if you are writting a search engine and you want to change \core_search\document
33  * behaviour, you can overwrite this class, will be automatically loaded from \search_YOURENGINE\document.
34  *
35  * @package    core_search
36  * @copyright  2015 David Monllao {@link http://www.davidmonllao.com}
37  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class document implements \renderable, \templatable {
41     /**
42      * @var array $data The document data.
43      */
44     protected $data = array();
46     /**
47      * @var array Extra data needed to render the document.
48      */
49     protected $extradata = array();
51     /**
52      * @var \moodle_url Link to the document.
53      */
54     protected $docurl = null;
56     /**
57      * @var \moodle_url Link to the document context.
58      */
59     protected $contexturl = null;
61     /**
62      * @var int|null The content field filearea.
63      */
64     protected $contentfilearea = null;
66     /**
67      * @var int|null The content field itemid.
68      */
69     protected $contentitemid = null;
71     /**
72      * All required fields any doc should contain.
73      *
74      * We have to choose a format to specify field types, using solr format as we have to choose one and solr is the
75      * default search engine.
76      *
77      * Search engine plugins are responsible of setting their appropriate field types and map these naming to whatever format
78      * they need.
79      *
80      * @var array
81      */
82     protected static $requiredfields = array(
83         'id' => array(
84             'type' => 'string',
85             'stored' => true,
86             'indexed' => false
87         ),
88         'itemid' => array(
89             'type' => 'int',
90             'stored' => true,
91             'indexed' => true
92         ),
93         'title' => array(
94             'type' => 'text',
95             'stored' => true,
96             'indexed' => true
97         ),
98         'content' => array(
99             'type' => 'text',
100             'stored' => true,
101             'indexed' => true
102         ),
103         'contextid' => array(
104             'type' => 'int',
105             'stored' => true,
106             'indexed' => true
107         ),
108         'areaid' => array(
109             'type' => 'string',
110             'stored' => true,
111             'indexed' => true
112         ),
113         'type' => array(
114             'type' => 'int',
115             'stored' => true,
116             'indexed' => true
117         ),
118         'courseid' => array(
119             'type' => 'int',
120             'stored' => true,
121             'indexed' => false
122         ),
123         'owneruserid' => array(
124             'type' => 'int',
125             'stored' => true,
126             'indexed' => true
127         ),
128         'modified' => array(
129             'type' => 'tdate',
130             'stored' => true,
131             'indexed' => true
132         ),
133     );
135     /**
136      * All optional fields docs can contain.
137      *
138      * Although it matches solr fields format, this is just to define the field types. Search
139      * engine plugins are responsible of setting their appropriate field types and map these
140      * naming to whatever format they need.
141      *
142      * @var array
143      */
144     protected static $optionalfields = array(
145         'userid' => array(
146             'type' => 'int',
147             'stored' => true,
148             'indexed' => false
149         ),
150         'description1' => array(
151             'type' => 'text',
152             'stored' => true,
153             'indexed' => true
154         ),
155         'description2' => array(
156             'type' => 'text',
157             'stored' => true,
158             'indexed' => true
159         ),
160     );
162     /**
163      * We ensure that the document has a unique id across search areas.
164      *
165      * @param int $itemid An id unique to the search area
166      * @param string $componentname The search area component Frankenstyle name
167      * @param string $areaname The area name (the search area class name)
168      * @return void
169      */
170     public function __construct($itemid, $componentname, $areaname) {
172         if (!is_numeric($itemid)) {
173             throw new \coding_exception('The itemid should be an integer');
174         }
176         $this->data['areaid'] = \core_search\manager::generate_areaid($componentname, $areaname);
177         $this->data['id'] = $this->data['areaid'] . '-' . $itemid;
178         $this->data['itemid'] = intval($itemid);
179     }
181     /**
182      * Setter.
183      *
184      * Basic checkings to prevent common issues.
185      *
186      * If the field is a string tags will be stripped, if it is an integer or a date it
187      * will be casted to a PHP integer. tdate fields values are expected to be timestamps.
188      *
189      * @throws \coding_exception
190      * @param string $fieldname The field name
191      * @param string|int $value The value to store
192      * @return string|int The stored value
193      */
194     public function set($fieldname, $value) {
196         if (!empty(static::$requiredfields[$fieldname])) {
197             $fielddata = static::$requiredfields[$fieldname];
198         } else if (!empty(static::$optionalfields[$fieldname])) {
199             $fielddata = static::$optionalfields[$fieldname];
200         }
202         if (empty($fielddata)) {
203             throw new \coding_exception('"' . $fieldname . '" field does not exist.');
204         }
206         // tdate fields should be set as timestamps, later they might be converted to
207         // a date format, it depends on the search engine.
208         if (($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') && !is_numeric($value)) {
209             throw new \coding_exception('"' . $fieldname . '" value should be an integer and its value is "' . $value . '"');
210         }
212         // We want to be strict here, there might be engines that expect us to
213         // provide them data with the proper type already set.
214         if ($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') {
215             $this->data[$fieldname] = intval($value);
216         } else {
217             // Clean up line breaks and extra spaces.
218             $this->data[$fieldname] = preg_replace("/\s+/", ' ', trim($value, "\r\n"));
219         }
221         return $this->data[$fieldname];
222     }
224     /**
225      * Sets data to this->extradata
226      *
227      * This data can be retrieved using \core_search\document->get($fieldname).
228      *
229      * @param string $fieldname
230      * @param string $value
231      * @return void
232      */
233     public function set_extra($fieldname, $value) {
234         $this->extradata[$fieldname] = $value;
235     }
237     /**
238      * Getter.
239      *
240      * Use self::is_set if you are not sure if this field is set or not
241      * as otherwise it will trigger a \coding_exception
242      *
243      * @throws \coding_exception
244      * @param string $field
245      * @return string|int
246      */
247     public function get($field) {
249         if (isset($this->data[$field])) {
250             return $this->data[$field];
251         }
253         // Fallback to extra data.
254         if (isset($this->extradata[$field])) {
255             return $this->extradata[$field];
256         }
258         throw new \coding_exception('Field "' . $field . '" is not set in the document');
259     }
261     /**
262      * Checks if a field is set.
263      *
264      * @param string $field
265      * @return bool
266      */
267     public function is_set($field) {
268         return (isset($this->data[$field]) || isset($this->extradata[$field]));
269     }
271     /**
272      * Returns all default fields definitions.
273      *
274      * @return array
275      */
276     public static function get_default_fields_definition() {
277         return static::$requiredfields + static::$optionalfields;
278     }
280     /**
281      * Formats the timestamp preparing the time fields to be inserted into the search engine.
282      *
283      * By default it just returns a timestamp so any search engine could just store integers
284      * and use integers comparison to get documents between x and y timestamps, but search
285      * engines might be interested in using their own field formats. They can do it extending
286      * this class in \search_xxx\document.
287      *
288      * @param int $timestamp
289      * @return string
290      */
291     public static function format_time_for_engine($timestamp) {
292         return $timestamp;
293     }
295     /**
296      * Formats a string value for the search engine.
297      *
298      * Search engines may overwrite this method to apply restrictions, like limiting the size.
299      * The default behaviour is just returning the string.
300      *
301      * @param string $string
302      * @return string
303      */
304     public static function format_string_for_engine($string) {
305         return $string;
306     }
308     /**
309      * Formats a text value for the search engine.
310      *
311      * Search engines may overwrite this method to apply restrictions, like limiting the size.
312      * The default behaviour is just returning the string.
313      *
314      * @param string $text
315      * @return string
316      */
317     public static function format_text_for_engine($text) {
318         return $text;
319     }
321     /**
322      * Returns a timestamp from the value stored in the search engine.
323      *
324      * By default it just returns a timestamp so any search engine could just store integers
325      * and use integers comparison to get documents between x and y timestamps, but search
326      * engines might be interested in using their own field formats. They should do it extending
327      * this class in \search_xxx\document.
328      *
329      * @param string $time
330      * @return int
331      */
332     public static function import_time_from_engine($time) {
333         return $time;
334     }
336     /**
337      * Returns how text is returned from the search engine.
338      *
339      * @return int
340      */
341     protected function get_text_format() {
342         return FORMAT_PLAIN;
343     }
345     /**
346      * Fills the document with data coming from the search engine.
347      *
348      * @throws \core_search\engine_exception
349      * @param array $docdata
350      * @return void
351      */
352     public function set_data_from_engine($docdata) {
353         $fields = static::$requiredfields + static::$optionalfields;
354         foreach ($fields as $fieldname => $field) {
356             // Optional params might not be there.
357             if (isset($docdata[$fieldname])) {
358                 if ($field['type'] === 'tdate') {
359                     // Time fields may need a preprocessing.
360                     $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname]));
361                 } else {
362                     // No way we can make this work if there is any multivalue field.
363                     if (is_array($docdata[$fieldname])) {
364                         throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname);
365                     }
366                     $this->set($fieldname, $docdata[$fieldname]);
367                 }
368             }
369         }
370     }
372     /**
373      * Sets the document url.
374      *
375      * @param \moodle_url $url
376      * @return void
377      */
378     public function set_doc_url(\moodle_url $url) {
379         $this->docurl = $url;
380     }
382     /**
383      * Gets the url to the doc.
384      *
385      * @return \moodle_url
386      */
387     public function get_doc_url() {
388         return $this->docurl;
389     }
391     public function set_context_url(\moodle_url $url) {
392         $this->contexturl = $url;
393     }
395     /**
396      * Gets the url to the context.
397      *
398      * @return \moodle_url
399      */
400     public function get_context_url() {
401         return $this->contexturl;
402     }
404     /**
405      * Returns the document ready to submit to the search engine.
406      *
407      * @throws \coding_exception
408      * @return array
409      */
410     public function export_for_engine() {
412         // We don't want to affect the document instance.
413         $data = $this->data;
415         // Apply specific engine-dependant formats and restrictions.
416         foreach (static::$requiredfields as $fieldname => $field) {
418             // We also check that we have everything we need.
419             if (!isset($data[$fieldname])) {
420                 throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"');
421             }
423             if ($field['type'] === 'tdate') {
424                 // Overwrite the timestamp with the engine dependant format.
425                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
426             } else if ($field['type'] === 'string') {
427                 // Overwrite the string with the engine dependant format.
428                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
429             } else if ($field['type'] === 'text') {
430                 // Overwrite the text with the engine dependant format.
431                 $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
432             }
434         }
436         foreach (static::$optionalfields as $fieldname => $field) {
437             if (!isset($data[$fieldname])) {
438                 continue;
439             }
440             if ($field['type'] === 'tdate') {
441                 // Overwrite the timestamp with the engine dependant format.
442                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
443             } else if ($field['type'] === 'string') {
444                 // Overwrite the string with the engine dependant format.
445                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
446             } else if ($field['type'] === 'text') {
447                 // Overwrite the text with the engine dependant format.
448                 $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
449             }
450         }
452         return $data;
453     }
455     /**
456      * Export the document data to be used as a template context.
457      *
458      * Adding more info than the required one as people might be interested in extending the template.
459      *
460      * Although content is a required field when setting up the document, it accepts '' (empty) values
461      * as they may be the result of striping out HTML.
462      *
463      * @param renderer_base $output The renderer.
464      * @return array
465      */
466     public function export_for_template(\renderer_base $output) {
467         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid'));
469         $title = $this->is_set('title') ? $this->format_text($this->get('title')) : '';
470         $data = [
471             'courseurl' => new \moodle_url('/course/view.php?id=' . $this->get('courseid')),
472             'coursefullname' => format_string($this->get('coursefullname'), true, array('context' => $this->get('contextid'))),
473             'modified' => userdate($this->get('modified')),
474             'title' => ($title !== '') ? $title : get_string('notitle', 'search'),
475             'docurl' => $this->get_doc_url(),
476             'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null,
477             'contexturl' => $this->get_context_url(),
478             'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null,
479             'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null,
480         ];
482         if ($this->is_set('userid')) {
483             $data['userurl'] = new \moodle_url('/user/view.php', array('id' => $this->get('userid'), 'course' => $this->get('courseid')));
484             $data['userfullname'] = format_string($this->get('userfullname'), true, array('context' => $this->get('contextid')));
485         }
487         return $data;
488     }
490     /**
491      * Formats a text string coming from the search engine.
492      *
493      * By default just return the text as it is:
494      * - Search areas are responsible of sending just plain data, the search engine may
495      *   append HTML or markdown to it (highlighing for example).
496      * - The view is responsible of shortening the text if it is too big
497      *
498      * @param  string $text Text to format
499      * @return string HTML text to be renderer
500      */
501     protected function format_text($text) {
502         return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid')));
503     }