MDL-53515 search: Extend search API to allow file indexing
[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' => 'string',
105             'stored' => true,
106             'indexed' => true
107         ),
108         'content' => array(
109             'type' => 'string',
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' => 'string',
162             'stored' => true,
163             'indexed' => true
164         ),
165         'description2' => array(
166             'type' => 'string',
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      * Returns a timestamp from the value stored in the search engine.
386      *
387      * By default it just returns a timestamp so any search engine could just store integers
388      * and use integers comparison to get documents between x and y timestamps, but search
389      * engines might be interested in using their own field formats. They should do it extending
390      * this class in \search_xxx\document.
391      *
392      * @param string $time
393      * @return int
394      */
395     public static function import_time_from_engine($time) {
396         return $time;
397     }
399     /**
400      * Returns how text is returned from the search engine.
401      *
402      * @return int
403      */
404     protected function get_text_format() {
405         return FORMAT_PLAIN;
406     }
408     /**
409      * Fills the document with data coming from the search engine.
410      *
411      * @throws \core_search\engine_exception
412      * @param array $docdata
413      * @return void
414      */
415     public function set_data_from_engine($docdata) {
416         $fields = static::$requiredfields + static::$optionalfields + static::$enginefields;
417         foreach ($fields as $fieldname => $field) {
419             // Optional params might not be there.
420             if (isset($docdata[$fieldname])) {
421                 if ($field['type'] === 'tdate') {
422                     // Time fields may need a preprocessing.
423                     $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname]));
424                 } else {
425                     // No way we can make this work if there is any multivalue field.
426                     if (is_array($docdata[$fieldname])) {
427                         throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname);
428                     }
429                     $this->set($fieldname, $docdata[$fieldname]);
430                 }
431             }
432         }
433     }
435     /**
436      * Sets the document url.
437      *
438      * @param \moodle_url $url
439      * @return void
440      */
441     public function set_doc_url(\moodle_url $url) {
442         $this->docurl = $url;
443     }
445     /**
446      * Gets the url to the doc.
447      *
448      * @return \moodle_url
449      */
450     public function get_doc_url() {
451         return $this->docurl;
452     }
454     public function set_context_url(\moodle_url $url) {
455         $this->contexturl = $url;
456     }
458     /**
459      * Gets the url to the context.
460      *
461      * @return \moodle_url
462      */
463     public function get_context_url() {
464         return $this->contexturl;
465     }
467     /**
468      * Returns the document ready to submit to the search engine.
469      *
470      * @throws \coding_exception
471      * @return array
472      */
473     public function export_for_engine() {
474         // Set any unset defaults.
475         $this->apply_defaults();
477         // We don't want to affect the document instance.
478         $data = $this->data;
480         // Apply specific engine-dependant formats and restrictions.
481         foreach (static::$requiredfields as $fieldname => $field) {
483             // We also check that we have everything we need.
484             if (!isset($data[$fieldname])) {
485                 throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"');
486             }
488             if ($field['type'] === 'tdate') {
489                 // Overwrite the timestamp with the engine dependant format.
490                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
491             } else if ($field['type'] === 'string') {
492                 // Overwrite the timestamp with the engine dependant format.
493                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
494             }
495         }
497         $fields = static::$optionalfields + static::$enginefields;
498         foreach ($fields as $fieldname => $field) {
499             if (!isset($data[$fieldname])) {
500                 continue;
501             }
502             if ($field['type'] === 'tdate') {
503                 // Overwrite the timestamp with the engine dependant format.
504                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
505             } else if ($field['type'] === 'string') {
506                 // Overwrite the timestamp with the engine dependant format.
507                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
508             }
509         }
511         return $data;
512     }
514     /**
515      * Apply any defaults to unset fields before export. Called after document building, but before export.
516      *
517      * Sub-classes of this should make sure to call parent::apply_defaults().
518      */
519     protected function apply_defaults() {
520         // Set the default type, TYPE_TEXT.
521         if (!isset($this->data['type'])) {
522             $this->data['type'] = manager::TYPE_TEXT;
523         }
524     }
526     /**
527      * Export the document data to be used as a template context.
528      *
529      * Adding more info than the required one as people might be interested in extending the template.
530      *
531      * Although content is a required field when setting up the document, it accepts '' (empty) values
532      * as they may be the result of striping out HTML.
533      *
534      * @param renderer_base $output The renderer.
535      * @return array
536      */
537     public function export_for_template(\renderer_base $output) {
539         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid'));
541         $data = [
542             'courseurl' => new \moodle_url('/course/view.php?id=' . $this->get('courseid')),
543             'coursefullname' => format_string($this->get('coursefullname'), true, array('context' => $this->get('contextid'))),
544             'modified' => userdate($this->get('modified')),
545             'title' => format_string($this->get('title'), true, array('context' => $this->get('contextid'))),
546             'docurl' => $this->get_doc_url(),
547             'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null,
548             'contexturl' => $this->get_context_url(),
549             'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null,
550             'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null,
551         ];
553         // Now take any attached any files.
554         $files = $this->get_files();
555         if (!empty($files)) {
556             if (count($files) > 1) {
557                 $filenames = array();
558                 foreach ($files as $file) {
559                     $filenames[] = $file->get_filename();
560                 }
561                 $data['multiplefiles'] = true;
562                 $data['filenames'] = $filenames;
563             } else {
564                 $file = reset($files);
565                 $data['filename'] = $file->get_filename();
566             }
567         }
569         if ($this->is_set('userid')) {
570             $data['userurl'] = new \moodle_url('/user/view.php', array('id' => $this->get('userid'), 'course' => $this->get('courseid')));
571             $data['userfullname'] = format_string($this->get('userfullname'), true, array('context' => $this->get('contextid')));
572         }
574         return $data;
575     }
577     /**
578      * Formats a text string coming from the search engine.
579      *
580      * By default just return the text as it is:
581      * - Search areas are responsible of sending just plain data, the search engine may
582      *   append HTML or markdown to it (highlighing for example).
583      * - The view is responsible of shortening the text if it is too big
584      *
585      * @param  string $text Text to format
586      * @return string HTML text to be renderer
587      */
588     protected function format_text($text) {
589         return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid')));
590     }