From f8e48df71540b268ceac058d32b8ee848fc2ab6b Mon Sep 17 00:00:00 2001
From: alecpl <alec@alec.pl>
Date: Tue, 06 Sep 2011 12:35:14 -0400
Subject: [PATCH] - Merge devel-saved_search branch (Addressbook Saved Searches)

---
 CHANGELOG                                  |    1 
 config/db.inc.php.dist                     |    2 
 skins/default/images/icons/folders.png     |    0 
 program/steps/addressbook/func.inc         |   29 ++
 skins/default/images/icons/folders.gif     |    0 
 skins/default/includes/messagetoolbar.html |    2 
 program/include/rcube_user.php             |  127 ++++++++++++
 skins/default/addressbook.css              |   10 
 SQL/mysql.update.sql                       |   12 +
 SQL/sqlite.initial.sql                     |   15 +
 SQL/sqlite.update.sql                      |   11 +
 program/localization/en_US/messages.inc    |    6 
 program/localization/en_US/labels.inc      |    6 
 SQL/postgres.update.sql                    |   16 +
 skins/default/templates/addressbook.html   |    4 
 SQL/postgres.initial.sql                   |   26 ++
 SQL/mysql.initial.sql                      |   16 +
 SQL/mssql.initial.sql                      |   29 ++
 program/js/app.js                          |  184 ++++++++++++++---
 SQL/mssql.upgrade.sql                      |   30 +++
 program/steps/addressbook/search.inc       |   71 ++++++
 21 files changed, 549 insertions(+), 48 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG
index ecc67ce..4d6b3e8 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,7 @@
 CHANGELOG Roundcube Webmail
 ===========================
 
+- Addressbook Saved Searches
 - Added spellchecker exceptions dictionary (shared or per-user)
 - Added possibility to ignore words containing caps, numbers, symbols (spellcheck_ignore_* options)
 
diff --git a/SQL/mssql.initial.sql b/SQL/mssql.initial.sql
index 321da7c..8e103eb 100644
--- a/SQL/mssql.initial.sql
+++ b/SQL/mssql.initial.sql
@@ -100,6 +100,15 @@
 ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
 GO
 
+CREATE TABLE [dbo].[searches] (
+	[search_id] [int] IDENTITY (1, 1) NOT NULL ,
+	[user_id] [int] NOT NULL ,
+	[type] [tinyint] NOT NULL ,
+	[name] [varchar] (128) 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 
 	(
@@ -154,6 +163,13 @@
 	(
 		[user_id]
 	)  ON [PRIMARY] 
+GO
+
+ALTER TABLE [dbo].[searches] WITH NOCHECK ADD 
+	CONSTRAINT [PK_searches_search_id] PRIMARY KEY CLUSTERED 
+	(
+		[search_id]
+	) ON [PRIMARY] 
 GO
 
 ALTER TABLE [dbo].[cache] ADD 
@@ -274,6 +290,14 @@
 CREATE  UNIQUE INDEX [IX_dictionary_user_language] ON [dbo].[dictionary]([user_id],[language]) ON [PRIMARY]
 GO
 
+ALTER TABLE [dbo].[searches] ADD 
+	CONSTRAINT [DF_searches_user] DEFAULT (0) FOR [user_id],
+	CONSTRAINT [DF_searches_type] DEFAULT (0) FOR [type],
+GO
+
+CREATE UNIQUE INDEX [IX_searches_user_type_name] ON [dbo].[searches]([user_id],[type],[name]) 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
@@ -304,6 +328,11 @@
     ON DELETE CASCADE ON UPDATE CASCADE
 GO
 
+ALTER TABLE [dbo].[searches] ADD CONSTRAINT [FK_searches_user_id]
+    FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id])
+    ON DELETE CASCADE ON UPDATE CASCADE
+GO
+
 -- Use trigger instead of foreign key (#1487112)
 -- "Introducing FOREIGN KEY constraint ... may cause cycles or multiple cascade paths."
 CREATE TRIGGER [contact_delete_member] ON [dbo].[contacts]
diff --git a/SQL/mssql.upgrade.sql b/SQL/mssql.upgrade.sql
index a77362a..258b1d7 100644
--- a/SQL/mssql.upgrade.sql
+++ b/SQL/mssql.upgrade.sql
@@ -121,3 +121,33 @@
 GO
 CREATE  UNIQUE INDEX [IX_dictionary_user_language] ON [dbo].[dictionary]([user_id],[language]) ON [PRIMARY]
 GO
+
+CREATE TABLE [dbo].[searches] (
+	[search_id] [int] IDENTITY (1, 1) NOT NULL ,
+	[user_id] [int] NOT NULL ,
+	[type] [tinyint] NOT NULL ,
+	[name] [varchar] (128) 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].[searches] WITH NOCHECK ADD 
+	CONSTRAINT [PK_searches_search_id] PRIMARY KEY CLUSTERED 
+	(
+		[search_id]
+	) ON [PRIMARY] 
+GO
+
+ALTER TABLE [dbo].[searches] ADD 
+	CONSTRAINT [DF_searches_user] DEFAULT (0) FOR [user_id],
+	CONSTRAINT [DF_searches_type] DEFAULT (0) FOR [type],
+GO
+
+CREATE UNIQUE INDEX [IX_searches_user_type_name] ON [dbo].[searches]([user_id],[type],[name]) ON [PRIMARY]
+GO
+
+ALTER TABLE [dbo].[searches] ADD CONSTRAINT [FK_searches_user_id]
+    FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id])
+    ON DELETE CASCADE ON UPDATE CASCADE
+GO
+
diff --git a/SQL/mysql.initial.sql b/SQL/mysql.initial.sql
index 4cabb81..6c26690 100644
--- a/SQL/mysql.initial.sql
+++ b/SQL/mysql.initial.sql
@@ -155,4 +155,20 @@
   UNIQUE `uniqueness` (`user_id`, `language`)
 ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */;
 
