Merge branch 'MDL-53515-master' of git://github.com/merrill-oakland/moodle
[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      * @var bool Should be set to true if document hasn't been indexed before. False if unknown.
73      */
74     protected $isnew = false;
76     /**
77      * @var \stored_file[] An array of stored files to attach to the document.
78      */
79     protected $files = array();
81     /**
82      * All required fields any doc should contain.
83      *
84      * We have to choose a format to specify field types, using solr format as we have to choose one and solr is the
85      * default search engine.
86      *
87      * Search engine plugins are responsible of setting their appropriate field types and map these naming to whatever format
88      * they need.
89      *
90      * @var array
91      */
92     protected static $requiredfields = array(
93         'id' => array(
94             'type' => 'string',
95             'stored' => true,
96             'indexed' => false
97         ),
98         'itemid' => array(
99             'type' => 'int',
100             'stored' => true,
101             'indexed' => true
102         ),
103         'title' => array(
104             'type' => 'text',
105             'stored' => true,
106             'indexed' => true
107         ),
108         'content' => array(
109             'type' => 'text',
110             'stored' => true,
111             'indexed' => true
112         ),
113         'contextid' => array(
114             'type' => 'int',
115             'stored' => true,
116             'indexed' => true
117         ),
118         'areaid' => array(
119             'type' => 'string',
120             'stored' => true,
121             'indexed' => true
122         ),
123         'type' => array(
124             'type' => 'int',
125             'stored' => true,
126             'indexed' => true
127         ),
128         'courseid' => array(
129             'type' => 'int',
130             'stored' => true,
131             'indexed' => false
132         ),
133         'owneruserid' => array(
134             'type' => 'int',
135             'stored' => true,
136             'indexed' => true
137         ),
138         'modified' => array(
139             'type' => 'tdate',
140             'stored' => true,
141             'indexed' => true
142         ),
143     );
145     /**
146      * All optional fields docs can contain.
147      *
148      * Although it matches solr fields format, this is just to define the field types. Search
149      * engine plugins are responsible of setting their appropriate field types and map these
150      * naming to whatever format they need.
151      *
152      * @var array
153      */
154     protected static $optionalfields = array(
155         'userid' => array(
156             'type' => 'int',
157             'stored' => true,
158             'indexed' => false
159         ),
160         'description1' => array(
161             'type' => 'text',
162             'stored' => true,
163             'indexed' => true
164         ),
165         'description2' => array(
166             'type' => 'text',
167             'stored' => true,
168             'indexed' => true
169         )
170     );
172     /**
173      * Any fields that are engine specifc. These are fields that are solely used by a search engine plugin
174      * for internal purposes.
175      *
176      * Field names should be prefixed with engine name to avoid potential conflict with core fields.
177      *
178      * Uses same format as fields above.
179      *
180      * @var array
181      */
182     protected static $enginefields = array();
184     /**
185      * We ensure that the document has a unique id across search areas.
186      *
187      * @param int $itemid An id unique to the search area
188      * @param string $componentname The search area component Frankenstyle name
189      * @param string $areaname The area name (the search area class name)
190      * @return void
191      */
192     public function __construct($itemid, $componentname, $areaname) {
194         if (!is_numeric($itemid)) {
195             throw new \coding_exception('The itemid should be an integer');
196         }
198         $this->data['areaid'] = \core_search\manager::generate_areaid($componentname, $areaname);
199         $this->data['id'] = $this->data['areaid'] . '-' . $itemid;
200         $this->data['itemid'] = intval($itemid);
201     }
203     /**
204      * Add a stored file to the document.
205      *
206      * @param \stored_file|int $file The file to add, or file id.
207      * @return void
208      */
209     public function add_stored_file($file) {
210         if (is_numeric($file)) {
211             $this->files[$file] = $file;
212         } else {
213             $this->files[$file->get_id()] = $file;
214         }
215     }
217     /**
218      * Returns the array of attached files.
219      *
220      * @return \stored_file[]
221      */
222     public function get_files() {
223         // The files array can contain stored file ids, so we need to get instances if asked.
224         foreach ($this->files as $id => $listfile) {
225             if (is_numeric($listfile)) {
226                 $fs = get_file_storage();
228                 if ($file = $fs->get_file_by_id($id)) {
229                     $this->files[$id] = $file;
230                 }
231             }
232         }
234         return $this->files;
235     }
237     /**
238      * Setter.
239      *
240      * Basic checkings to prevent common issues.
241      *
242      * If the field is a string tags will be stripped, if it is an integer or a date it
243      * will be casted to a PHP integer. tdate fields values are expected to be timestamps.
244      *
245      * @throws \coding_exception
246      * @param string $fieldname The field name
247      * @param string|int $value The value to store
248      * @return string|int The stored value
249      */
250     public function set($fieldname, $value) {
252         if (!empty(static::$requiredfields[$fieldname])) {
253             $fielddata = static::$requiredfields[$fieldname];
254         } else if (!empty(static::$optionalfields[$fieldname])) {
255             $fielddata = static::$optionalfields[$fieldname];
256         } else if (!empty(static::$enginefields[$fieldname])) {
257             $fielddata = static::$enginefields[$fieldname];
258         }
260         if (empty($fielddata)) {
261             throw new \coding_exception('"' . $fieldname . '" field does not exist.');
262         }
264         // tdate fields should be set as timestamps, later they might be converted to
265         // a date format, it depends on the search engine.
266         if (($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') && !is_numeric($value)) {
267             throw new \coding_exception('"' . $fieldname . '" value should be an integer and its value is "' . $value . '"');
268         }
270         // We want to be strict here, there might be engines that expect us to
271         // provide them data with the proper type already set.
272         if ($fielddata['type'] === 'int' || $fielddata['type'] === 'tdate') {
273             $this->data[$fieldname] = intval($value);
274         } else {
275             // Clean up line breaks and extra spaces.
276             $this->data[$fieldname] = preg_replace("/\s+/", ' ', trim($value, "\r\n"));
277         }
279         return $this->data[$fieldname];
280     }
282     /**
283      * Sets data to this->extradata
284      *
285      * This data can be retrieved using \core_search\document->get($fieldname).
286      *
287      * @param string $fieldname
288      * @param string $value
289      * @return void
290      */
291     public function set_extra($fieldname, $value) {
292         $this->extradata[$fieldname] = $value;
293     }
295     /**
296      * Getter.
297      *
298      * Use self::is_set if you are not sure if this field is set or not
299      * as otherwise it will trigger a \coding_exception
300      *
301      * @throws \coding_exception
302      * @param string $field
303      * @return string|int
304      */
305     public function get($field) {
307         if (isset($this->data[$field])) {
308             return $this->data[$field];
309         }
311         // Fallback to extra data.
312         if (isset($this->extradata[$field])) {
313             return $this->extradata[$field];
314         }
316         throw new \coding_exception('Field "' . $field . '" is not set in the document');
317     }
319     /**
320      * Checks if a field is set.
321      *
322      * @param string $field
323      * @return bool
324      */
325     public function is_set($field) {
326         return (isset($this->data[$field]) || isset($this->extradata[$field]));
327     }
329     /**
330      * Set if this is a new document. False if unknown.
331      *
332      * @param bool $new
333      */
334     public function set_is_new($new) {
335        $this->isnew = (bool)$new;
336     }
338     /**
339      * Returns if the document is new. False if unknown.
340      *
341      * @return bool
342      */
343     public function get_is_new() {
344        return $this->isnew;
345     }
347     /**
348      * Returns all default fields definitions.
349      *
350      * @return array
351      */
352     public static function get_default_fields_definition() {
353         return static::$requiredfields + static::$optionalfields + static::$enginefields;
354     }
356     /**
357      * Formats the timestamp preparing the time fields to be inserted into the search engine.
358      *
359      * By default it just returns a timestamp so any search engine could just store integers
360      * and use integers comparison to get documents between x and y timestamps, but search
361      * engines might be interested in using their own field formats. They can do it extending
362      * this class in \search_xxx\document.
363      *
364      * @param int $timestamp
365      * @return string
366      */
367     public static function format_time_for_engine($timestamp) {
368         return $timestamp;
369     }
371     /**
372      * Formats a string value for the search engine.
373      *
374      * Search engines may overwrite this method to apply restrictions, like limiting the size.
375      * The default behaviour is just returning the string.
376      *
377      * @param string $string
378      * @return string
379      */
380     public static function format_string_for_engine($string) {
381         return $string;
382     }
384     /**
385      * Formats a text value for the search engine.
386      *
387      * Search engines may overwrite this method to apply restrictions, like limiting the size.
388      * The default behaviour is just returning the string.
389      *
390      * @param string $text
391      * @return string
392      */
393     public static function format_text_for_engine($text) {
394         return $text;
395     }
397     /**
398      * Returns a timestamp from the value stored in the search engine.
399      *
400      * By default it just returns a timestamp so any search engine could just store integers
401      * and use integers comparison to get documents between x and y timestamps, but search
402      * engines might be interested in using their own field formats. They should do it extending
403      * this class in \search_xxx\document.
404      *
405      * @param string $time
406      * @return int
407      */
408     public static function import_time_from_engine($time) {
409         return $time;
410     }
412     /**
413      * Returns how text is returned from the search engine.
414      *
415      * @return int
416      */
417     protected function get_text_format() {
418         return FORMAT_PLAIN;
419     }
421     /**
422      * Fills the document with data coming from the search engine.
423      *
424      * @throws \core_search\engine_exception
425      * @param array $docdata
426      * @return void
427      */
428     public function set_data_from_engine($docdata) {
429         $fields = static::$requiredfields + static::$optionalfields + static::$enginefields;
430         foreach ($fields as $fieldname => $field) {
432             // Optional params might not be there.
433             if (isset($docdata[$fieldname])) {
434                 if ($field['type'] === 'tdate') {
435                     // Time fields may need a preprocessing.
436                     $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname]));
437                 } else {
438                     // No way we can make this work if there is any multivalue field.
439                     if (is_array($docdata[$fieldname])) {
440                         throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname);
441                     }
442                     $this->set($fieldname, $docdata[$fieldname]);
443                 }
444             }
445         }
446     }
448     /**
449      * Sets the document url.
450      *
451      * @param \moodle_url $url
452      * @return void
453      */
454     public function set_doc_url(\moodle_url $url) {
455         $this->docurl = $url;
456     }
458     /**
459      * Gets the url to the doc.
460      *
461      * @return \moodle_url
462      */
463     public function get_doc_url() {
464         return $this->docurl;
465     }
467     public function set_context_url(\moodle_url $url) {
468         $this->contexturl = $url;
469     }
471     /**
472      * Gets the url to the context.
473      *
474      * @return \moodle_url
475      */
476     public function get_context_url() {
477         return $this->contexturl;
478     }
480     /**
481      * Returns the document ready to submit to the search engine.
482      *
483      * @throws \coding_exception
484      * @return array
485      */
486     public function export_for_engine() {
487         // Set any unset defaults.
488         $this->apply_defaults();
490         // We don't want to affect the document instance.
491         $data = $this->data;
493         // Apply specific engine-dependant formats and restrictions.
494         foreach (static::$requiredfields as $fieldname => $field) {
496             // We also check that we have everything we need.
497             if (!isset($data[$fieldname])) {
498                 throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"');
499             }
501             if ($field['type'] === 'tdate') {
502                 // Overwrite the timestamp with the engine dependant format.
503                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
504             } else if ($field['type'] === 'string') {
505                 // Overwrite the string with the engine dependant format.
506                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
507             } else if ($field['type'] === 'text') {
508                 // Overwrite the text with the engine dependant format.
509                 $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
510             }
512         }
514         $fields = static::$optionalfields + static::$enginefields;
515         foreach ($fields as $fieldname => $field) {
516             if (!isset($data[$fieldname])) {
517                 continue;
518             }
519             if ($field['type'] === 'tdate') {
520                 // Overwrite the timestamp with the engine dependant format.
521                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
522             } else if ($field['type'] === 'string') {
523                 // Overwrite the string with the engine dependant format.
524                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
525             } else if ($field['type'] === 'text') {
526                 // Overwrite the text with the engine dependant format.
527                 $data[$fieldname] = static::format_text_for_engine($data[$fieldname]);
528             }
529         }
531         return $data;
532     }
534     /**
535      * Apply any defaults to unset fields before export. Called after document building, but before export.
536      *
537      * Sub-classes of this should make sure to call parent::apply_defaults().
538      */
539     protected function apply_defaults() {
540         // Set the default type, TYPE_TEXT.
541         if (!isset($this->data['type'])) {
542             $this->data['type'] = manager::TYPE_TEXT;
543         }
544     }
546     /**
547      * Export the document data to be used as a template context.
548      *
549      * Adding more info than the required one as people might be interested in extending the template.
550      *
551      * Although content is a required field when setting up the document, it accepts '' (empty) values
552      * as they may be the result of striping out HTML.
553      *
554      * @param renderer_base $output The renderer.
555      * @return array
556      */
557     public function export_for_template(\renderer_base $output) {
558         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid'));
560         $title = $this->is_set('title') ? $this->format_text($this->get('title')) : '';
561         $data = [
562             'courseurl' => new \moodle_url('/course/view.php?id=' . $this->get('courseid')),
563             'coursefullname' => format_string($this->get('coursefullname'), true, array('context' => $this->get('contextid'))),
564             'modified' => userdate($this->get('modified')),
565             'title' => ($title !== '') ? $title : get_string('notitle', 'search'),
566             'docurl' => $this->get_doc_url(),
567             'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null,
568             'contexturl' => $this->get_context_url(),
569             'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null,
570             'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null,
571         ];
573         // Now take any attached any files.
574         $files = $this->get_files();
575         if (!empty($files)) {
576             if (count($files) > 1) {
577                 $filenames = array();
578                 foreach ($files as $file) {
579                     $filenames[] = $file->get_filename();
580                 }
581                 $data['multiplefiles'] = true;
582                 $data['filenames'] = $filenames;
583             } else {
584                 $file = reset($files);
585                 $data['filename'] = $file->get_filename();
586             }
587         }
589         if ($this->is_set('userid')) {
590             $data['userurl'] = new \moodle_url('/user/view.php', array('id' => $this->get('userid'), 'course' => $this->get('courseid')));
591             $data['userfullname'] = format_string($this->get('userfullname'), true, array('context' => $this->get('contextid')));
592         }
594         return $data;
595     }
597     /**
598      * Formats a text string coming from the search engine.
599      *
600      * By default just return the text as it is:
601      * - Search areas are responsible of sending just plain data, the search engine may
602      *   append HTML or markdown to it (highlighing for example).
603      * - The view is responsible of shortening the text if it is too big
604      *
605      * @param  string $text Text to format
606      * @return string HTML text to be renderer
607      */
608     protected function format_text($text) {
609         return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid')));
610     }