From 66df084203a217ab74a416064c459cc3420a648c Mon Sep 17 00:00:00 2001
From: alecpl <alec@alec.pl>
Date: Tue, 06 Sep 2011 09:39:45 -0400
Subject: [PATCH] - Merge devel-spellcheck branch:   - Added spellchecker exceptions dictionary (shared or per-user)   - Added possibility to ignore words containing caps, numbers, symbols (spellcheck_ignore_* options)

---
 CHANGELOG                              |    3 
 program/include/main.inc               |   13 
 program/steps/mail/compose.inc         |   11 
 program/js/editor.js                   |   13 
 program/steps/utils/spell_html.inc     |    4 
 SQL/mysql.update.sql                   |   11 
 config/main.inc.php.dist               |   13 +
 program/include/rcube_spellchecker.php |  238 ++++++++++++++++++-
 SQL/sqlite.initial.sql                 |   15 +
 program/steps/utils/spell.inc          |   13 
 program/steps/settings/save_prefs.inc  |    5 
 SQL/sqlite.update.sql                  |   10 
 program/steps/settings/func.inc        |   21 +
 program/localization/en_US/labels.inc  |    5 
 SQL/postgres.update.sql                |   10 
 program/js/googiespell.js              |  245 ++++++++++++++------
 SQL/postgres.initial.sql               |   13 +
 SQL/mysql.initial.sql                  |   11 
 SQL/mssql.initial.sql                  |   10 
 SQL/mssql.upgrade.sql                  |   11 
 20 files changed, 563 insertions(+), 112 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG
index 3c63d42..ecc67ce 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,9 @@
 CHANGELOG Roundcube Webmail
 ===========================
 
+- Added spellchecker exceptions dictionary (shared or per-user)
+- Added possibility to ignore words containing caps, numbers, symbols (spellcheck_ignore_* options)
+
 RELEASE 0.6-rc
 ----------------
 - Send X-Frame-Options headers to protect from clickjacking (#1487037)
diff --git a/SQL/mssql.initial.sql b/SQL/mssql.initial.sql
index 4aa6fc9..321da7c 100644
--- a/SQL/mssql.initial.sql
+++ b/SQL/mssql.initial.sql
@@ -93,6 +93,13 @@
 ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
 GO
 
+CREATE TABLE [dbo].[dictionary] (
+	[user_id] [int] ,
+	[language] [varchar] (5) COLLATE Latin1_General_CI_AI NOT NULL ,
+	[data] [text] COLLATE Latin1_General_CI_AI NOT NULL 
+) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
+GO
+
 ALTER TABLE [dbo].[cache] WITH NOCHECK ADD 
 	 PRIMARY KEY  CLUSTERED 
 	(
@@ -264,6 +271,9 @@
 CREATE  INDEX [IX_users_alias] ON [dbo].[users]([alias]) ON [PRIMARY]
 GO
 
+CREATE  UNIQUE INDEX [IX_dictionary_user_language] ON [dbo].[dictionary]([user_id],[language]) ON [PRIMARY]
+GO
+
 ALTER TABLE [dbo].[identities] ADD CONSTRAINT [FK_identities_user_id] 
     FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id])
     ON DELETE CASCADE ON UPDATE CASCADE
diff --git a/SQL/mssql.upgrade.sql b/SQL/mssql.upgrade.sql
index 606db60..a77362a 100644
--- a/SQL/mssql.upgrade.sql
+++ b/SQL/mssql.upgrade.sql
@@ -110,3 +110,14 @@
 GO
 DELETE FROM [dbo].[cache]
 GO
+
+-- Updates from version 0.6-stable
+
+CREATE TABLE [dbo].[dictionary] (
+    [user_id] [int] ,
+    [language] [varchar] (5) COLLATE Latin1_General_CI_AI NOT NULL ,
+    [data] [text] COLLATE Latin1_General_CI_AI NOT NULL 
+) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
+GO
+CREATE  UNIQUE INDEX [IX_dictionary_user_language] ON [dbo].[dictionary]([user_id],[language]) ON [PRIMARY]
+GO
diff --git a/SQL/mysql.initial.sql b/SQL/mysql.initial.sql
index 14bbb96..4cabb81 100644
--- a/SQL/mysql.initial.sql
+++ b/SQL/mysql.initial.sql
@@ -144,4 +144,15 @@
 ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */;
 
 
+-- Table structure for table `dictionary`
+
+CREATE TABLE `dictionary` (
+  `user_id` int(10) UNSIGNED DEFAULT NULL,
+  `language` varchar(5) NOT NULL,
+  `data` longtext NOT NULL,
+  CONSTRAINT `user_id_fk_dictionary` FOREIGN KEY (`user_id`)
+    REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+  UNIQUE `uniqueness` (`user_id`, `language`)
+) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */;
+
 /*!40014 SET FOREIGN_KEY_CHECKS=1 */;
diff --git a/SQL/mysql.update.sql b/SQL/mysql.update.sql
index ed21bda..ee3929c 100644
--- a/SQL/mysql.update.sql
+++ b/SQL/mysql.update.sql
@@ -144,3 +144,14 @@
 
 TRUNCATE TABLE `messages`;
 TRUNCATE TABLE `cache`;
+
+-- Updates from version 0.6-stable
+
+CREATE TABLE `dictionary` (
+  `user_id` int(10) UNSIGNED DEFAULT NULL,
+  `language` varchar(5) NOT NULL,
+  `data` longtext NOT NULL,
+  CONSTRAINT `user_id_fk_dictionary` FOREIGN KEY (`user_id`)
+    REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+  UNIQUE `uniqueness` (`user_id`, `language`)
+) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */;
diff --git a/SQL/postgres.initial.sql b/SQL/postgres.initial.sql
index 5350e79..c801a77 100644
--- a/SQL/postgres.initial.sql
+++ b/SQL/postgres.initial.sql
@@ -225,3 +225,16 @@
 
 CREATE INDEX messages_index_idx ON messages (user_id, cache_key, idx);
 CREATE INDEX messages_created_idx ON messages (created);
+
+--
+-- Table "dictionary"
+-- Name: dictionary; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE dictionary (
+    user_id integer DEFAULT NULL
+    	REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
+   "language" varchar(5) NOT NULL,
+    data text NOT NULL,
+    CONSTRAINT dictionary_user_id_language_key UNIQUE (user_id, "language")
+);
diff --git a/SQL/postgres.update.sql b/SQL/postgres.update.sql
index 94513c5..0a2ed99 100644
--- a/SQL/postgres.update.sql
+++ b/SQL/postgres.update.sql
@@ -100,3 +100,13 @@
 
 TRUNCATE messages;
 TRUNCATE cache;