+
+-- Table structure for table `searches`
+
+CREATE TABLE `searches` (
+ `search_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` int(10) UNSIGNED NOT NULL DEFAULT '0',
+ `type` int(3) NOT NULL DEFAULT '0',
+ `name` varchar(128) NOT NULL,
+ `data` text,
+ PRIMARY KEY(`search_id`),
+ CONSTRAINT `user_id_fk_searches` FOREIGN KEY (`user_id`)
+   REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ UNIQUE `uniqueness` (`user_id`, `type`, `name`)
+) /*!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 ee3929c..afeb3a5 100644
--- a/SQL/mysql.update.sql
+++ b/SQL/mysql.update.sql
@@ -155,3 +155,15 @@
     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 */;
+
+CREATE TABLE `searches` (
+  `search_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `user_id` int(10) UNSIGNED NOT NULL DEFAULT '0',
+  `type` int(3) NOT NULL DEFAULT '0',
+  `name` varchar(128) NOT NULL,
+  `data` text,
+  PRIMARY KEY(`search_id`),
+  CONSTRAINT `user_id_fk_searches` FOREIGN KEY (`user_id`)
+    REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+  UNIQUE `uniqueness` (`user_id`, `type`, `name`)
+) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */;
diff --git a/SQL/postgres.initial.sql b/SQL/postgres.initial.sql
index c801a77..01221c4 100644
--- a/SQL/postgres.initial.sql
+++ b/SQL/postgres.initial.sql
@@ -238,3 +238,29 @@
     data text NOT NULL,
     CONSTRAINT dictionary_user_id_language_key UNIQUE (user_id, "language")
 );
+
+--
+-- Sequence "searches_ids"
+-- Name: searches_ids; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+CREATE SEQUENCE search_ids
+    INCREMENT BY 1
+    NO MAXVALUE
+    NO MINVALUE
+    CACHE 1;
+
+--
+-- Table "searches"
+-- Name: searches; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE searches (
+    search_id integer DEFAULT nextval('search_ids'::text) PRIMARY KEY,
+    user_id integer NOT NULL
+        REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
+    "type" smallint DEFAULT 0 NOT NULL,
+    name varchar(128) NOT NULL,
+    data text NOT NULL,
+    CONSTRAINT searches_user_id_key UNIQUE (user_id, "type", name)
+);
diff --git a/SQL/postgres.update.sql b/SQL/postgres.update.sql
index 0a2ed99..e316ff5 100644
--- a/SQL/postgres.update.sql
+++ b/SQL/postgres.update.sql
@@ -110,3 +110,19 @@
     data text NOT NULL,
     CONSTRAINT dictionary_user_id_language_key UNIQUE (user_id, "language")
 );
+
+CREATE SEQUENCE search_ids
+    INCREMENT BY 1
+    NO MAXVALUE
+    NO MINVALUE
+    CACHE 1;
+
+CREATE TABLE searches (
+    search_id integer DEFAULT nextval('search_ids'::text) PRIMARY KEY,
+    user_id integer NOT NULL
+        REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE,
+    "type" smallint DEFAULT 0 NOT NULL,
+    name varchar(128) NOT NULL,
+    data text NOT NULL,
+    CONSTRAINT searches_user_id_key UNIQUE (user_id, "type", name)
+);
diff --git a/SQL/sqlite.initial.sql b/SQL/sqlite.initial.sql
index 337dfbe..46ee530 100644
--- a/SQL/sqlite.initial.sql
+++ b/SQL/sqlite.initial.sql
@@ -161,3 +161,18 @@
 
 CREATE UNIQUE INDEX ix_dictionary_user_language ON dictionary (user_id, "language");
 
+-- --------------------------------------------------------
+
+--
+-- Table structure for table searches
+--
+
+CREATE TABLE searches (
+  search_id integer NOT NULL PRIMARY KEY,
+  user_id integer NOT NULL DEFAULT '0',
+  "type" smallint NOT NULL DEFAULT '0',
+  name varchar(128) NOT NULL,
+  data text NOT NULL
+);
+
+CREATE UNIQUE INDEX ix_searches_user_type_name (user_id, type, name);
diff --git a/SQL/sqlite.update.sql b/SQL/sqlite.update.sql
index 8d5163f..41ab020 100644
--- a/SQL/sqlite.update.sql
+++ b/SQL/sqlite.update.sql
@@ -227,6 +227,7 @@
 DELETE FROM cache;
 CREATE INDEX ix_contactgroupmembers_contact_id ON contactgroupmembers (contact_id);
 
+
 -- Updates from version 0.6-stable
 
 CREATE TABLE dictionary (
@@ -236,3 +237,13 @@
 );
 
 CREATE UNIQUE INDEX ix_dictionary_user_language ON dictionary (user_id, "language");
+
+CREATE TABLE searches (
+  search_id integer NOT NULL PRIMARY KEY,
+  user_id integer NOT NULL DEFAULT '0',
+  "type" smallint NOT NULL DEFAULT '0',
+  name varchar(128) NOT NULL,
+  data text NOT NULL
+);
+
+CREATE UNIQUE INDEX ix_searches_user_type_name (user_id, type, name);
diff --git a/config/db.inc.php.dist b/config/db.inc.php.dist
index 78cd968..12304cd 100644
--- a/config/db.inc.php.dist
+++ b/config/db.inc.php.dist
@@ -68,6 +68,8 @@
 
 $rcmail_config['db_sequence_messages'] = 'message_ids';
 
+$rcmail_config['db_sequence_searches'] = 'search_ids';
+
 
 // end db config file
 
diff --git a/program/include/rcube_user.php b/program/include/rcube_user.php
index dc5767d..90edad6 100644
--- a/program/include/rcube_user.php
+++ b/program/include/rcube_user.php
@@ -47,6 +47,8 @@
      */
     private $rc;
 
+    const SEARCH_ADDRESSBOOK = 1;
+    const SEARCH_MAIL = 2;
 
     /**
      * Object constructor
@@ -551,4 +553,129 @@
         return empty($plugin['email']) ? NULL : $plugin['email'];
     }
 
+
+    /**
+     * Return a list of saved searches linked with this user
+     *
+     * @param int  $type  Search type
+     *
+     * @return array List of saved searches indexed by search ID
+     */
+    function list_searches($type)
+    {
+        $plugin = $this->rc->plugins->exec_hook('saved_search_list', array('type' => $type));
+
+        if ($plugin['abort']) {
+            return (array) $plugin['result'];
+        }
+
+        $result = array();
+
+        $sql_result = $this->db->query(
+            "SELECT search_id AS id, ".$this->db->quoteIdentifier('name')
+            ." FROM ".get_table_name('searches')
+            ." WHERE user_id = ?"
+                ." AND ".$this->db->quoteIdentifier('type')." = ?"
+            ." ORDER BY ".$this->db->quoteIdentifier('name'),
+            (int) $this->ID, (int) $type);
+
+        while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+            $sql_arr['data'] = unserialize($sql_arr['data']);
+            $result[$sql_arr['id']] = $sql_arr;
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * Return saved search data.
+     *
+     * @param int  $id  Row identifier
+     *
+     * @return array Data
+     */
+    function get_search($id)
+    {
+        $plugin = $this->rc->plugins->exec_hook('saved_search_get', array('id' => $id));
+
+        if ($plugin['abort']) {
+            return $plugin['result'];
+        }
+
+        $sql_result = $this->db->query(
+            "SELECT ".$this->db->quoteIdentifier('name')
+                .", ".$this->db->quoteIdentifier('data')
+                .", ".$this->db->quoteIdentifier('type')
+            ." FROM ".get_table_name('searches')
+            ." WHERE user_id = ?"
+                ." AND search_id = ?",
+            (int) $this->ID, (int) $id);
+
+        while ($sql_arr = $this->db->fetch_assoc($sql_result)) {
+            return array(
+                'id'   => $id,
+                'name' => $sql_arr['name'],
+                'type' => $sql_arr['type'],
+                'data' => unserialize($sql_arr['data']),
+            );
+        }
+
+        return null;
+    }
+
+
+    /**
+     * Deletes given saved search record
+     *
+     * @param  int  $sid  Search ID
+     *
+     * @return boolean True if deleted successfully, false if nothing changed
+     */
+    function delete_search($sid)
+    {
+        if (!$this->ID)
+            return false;
+
+        $this->db->query(
+            "DELETE FROM ".get_table_name('searches')
+            ." WHERE user_id = ?"
+                ." AND search_id = ?",
+            (int) $this->ID, $sid);
+
+        return $this->db->affected_rows();
+    }
+
+
+    /**
+     * Create a new saved search record linked with this user
+     *
+     * @param array $data Hash array with col->value pairs to save
+     *
+     * @return int  The inserted search ID or false on error
+     */
+    function insert_search($data)
+    {
+        if (!$this->ID)
+            return false;
+
+        $insert_cols[]   = 'user_id';
+        $insert_values[] = (int) $this->ID;
+        $insert_cols[]   = $this->db->quoteIdentifier('type');
+        $insert_values[] = (int) $data['type'];
+        $insert_cols[]   = $this->db->quoteIdentifier('name');
+        $insert_values[] = $data['name'];
+        $insert_cols[]   = $this->db->quoteIdentifier('data');
+        $insert_values[] = serialize($data['data']);
+
+        $sql = "INSERT INTO ".get_table_name('searches')
+            ." (".join(', ', $insert_cols).")"
+            ." VALUES (".join(', ', array_pad(array(), sizeof($insert_values), '?')).")";
+
+        call_user_func_array(array($this->db, 'query'),
+            array_merge(array($sql), $insert_values));
+
+        return $this->db->insert_id('searches');
+    }
+
 }
diff --git a/program/js/app.js b/program/js/app.js
index e671ce4..002f345 100644
--- a/program/js/app.js
+++ b/program/js/app.js
@@ -328,7 +328,7 @@
           this.enable_command('export', true);
 
         this.enable_command('add', 'import', this.env.writable_source);
-        this.enable_command('list', 'listgroup', 'advanced-search', true);
+        this.enable_command('list', 'listgroup', 'listsearch', 'advanced-search', true);
 
         // load contacts of selected source
         if (!this.env.action)
@@ -524,21 +524,15 @@
         break;
 
       case 'list':
-        if (this.task=='mail') {
-          if (!this.env.search_request || (props && props != this.env.mailbox))
-            this.reset_qsearch();
-
+        this.reset_qsearch();
+        if (this.task == 'mail') {
           this.list_mailbox(props);
 
           if (this.env.trash_mailbox && !this.env.flag_for_deletion)
             this.set_alttext('delete', this.env.mailbox != this.env.trash_mailbox ? 'movemessagetotrash' : 'deletemessage');
         }
         else if (this.task == 'addressbook') {
-          if (!this.env.search_request || (props != this.env.source))
-            this.reset_qsearch();
-
           this.list_contacts(props);
-          this.enable_command('add', 'import', this.env.writable_source);
         }
         break;
 
@@ -1008,6 +1002,7 @@
         break;
 
       case 'listgroup':
+        this.reset_qsearch();
         this.list_contacts(props.source, props.id);
         break;
 
@@ -1994,7 +1989,7 @@
     if (mbox != this.env.mailbox || (mbox == this.env.mailbox && !page && !sort))
       url += '&_refresh=1';
 
-    this.select_folder(mbox, this.env.mailbox);
+    this.select_folder(mbox);
     this.env.mailbox = mbox;
 
     // load message list remotely
@@ -3455,6 +3450,7 @@
 
     this.env.qsearch = null;
     this.env.search_request = null;
+    this.env.search_id = null;
   };
 
   this.sent_successfully = function(type, msg)
@@ -3829,7 +3825,7 @@
 
   this.list_contacts = function(src, group, page)
   {
-    var add_url = '',
+    var folder, add_url = '',
       target = window;
 
     if (!src)
@@ -3845,7 +3841,12 @@
     else if (group != this.env.group)
       page = this.env.current_page = 1;
 
-    this.select_folder((group ? 'G'+src+group : src), (this.env.group ? 'G'+this.env.source+this.env.group : this.env.source));
+    if (this.env.search_id)
+      folder = 'S'+this.env.search_id;
+    else
+      folder = group ? 'G'+src+group : src;
+
+    this.select_folder(folder);
 
     this.env.source = src;
     this.env.group = group;
@@ -3890,8 +3891,8 @@
     if (group)
       url += '&_gid='+group;
 
-    // also send search request to get the right messages 
-    if (this.env.search_request) 
+    // also send search request to get the right messages
+    if (this.env.search_request)
       url += '&_search='+this.env.search_request;
 
     this.http_request('list', url, lock);
@@ -4092,19 +4093,7 @@
 
   this.group_create = function()
   {
-    if (!this.gui_objects.folderlist)
-      return;
-
-    if (!this.name_input) {
-      this.name_input = $('<input>').attr('type', 'text');
-      this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
-      this.name_input_li = $('<li>').addClass('contactgroup').append(this.name_input);
-
-      var li = this.get_folder_li(this.env.source)
-      this.name_input_li.insertAfter(li);
-    }
-
-    this.name_input.select().focus();
+    this.add_input_row('contactgroup');
   };
 
   this.group_rename = function()
@@ -4150,18 +4139,40 @@
     this.list_contacts(prop.source, 0);
   };
 
+  // @TODO: maybe it would be better to use popup instead of inserting input to the list?
+  this.add_input_row = function(type)
+  {
+    if (!this.gui_objects.folderlist)
+      return;
+
+    if (!this.name_input) {
+      this.name_input = $('<input>').attr('type', 'text').data('tt', type);
+      this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
+      this.name_input_li = $('<li>').addClass(type).append(this.name_input);
+
+      var li = type == 'contactsearch' ? $('li:last', this.gui_objects.folderlist) : this.get_folder_li(this.env.source);
+      this.name_input_li.insertAfter(li);
+    }
+
+    this.name_input.select().focus();
+  };
+
   // handler for keyboard events on the input field
   this.add_input_keydown = function(e)
   {
-    var key = rcube_event.get_keycode(e);
+    var key = rcube_event.get_keycode(e),
+      input = $(e.target), itype = input.data('tt');
 
     // enter
     if (key == 13) {
-      var newname = this.name_input.val();
+      var newname = input.val();
 
       if (newname) {
         var lock = this.set_busy(true, 'loading');
-        if (this.env.group_renaming)
+
+        if (itype == 'contactsearch')
+          this.http_post('search-create', '_search='+urlencode(this.env.search_request)+'&_name='+urlencode(newname), lock);
+        else if (this.env.group_renaming)
           this.http_post('group-rename', '_source='+urlencode(this.env.source)+'&_gid='+urlencode(this.env.group)+'&_name='+urlencode(newname), lock);
         else
           this.http_post('group-create', '_source='+urlencode(this.env.source)+'&_name='+urlencode(newname), lock);
@@ -4477,11 +4488,106 @@
   // unselect directory/group
   this.unselect_directory = function()
   {
-    if (this.env.address_sources.length > 1 || this.env.group != '') {
-      this.select_folder('', (this.env.group ? 'G'+this.env.source+this.env.group : this.env.source));
-      this.env.group = '';
-      this.env.source = '';
+    this.select_folder('');
+    this.enable_command('search-delete', false);
+  };
+
+  // callback for creating a new saved search record
+  this.insert_saved_search = function(name, id)
+  {
+    this.reset_add_input();
+
+    var key = 'S'+id,
+      link = $('<a>').attr('href', '#')
+        .attr('rel', id)
+        .click(function() { return rcmail.command('listsearch', id, this); })
+        .html(name),
+      li = $('<li>').attr({id: 'rcmli'+key.replace(this.identifier_expr, '_'), 'class': 'contactsearch'})
+        .append(link),
+      prop = {name:name, id:id, li:li[0]};
+
+    this.add_saved_search_row(prop, li);
+    this.select_folder('S'+id);
+    this.enable_command('search-delete', true);
+    this.env.search_id = id;
+
+    this.triggerEvent('abook_search_insert', prop);
+  };
+
+  // add saved search row to the list, with sorting
+  this.add_saved_search_row = function(prop, li, reloc)
+  {
+    var row, sibling, name = prop.name.toUpperCase();
+
+    // When renaming groups, we need to remove it from DOM and insert it in the proper place
+    if (reloc) {
+      row = li.clone(true);
+      li.remove();
     }
+    else
+      row = li;
+
+    $('li[class~="contactsearch"]', this.gui_objects.folderlist).each(function(i, elem) {
+      if (!sibling)
+        sibling = this.previousSibling;
+
+      if (name >= $(this).text().toUpperCase())
+        sibling = elem;
+      else
+        return false;
+    });
+
+    if (sibling)
+      row.insertAfter(sibling);
+    else
+      row.appendTo(this.gui_objects.folderlist);
+  };
+
+  // creates an input for saved search name
+  this.search_create = function()
+  {
+    this.add_input_row('contactsearch');
+  };
+
+  this.search_delete = function()
+  {
+    if (this.env.search_request) {
+      var lock = this.set_busy(true, 'savedsearchdeleting');
+      this.http_post('search-delete', '_sid='+urlencode(this.env.search_id), lock);
+    }
+  };
+
+  // callback from server upon search-delete command
+  this.remove_search_item = function(id)
+  {
+    var li, key = 'S'+id;
+    if ((li = this.get_folder_li(key))) {
+      this.triggerEvent('search_delete', { id:id, li:li });
+
+      li.parentNode.removeChild(li);
+    }
+
+    this.env.search_id = null;
+    this.env.search_request = null;
+    this.list_contacts_clear();
+    this.reset_qsearch();
+    this.enable_command('search-delete', 'search-create', false);
+  };
+
+  this.listsearch = function(id)
+  {
+    var folder, lock = this.set_busy(true, 'searching');
+
+    if (this.contact_list) {
+      this.list_contacts_clear();
+    }
+
+    this.reset_qsearch();
+    this.select_folder('S'+id);
+
+    // reset vars
+    this.env.current_page = 1;
+    this.http_request('search', '_sid='+urlencode(id), lock);
   };
 
 
@@ -5231,20 +5337,20 @@
   };
 
   // mark a mailbox as selected and set environment variable
-  this.select_folder = function(name, old, prefix)
+  this.select_folder = function(name, prefix)
   {
     if (this.gui_objects.folderlist) {
       var current_li, target_li;
 
-      if ((current_li = this.get_folder_li(old, prefix))) {
-        $(current_li).removeClass('selected').addClass('unfocused');
+      if ((current_li = $('li.selected', this.gui_objects.folderlist))) {
+        current_li.removeClass('selected').addClass('unfocused');
       }
       if ((target_li = this.get_folder_li(name, prefix))) {
         $(target_li).removeClass('unfocused').addClass('selected');
       }
 
       // trigger event hook
-      this.triggerEvent('selectfolder', { folder:name, old:old, prefix:prefix });
+      this.triggerEvent('selectfolder', { folder:name, prefix:prefix });
     }
   };
 
@@ -5791,6 +5897,8 @@
           this.enable_command('export', (this.contact_list && this.contact_list.rowcount > 0));
 
           if (response.action == 'list' || response.action == 'search') {
+            this.enable_command('search-create', this.env.source == '');
+            this.enable_command('search-delete', this.env.search_id);
             this.update_group_commands();
             this.triggerEvent('listupdate', { folder:this.env.source, rowcount:this.contact_list.rowcount });
           }
diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc
index 7facc12..17544bb 100644
--- a/program/localization/en_US/labels.inc
+++ b/program/localization/en_US/labels.inc
@@ -140,7 +140,7 @@
 $labels['markunread']       = 'As unread';
 $labels['markflagged']      = 'As flagged';
 $labels['markunflagged']    = 'As unflagged';
-$labels['messageactions']   = 'More actions...';
+$labels['moreactions']      = 'More actions...';
 
 $labels['select'] = 'Select';
 $labels['all'] = 'All';
@@ -317,7 +317,6 @@
 $labels['export']         = 'Export';
 $labels['exportvcards']   = 'Export contacts in vCard format';
 $labels['newcontactgroup'] = 'Create new contact group';
-$labels['groupactions']   = 'Actions for contact groups...';
 $labels['grouprename']    = 'Rename group';
 $labels['groupdelete']    = 'Delete group';
 
@@ -330,6 +329,9 @@
 $labels['groups'] = 'Groups';
 $labels['personaladrbook'] = 'Personal Addresses';
 
+$labels['searchsave'] = 'Save search';
+$labels['searchdelete'] = 'Delete search';
+
 $labels['import'] = 'Import';
 $labels['importcontacts'] = 'Import contacts';
 $labels['importfromfile'] = 'Import from file:';
diff --git a/program/localization/en_US/messages.inc b/program/localization/en_US/messages.inc
index c871ca5..65bc9ca 100644
--- a/program/localization/en_US/messages.inc
+++ b/program/localization/en_US/messages.inc
@@ -76,10 +76,10 @@
 $messages['nobodywarning'] = 'Send this message without text?';
 $messages['notsentwarning'] = 'Message has not been sent. Do you want to discard your message?';
 $messages['noldapserver'] = 'Please select an ldap server to search.';
-$messages['nocontactsreturned'] = 'No contacts were found.';
 $messages['nosearchname'] = 'Please enter a contact name or email address.';
 $messages['notuploadedwarning'] = 'Not all attachments have been uploaded yet. Please wait or cancel the upload.';
 $messages['searchsuccessful'] = '$nr messages found.';
+$messages['contactsearchsuccessful'] = '$nr contacts found.';
 $messages['searchnomatch'] = 'Search returned no matches.';
 $messages['searching'] = 'Searching...';
 $messages['checking'] = 'Checking...';
@@ -139,6 +139,10 @@
 $messages['groupdeleted'] = 'Group deleted successfully.';
 $messages['grouprenamed'] = 'Group renamed successfully.';
 $messages['groupcreated'] = 'Group created successfully.';
+$messages['savedsearchdeleted'] = 'Saved search deleted successfully.';
+$messages['savedsearchdeleteerror'] = 'Could not delete saved search.';
+$messages['savedsearchcreated'] = 'Saved search created successfully.';
+$messages['savedsearchcreateerror'] = 'Could not create saved search.';
 $messages['messagedeleted'] = 'Message(s) deleted successfully.';
 $messages['messagemoved'] = 'Message(s) moved successfully.';
 $messages['messagecopied'] = 'Message(s) copied successfully.';
diff --git a/program/steps/addressbook/func.inc b/program/steps/addressbook/func.inc
index 55d4255..b290bbb 100644
--- a/program/steps/addressbook/func.inc
+++ b/program/steps/addressbook/func.inc
@@ -227,7 +227,32 @@
         $out = $groupdata['out'];
     }
 
-    $OUTPUT->set_env('contactgroups', $jsdata); 
+    $line_templ = html::tag('li', array(
+        'id' => 'rcmliS%s', 'class' => '%s'),
+        html::a(array('href' => '#', 'rel' => 'S%s',
+            'onclick' => "return ".JS_OBJECT_NAME.".command('listsearch', '%s', this)"), '%s'));
+
+    // Saved searches
+    $sources = $RCMAIL->user->list_searches(rcube_user::SEARCH_ADDRESSBOOK);
+    foreach ($sources as $j => $source) {
+        $id = $source['id'];
+        $js_id = JQ($id);
+
+        // set class name(s)
+        $class_name = 'contactsearch';
+        if ($current === $id)
+            $class_name .= ' selected';
+        if ($source['class_name'])
+            $class_name .= ' ' . $source['class_name'];
+
+        $out .= sprintf($line_templ,
+            html_identifier($id),
+            $class_name,
+            $id,
+            $js_id, (!empty($source['name']) ? Q($source['name']) : Q($id)));
+    }
+
+    $OUTPUT->set_env('contactgroups', $jsdata);
     $OUTPUT->add_gui_object('folderlist', $attrib['id']);
     // add some labels to client
     $OUTPUT->add_label('deletegroupconfirm', 'groupdeleting', 'addingmember', 'removingmember');
@@ -745,4 +770,6 @@
     'group-delete' => 'groups.inc',
     'group-addmembers' => 'groups.inc',
     'group-delmembers' => 'groups.inc',
+    'search-create' => 'search.inc',
+    'search-delete' => 'search.inc',
 ));
diff --git a/program/steps/addressbook/search.inc b/program/steps/addressbook/search.inc
index 352556d..ad1df97 100644
--- a/program/steps/addressbook/search.inc
+++ b/program/steps/addressbook/search.inc
@@ -21,6 +21,60 @@
 
 */
 
+if ($RCMAIL->action == 'search-create') {
+    $id   = get_input_value('_search', RCUBE_INPUT_POST);
+    $name = get_input_value('_name', RCUBE_INPUT_POST, true);
+
+    if (($params = $_SESSION['search_params']) && $params['id'] == $id) {
+
+        $data = array(
+            'type' => rcube_user::SEARCH_ADDRESSBOOK,
+            'name' => $name,
+            'data' => array(
+                'fields' => $params['data'][0],
+                'search' => $params['data'][1],
+            ),
+        );
+
+        $plugin = $RCMAIL->plugins->exec_hook('saved_search_create', array('data' => $data));
+
+        if (!$plugin['abort'])
+            $result = $RCMAIL->user->insert_search($plugin['data']);
+        else
+            $result = $plugin['result'];
+    }
+
+    if ($result) {
+        $OUTPUT->show_message('savedsearchcreated', 'confirmation');
+        $OUTPUT->command('insert_saved_search', Q($name), Q($result));
+    }
+    else
+        $OUTPUT->show_message($plugin['message'] ? $plugin['message'] : 'savedsearchcreateerror', 'error');
+
+    $OUTPUT->send();
+}
+
+if ($RCMAIL->action == 'search-delete') {
+    $id = get_input_value('_sid', RCUBE_INPUT_POST);
+
+    $plugin = $RCMAIL->plugins->exec_hook('saved_search_delete', array('id' => $id));
+
+    if (!$plugin['abort'])
+        $result = $RCMAIL->user->delete_search($id);
+    else
+        $result = $plugin['result'];
+
+    if ($result) {
+        $OUTPUT->show_message('savedsearchdeleted', 'confirmation');
+        $OUTPUT->command('remove_search_item', Q($id));
+    }
+    else
+        $OUTPUT->show_message($plugin['message'] ? $plugin['message'] : 'savedsearchdeleteerror', 'error');
+
+    $OUTPUT->send();
+}
+
+
 if (!isset($_GET['_form'])) {
     rcmail_contact_search();
 }
@@ -34,9 +88,15 @@
     global $RCMAIL, $OUTPUT, $CONFIG, $SEARCH_MODS_DEFAULT;
 
     $adv = isset($_POST['_adv']);
+    $sid = get_input_value('_sid', RCUBE_INPUT_GET);
 
+    // get search criteria from saved search
+    if ($sid && ($search = $RCMAIL->user->get_search($sid))) {
+        $fields = $search['data']['fields'];
+        $search = $search['data']['search'];
+    }
     // get fields/values from advanced search form
-    if ($adv) {
+    else if ($adv) {
         foreach (array_keys($_POST) as $key) {
             $s = trim(get_input_value($key, RCUBE_INPUT_POST, true));
             if (strlen($s) && preg_match('/^_search_([a-zA-Z0-9_-]+)$/', $key, $m)) {
@@ -145,6 +205,7 @@
 
     // save search settings in session
     $_SESSION['search'][$search_request] = $search_set;
+    $_SESSION['search_params'] = array('id' => $search_request, 'data' => array($fields, $search));
     $_SESSION['page'] = 1;
 
     if ($adv)
@@ -153,6 +214,7 @@
     if ($result->count > 0) {
         // create javascript list
         rcmail_js_contacts_list($result);
+        $OUTPUT->show_message('contactsearchsuccessful', 'confirmation', array('nr' => $result->count));
     }
     else {
         $OUTPUT->show_message('nocontactsfound', 'notice');
@@ -162,9 +224,14 @@
     $OUTPUT->command('set_env', 'search_request', $search_request);
     $OUTPUT->command('set_env', 'pagecount', ceil($result->count / $CONFIG['pagesize']));
     $OUTPUT->command('set_rowcount', rcmail_get_rowcount_text($result));
+    // Re-set current source
+    $OUTPUT->command('set_env', 'search_id', $sid);
+    $OUTPUT->command('set_env', 'source', '');
+    $OUTPUT->command('set_env', 'group', '');
 
     // unselect currently selected directory/group
-    $OUTPUT->command('unselect_directory');
+    if (!$sid)
+        $OUTPUT->command('unselect_directory');
     $OUTPUT->command('update_group_commands');
 
     // send response
diff --git a/skins/default/addressbook.css b/skins/default/addressbook.css
index f3b52c8..c604c75 100644
--- a/skins/default/addressbook.css
+++ b/skins/default/addressbook.css
@@ -109,7 +109,8 @@
 
 #directorylistbox input
 {
-  margin: 2px;
+  margin: 0px;
+  font-size: 11px;
   width: 90%;
 }
 
@@ -165,7 +166,12 @@
 #directorylist li.contactgroup
 {
   padding-left: 15px;
-  background-position: 20px -144px;
+  background-position: 20px -143px;
+}
+
+#directorylist li.contactsearch
+{
+  background-position: 6px -162px;
 }
 
 #directorylist li.selected
diff --git a/skins/default/images/icons/folders.gif b/skins/default/images/icons/folders.gif
index 0fccb2c..1002cb7 100644
--- a/skins/default/images/icons/folders.gif
+++ b/skins/default/images/icons/folders.gif
Binary files differ
diff --git a/skins/default/images/icons/folders.png b/skins/default/images/icons/folders.png
index 5013318..d8b9e32 100644
--- a/skins/default/images/icons/folders.png
+++ b/skins/default/images/icons/folders.png
Binary files differ
diff --git a/skins/default/includes/messagetoolbar.html b/skins/default/includes/messagetoolbar.html
index f670182..57bed8a 100644
--- a/skins/default/includes/messagetoolbar.html
+++ b/skins/default/includes/messagetoolbar.html
@@ -19,7 +19,7 @@
 <roundcube:if condition="template:name == 'mail'" />
 <roundcube:button name="markmenulink" id="markmenulink" type="link" class="button markmessage" title="markmessages" onclick="rcmail_ui.show_popup('markmenu');return false" content=" " />                                                                   
 <roundcube:endif />
-<roundcube:button name="messagemenulink" id="messagemenulink" type="link" class="button messagemenu" title="messageactions" onclick="rcmail_ui.show_popup('messagemenu');return false" content=" " />
+<roundcube:button name="messagemenulink" id="messagemenulink" type="link" class="button messagemenu" title="moreactions" onclick="rcmail_ui.show_popup('messagemenu');return false" content=" " />
 <roundcube:if condition="template:name == 'message'" />
 <roundcube:object name="mailboxlist" type="select" noSelection="moveto" maxlength="25" onchange="rcmail.command('moveto', this.options[this.selectedIndex].value)" class="mboxlist" folder_filter="mail" />
 <roundcube:endif />
diff --git a/skins/default/templates/addressbook.html b/skins/default/templates/addressbook.html
index a85c889..1930deb 100644
--- a/skins/default/templates/addressbook.html
+++ b/skins/default/templates/addressbook.html
@@ -58,7 +58,7 @@
 </div>
 <div class="boxfooter">
   <roundcube:button command="group-create" type="link" title="newcontactgroup" class="buttonPas addgroup" classAct="button addgroup" content=" " />
-  <roundcube:button name="groupmenulink" id="groupmenulink" type="link" title="groupactions" class="button groupactions" onclick="rcmail_ui.show_popup('groupmenu');return false" content=" " />
+  <roundcube:button name="groupmenulink" id="groupmenulink" type="link" title="moreactions" class="button groupactions" onclick="rcmail_ui.show_popup('groupmenu');return false" content=" " />
 </div>
 </div>
 
@@ -99,6 +99,8 @@
   <ul>
     <li><roundcube:button command="group-rename" label="grouprename" classAct="active" /></li>
     <li><roundcube:button command="group-delete" label="groupdelete" classAct="active" /></li>
+    <li class="separator_above"><roundcube:button command="search-create" label="searchsave" classAct="active" /></li>
+    <li><roundcube:button command="search-delete" label="searchdelete" classAct="active" /></li>
     <roundcube:container name="groupoptions" id="groupoptionsmenu" />
   </ul>
 </div>

--
Gitblit v1.9.1