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