+
+-- Updates from version 0.6-stable
+
+CREATE TABLE dictionary (
+    user_id integer DEFAULT NULL
+        REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
+   "language" varchar(5) NOT NULL,
+    data text NOT NULL,
+    CONSTRAINT dictionary_user_id_language_key UNIQUE (user_id, "language")
+);
diff --git a/SQL/sqlite.initial.sql b/SQL/sqlite.initial.sql
index d2885e9..337dfbe 100644
--- a/SQL/sqlite.initial.sql
+++ b/SQL/sqlite.initial.sql
@@ -146,3 +146,18 @@
 CREATE UNIQUE INDEX ix_messages_user_cache_uid ON messages (user_id,cache_key,uid);
 CREATE INDEX ix_messages_index ON messages (user_id,cache_key,idx);
 CREATE INDEX ix_messages_created ON messages (created);
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table dictionary
+--
+
+CREATE TABLE dictionary (
+    user_id integer DEFAULT NULL,
+   "language" varchar(5) NOT NULL,
+    data text NOT NULL
+);
+
+CREATE UNIQUE INDEX ix_dictionary_user_language ON dictionary (user_id, "language");
+
diff --git a/SQL/sqlite.update.sql b/SQL/sqlite.update.sql
index 30c3ae9..8d5163f 100644
--- a/SQL/sqlite.update.sql
+++ b/SQL/sqlite.update.sql
@@ -226,3 +226,13 @@
 DELETE FROM messages;
 DELETE FROM cache;
 CREATE INDEX ix_contactgroupmembers_contact_id ON contactgroupmembers (contact_id);
+
+-- Updates from version 0.6-stable
+
+CREATE TABLE dictionary (
+    user_id integer DEFAULT NULL,
+   "language" varchar(5) NOT NULL,
+    data text NOT NULL
+);
+
+CREATE UNIQUE INDEX ix_dictionary_user_language ON dictionary (user_id, "language");
diff --git a/config/main.inc.php.dist b/config/main.inc.php.dist
index 8421d93..ad2e17e 100644
--- a/config/main.inc.php.dist
+++ b/config/main.inc.php.dist
@@ -427,6 +427,10 @@
 // requires to be compiled with Open SSL support
 $rcmail_config['enable_spellcheck'] = true;
 
+// Enables spellchecker exceptions dictionary.
+// Setting it to 'shared' will make the dictionary shared by all users.
+$rcmail_config['spellcheck_dictionary'] = false;
+
 // Set the spell checking engine. 'googie' is the default. 'pspell' is also available,
 // but requires the Pspell extensions. When using Nox Spell Server, also set 'googie' here.
 $rcmail_config['spellcheck_engine'] = 'googie';
@@ -442,6 +446,15 @@
 // Leave empty for default set of available language.
 $rcmail_config['spellcheck_languages'] = NULL;
 
+// Makes that words with all letters capitalized will be ignored (e.g. GOOGLE)
+$rcmail_config['spellcheck_ignore_caps'] = false;
+
+// Makes that words with numbers will be ignored (e.g. g00gle)
+$rcmail_config['spellcheck_ignore_nums'] = false;
+
+// Makes that words with symbols will be ignored (e.g. g@@gle)
+$rcmail_config['spellcheck_ignore_syms'] = false;
+
 // don't let users set pagesize to more than this value if set
 $rcmail_config['max_pagesize'] = 200;
 
diff --git a/program/include/main.inc b/program/include/main.inc
index 100feb6..4c24ce3 100644
--- a/program/include/main.inc
+++ b/program/include/main.inc
@@ -1595,7 +1595,7 @@
   $hook = $RCMAIL->plugins->exec_hook('html_editor', array('mode' => $mode));
 
   if ($hook['abort'])
-    return;  
+    return;
 
   $lang = strtolower($_SESSION['language']);
 
@@ -1607,9 +1607,14 @@
 
   $RCMAIL->output->include_script('tiny_mce/tiny_mce.js');
   $RCMAIL->output->include_script('editor.js');
-  $RCMAIL->output->add_script(sprintf("rcmail_editor_init('\$__skin_path', '%s', %d, '%s');",
-    JQ($lang), intval($CONFIG['enable_spellcheck']), $mode),
-    'foot');
+  $RCMAIL->output->add_script(sprintf("rcmail_editor_init(%s)",
+    json_encode(array(
+        'mode'       => $mode,
+        'skin_path'  => '$__skin_path',
+        'lang'       => $lang,
+        'spellcheck' => intval($CONFIG['enable_spellcheck']),
+        'spelldict'  => intval($CONFIG['spellcheck_dictionary']),
+    ))), 'foot');
 }
 
 
diff --git a/program/include/rcube_spellchecker.php b/program/include/rcube_spellchecker.php
index 8282406..e6bd131 100644
--- a/program/include/rcube_spellchecker.php
+++ b/program/include/rcube_spellchecker.php
@@ -34,8 +34,11 @@
     private $lang;
     private $rc;
     private $error;
-    private $separator = '/[ !"#$%&()*+\\,\/\n:;<=>?@\[\]^_{|}-]+|\.[^\w]/';
-    
+    private $separator = '/[\s\r\n\t\(\)\/\[\]{}<>\\"]+|[:;?!,\.]([^\w]|$)/';
+    private $options = array();
+    private $dict;
+    private $have_dict;
+
 
     // default settings
     const GOOGLE_HOST = 'ssl://www.google.com';
@@ -50,9 +53,9 @@
      */
     function __construct($lang = 'en')
     {
-        $this->rc = rcmail::get_instance();
+        $this->rc     = rcmail::get_instance();
         $this->engine = $this->rc->config->get('spellcheck_engine', 'googie');
-        $this->lang = $lang ? $lang : 'en';
+        $this->lang   = $lang ? $lang : 'en';
 
         if ($this->engine == 'pspell' && !extension_loaded('pspell')) {
             raise_error(array(
@@ -60,6 +63,13 @@
                 'file' => __FILE__, 'line' => __LINE__,
                 'message' => "Pspell extension not available"), true, true);
         }
+
+        $this->options = array(
+            'ignore_syms' => $this->rc->config->get('spellcheck_ignore_syms'),
+            'ignore_nums' => $this->rc->config->get('spellcheck_ignore_nums'),
+            'ignore_caps' => $this->rc->config->get('spellcheck_ignore_caps'),
+            'dictionary'  => $this->rc->config->get('spellcheck_dictionary'),
+        );
     }
 
 
@@ -71,7 +81,7 @@
      *
      * @return bool True when no mispelling found, otherwise false
      */
-    function check($text, $is_html=false)
+    function check($text, $is_html = false)
     {
         // convert to plain text
         if ($is_html) {
@@ -116,9 +126,9 @@
             return $this->_pspell_suggestions($word);
         }
 
-        return $this->_googie_suggestions($word);    
+        return $this->_googie_suggestions($word);
     }
