]> git.donarmstrong.com Git - roundcube.git/blob - program/include/rcube_spellchecker.php
Imported Upstream version 0.7
[roundcube.git] / program / include / rcube_spellchecker.php
1 <?php
2
3 /*
4  +-----------------------------------------------------------------------+
5  | program/include/rcube_spellchecker.php                                |
6  |                                                                       |
7  | This file is part of the Roundcube Webmail client                     |
8  | Copyright (C) 2011, Kolab Systems AG                                  |
9  | Copyright (C) 2008-2011, The Roundcube Dev Team                       |
10  | Licensed under the GNU GPL                                            |
11  |                                                                       |
12  | PURPOSE:                                                              |
13  |   Spellchecking using different backends                              |
14  |                                                                       |
15  +-----------------------------------------------------------------------+
16  | Author: Aleksander Machniak <machniak@kolabsys.com>                   |
17  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
18  +-----------------------------------------------------------------------+
19
20  $Id: rcube_spellchecker.php 5181 2011-09-06 13:39:45Z alec $
21
22 */
23
24
25 /**
26  * Helper class for spellchecking with Googielspell and PSpell support.
27  *
28  * @package Core
29  */
30 class rcube_spellchecker
31 {
32     private $matches = array();
33     private $engine;
34     private $lang;
35     private $rc;
36     private $error;
37     private $separator = '/[\s\r\n\t\(\)\/\[\]{}<>\\"]+|[:;?!,\.]([^\w]|$)/';
38     private $options = array();
39     private $dict;
40     private $have_dict;
41
42
43     // default settings
44     const GOOGLE_HOST = 'ssl://www.google.com';
45     const GOOGLE_PORT = 443;
46     const MAX_SUGGESTIONS = 10;
47
48
49     /**
50      * Constructor
51      *
52      * @param string $lang Language code
53      */
54     function __construct($lang = 'en')
55     {
56         $this->rc     = rcmail::get_instance();
57         $this->engine = $this->rc->config->get('spellcheck_engine', 'googie');
58         $this->lang   = $lang ? $lang : 'en';
59
60         if ($this->engine == 'pspell' && !extension_loaded('pspell')) {
61             raise_error(array(
62                 'code' => 500, 'type' => 'php',
63                 'file' => __FILE__, 'line' => __LINE__,
64                 'message' => "Pspell extension not available"), true, true);
65         }
66
67         $this->options = array(
68             'ignore_syms' => $this->rc->config->get('spellcheck_ignore_syms'),
69             'ignore_nums' => $this->rc->config->get('spellcheck_ignore_nums'),
70             'ignore_caps' => $this->rc->config->get('spellcheck_ignore_caps'),
71             'dictionary'  => $this->rc->config->get('spellcheck_dictionary'),
72         );
73     }
74
75
76     /**
77      * Set content and check spelling
78      *
79      * @param string $text    Text content for spellchecking
80      * @param bool   $is_html Enables HTML-to-Text conversion
81      *
82      * @return bool True when no mispelling found, otherwise false
83      */
84     function check($text, $is_html = false)
85     {
86         // convert to plain text
87         if ($is_html) {
88             $this->content = $this->html2text($text);
89         }
90         else {
91             $this->content = $text;
92         }
93
94         if ($this->engine == 'pspell') {
95             $this->matches = $this->_pspell_check($this->content);
96         }
97         else {
98             $this->matches = $this->_googie_check($this->content);
99         }
100
101         return $this->found() == 0;
102     }
103
104
105     /**
106      * Number of mispellings found (after check)
107      *
108      * @return int Number of mispellings
109      */
110     function found()
111     {
112         return count($this->matches);
113     }
114
115
116     /**
117      * Returns suggestions for the specified word
118      *
119      * @param string $word The word
120      *
121      * @return array Suggestions list
122      */
123     function get_suggestions($word)
124     {
125         if ($this->engine == 'pspell') {
126             return $this->_pspell_suggestions($word);
127         }
128
129         return $this->_googie_suggestions($word);
130     }
131
132
133     /**
134      * Returns mispelled words
135      *
136      * @param string $text The content for spellchecking. If empty content
137      *                     used for check() method will be used.
138      *
139      * @return array List of mispelled words
140      */
141     function get_words($text = null, $is_html=false)
142     {
143         if ($this->engine == 'pspell') {
144             return $this->_pspell_words($text, $is_html);
145         }
146
147         return $this->_googie_words($text, $is_html);
148     }
149
150
151     /**
152      * Returns checking result in XML (Googiespell) format
153      *
154      * @return string XML content
155      */
156     function get_xml()
157     {
158         // send output
159         $out = '<?xml version="1.0" encoding="'.RCMAIL_CHARSET.'"?><spellresult charschecked="'.mb_strlen($this->content).'">';
160
161         foreach ($this->matches as $item) {
162             $out .= '<c o="'.$item[1].'" l="'.$item[2].'">';
163             $out .= is_array($item[4]) ? implode("\t", $item[4]) : $item[4];
164             $out .= '</c>';
165         }
166
167         $out .= '</spellresult>';
168
169         return $out;
170     }
171
172
173     /**
174      * Returns checking result (mispelled words with suggestions)
175      *
176      * @return array Spellchecking result. An array indexed by word.
177      */
178     function get()
179     {
180         $result = array();
181
182         foreach ($this->matches as $item) {
183             if ($this->engine == 'pspell') {
184                 $word = $item[0];
185             }
186             else {
187                 $word = mb_substr($this->content, $item[1], $item[2], RCMAIL_CHARSET);
188             }
189             $result[$word] = is_array($item[4]) ? implode("\t", $item[4]) : $item[4];
190         }
191
192         return $result;
193     }
194
195
196     /**
197      * Returns error message
198      *
199      * @return string Error message
200      */
201     function error()
202     {
203         return $this->error;
204     }
205
206
207     /**
208      * Checks the text using pspell
209      *
210      * @param string $text Text content for spellchecking
211      */
212     private function _pspell_check($text)
213     {
214         // init spellchecker
215         $this->_pspell_init();
216
217         if (!$this->plink) {
218             return array();
219         }
220
221         // tokenize
222         $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE);
223
224         $diff       = 0;
225         $matches    = array();
226
227         foreach ($text as $w) {
228             $word = trim($w[0]);
229             $pos  = $w[1] - $diff;
230             $len  = mb_strlen($word);
231
232             // skip exceptions
233             if ($this->is_exception($word)) {
234             }
235             else if (!pspell_check($this->plink, $word)) {
236                 $suggestions = pspell_suggest($this->plink, $word);
237
238                     if (sizeof($suggestions) > self::MAX_SUGGESTIONS)
239                         $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS);
240
241                 $matches[] = array($word, $pos, $len, null, $suggestions);
242             }
243
244             $diff += (strlen($word) - $len);
245         }
246
247         return $matches;
248     }
249
250
251     /**
252      * Returns the mispelled words
253      */
254     private function _pspell_words($text = null, $is_html=false)
255     {
256         $result = array();
257
258         if ($text) {
259             // init spellchecker
260             $this->_pspell_init();
261
262             if (!$this->plink) {
263                 return array();
264             }
265
266             // With PSpell we don't need to get suggestions to return mispelled words
267             if ($is_html) {
268                 $text = $this->html2text($text);
269             }
270
271             $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE);
272
273             foreach ($text as $w) {
274                 $word = trim($w[0]);
275
276                 // skip exceptions
277                 if ($this->is_exception($word)) {
278                     continue;
279                 }
280
281                 if (!pspell_check($this->plink, $word)) {
282                     $result[] = $word;
283                 }
284             }
285
286             return $result;
287         }
288
289         foreach ($this->matches as $m) {
290             $result[] = $m[0];
291         }
292
293         return $result;
294     }
295
296
297     /**
298      * Returns suggestions for mispelled word
299      */
300     private function _pspell_suggestions($word)
301     {
302         // init spellchecker
303         $this->_pspell_init();
304
305         if (!$this->plink) {
306             return array();
307         }
308
309         $suggestions = pspell_suggest($this->plink, $word);
310
311         if (sizeof($suggestions) > self::MAX_SUGGESTIONS)
312             $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS);
313
314         return is_array($suggestions) ? $suggestions : array();
315     }
316
317
318     /**
319      * Initializes PSpell dictionary
320      */
321     private function _pspell_init()
322     {
323         if (!$this->plink) {
324             $this->plink = pspell_new($this->lang, null, null, RCMAIL_CHARSET, PSPELL_FAST);
325         }
326
327         if (!$this->plink) {
328             $this->error = "Unable to load Pspell engine for selected language";
329         }
330     }
331
332
333     private function _googie_check($text)
334     {
335         // spell check uri is configured
336         $url = $this->rc->config->get('spellcheck_uri');
337
338         if ($url) {
339             $a_uri = parse_url($url);
340             $ssl   = ($a_uri['scheme'] == 'https' || $a_uri['scheme'] == 'ssl');
341             $port  = $a_uri['port'] ? $a_uri['port'] : ($ssl ? 443 : 80);
342             $host  = ($ssl ? 'ssl://' : '') . $a_uri['host'];
343             $path  = $a_uri['path'] . ($a_uri['query'] ? '?'.$a_uri['query'] : '') . $this->lang;
344         }
345         else {
346             $host = self::GOOGLE_HOST;
347             $port = self::GOOGLE_PORT;
348             $path = '/tbproxy/spell?lang=' . $this->lang;
349         }
350
351         // Google has some problem with spaces, use \n instead
352         $gtext = str_replace(' ', "\n", $text);
353
354         $gtext = '<?xml version="1.0" encoding="utf-8" ?>'
355             .'<spellrequest textalreadyclipped="0" ignoredups="0" ignoredigits="1" ignoreallcaps="1">'
356             .'<text>' . $gtext . '</text>'
357             .'</spellrequest>';
358
359         $store = '';
360         if ($fp = fsockopen($host, $port, $errno, $errstr, 30)) {
361             $out = "POST $path HTTP/1.0\r\n";
362             $out .= "Host: " . str_replace('ssl://', '', $host) . "\r\n";
363             $out .= "Content-Length: " . strlen($gtext) . "\r\n";
364             $out .= "Content-Type: application/x-www-form-urlencoded\r\n";
365             $out .= "Connection: Close\r\n\r\n";
366             $out .= $gtext;
367             fwrite($fp, $out);
368
369             while (!feof($fp))
370                 $store .= fgets($fp, 128);
371             fclose($fp);
372         }
373
374         if (!$store) {
375             $this->error = "Empty result from spelling engine";
376         }
377
378         preg_match_all('/<c o="([^"]*)" l="([^"]*)" s="([^"]*)">([^<]*)<\/c>/', $store, $matches, PREG_SET_ORDER);
379
380         // skip exceptions (if appropriate options are enabled)
381         if (!empty($this->options['ignore_syms']) || !empty($this->options['ignore_nums'])
382             || !empty($this->options['ignore_caps']) || !empty($this->options['dictionary'])
383         ) {
384             foreach ($matches as $idx => $m) {
385                 $word = mb_substr($text, $m[1], $m[2], RCMAIL_CHARSET);
386                 // skip  exceptions
387                 if ($this->is_exception($word)) {
388                     unset($matches[$idx]);
389                 }
390             }
391         }
392
393         return $matches;
394     }
395
396
397     private function _googie_words($text = null, $is_html=false)
398     {
399         if ($text) {
400             if ($is_html) {
401                 $text = $this->html2text($text);
402             }
403
404             $matches = $this->_googie_check($text);
405         }
406         else {
407             $matches = $this->matches;
408             $text    = $this->content;
409         }
410
411         $result = array();
412
413         foreach ($matches as $m) {
414             $result[] = mb_substr($text, $m[1], $m[2], RCMAIL_CHARSET);
415         }
416
417         return $result;
418     }
419
420
421     private function _googie_suggestions($word)
422     {
423         if ($word) {
424             $matches = $this->_googie_check($word);
425         }
426         else {
427             $matches = $this->matches;
428         }
429
430         if ($matches[0][4]) {
431             $suggestions = explode("\t", $matches[0][4]);
432             if (sizeof($suggestions) > self::MAX_SUGGESTIONS) {
433                 $suggestions = array_slice($suggestions, 0, MAX_SUGGESTIONS);
434             }
435
436             return $suggestions;
437         }
438
439         return array();
440     }
441
442
443     private function html2text($text)
444     {
445         $h2t = new html2text($text, false, true, 0);
446         return $h2t->get_text();
447     }
448
449
450     /**
451      * Check if the specified word is an exception accoring to 
452      * spellcheck options.
453      *
454      * @param string  $word  The word
455      *
456      * @return bool True if the word is an exception, False otherwise
457      */
458     public function is_exception($word)
459     {
460         // Contain only symbols (e.g. "+9,0", "2:2")
461         if (!$word || preg_match('/^[0-9@#$%^&_+~*=:;?!,.-]+$/', $word))
462             return true;
463
464         // Contain symbols (e.g. "g@@gle"), all symbols excluding separators
465         if (!empty($this->options['ignore_syms']) && preg_match('/[@#$%^&_+~*=-]/', $word))
466             return true;
467
468         // Contain numbers (e.g. "g00g13")
469         if (!empty($this->options['ignore_nums']) && preg_match('/[0-9]/', $word))
470             return true;
471
472         // Blocked caps (e.g. "GOOGLE")
473         if (!empty($this->options['ignore_caps']) && $word == mb_strtoupper($word))
474             return true;
475
476         // Use exceptions from dictionary
477         if (!empty($this->options['dictionary'])) {
478             $this->load_dict();
479
480             // @TODO: should dictionary be case-insensitive?
481             if (!empty($this->dict) && in_array($word, $this->dict))
482                 return true;
483         }
484
485         return false;
486     }
487
488
489     /**
490      * Add a word to dictionary
491      *
492      * @param string  $word  The word to add
493      */
494     public function add_word($word)
495     {
496         $this->load_dict();
497
498         foreach (explode(' ', $word) as $word) {
499             // sanity check
500             if (strlen($word) < 512) {
501                 $this->dict[] = $word;
502                 $valid = true;
503             }
504         }
505
506         if ($valid) {
507             $this->dict = array_unique($this->dict);
508             $this->update_dict();
509         }
510     }
511
512
513     /**
514      * Remove a word from dictionary
515      *
516      * @param string  $word  The word to remove
517      */
518     public function remove_word($word)
519     {
520         $this->load_dict();
521
522         if (($key = array_search($word, $this->dict)) !== false) {
523             unset($this->dict[$key]);
524             $this->update_dict();
525         }
526     }
527
528
529     /**
530      * Update dictionary row in DB
531      */
532     private function update_dict()
533     {
534         if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
535             $userid = (int) $this->rc->user->ID;
536         }
537
538         $plugin = $this->rc->plugins->exec_hook('spell_dictionary_save', array(
539             'userid' => $userid, 'language' => $this->lang, 'dictionary' => $this->dict));
540
541         if (!empty($plugin['abort'])) {
542             return;
543         }
544
545         if ($this->have_dict) {
546             if (!empty($this->dict)) {
547                 $this->rc->db->query(
548                     "UPDATE ".get_table_name('dictionary')
549                     ." SET data = ?"
550                     ." WHERE user_id " . ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
551                         ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
552                     implode(' ', $plugin['dictionary']), $plugin['language']);
553             }
554             // don't store empty dict
555             else {
556                 $this->rc->db->query(
557                     "DELETE FROM " . get_table_name('dictionary')
558                     ." WHERE user_id " . ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
559                         ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
560                     $plugin['language']);
561             }
562         }
563         else if (!empty($this->dict)) {
564             $this->rc->db->query(
565                 "INSERT INTO " .get_table_name('dictionary')
566                 ." (user_id, " . $this->rc->db->quoteIdentifier('language') . ", data) VALUES (?, ?, ?)",
567                 $plugin['userid'], $plugin['language'], implode(' ', $plugin['dictionary']));
568         }
569     }
570
571
572     /**
573      * Get dictionary from DB
574      */
575     private function load_dict()
576     {
577         if (is_array($this->dict)) {
578             return $this->dict;
579         }
580
581         if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
582             $userid = (int) $this->rc->user->ID;
583         }
584
585         $plugin = $this->rc->plugins->exec_hook('spell_dictionary_get', array(
586             'userid' => $userid, 'language' => $this->lang, 'dictionary' => array()));
587
588         if (empty($plugin['abort'])) {
589             $dict = array();
590             $this->rc->db->query(
591                 "SELECT data FROM ".get_table_name('dictionary')
592                 ." WHERE user_id ". ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
593                     ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
594                 $plugin['language']);
595
596             if ($sql_arr = $this->rc->db->fetch_assoc($sql_result)) {
597                 $this->have_dict = true;
598                 if (!empty($sql_arr['data'])) {
599                     $dict = explode(' ', $sql_arr['data']);
600                 }
601             }
602
603             $plugin['dictionary'] = array_merge((array)$plugin['dictionary'], $dict);
604         }
605
606         if (!empty($plugin['dictionary']) && is_array($plugin['dictionary'])) {
607             $this->dict = $plugin['dictionary'];
608         }
609         else {
610             $this->dict = array();
611         }
612
613         return $this->dict;
614     }
615
616 }