9d47ab9f8b286705746118bd48ae47b78bec9708
[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' => 'string',
95             'stored' => true,
96             'indexed' => true
97         ),
98         'content' => array(
99             'type' => 'string',
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' => 'string',
152             'stored' => true,
153             'indexed' => true
154         ),
155         'description2' => array(
156             'type' => 'string',
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      * Returns a timestamp from the value stored in the search engine.
310      *
311      * By default it just returns a timestamp so any search engine could just store integers
312      * and use integers comparison to get documents between x and y timestamps, but search
313      * engines might be interested in using their own field formats. They should do it extending
314      * this class in \search_xxx\document.
315      *
316      * @param string $time
317      * @return int
318      */
319     public static function import_time_from_engine($time) {
320         return $time;
321     }
323     /**
324      * Returns how text is returned from the search engine.
325      *
326      * @return int
327      */
328     protected function get_text_format() {
329         return FORMAT_PLAIN;
330     }
332     /**
333      * Fills the document with data coming from the search engine.
334      *
335      * @throws \core_search\engine_exception
336      * @param array $docdata
337      * @return void
338      */
339     public function set_data_from_engine($docdata) {
340         $fields = static::$requiredfields + static::$optionalfields;
341         foreach ($fields as $fieldname => $field) {
343             // Optional params might not be there.
344             if (isset($docdata[$fieldname])) {
345                 if ($field['type'] === 'tdate') {
346                     // Time fields may need a preprocessing.
347                     $this->set($fieldname, static::import_time_from_engine($docdata[$fieldname]));
348                 } else {
349                     // No way we can make this work if there is any multivalue field.
350                     if (is_array($docdata[$fieldname])) {
351                         throw new \core_search\engine_exception('multivaluedfield', 'search_solr', '', $fieldname);
352                     }
353                     $this->set($fieldname, $docdata[$fieldname]);
354                 }
355             }
356         }
357     }
359     /**
360      * Sets the document url.
361      *
362      * @param \moodle_url $url
363      * @return void
364      */
365     public function set_doc_url(\moodle_url $url) {
366         $this->docurl = $url;
367     }
369     /**
370      * Gets the url to the doc.
371      *
372      * @return \moodle_url
373      */
374     public function get_doc_url() {
375         return $this->docurl;
376     }
378     public function set_context_url(\moodle_url $url) {
379         $this->contexturl = $url;
380     }
382     /**
383      * Gets the url to the context.
384      *
385      * @return \moodle_url
386      */
387     public function get_context_url() {
388         return $this->contexturl;
389     }
391     /**
392      * Returns the document ready to submit to the search engine.
393      *
394      * @throws \coding_exception
395      * @return array
396      */
397     public function export_for_engine() {
399         // We don't want to affect the document instance.
400         $data = $this->data;
402         // Apply specific engine-dependant formats and restrictions.
403         foreach (static::$requiredfields as $fieldname => $field) {
405             // We also check that we have everything we need.
406             if (!isset($data[$fieldname])) {
407                 throw new \coding_exception('Missing "' . $fieldname . '" field in document with id "' . $this->data['id'] . '"');
408             }
410             if ($field['type'] === 'tdate') {
411                 // Overwrite the timestamp with the engine dependant format.
412                 $data[$fieldname] = static::format_time_for_engine($data[$fieldname]);
413             } else if ($field['type'] === 'string') {
414                 // Overwrite the timestamp with the engine dependant format.
415                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
416             }
417         }
419         foreach (static::$optionalfields as $fieldname => $field) {
420             if (!isset($data[$fieldname])) {
421                 continue;
422             }
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 timestamp with the engine dependant format.
428                 $data[$fieldname] = static::format_string_for_engine($data[$fieldname]);
429             }
430         }
432         return $data;
433     }
435     /**
436      * Export the document data to be used as a template context.
437      *
438      * Adding more info than the required one as people might be interested in extending the template.
439      *
440      * Although content is a required field when setting up the document, it accepts '' (empty) values
441      * as they may be the result of striping out HTML.
442      *
443      * @param renderer_base $output The renderer.
444      * @return array
445      */
446     public function export_for_template(\renderer_base $output) {
448         list($componentname, $areaname) = \core_search\manager::extract_areaid_parts($this->get('areaid'));
450         $data = [
451             'courseurl' => new \moodle_url('/course/view.php?id=' . $this->get('courseid')),
452             'coursefullname' => format_string($this->get('coursefullname'), true, array('context' => $this->get('contextid'))),
453             'modified' => userdate($this->get('modified')),
454             'title' => format_string($this->get('title'), true, array('context' => $this->get('contextid'))),
455             'docurl' => $this->get_doc_url(),
456             'content' => $this->is_set('content') ? $this->format_text($this->get('content')) : null,
457             'contexturl' => $this->get_context_url(),
458             'description1' => $this->is_set('description1') ? $this->format_text($this->get('description1')) : null,
459             'description2' => $this->is_set('description2') ? $this->format_text($this->get('description2')) : null,
460         ];
462         if ($this->is_set('userid')) {
463             $data['userurl'] = new \moodle_url('/user/view.php', array('id' => $this->get('userid'), 'course' => $this->get('courseid')));
464             $data['userfullname'] = format_string($this->get('userfullname'), true, array('context' => $this->get('contextid')));
465         }
467         return $data;
468     }
470     /**
471      * Formats a text string coming from the search engine.
472      *
473      * By default just return the text as it is:
474      * - Search areas are responsible of sending just plain data, the search engine may
475      *   append HTML or markdown to it (highlighing for example).
476      * - The view is responsible of shortening the text if it is too big
477      *
478      * @param  string $text Text to format
479      * @return string HTML text to be renderer
480      */
481     protected function format_text($text) {
482         return format_text($text, $this->get_text_format(), array('context' => $this->get('contextid')));
483     }