-    
+
 
     /**
      * Returns mispelled words
@@ -179,7 +189,7 @@
             $result[$word] = is_array($item[4]) ? implode("\t", $item[4]) : $item[4];
         }
 
-        return $out;
+        return $result;
     }
 
 
@@ -211,15 +221,18 @@
         // tokenize
         $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE);
 
-        $diff = 0;
-        $matches = array();
+        $diff       = 0;
+        $matches    = array();
 
         foreach ($text as $w) {
             $word = trim($w[0]);
             $pos  = $w[1] - $diff;
             $len  = mb_strlen($word);
 
-            if ($word && preg_match('/[^0-9\.]/', $word) && !pspell_check($this->plink, $word)) {
+            // skip exceptions
+            if ($this->is_exception($word)) {
+            }
+            else if (!pspell_check($this->plink, $word)) {
                 $suggestions = pspell_suggest($this->plink, $word);
 
 	            if (sizeof($suggestions) > self::MAX_SUGGESTIONS)
@@ -240,6 +253,8 @@
      */
     private function _pspell_words($text = null, $is_html=false)
     {
+        $result = array();
+
         if ($text) {
             // init spellchecker
             $this->_pspell_init();
@@ -257,15 +272,19 @@
 
             foreach ($text as $w) {
                 $word = trim($w[0]);
-                if ($word && preg_match('/[^0-9\.]/', $word) && !pspell_check($this->plink, $word)) {
+
+                // skip exceptions
+                if ($this->is_exception($word)) {
+                    continue;
+                }
+
+                if (!pspell_check($this->plink, $word)) {
                     $result[] = $word;
                 }
             }
 
             return $result;
         }
-
-        $result = array();
 
         foreach ($this->matches as $m) {
             $result[] = $m[0];
@@ -330,21 +349,21 @@
         }
 
         // Google has some problem with spaces, use \n instead
-        $text = str_replace(' ', "\n", $text);
+        $gtext = str_replace(' ', "\n", $text);
 
-        $text = '<?xml version="1.0" encoding="utf-8" ?>'
+        $gtext = '<?xml version="1.0" encoding="utf-8" ?>'
             .'<spellrequest textalreadyclipped="0" ignoredups="0" ignoredigits="1" ignoreallcaps="1">'
-            .'<text>' . $text . '</text>'
+            .'<text>' . $gtext . '</text>'
             .'</spellrequest>';
 
         $store = '';
         if ($fp = fsockopen($host, $port, $errno, $errstr, 30)) {
             $out = "POST $path HTTP/1.0\r\n";
             $out .= "Host: " . str_replace('ssl://', '', $host) . "\r\n";
-            $out .= "Content-Length: " . strlen($text) . "\r\n";
+            $out .= "Content-Length: " . strlen($gtext) . "\r\n";
             $out .= "Content-Type: application/x-www-form-urlencoded\r\n";
             $out .= "Connection: Close\r\n\r\n";
-            $out .= $text;
+            $out .= $gtext;
             fwrite($fp, $out);
 
             while (!feof($fp))
@@ -357,6 +376,19 @@
         }
 
         preg_match_all('/<c o="([^"]*)" l="([^"]*)" s="([^"]*)">([^<]*)<\/c>/', $store, $matches, PREG_SET_ORDER);
+
+        // skip exceptions (if appropriate options are enabled)
+        if (!empty($this->options['ignore_syms']) || !empty($this->options['ignore_nums'])
+            || !empty($this->options['ignore_caps']) || !empty($this->options['dictionary'])
+        ) {
+            foreach ($matches as $idx => $m) {
+                $word = mb_substr($text, $m[1], $m[2], RCMAIL_CHARSET);
+                // skip  exceptions
+                if ($this->is_exception($word)) {
+                    unset($matches[$idx]);
+                }
+            }
+        }
 
         return $matches;
     }
@@ -413,4 +445,172 @@
         $h2t = new html2text($text, false, true, 0);
         return $h2t->get_text();
     }
+
+
+    /**
+     * Check if the specified word is an exception accoring to 
+     * spellcheck options.
+     *
+     * @param string  $word  The word
+     *
+     * @return bool True if the word is an exception, False otherwise
+     */
+    public function is_exception($word)
+    {
+        // Contain only symbols (e.g. "+9,0", "2:2")
+        if (!$word || preg_match('/^[0-9@#$%^&_+~*=:;?!,.-]+$/', $word))
+            return true;
+
+        // Contain symbols (e.g. "g@@gle"), all symbols excluding separators
+        if (!empty($this->options['ignore_syms']) && preg_match('/[@#$%^&_+~*=-]/', $word))
+            return true;
+
+        // Contain numbers (e.g. "g00g13")
+        if (!empty($this->options['ignore_nums']) && preg_match('/[0-9]/', $word))
+            return true;
+
+        // Blocked caps (e.g. "GOOGLE")
+        if (!empty($this->options['ignore_caps']) && $word == mb_strtoupper($word))
+            return true;
+
+        // Use exceptions from dictionary
+        if (!empty($this->options['dictionary'])) {
+            $this->load_dict();
+
+            // @TODO: should dictionary be case-insensitive?
+            if (!empty($this->dict) && in_array($word, $this->dict))
+                return true;
+        }
+
+        return false;
+    }
+
+
+    /**
+     * Add a word to dictionary
+     *
+     * @param string  $word  The word to add
+     */
+    public function add_word($word)
+    {
+        $this->load_dict();
+
+        foreach (explode(' ', $word) as $word) {
+            // sanity check
+            if (strlen($word) < 512) {
+                $this->dict[] = $word;
+                $valid = true;
+            }
+        }
+
+        if ($valid) {
+            $this->dict = array_unique($this->dict);
+            $this->update_dict();
+        }
+    }
+
+
+    /**
+     * Remove a word from dictionary
+     *
+     * @param string  $word  The word to remove
+     */
+    public function remove_word($word)
+    {
+        $this->load_dict();
+
+        if (($key = array_search($word, $this->dict)) !== false) {
+            unset($this->dict[$key]);
+            $this->update_dict();
+        }
+    }
+
+
+    /**
+     * Update dictionary row in DB
+     */
+    private function update_dict()
+    {
+        if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
+            $userid = (int) $this->rc->user->ID;
+        }
+
+        $plugin = $this->rc->plugins->exec_hook('spell_dictionary_save', array(
+            'userid' => $userid, 'language' => $this->lang, 'dictionary' => $this->dict));
+
+        if (!empty($plugin['abort'])) {
+            return;
+        }
+
+        if ($this->have_dict) {
+            if (!empty($this->dict)) {
+                $this->rc->db->query(
+                    "UPDATE ".get_table_name('dictionary')
+                    ." SET data = ?"
+                    ." WHERE user_id " . ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
+                        ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
+                    implode(' ', $plugin['dictionary']), $plugin['language']);
+            }
+            // don't store empty dict
+            else {
+                $this->rc->db->query(
+                    "DELETE FROM " . get_table_name('dictionary')
+                    ." WHERE user_id " . ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
+                        ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
+                    $plugin['language']);
+            }
+        }
+        else if (!empty($this->dict)) {
+            $this->rc->db->query(
+                "INSERT INTO " .get_table_name('dictionary')
+                ." (user_id, " . $this->rc->db->quoteIdentifier('language') . ", data) VALUES (?, ?, ?)",
+                $plugin['userid'], $plugin['language'], implode(' ', $plugin['dictionary']));
+        }
+    }
+
+
+    /**
+     * Get dictionary from DB
+     */
+    private function load_dict()
+    {
+        if (is_array($this->dict)) {
+            return $this->dict;
+        }
+
+        if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
+            $userid = (int) $this->rc->user->ID;
+        }
+
+        $plugin = $this->rc->plugins->exec_hook('spell_dictionary_get', array(
+            'userid' => $userid, 'language' => $this->lang, 'dictionary' => array()));
+
+        if (empty($plugin['abort'])) {
+            $dict = array();
+            $this->rc->db->query(
+                "SELECT data FROM ".get_table_name('dictionary')
+                ." WHERE user_id ". ($plugin['userid'] ? "= ".$plugin['userid'] : "IS NULL")
+                    ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
+                $plugin['language']);
+
+            if ($sql_arr = $this->rc->db->fetch_assoc($sql_result)) {
+                $this->have_dict = true;
+                if (!empty($sql_arr['data'])) {
+                    $dict = explode(' ', $sql_arr['data']);
+                }
+            }
+
+            $plugin['dictionary'] = array_merge((array)$plugin['dictionary'], $dict);
+        }
+
+        if (!empty($plugin['dictionary']) && is_array($plugin['dictionary'])) {
+            $this->dict = $plugin['dictionary'];
+        }
+        else {
+            $this->dict = array();
+        }
+
+        return $this->dict;
+    }
+
 }
diff --git a/program/js/editor.js b/program/js/editor.js
index a3aef72..906faec 100644
--- a/program/js/editor.js
+++ b/program/js/editor.js
@@ -14,15 +14,15 @@
 */
 
 // Initialize HTML editor
-function rcmail_editor_init(skin_path, editor_lang, spellcheck, mode)
+function rcmail_editor_init(config)
 {
   var ret, conf = {
       mode: 'textareas',
       editor_selector: 'mce_editor',
       apply_source_formatting: true,
       theme: 'advanced',
-      language: editor_lang,
-      content_css: skin_path + '/editor_content.css',
+      language: config.lang,
+      content_css: config.skin_path + '/editor_content.css',
       theme_advanced_toolbar_location: 'top',
       theme_advanced_toolbar_align: 'left',
       theme_advanced_buttons3: '',
@@ -35,7 +35,7 @@
       rc_client: rcmail
     };
 
-  if (mode == 'identity')
+  if (config.mode == 'identity')
     $.extend(conf, {
       plugins: 'paste,tabfocus',
       theme_advanced_buttons1: 'bold,italic,underline,strikethrough,justifyleft,justifycenter,justifyright,justifyfull,separator,outdent,indent,charmap,hr,link,unlink,code,forecolor',
@@ -43,11 +43,12 @@
     });
   else // mail compose
     $.extend(conf, {
-      plugins: 'paste,emotions,media,nonbreaking,table,searchreplace,visualchars,directionality,tabfocus' + (spellcheck ? ',spellchecker' : ''),
+      plugins: 'paste,emotions,media,nonbreaking,table,searchreplace,visualchars,directionality,tabfocus' + (config.spellcheck ? ',spellchecker' : ''),
       theme_advanced_buttons1: 'bold,italic,underline,|,justifyleft,justifycenter,justifyright,justifyfull,|,bullist,numlist,outdent,indent,ltr,rtl,blockquote,|,forecolor,backcolor,fontselect,fontsizeselect',
-      theme_advanced_buttons2: 'link,unlink,table,|,emotions,charmap,image,media,|,code,search' + (spellcheck ? ',spellchecker' : '') + ',undo,redo',
+      theme_advanced_buttons2: 'link,unlink,table,|,emotions,charmap,image,media,|,code,search' + (config.spellcheck ? ',spellchecker' : '') + ',undo,redo',
       spellchecker_languages: (rcmail.env.spellcheck_langs ? rcmail.env.spellcheck_langs : 'Dansk=da,Deutsch=de,+English=en,Espanol=es,Francais=fr,Italiano=it,Nederlands=nl,Polski=pl,Portugues=pt,Suomi=fi,Svenska=sv'),
       spellchecker_rpc_url: '?_task=utils&_action=spell_html',
+      spellchecker_enable_learn_rpc: config.spelldict,
       accessibility_focus: false,
       oninit: 'rcmail_editor_callback'
     });
diff --git a/program/js/googiespell.js b/program/js/googiespell.js
index c1b04ca..6101fd2 100644
--- a/program/js/googiespell.js
+++ b/program/js/googiespell.js
@@ -1,8 +1,9 @@
 /*
  SpellCheck
     jQuery'fied spell checker based on GoogieSpell 4.0
- Copyright Amir Salihefendic 2006
- Copyright Aleksander Machniak 2009
+ Copyright (C) 2006 Amir Salihefendic
+ Copyright (C) 2009 Aleksander Machniak
+ Copyright (C) 2011 Kolab Systems AG
      LICENSE
          GPL
      AUTHORS
@@ -13,7 +14,8 @@
 var GOOGIE_CUR_LANG,
     GOOGIE_DEFAULT_LANG = 'en';
 
-function GoogieSpell(img_dir, server_url) {
+function GoogieSpell(img_dir, server_url, has_dict)
+{
     var ref = this,
         cookie_value = getCookie('language');
 
@@ -49,6 +51,7 @@
     this.lang_rsm_edt = "Resume editing";
     this.lang_no_error_found = "No spelling errors found";
     this.lang_no_suggestions = "No suggestions";
+    this.lang_learn_word = "Add to dictionary";
 
     this.show_spell_img = false; // roundcube mod.
     this.decoration = true;
@@ -64,6 +67,7 @@
     this.extra_menu_items = [];
     this.custom_spellcheck_starter = null;
     this.main_controller = true;
+    this.has_dictionary = has_dict;
 
     // Observers
     this.lang_state_observer = null;
@@ -90,7 +94,8 @@
     });
 
 
-this.decorateTextarea = function(id) {
+this.decorateTextarea = function(id)
+{
     this.text_area = typeof id === 'string' ? document.getElementById(id) : id;
 
     if (this.text_area) {
@@ -119,16 +124,19 @@
 //////
 // API Functions (the ones that you can call)
 /////
-this.setSpellContainer = function(id) {
+this.setSpellContainer = function(id)
+{
     this.spell_container = typeof id === 'string' ? document.getElementById(id) : id;
 };
 
-this.setLanguages = function(lang_dict) {
+this.setLanguages = function(lang_dict)
+{
     this.lang_to_word = lang_dict;
     this.langlist_codes = this.array_keys(lang_dict);
 };
 
-this.setCurrentLanguage = function(lan_code) {
+this.setCurrentLanguage = function(lan_code)
+{
     GOOGIE_CUR_LANG = lan_code;
 
     //Set cookie
@@ -137,29 +145,35 @@
     setCookie('language', lan_code, now);
 };
 
-this.setForceWidthHeight = function(width, height) {
+this.setForceWidthHeight = function(width, height)
+{
     // Set to null if you want to use one of them
     this.force_width = width;
     this.force_height = height;
 };
 
-this.setDecoration = function(bool) {
+this.setDecoration = function(bool)
+{
     this.decoration = bool;
 };
 
-this.dontUseCloseButtons = function() {
+this.dontUseCloseButtons = function()
+{
     this.use_close_btn = false;
 };
 
-this.appendNewMenuItem = function(name, call_back_fn, checker) {
+this.appendNewMenuItem = function(name, call_back_fn, checker)
+{
     this.extra_menu_items.push([name, call_back_fn, checker]);
 };
 
-this.appendCustomMenuBuilder = function(eval, builder) {
+this.appendCustomMenuBuilder = function(eval, builder)
+{
     this.custom_menu_builder.push([eval, builder]);
 };
 
-this.setFocus = function() {
+this.setFocus = function()
+{
     try {
         this.focus_link_b.focus();
         this.focus_link_t.focus();
@@ -174,13 +188,15 @@
 //////
 // Set functions (internal)
 /////
-this.setStateChanged = function(current_state) {
+this.setStateChanged = function(current_state)
+{
     this.state = current_state;
     if (this.spelling_state_observer != null && this.report_state_change)
         this.spelling_state_observer(current_state, this);
 };
 
-this.setReportStateChange = function(bool) {
+this.setReportStateChange = function(bool)
+{
     this.report_state_change = bool;
 };
 
@@ -188,28 +204,31 @@
 //////
 // Request functions
 /////
-this.getUrl = function() {
+this.getUrl = function()
+{
     return this.server_url + GOOGIE_CUR_LANG;
 };
 
-this.escapeSpecial = function(val) {
+this.escapeSpecial = function(val)
+{
     return val.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
 };
 
-this.createXMLReq = function (text) {
+this.createXMLReq = function (text)
+{
     return '<?xml version="1.0" encoding="utf-8" ?>'
 	+ '<spellrequest textalreadyclipped="0" ignoredups="0" ignoredigits="1" ignoreallcaps="1">'
 	+ '<text>' + text + '</text></spellrequest>';
 };
 
-this.spellCheck = function(ignore) {
+this.spellCheck = function(ignore)
+{
     this.prepare(ignore);
 
     var req_text = this.escapeSpecial(this.orginal_text),
         ref = this;
 
-    $.ajax({ type: 'POST', url: this.getUrl(),
-	data: this.createXMLReq(req_text), dataType: 'text',
+    $.ajax({ type: 'POST', url: this.getUrl(), data: this.createXMLReq(req_text), dataType: 'text',
 	    error: function(o) {
             if (ref.custom_ajax_error)
         	    ref.custom_ajax_error(ref);
@@ -230,6 +249,25 @@
                 	ref.custom_no_spelling_error(ref);
     	    }
     	    ref.removeIndicator();
+	    }
+    });
+};
+
+this.learnWord = function(word, id)
+{
+    word = this.escapeSpecial(word.innerHTML);
+
+    var ref = this,
+        req_text = '<?xml version="1.0" encoding="utf-8" ?><learnword><text>' + word + '</text></learnword>';
+
+    $.ajax({ type: 'POST', url: this.getUrl(), data: req_text, dataType: 'text',
+	    error: function(o) {
+            if (ref.custom_ajax_error)
+        	    ref.custom_ajax_error(ref);
+            else
+        	    alert('An error was encountered on the server. Please try again later.');
+	    },
+        success: function(data) {
 	    }
     });
 };
@@ -274,7 +312,8 @@
     this.orginal_text = $(this.text_area).val();
 };
 
-this.parseResult = function(r_text) {
+this.parseResult = function(r_text)
+{
     // Returns an array: result[item] -> ['attrs'], ['suggestions']
     var re_split_attr_c = /\w+="(\d+|true)"/g,
         re_split_text = /\t/g,
@@ -324,21 +363,25 @@
 //////
 // Error menu functions
 /////
-this.createErrorWindow = function() {
+this.createErrorWindow = function()
+{
     this.error_window = document.createElement('div');
     $(this.error_window).addClass('googie_window popupmenu').attr('googie_action_btn', '1');
 };
 
-this.isErrorWindowShown = function() {
+this.isErrorWindowShown = function()
+{
     return $(this.error_window).is(':visible');
 };
 
-this.hideErrorWindow = function() {
+this.hideErrorWindow = function()
+{
     $(this.error_window).hide();
     $(this.error_window_iframe).hide();
 };
 
-this.updateOrginalText = function(offset, old_value, new_value, id) {
+this.updateOrginalText = function(offset, old_value, new_value, id)
+{
     var part_1 = this.orginal_text.substring(0, offset),
         part_2 = this.orginal_text.substring(offset+old_value.length),
         add_2_offset = new_value.length - old_value.length;
@@ -357,18 +400,20 @@
     elm.old_value = old_value;
 };
 
-this.createListSeparator = function() {
+this.createListSeparator = function()
+{
     var td = document.createElement('td'),
         tr = document.createElement('tr');
 
     $(td).html(' ').attr('googie_action_btn', '1')
-	.css({'cursor': 'default', 'font-size': '3px', 'border-top': '1px solid #ccc', 'padding-top': '3px'});
+	    .css({'cursor': 'default', 'font-size': '3px', 'border-top': '1px solid #ccc', 'padding-top': '3px'});
     tr.appendChild(td);
 
     return tr;
 };
 
-this.correctError = function(id, elm, l_elm, rm_pre_space) {
+this.correctError = function(id, elm, l_elm, rm_pre_space)
+{
     var old_value = elm.innerHTML,
         new_value = l_elm.nodeType == 3 ? l_elm.nodeValue : l_elm.innerHTML,
         offset = this.results[id]['attrs']['o'];
@@ -393,7 +438,15 @@
     this.errorFixed();
 };
 
-this.showErrorWindow = function(elm, id) {
+this.ignoreError = function(elm, id)
+{
+    // @TODO: ignore all same words
+    $(elm).removeAttr('class').css('color', '').unbind();
+    this.hideErrorWindow();
+};
+
+this.showErrorWindow = function(elm, id)
+{
     if (this.show_menu_observer)
         this.show_menu_observer(this);
 
@@ -414,6 +467,7 @@
             break;
         }
     }
+
     if (!changed) {
         // Build up the result list
         var suggestions = this.results[id]['suggestions'],
@@ -421,6 +475,26 @@
             len = this.results[id]['attrs']['l'],
             row, item, dummy;
 
+        // [Add to dictionary] button
+        if (this.has_dictionary && !$(elm).attr('is_corrected')) {
+            row = document.createElement('tr'),
+            item = document.createElement('td'),
+            dummy = document.createElement('span');
+
+            $(dummy).text(this.lang_learn_word);
+            $(item).attr('googie_action_btn', '1').css('cursor', 'default')
+                .mouseover(ref.item_onmouseover)
+                .mouseout(ref.item_onmouseout)
+			    .click(function(e) {
+			        ref.learnWord(elm, id);
+			        ref.ignoreError(elm, id);
+			    });
+
+            item.appendChild(dummy);
+            row.appendChild(item);
+            list.appendChild(row);
+        }
+/*
         if (suggestions.length == 0) {
             row = document.createElement('tr'),
             item = document.createElement('td'),
@@ -433,7 +507,7 @@
             row.appendChild(item);
             list.appendChild(row);
         }
-
+*/
         for (var i=0, len=suggestions.length; i < len; i++) {
             row = document.createElement('tr'),
             item = document.createElement('td'),
@@ -441,16 +515,15 @@
 
             $(dummy).html(suggestions[i]);
 
-            $(item).bind('mouseover', this.item_onmouseover)
-        	    .bind('mouseout', this.item_onmouseout)
-        	    .bind('click', function(e) { ref.correctError(id, elm, e.target.firstChild) });
+            $(item).mouseover(this.item_onmouseover).mouseout(this.item_onmouseout)
+        	    .click(function(e) { ref.correctError(id, elm, e.target.firstChild) });
 
             item.appendChild(dummy);
             row.appendChild(item);
             list.appendChild(row);
         }
 
-        //The element is changed, append the revert
+        // The element is changed, append the revert
         if (elm.is_changed && elm.innerHTML != elm.old_value) {
             var old_value = elm.old_value,
                 revert_row = document.createElement('tr'),
@@ -459,11 +532,10 @@
 
 	        $(rev_span).addClass('googie_list_revert').html(this.lang_revert + ' ' + old_value);
 
-            $(revert).bind('mouseover', this.item_onmouseover)
-        	    .bind('mouseout', this.item_onmouseout)
-        	    .bind('click', function(e) {
+            $(revert).mouseover(this.item_onmouseover).mouseout(this.item_onmouseout)
+        	    .click(function(e) {
             	    ref.updateOrginalText(offset, elm.innerHTML, old_value, id);
-            	    $(elm).attr('is_corrected', true).css('color', '#b91414').html(old_value);
+            	    $(elm).removeAttr('is_corrected').css('color', '#b91414').html(old_value);
             	    ref.hideErrorWindow();
         	    });
 
@@ -498,11 +570,11 @@
 	    $(ok_pic).attr('src', this.img_dir + 'ok.gif')
 	        .width(32).height(16)
     	    .css({'cursor': 'pointer', 'margin-left': '2px', 'margin-right': '2px'})
-	        .bind('click', onsub);
+	        .click(onsub);
 
         $(edit_form).attr('googie_action_btn', '1')
 	        .css({'margin': 0, 'padding': 0, 'cursor': 'default', 'white-space': 'nowrap'})
-	        .bind('submit', onsub);
+	        .submit(onsub);
 
 	    edit_form.appendChild(edit_input);
 	    edit_form.appendChild(ok_pic);
@@ -523,9 +595,9 @@
                       e_col = document.createElement('td');
 
 			        $(e_col).html(e_elm[0])
-                        .bind('mouseover', ref.item_onmouseover)
-                    	.bind('mouseout', ref.item_onmouseout)
-			            .bind('click', function() { return e_elm[1](elm, ref) });
+                        .mouseover(ref.item_onmouseover)
+                    	.mouseout(ref.item_onmouseout)
+			            .click(function() { return e_elm[1](elm, ref) });
 
 			        e_row.appendChild(e_col);
                     list.appendChild(e_row);
@@ -575,7 +647,8 @@
 //////
 // Edit layer (the layer where the suggestions are stored)
 //////
-this.createEditLayer = function(width, height) {
+this.createEditLayer = function(width, height)
+{
     this.edit_layer = document.createElement('div');
     $(this.edit_layer).addClass('googie_edit_layer').attr('id', 'googie_edit_layer')
         .width('auto').height(height);
@@ -603,7 +676,8 @@
     }
 };
 
-this.resumeEditing = function() {
+this.resumeEditing = function()
+{
     this.setStateChanged('ready');
 
     if (this.edit_layer)
@@ -629,7 +703,8 @@
     this.checkSpellingState(false);
 };
 
-this.createErrorLink = function(text, id) {
+this.createErrorLink = function(text, id)
+{
     var elm = document.createElement('span'),
         ref = this,
         d = function (e) {
@@ -638,13 +713,14 @@
     	    return false;
         };
 
-    $(elm).html(text).addClass('googie_link').bind('click', d)
-	    .attr({'googie_action_btn' : '1', 'g_id' : id, 'is_corrected' : false});
+    $(elm).html(text).addClass('googie_link').click(d).removeAttr('is_corrected')
+	    .attr({'googie_action_btn' : '1', 'g_id' : id});
 
     return elm;
 };
 
-this.createPart = function(txt_part) {
+this.createPart = function(txt_part)
+{
     if (txt_part == " ")
         return document.createTextNode(" ");
 
@@ -659,7 +735,8 @@
     return span;
 };
 
-this.showErrorsInIframe = function() {
+this.showErrorsInIframe = function()
+{
     var output = document.createElement('div'),
         pointer = 0,
         results = this.results;
@@ -717,7 +794,8 @@
 //////
 // Choose language menu
 //////
-this.createLangWindow = function() {
+this.createLangWindow = function()
+{
     this.language_window = document.createElement('div');
     $(this.language_window).addClass('googie_window popupmenu')
 	    .width(100).attr('googie_action_btn', '1');
@@ -776,16 +854,19 @@
     this.language_window.appendChild(table);
 };
 
-this.isLangWindowShown = function() {
+this.isLangWindowShown = function()
+{
     return $(this.language_window).is(':visible');
 };
 
-this.hideLangWindow = function() {
+this.hideLangWindow = function()
+{
     $(this.language_window).hide();
     $(this.switch_lan_pic).removeClass().addClass('googie_lang_3d_on');
 };
 
-this.showLangWindow = function(elm) {
+this.showLangWindow = function(elm)
+{
     if (this.show_menu_observer)
         this.show_menu_observer(this);
 
@@ -806,11 +887,13 @@
     this.highlightCurSel();
 };
 
-this.deHighlightCurSel = function() {
+this.deHighlightCurSel = function()
+{
     $(this.lang_cur_elm).removeClass().addClass('googie_list_onout');
 };
 
-this.highlightCurSel = function() {
+this.highlightCurSel = function()
+{
     if (GOOGIE_CUR_LANG == null)
         GOOGIE_CUR_LANG = GOOGIE_DEFAULT_LANG;
     for (var i=0; i < this.lang_elms.length; i++) {
@@ -824,7 +907,8 @@
     }
 };
 
-this.createChangeLangPic = function() {
+this.createChangeLangPic = function()
+{
     var img = $('<img>')
 	    .attr({src: this.img_dir + 'change_lang.gif', 'alt': 'Change language', 'googie_action_btn': '1'}),
         switch_lan = document.createElement('span');
@@ -847,7 +931,8 @@
     return switch_lan;
 };
 
-this.createSpellDiv = function() {
+this.createSpellDiv = function()
+{
     var span = document.createElement('span');
 
     $(span).addClass('googie_check_spelling_link').text(this.lang_chck_spell);
@@ -862,7 +947,8 @@
 //////
 // State functions
 /////
-this.flashNoSpellingErrorState = function(on_finish) {
+this.flashNoSpellingErrorState = function(on_finish)
+{
     this.setStateChanged('no_error_found');
 
     var ref = this;
@@ -888,7 +974,8 @@
     }
 };
 
-this.resumeEditingState = function() {
+this.resumeEditingState = function()
+{
     this.setStateChanged('resume_editing');
 
     //Change link text to resume
@@ -906,7 +993,8 @@
     catch (e) {};
 };
 
-this.checkSpellingState = function(fire) {
+this.checkSpellingState = function(fire)
+{
     if (fire)
         this.setStateChanged('ready');
 
@@ -939,12 +1027,14 @@
 //////
 // Misc. functions
 /////
-this.isDefined = function(o) {
+this.isDefined = function(o)
+{
     return (o !== undefined && o !== null)
 };
 
-this.errorFixed = function() { 
-    this.cnt_errors_fixed++; 
+this.errorFixed = function()
+{
+    this.cnt_errors_fixed++;
     if (this.all_errors_fixed_observer)
         if (this.cnt_errors_fixed == this.cnt_errors) {
             this.hideErrorWindow();
@@ -952,15 +1042,18 @@
         }
 };
 
-this.errorFound = function() {
+this.errorFound = function()
+{
     this.cnt_errors++;
 };
 
-this.createCloseButton = function(c_fn) {
+this.createCloseButton = function(c_fn)
+{
     return this.createButton(this.lang_close, 'googie_list_close', c_fn);
 };
 
-this.createButton = function(name, css_class, c_fn) {
+this.createButton = function(name, css_class, c_fn)
+{
     var btn_row = document.createElement('tr'),
         btn = document.createElement('td'),
         spn_btn;
@@ -982,14 +1075,16 @@
     return btn_row;
 };
 
-this.removeIndicator = function(elm) {
+this.removeIndicator = function(elm)
+{
     //$(this.indicator).remove();
     // roundcube mod.
     if (window.rcmail)
         rcmail.set_busy(false, null, this.rc_msg_id);
 };
 
-this.appendIndicator = function(elm) {
+this.appendIndicator = function(elm)
+{
     // modified by roundcube
     if (window.rcmail)
 	    this.rc_msg_id = rcmail.set_busy(true, 'checking');
@@ -1005,19 +1100,23 @@
 */
 }
 
-this.createFocusLink = function(name) {
+this.createFocusLink = function(name)
+{
     var link = document.createElement('a');
     $(link).attr({'href': 'javascript:;', 'name': name});
     return link;
 };
 
-this.item_onmouseover = function(e) {
+this.item_onmouseover = function(e)
+{
     if (this.className != 'googie_list_revert' && this.className != 'googie_list_close')
         this.className = 'googie_list_onhover';
     else
         this.parentNode.className = 'googie_list_onhover';
 };
-this.item_onmouseout = function(e) {
+
+this.item_onmouseout = function(e)
+{
     if (this.className != 'googie_list_revert' && this.className != 'googie_list_close')
         this.className = 'googie_list_onout';
     else
diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc
index f37fe25..7facc12 100644
--- a/program/localization/en_US/labels.inc
+++ b/program/localization/en_US/labels.inc
@@ -427,6 +427,11 @@
 $labels['replysamefolder'] = 'Place replies in the folder of the message being replied to';
 $labels['defaultaddressbook'] = 'Add new contacts to the selected addressbook';
 $labels['spellcheckbeforesend'] = 'Check spelling before sending a message';
+$labels['spellcheckoptions'] = 'Spellcheck Options';
+$labels['spellcheckignoresyms'] = 'Ignore words with symbols';
+$labels['spellcheckignorenums'] = 'Ignore words with numbers';
+$labels['spellcheckignorecaps'] = 'Ignore words with all letters capitalized';
+$labels['addtodict'] = 'Add to dictionary';
 
 $labels['folder']  = 'Folder';
 $labels['folders']  = 'Folders';
diff --git a/program/steps/mail/compose.inc b/program/steps/mail/compose.inc
index 3874967..ddfd627 100644
--- a/program/steps/mail/compose.inc
+++ b/program/steps/mail/compose.inc
@@ -697,8 +697,8 @@
 
   // include GoogieSpell
   if (!empty($CONFIG['enable_spellcheck'])) {
-
-    $engine = $RCMAIL->config->get('spellcheck_engine','googie');
+    $engine           = $RCMAIL->config->get('spellcheck_engine','googie');
+    $dictionary       = (bool) $RCMAIL->config->get('spellcheck_dictionary');
     $spellcheck_langs = (array) $RCMAIL->config->get('spellcheck_languages',
       array('da'=>'Dansk', 'de'=>'Deutsch', 'en' => 'English', 'es'=>'Español',
             'fr'=>'Français', 'it'=>'Italiano', 'nl'=>'Nederlands', 'pl'=>'Polski',
@@ -728,25 +728,28 @@
     foreach ($spellcheck_langs as $key => $name) {
       $editor_lang_set[] = ($key == $lang ? '+' : '') . JQ($name).'='.JQ($key);
     }
-    
+
     $OUTPUT->include_script('googiespell.js');
     $OUTPUT->add_script(sprintf(
-      "var googie = new GoogieSpell('\$__skin_path/images/googiespell/','?_task=utils&_action=spell&lang=');\n".
+      "var googie = new GoogieSpell('\$__skin_path/images/googiespell/','?_task=utils&_action=spell&lang=', %s);\n".
       "googie.lang_chck_spell = \"%s\";\n".
       "googie.lang_rsm_edt = \"%s\";\n".
       "googie.lang_close = \"%s\";\n".
       "googie.lang_revert = \"%s\";\n".
       "googie.lang_no_error_found = \"%s\";\n".
+      "googie.lang_learn_word = \"%s\";\n".
       "googie.setLanguages(%s);\n".
       "googie.setCurrentLanguage('%s');\n".
       "googie.setSpellContainer('spellcheck-control');\n".
       "googie.decorateTextarea('%s');\n".
       "%s.set_env('spellcheck', googie);",
+      !empty($dictionary) ? 'true' : 'false',
       JQ(Q(rcube_label('checkspelling'))),
       JQ(Q(rcube_label('resumeediting'))),
       JQ(Q(rcube_label('close'))),
       JQ(Q(rcube_label('revertto'))),
       JQ(Q(rcube_label('nospellerrors'))),
+      JQ(Q(rcube_label('addtodict'))),
       json_serialize($spellcheck_langs),
       $lang,
       $attrib['id'],
diff --git a/program/steps/settings/func.inc b/program/steps/settings/func.inc
index 54f9552..9e6b601 100644
--- a/program/steps/settings/func.inc
+++ b/program/steps/settings/func.inc
@@ -448,8 +448,9 @@
     case 'compose':
 
     $blocks = array(
-      'main' => array('name' => Q(rcube_label('mainoptions'))),
-      'sig' => array('name' => Q(rcube_label('signatureoptions'))),
+      'main'       => array('name' => Q(rcube_label('mainoptions'))),
+      'spellcheck' => array('name' => Q(rcube_label('spellcheckoptions'))),
+      'sig'        => array('name' => Q(rcube_label('signatureoptions'))),
     );
 
     // Show checkbox for HTML Editor
@@ -549,12 +550,26 @@
       $field_id = 'rcmfd_spellcheck_before_send';
       $input_spellcheck = new html_checkbox(array('name' => '_spellcheck_before_send', 'id' => $field_id, 'value' => 1));
 
-      $blocks['main']['options']['spellcheck_before_send'] = array(
+      $blocks['spellcheck']['options']['spellcheck_before_send'] = array(
         'title' => html::label($field_id, Q(rcube_label('spellcheckbeforesend'))),
         'content' => $input_spellcheck->show($config['spellcheck_before_send']?1:0),
       );
     }
 
+    if ($config['enable_spellcheck']) {
+      foreach (array('syms', 'nums', 'caps') as $key) {
+        $key = 'spellcheck_ignore_'.$key;
+        if (!isset($no_override[$key])) {
+          $input_spellcheck = new html_checkbox(array('name' => '_'.$key, 'id' => 'rcmfd_'.$key, 'value' => 1));
+
+          $blocks['spellcheck']['options'][$key] = array(
+            'title' => html::label($field_id, Q(rcube_label(str_replace('_', '', $key)))),
+            'content' => $input_spellcheck->show($config[$key]?1:0),
+          );
+        }
+      }
+    }
+
     if (!isset($no_override['show_sig'])) {
       $field_id = 'rcmfd_show_sig';
       $select_show_sig = new html_select(array('name' => '_show_sig', 'id' => $field_id));
diff --git a/program/steps/settings/save_prefs.inc b/program/steps/settings/save_prefs.inc
index 208874f..7155575 100644
--- a/program/steps/settings/save_prefs.inc
+++ b/program/steps/settings/save_prefs.inc
@@ -71,6 +71,9 @@
       'dsn_default'        => isset($_POST['_dsn_default']) ? TRUE : FALSE,
       'reply_same_folder'  => isset($_POST['_reply_same_folder']) ? TRUE : FALSE,
       'spellcheck_before_send' => isset($_POST['_spellcheck_before_send']) ? TRUE : FALSE,
+      'spellcheck_ignore_syms' => isset($_POST['_spellcheck_ignore_syms']) ? TRUE : FALSE,
+      'spellcheck_ignore_nums' => isset($_POST['_spellcheck_ignore_nums']) ? TRUE : FALSE,
+      'spellcheck_ignore_caps' => isset($_POST['_spellcheck_ignore_caps']) ? TRUE : FALSE,
       'show_sig'           => isset($_POST['_show_sig']) ? intval($_POST['_show_sig']) : 1,
       'top_posting'        => !empty($_POST['_top_posting']),
       'strip_existing_sig' => isset($_POST['_strip_existing_sig']),
@@ -167,7 +170,7 @@
           $a_user_prefs['default_imap_folders'][] = $a_user_prefs[$p];
       }
     }
-  
+
   break;
 }
 
diff --git a/program/steps/utils/spell.inc b/program/steps/utils/spell.inc
index 358576c..b485545 100644
--- a/program/steps/utils/spell.inc
+++ b/program/steps/utils/spell.inc
@@ -23,6 +23,8 @@
 $lang = get_input_value('lang', RCUBE_INPUT_GET);
 $data = file_get_contents('php://input');
 
+$learn_word = strpos($data, '<learnword>');
+
 // Get data string
 $left = strpos($data, '<text>');
 $right = strrpos($data, '</text>');
@@ -30,8 +32,15 @@
 $data = html_entity_decode($data, ENT_QUOTES, RCMAIL_CHARSET);
 
 $spellchecker = new rcube_spellchecker($lang);
-$spellchecker->check($data);
-$result = $spellchecker->get_xml();
+
+if ($learn_word) {
+    $spellchecker->add_word($data);
+    $result = '<?xml version="1.0" encoding="'.RCMAIL_CHARSET.'"?><learnwordresult></learnwordresult>';
+}
+else {
+    $spellchecker->check($data);
+    $result = $spellchecker->get_xml();
+}
 
 // set response length
 header("Content-Length: " . strlen($result));
diff --git a/program/steps/utils/spell_html.inc b/program/steps/utils/spell_html.inc
index d69c73f..2af30ba 100644
--- a/program/steps/utils/spell_html.inc
+++ b/program/steps/utils/spell_html.inc
@@ -40,6 +40,10 @@
 else if ($request['method'] == 'getSuggestions') {
     $result['result'] = $spellchecker->get_suggestions($data);
 }
+else if ($request['method'] == 'learnWord') {
+    $spellchecker->add_word($data);
+    $result['result'] = true;
+}
 
 if ($error = $spellchecker->error()) {
     echo '{"error":{"errstr":"' . addslashes($error) . '","errfile":"","errline":null,"errcontext":"","level":"FATAL"}}';

--
Gitblit v1.9.1