1 /*
  2  * ***** BEGIN LICENSE BLOCK *****
  3  * Zimbra Collaboration Suite Web Client
  4  * Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc.
  5  *
  6  * The contents of this file are subject to the Common Public Attribution License Version 1.0 (the "License");
  7  * you may not use this file except in compliance with the License.
  8  * You may obtain a copy of the License at: https://www.zimbra.com/license
  9  * The License is based on the Mozilla Public License Version 1.1 but Sections 14 and 15
 10  * have been added to cover use of software over a computer network and provide for limited attribution
 11  * for the Original Developer. In addition, Exhibit A has been modified to be consistent with Exhibit B.
 12  *
 13  * Software distributed under the License is distributed on an "AS IS" basis,
 14  * WITHOUT WARRANTY OF ANY KIND, either express or implied.
 15  * See the License for the specific language governing rights and limitations under the License.
 16  * The Original Code is Zimbra Open Source Web Client.
 17  * The Initial Developer of the Original Code is Zimbra, Inc.  All rights to the Original Code were
 18  * transferred by Zimbra, Inc. to Synacor, Inc. on September 14, 2015.
 19  *
 20  * All portions of the code are Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. All Rights Reserved.
 21  * ***** END LICENSE BLOCK *****
 22  */
 23 
 24 /**
 25  * @overview
 26  * The file defines a search class.
 27  * 
 28  */
 29 
 30 /**
 31  * Creates a new search with the given properties.
 32  * @class
 33  * This class represents a search to be performed on the server. It has properties for
 34  * the different search parameters that may be used. It can be used for a regular search,
 35  * or to search within a conversation. The results are returned via a callback.
 36  *
 37  * @param {Hash}		params		a hash of parameters
 38  * @param   {String}	params.query					the query string
 39  * @param	{String}	params.queryHint				the query string that gets appended to the query but not something the user needs to know about
 40  * @param	{AjxVector}	params.types					the item types to search for
 41  * @param	{Boolean}	params.forceTypes				use the types we pass, do not override (in case of mail) to the current user's view pref (MSG vs. CONV).
 42  * @param	{constant}	params.sortBy					the sort order
 43  * @param	{int}		params.offset					the starting point within result set
 44  * @param	{int}		params.limit					the number of results to return
 45  * @param	{Boolean}	params.getHtml					if <code>true</code>, return HTML part for inlined msg
 46  * @param	{constant}	params.contactSource			where to search for contacts (GAL or personal)
 47  * @param	{Boolean}	params.isGalAutocompleteSearch	if <code>true</code>, autocomplete against GAL
 48  * @param	{constant}	params.galType					the type of GAL autocomplete (account or resource)
 49  * @param	{constant}	params.autocompleteType			the type of autocomplete (account or resource or all)
 50  * @param	{int}		params.lastId					the ID of last item displayed (for pagination)
 51  * @param	{String}	params.lastSortVal				the value of sort field for above item
 52  * @param	{Boolean}	params.fetch					if <code>true</code>, fetch first hit message
 53  * @param	{int}		params.searchId					the ID of owning search folder (if any)
 54  * @param	{Array}		params.conds					the list of search conditions (<code><SearchCalendarResourcesRequest></code>)
 55  * @param	{Array}		params.attrs					the list of attributes to return (<code><SearchCalendarResourcesRequest></code>)
 56  * @param	{String}	params.field					the field to search within (instead of default)
 57  * @param	{Object}	params.soapInfo					the object with method, namespace, response, and additional attribute fields for creating soap doc
 58  * @param	{Object}	params.response					the canned JSON response (no request will be made)
 59  * @param	{Array}		params.folders					the list of folders for autocomplete
 60  * @param	{Array}		params.allowableTaskStatus		the list of task status types to return (assuming one of the values for "types" is "task")
 61  * @param	{String}	params.accountName				the account name to run this search against
 62  * @param	{Boolean}	params.idsOnly					if <code>true</code>, response returns item IDs only
 63  * @param   {Boolean}   params.inDumpster               if <code>true</code>, search in the dumpster
 64  * @param	{string}	params.origin					indicates what initiated the search
 65  * @param	{boolean}	params.isEmpty					if true, return empty response without sending a request
 66  */
 67 ZmSearch = function(params) {
 68 
 69 	params = params || {};
 70 	for (var p in params) {
 71 		this[p] = params[p];
 72 	}
 73 	this.galType					= this.galType || ZmSearch.GAL_ACCOUNT;
 74 	this.join						= this.join || ZmSearch.JOIN_AND;
 75 
 76 	if (this.query || this.queryHint) {
 77 		// only parse regular searches
 78 		if (!this.isGalSearch && !this.isAutocompleteSearch &&
 79 			!this.isGalAutocompleteSearch && !this.isCalResSearch) {
 80 			
 81 			var pq = this.parsedQuery = new ZmParsedQuery(this.query || this.queryHint);
 82 			this._setProperties();
 83 			var sortTerm = pq.getTerm("sort");
 84 			if (sortTerm) {
 85 				this.sortBy = sortTerm.arg;
 86 			}
 87 		}
 88 	}
 89 
 90 	this.isGalSearch = false;
 91 	this.isCalResSearch = false;
 92 
 93 	if (ZmSearch._mailEnabled == null) {
 94 		ZmSearch._mailEnabled = appCtxt.get(ZmSetting.MAIL_ENABLED);
 95 		if (ZmSearch._mailEnabled) {
 96 			AjxDispatcher.require("MailCore");
 97 		}
 98 	}
 99 
100 	if (params.checkTypes) {
101 		var types = AjxUtil.toArray(this.types);
102 		var enabledTypes = [];
103 		for (var i = 0; i < types.length; i++) {
104 			var type = types[i];
105 			var app = ZmItem.APP[type];
106 			if (appCtxt.get(ZmApp.SETTING[app])) {
107 				enabledTypes.push(type);
108 			}
109 		}
110 		this.types = AjxVector.fromArray(enabledTypes);
111 	}
112 };
113 
114 ZmSearch.prototype.isZmSearch = true;
115 ZmSearch.prototype.toString = function() { return "ZmSearch"; };
116 
117 // Search types
118 ZmSearch.TYPE = {};
119 ZmSearch.TYPE_ANY = "any";
120 
121 ZmSearch.GAL_ACCOUNT	= "account";
122 ZmSearch.GAL_RESOURCE	= "resource";
123 ZmSearch.GAL_ALL		= "";
124 
125 ZmSearch.JOIN_AND	= 1;
126 ZmSearch.JOIN_OR	= 2;
127 
128 ZmSearch.TYPE_MAP = {};
129 
130 ZmSearch.DEFAULT_LIMIT = DwtListView.DEFAULT_LIMIT;
131 
132 // Sort By
133 ZmSearch.DATE_DESC 		= "dateDesc";
134 ZmSearch.DATE_ASC 		= "dateAsc";
135 ZmSearch.SUBJ_DESC 		= "subjDesc";
136 ZmSearch.SUBJ_ASC 		= "subjAsc";
137 ZmSearch.NAME_DESC 		= "nameDesc";
138 ZmSearch.NAME_ASC 		= "nameAsc";
139 ZmSearch.SIZE_DESC 		= "sizeDesc";
140 ZmSearch.SIZE_ASC 		= "sizeAsc";
141 ZmSearch.RCPT_ASC       = "rcptAsc";
142 ZmSearch.RCPT_DESC      = "rcptDesc";
143 ZmSearch.ATTACH_ASC     = "attachAsc"
144 ZmSearch.ATTACH_DESC    = "attachDesc"
145 ZmSearch.FLAG_ASC       = "flagAsc";
146 ZmSearch.FLAG_DESC      = "flagDesc";
147 ZmSearch.MUTE_ASC       = "muteAsc";
148 ZmSearch.MUTE_DESC      = "muteDesc";
149 ZmSearch.READ_ASC       = "readAsc";
150 ZmSearch.READ_DESC      = "readDesc";
151 ZmSearch.PRIORITY_ASC   = "priorityAsc";
152 ZmSearch.PRIORITY_DESC  = "priorityDesc";
153 ZmSearch.SCORE_DESC 	= "scoreDesc";
154 ZmSearch.DURATION_DESC	= "durDesc";
155 ZmSearch.DURATION_ASC	= "durAsc";
156 ZmSearch.STATUS_DESC	= "taskStatusDesc";
157 ZmSearch.STATUS_ASC		= "taskStatusAsc";
158 ZmSearch.PCOMPLETE_DESC	= "taskPercCompletedDesc";
159 ZmSearch.PCOMPLETE_ASC	= "taskPercCompletedAsc";
160 ZmSearch.DUE_DATE_DESC	= "taskDueDesc";
161 ZmSearch.DUE_DATE_ASC	= "taskDueAsc";
162 
163 
164 
165 ZmSearch.prototype.execute =
166 function(params) {
167 	if (params.batchCmd || this.soapInfo) {
168 		return this._executeSoap(params);
169 	} else {
170 		return this._executeJson(params);
171 	}
172 };
173 
174 /**
175  * Creates a SOAP request that represents this search and sends it to the server.
176  *
177  * @param {Hash}	params		a hash of parameters
178  * @param {AjxCallback}	params.callback		the callback to run when response is received
179  * @param {AjxCallback}	params.errorCallback	the callback to run if there is an exception
180  * @param {ZmBatchCommand}	params.batchCmd		the batch command that contains this request
181  * @param {int}	params.timeout		the timeout value (in seconds)
182  * @param {Boolean}	params.noBusyOverlay	if <code>true</code>, don't use the busy overlay
183  * 
184  * @private
185  */
186 ZmSearch.prototype._executeSoap =
187 function(params) {
188 
189 	this.isGalSearch = (this.contactSource && (this.contactSource == ZmId.SEARCH_GAL));
190 	this.isCalResSearch = (!this.contactSource && this.conds != null);
191     if (appCtxt.isOffline && this.isCalResSearch) {
192         this.isCalResSearch =  appCtxt.isZDOnline();
193     }
194 	if (this.isEmpty) {
195 		this._handleResponseExecute(params.callback);
196 		return null;
197 	}
198 
199 	var soapDoc;
200 	if (!this.response) {
201 		if (this.isGalSearch) {
202 			// DEPRECATED: Use JSON version
203 			soapDoc = AjxSoapDoc.create("SearchGalRequest", "urn:zimbraAccount");
204 			var method = soapDoc.getMethod();
205 			if (this.galType) {
206 				method.setAttribute("type", this.galType);
207 			}
208 			soapDoc.set("name", this.query);
209 			var searchFilterEl = soapDoc.set("searchFilter");
210 			if (this.conds && this.conds.length) {
211 				var condsEl = soapDoc.set("conds", null, searchFilterEl);
212 				this._applySoapCond(this.conds, soapDoc, condsEl);
213 			}
214 		} else if (this.isAutocompleteSearch) {
215 			soapDoc = AjxSoapDoc.create("AutoCompleteRequest", "urn:zimbraMail");
216 			var method = soapDoc.getMethod();
217 			if (this.limit) {
218 				method.setAttribute("limit", this.limit);
219 			}
220 			soapDoc.set("name", this.query);
221 		} else if (this.isGalAutocompleteSearch) {
222 			soapDoc = AjxSoapDoc.create("AutoCompleteGalRequest", "urn:zimbraAccount");
223 			var method = soapDoc.getMethod();
224 			method.setAttribute("limit", this._getLimit());
225 			if (this.galType) {
226 				method.setAttribute("type", this.galType);
227 			}
228 			soapDoc.set("name", this.query);
229 		} else if (this.isCalResSearch) {
230 			soapDoc = AjxSoapDoc.create("SearchCalendarResourcesRequest", "urn:zimbraAccount");
231 			var method = soapDoc.getMethod();
232 			if (this.attrs) {
233 				var attrs = [].concat(this.attrs);
234 				AjxUtil.arrayRemove(attrs, "fullName");
235 				method.setAttribute("attrs", attrs.join(","));
236 			}
237 			var searchFilterEl = soapDoc.set("searchFilter");
238 			if (this.conds && this.conds.length) {
239 				var condsEl = soapDoc.set("conds", null, searchFilterEl);
240 				this._applySoapCond(this.conds, soapDoc, condsEl);
241 			}
242 		} else {
243 			if (this.soapInfo) {
244 				soapDoc = AjxSoapDoc.create(this.soapInfo.method, this.soapInfo.namespace);
245 				// Pass along any extra soap data. (Voice searches use this to pass user identification.)
246 				for (var nodeName in this.soapInfo.additional) {
247 					var node = soapDoc.set(nodeName);
248 					var attrs = this.soapInfo.additional[nodeName];
249 					for (var attr in attrs) {
250 						node.setAttribute(attr, attrs[attr]);
251 					}
252 				}
253 			} else {
254 				soapDoc = AjxSoapDoc.create("SearchRequest", "urn:zimbraMail");
255 			}
256 			var method = this._getStandardMethod(soapDoc);
257 			if (this.types) {
258 				var a = this.types.getArray();
259 				if (a.length) {
260 					var typeStr = [];
261 					for (var i = 0; i < a.length; i++) {
262 						typeStr.push(ZmSearch.TYPE[a[i]]);
263 					}
264 					method.setAttribute("types", typeStr.join(","));
265 					if (this.types.contains(ZmItem.MSG) || this.types.contains(ZmItem.CONV)) {
266 						// special handling for showing participants ("To" instead of "From")
267 						var folder = this.folderId && appCtxt.getById(this.folderId);
268 						method.setAttribute("recip", (folder && folder.isOutbound()) ? "1" : "0");
269 					}
270 					if (this.types.contains(ZmItem.CONV)) {
271 						// get ID/folder for every msg in each conv result
272 						method.setAttribute("fullConversation", 1);
273 					}
274 					// if we're prefetching the first hit message, also mark it as read
275 					if (this.fetch) {
276 
277 						method.setAttribute("fetch", ( this.fetch == "all" ) ? "all" : "1");
278 						// and set the html flag if we want the html version
279 						if (this.getHtml) {
280 							method.setAttribute("html", "1");
281 						}
282 					}
283 					if (this.markRead) {
284 						method.setAttribute("read", "1");
285 					}
286 				}
287 			}
288 			if (this.inDumpster) {
289 				method.setAttribute("inDumpster", "1");
290 			}
291 		}
292 	}
293 
294 	var soapMethod = this._getStandardMethod(soapDoc);
295 	soapMethod.setAttribute("needExp", 1);
296 
297 	var respCallback = this._handleResponseExecute.bind(this, params.callback);
298 
299 	if (params.batchCmd) {
300 		params.batchCmd.addRequestParams(soapDoc, respCallback);
301 	} else {
302 		return appCtxt.getAppController().sendRequest({soapDoc:soapDoc, asyncMode:true, callback:respCallback,
303 													   errorCallback:params.errorCallback,
304 													   timeout:params.timeout, noBusyOverlay:params.noBusyOverlay,
305 													   response:this.response});
306 	}
307 };
308 
309 /**
310  * Creates a JSON request that represents this search and sends it to the server.
311  *
312  * @param {Hash}	params		a hash of parameters
313  * @param {AjxCallback}	params.callback		the callback to run when response is received
314  * @param {AjxCallback}	params.errorCallback	the callback to run if there is an exception
315  * @param {ZmBatchCommand}	params.batchCmd		the batch command that contains this request
316  * @param {int}	params.timeout		the timeout value (in seconds)
317  * @param {Boolean}	params.noBusyOverlay	if <code>true</code>, don't use the busy overlay
318  * 
319  * @private
320  */
321 ZmSearch.prototype._executeJson =
322 function(params) {
323 
324 	this.isGalSearch = (this.contactSource && (this.contactSource == ZmId.SEARCH_GAL));
325 	this.isCalResSearch = (!this.contactSource && this.conds != null);
326     if (appCtxt.isOffline && this.isCalResSearch) {
327         this.isCalResSearch = appCtxt.isZDOnline();
328     }
329 	if (this.isEmpty) {
330 		this._handleResponseExecute(params.callback);
331 		return null;
332 	}
333 
334 	var jsonObj, request, soapDoc;
335 	if (!this.response) {
336 		if (this.isGalSearch) {
337 			request = {
338 				_jsns:"urn:zimbraAccount",
339 				needIsOwner: "1",
340 				needIsMember: "directOnly"
341 			};
342 			jsonObj = {SearchGalRequest: request};
343 			if (this.galType) {
344 				request.type = this.galType;
345 			}
346 			request.name = this.query;
347 
348 			// bug #36188 - add offset/limit for paging support
349 			request.offset = this.offset = (this.offset || 0);
350 			request.limit = this._getLimit();
351 
352 			// bug 15878: see same in ZmSearch.prototype._getStandardMethodJson
353 			request.locale = { _content: AjxEnv.DEFAULT_LOCALE };
354 
355 			if (this.lastId) { // add lastSortVal and lastId for cursor-based paging
356 				request.cursor = {id:this.lastId, sortVal:(this.lastSortVal || "")};
357 			}
358 			if (this.sortBy) {
359 				request.sortBy = this.sortBy;
360 			}
361 			if (this.conds && this.conds.length) {
362 				request.searchFilter = {conds:{}};
363 				request.searchFilter.conds = ZmSearch.prototype._applyJsonCond(this.conds, request);
364 			}
365 		} else if (this.isAutocompleteSearch) {
366 			jsonObj = {AutoCompleteRequest:{_jsns:"urn:zimbraMail"}};
367 			request = jsonObj.AutoCompleteRequest;
368 			if (this.limit) {
369 				request.limit = this.limit;
370 			}
371 			request.name = {_content:this.query};
372 			if (params.autocompleteType) {
373 				request.t = params.autocompleteType;
374 			}
375 		} else if (this.isGalAutocompleteSearch) {
376 			jsonObj = {AutoCompleteGalRequest:{_jsns:"urn:zimbraAccount"}};
377 			request = jsonObj.AutoCompleteGalRequest;
378 			request.limit = this._getLimit();
379 			request.name = this.query;
380 			if (this.galType) {
381 				request.type = this.galType;
382 			}
383 		} else if (this.isCalResSearch) {
384 			jsonObj = {SearchCalendarResourcesRequest:{_jsns:"urn:zimbraAccount"}};
385 			request = jsonObj.SearchCalendarResourcesRequest;
386 			if (this.attrs) {
387 				var attrs = [].concat(this.attrs);
388 				request.attrs = attrs.join(",");
389 			}
390             request.offset = this.offset = (this.offset || 0);
391             request.limit = this._getLimit();
392 			if (this.conds && this.conds.length) {
393 				request.searchFilter = {conds:{}};
394 				request.searchFilter.conds = ZmSearch.prototype._applyJsonCond(this.conds, request);
395 			}
396 		} else {
397 			if (this.soapInfo) {
398 				soapDoc = AjxSoapDoc.create(this.soapInfo.method, this.soapInfo.namespace);
399 				// Pass along any extra soap data. (Voice searches use this to pass user identification.)
400 				for (var nodeName in this.soapInfo.additional) {
401 					var node = soapDoc.set(nodeName);
402 					var attrs = this.soapInfo.additional[nodeName];
403 					for (var attr in attrs) {
404 						node.setAttribute(attr, attrs[attr]);
405 					}
406 				}
407 			} else {
408 				jsonObj = {SearchRequest:{_jsns:"urn:zimbraMail"}};
409 				request = jsonObj.SearchRequest;
410 			}
411 			this._getStandardMethodJson(request);
412 			if (this.types) {
413 				var a = this.types.getArray();
414 				if (a.length) {
415 					var typeStr = [];
416 					for (var i = 0; i < a.length; i++) {
417 						typeStr.push(ZmSearch.TYPE[a[i]]);
418 					}
419 					request.types = typeStr.join(",");
420 
421 					if (this.types.contains(ZmItem.MSG) || this.types.contains(ZmItem.CONV)) {
422 						// special handling for showing participants ("To" instead of "From")
423 						var folder = this.folderId && appCtxt.getById(this.folderId);
424 						request.recip = (folder && folder.isOutbound()) ? "2" : "0";
425 					}
426 
427 					if (this.types.contains(ZmItem.CONV)) {
428 						// get ID/folder for every msg in each conv result
429 						request.fullConversation = 1;
430 					}
431 
432 					// if we're prefetching the first hit message, also mark it as read
433 					if (this.fetch) {
434                         request.fetch = ( this.fetch == "all" ) ? "all" : 1;
435 						// and set the html flag if we want the html version
436 						if (this.getHtml) {
437 							request.html = 1;
438 						}
439 					}
440 
441 					if (this.markRead) {
442 						request.read = 1;
443 					}
444 
445                     if (this.headers) {
446                         for (var hdr in this.headers) {
447                             if (!request.header) { request.header = []; }
448                             request.header.push({n: this.headers[hdr]});
449                         }
450                     }
451 
452 					if (a.length == 1 && a[0] == ZmItem.TASK && this.allowableTaskStatus) {
453 						request.allowableTaskStatus = this.allowableTaskStatus;
454 					}
455                 }
456             }
457 			if (this.inDumpster) {
458 				request.inDumpster = 1;
459 			}
460         }
461     }
462 
463 	if (request) {
464 		request.needExp = 1;
465 	}
466 
467 
468 	var respCallback = this._handleResponseExecute.bind(this, params.callback);
469 
470 	if (params.batchCmd) {
471 		params.batchCmd.addRequestParams(soapDoc, respCallback);
472 	} else {
473 		var searchParams = {
474 			jsonObj:jsonObj,
475 			soapDoc:soapDoc,
476 			asyncMode:true,
477 			callback:respCallback,
478 			errorCallback:params.errorCallback,
479             offlineCallback:params.offlineCallback,
480 			timeout:params.timeout,
481             offlineCache:params.offlineCache,
482 			noBusyOverlay:params.noBusyOverlay,
483 			response:this.response,
484 			accountName:this.accountName,
485             offlineRequest:params.offlineRequest
486 		};
487 		return appCtxt.getAppController().sendRequest(searchParams);
488 	}
489 };
490 
491 ZmSearch.prototype._applySoapCond =
492 function(inConds, soapDoc, condsEl, or) {
493 	if (or || this.join == ZmSearch.JOIN_OR) {
494 		condsEl.setAttribute("or", 1);
495 	}
496 	for (var i = 0; i < inConds.length; i++) {
497 		var c = inConds[i];
498 		if (AjxUtil.isArray(c)) {
499 			var subCondsEl = soapDoc.set("conds", null, condsEl);
500 			this._applySoapCond(c, soapDoc, subCondsEl, true);
501 		} else if (c.attr=="fullName" && c.op=="has") {
502 			var nameEl = soapDoc.set("name", c.value);
503 		} else {
504 			var condEl = soapDoc.set("cond", null, condsEl);
505 			condEl.setAttribute("attr", c.attr);
506 			condEl.setAttribute("op", c.op);
507 			condEl.setAttribute("value", c.value);
508 		}
509 	}
510 };
511 
512 ZmSearch.prototype._applyJsonCond =
513 function(inConds, request, or) {
514 	var outConds = {};
515 	if (or || this.join == ZmSearch.JOIN_OR) {
516 		outConds.or = 1;
517 	}
518 
519 	for (var i = 0; i < inConds.length; i++) {
520 		var c = inConds[i];
521 		if (AjxUtil.isArray(c)) {
522 			if (!outConds.conds)
523 				outConds.conds = [];
524 			outConds.conds.push(this._applyJsonCond(c, request, true));
525 		} else if (c.attr=="fullName" && c.op=="has") {
526 			request.name = {_content: c.value};
527 		} else {
528 			if (!outConds.cond)
529 				outConds.cond = [];
530 			outConds.cond.push({attr:c.attr, op:c.op, value:c.value});
531 		}
532 	}
533 	return outConds;
534 };
535 
536 /**
537  * Converts the response into a {ZmSearchResult} and passes it along.
538  * 
539  * @private
540  */
541 ZmSearch.prototype._handleResponseExecute =
542 function(callback, result) {
543 	
544 	if (result) {
545 		var response = result.getResponse();
546 	
547 		if      (this.isGalSearch)				{ response = response.SearchGalResponse; }
548 		else if (this.isCalResSearch)			{ response = response.SearchCalendarResourcesResponse; }
549 		else if (this.isAutocompleteSearch)		{ response = response.AutoCompleteResponse; }
550 		else if (this.isGalAutocompleteSearch)	{ response = response.AutoCompleteGalResponse; }
551 		else if (this.soapInfo)					{ response = response[this.soapInfo.response]; }
552 		else									{ response = response.SearchResponse; }
553 	}
554 	else {
555 		response = { _jsns: "urn:zimbraMail", more: false };
556 	}
557 	var searchResult = new ZmSearchResult(this);
558 	searchResult.set(response);
559 	result = result || new ZmCsfeResult();
560 	result.set(searchResult);
561 
562 	if (callback) {
563 		callback.run(result);
564 	}
565 };
566 
567 /**
568  * Fetches a conversation from the server.
569  *
570  * @param {Hash}		params				a hash of parameters:
571  * @param {String}		params.cid			the conv ID
572  * @param {AjxCallback}	params.callback		the callback to run with result
573  * @param {String}		params.fetch		which msg bodies to load (see soap.txt)
574  * @param {Boolean}		params.markRead		if <code>true</code>, mark msg read
575  * @param {Boolean}		params.noTruncate	if <code>true</code>, do not limit size of msg
576  * @param {boolean}		params.needExp		if not <code>false</code>, have server check if addresses are DLs
577  */
578 ZmSearch.prototype.getConv =
579 function(params) {
580 	if ((!this.query && !this.queryHint) || !params.cid) { return; }
581 
582 	var jsonObj = {SearchConvRequest:{_jsns:"urn:zimbraMail"}};
583 	var request = jsonObj.SearchConvRequest;
584 	this._getStandardMethodJson(request);
585 	request.cid = params.cid;
586 	if (params.fetch) {
587 		request.fetch = params.fetch;
588 		if (params.markRead) {
589 			request.read = 1;			// mark that msg read
590 		}
591 		if (this.getHtml) {
592 			request.html = 1;			// get it as HTML
593 		}
594 		if (params.needExp !== false) {
595 			request.needExp = 1;
596 		}
597 	}
598 
599 	if (!params.noTruncate) {
600 		request.max = appCtxt.get(ZmSetting.MAX_MESSAGE_SIZE);
601 	}
602 
603 	//get both TO and From
604 	request.recip =  "2";
605 
606 	var searchParams = {
607 		jsonObj:		jsonObj,
608 		asyncMode:		true,
609 		callback:		this._handleResponseGetConv.bind(this, params.callback),
610 		accountName:	this.accountName
611 	};
612 	appCtxt.getAppController().sendRequest(searchParams);
613 };
614 
615 /**
616  * @private
617  */
618 ZmSearch.prototype._handleResponseGetConv =
619 function(callback, result) {
620 	var response = result.getResponse().SearchConvResponse;
621 	var searchResult = new ZmSearchResult(this);
622 	searchResult.set(response, null, true);
623 	result.set(searchResult);
624 
625 	if (callback) {
626 		callback.run(result);
627 	}
628 };
629 
630 /**
631  * Clears cursor-related fields from this search so that it will not create a cursor.
632  */
633 ZmSearch.prototype.clearCursor =
634 function() {
635 	this.lastId = this.lastSortVal = this.endSortVal = null;
636 };
637 
638 /**
639  * Gets a title that summarizes this search.
640  * 
641  * @return	{String}	the title
642  */
643 ZmSearch.prototype.getTitle =
644 function() {
645 	var where;
646 	var pq = this.parsedQuery;
647 	// if this is a saved search, show its name, otherwise show folder or tag name if it's the only term
648 	var orgId = this.searchId || ((pq && (pq.getNumTokens() == 1)) ? this.folderId || this.tagId : null);
649 	if (orgId) {
650 		var org = appCtxt.getById(ZmOrganizer.getSystemId(orgId));
651 		if (org) {
652 			where = org.getName(true, ZmOrganizer.MAX_DISPLAY_NAME_LENGTH, true);
653 		}
654 	}
655 	return where ? ([ZmMsg.zimbraTitle, where].join(": ")) : ([ZmMsg.zimbraTitle, ZmMsg.searchResults].join(": "));
656 };
657 
658 /**
659  * Checks if this search is multi-account.
660  * 
661  * @return	{Boolean}	<code>true</code> if multi-account
662  */
663 ZmSearch.prototype.isMultiAccount =
664 function() {
665 	if (!this._isMultiAccount) {
666 		this._isMultiAccount = (this.queryHint && this.queryHint.length > 0 &&
667 								(this.queryHint.split("inid:").length > 1 ||
668 								 this.queryHint.split("underid:").length > 1));
669 	}
670 	return this._isMultiAccount;
671 };
672 
673 /**
674  * @private
675  */
676 ZmSearch.prototype._getStandardMethod =
677 function(soapDoc) {
678 
679 	var method = soapDoc.getMethod();
680 
681 	if (this.sortBy) {
682 		method.setAttribute("sortBy", this.sortBy);
683 	}
684 
685 	if (this.types.contains(ZmItem.MSG) || this.types.contains(ZmItem.CONV)) {
686 		ZmMailMsg.addRequestHeaders(soapDoc);
687 	}
688 
689 	// bug 5771: add timezone and locale info
690 	ZmTimezone.set(soapDoc, AjxTimezone.DEFAULT, null);
691 	soapDoc.set("locale", appCtxt.get(ZmSetting.LOCALE_NAME), null);
692 
693 	if (this.lastId != null && this.lastSortVal) {
694 		// cursor is used for paginated searches
695 		var cursor = soapDoc.set("cursor");
696 		cursor.setAttribute("id", this.lastId);
697 		cursor.setAttribute("sortVal", this.lastSortVal);
698 		if (this.endSortVal) {
699 			cursor.setAttribute("endSortVal", this.endSortVal);
700 		}
701 	}
702 
703 	this.offset = this.offset || 0;
704 	method.setAttribute("offset", this.offset);
705 
706 	// always set limit
707 	method.setAttribute("limit", this._getLimit());
708 
709 	var query = this._getQuery();
710 
711 	soapDoc.set("query", query);
712 
713 	// set search field if provided
714 	if (this.field) {
715 		method.setAttribute("field", this.field);
716 	}
717 
718 	return method;
719 };
720 
721 /**
722  * @private
723  */
724 ZmSearch.prototype._getStandardMethodJson = 
725 function(req) {
726 
727 	if (this.sortBy) {
728 		req.sortBy = this.sortBy;
729 	}
730 
731 	if (this.types.contains(ZmItem.MSG) || this.types.contains(ZmItem.CONV)) {
732 		ZmMailMsg.addRequestHeaders(req);
733 	}
734 
735 	// bug 5771: add timezone and locale info
736 	ZmTimezone.set(req, AjxTimezone.DEFAULT);
737 	// bug 15878: We can't use appCtxt.get(ZmSetting.LOCALE) because that
738 	//            will return the server's default locale if it is not set
739 	//            set for the user or their COS. But AjxEnv.DEFAULT_LOCALE
740 	//            is set to the browser's locale setting in the case when
741 	//            the user's (or their COS) locale is not set.
742 	req.locale = { _content: AjxEnv.DEFAULT_LOCALE };
743 
744 	if (this.lastId != null && this.lastSortVal) {
745 		// cursor is used for paginated searches
746 		req.cursor = {id:this.lastId, sortVal:this.lastSortVal};
747 		if (this.endSortVal) {
748 			req.cursor.endSortVal = this.endSortVal;
749 		}
750 	}
751 
752 	req.offset = this.offset = this.offset || 0;
753 
754 	// always set limit
755 	req.limit = this._getLimit();
756 
757 	if (this.idsOnly) {
758 		req.resultMode = "IDS";
759 	}
760 
761 	req.query = this._getQuery();
762 
763 	// set search field if provided
764 	if (this.field) {
765 		req.field = this.field;
766 	}
767 };
768 
769 /**
770  * @private
771  */
772 ZmSearch.prototype._getQuery =
773 function() {
774 	// and of course, always set the query and append the query hint if applicable
775 	// only use query hint if this is not a "simple" search
776 	if (this.queryHint) {
777 		var query = this.query ? ["(", this.query, ") "].join("") : "";
778 		return [query, "(", this.queryHint, ")"].join("");
779 	}
780 	return this.query;
781 };
782 
783 /**
784  * @private
785  */
786 ZmSearch.prototype._getLimit =
787 function() {
788 
789 	if (this.limit) { return this.limit; }
790 
791 	var limit;
792 	if (this.isGalAutocompleteSearch) {
793 		limit = appCtxt.get(ZmSetting.AUTOCOMPLETE_LIMIT);
794 	} else {
795 		var type = this.types && this.types.get(0);
796 		var app = appCtxt.getApp(ZmItem.APP[type]) || appCtxt.getCurrentApp();
797 		if (app && app.getLimit) {
798 			limit = app.getLimit(this.offset);
799 		} else {
800 			limit = appCtxt.get(ZmSetting.PAGE_SIZE) || ZmSearch.DEFAULT_LIMIT;
801 		}
802 	}
803 
804 	this.limit = limit;
805 	return limit;
806 };
807 
808 /**
809  * Tests the given item against a matching function generated from the query.
810  * 
811  * @param {ZmItem}	item		an item
812  * @return	true if the item matches, false if it doesn't, and null if a matching function could not be generated
813  */
814 ZmSearch.prototype.matches =
815 function(item) {
816 
817 	if (!this.parsedQuery) {
818 		return null;
819 	}
820 
821 	// if search is constrained to a folder, we can return false if item is not in that folder
822 	if (this.folderId && !this.parsedQuery.hasOrTerm) {
823 		if (item.type === ZmItem.CONV) {
824 			if (item.folders && !item.folders[this.folderId]) {
825 				return false;
826 			}
827 		}
828 		else if (item.folderId && item.folderId !== this.folderId) {
829 			return false;
830 		}
831 	}
832 
833 	var matchFunc = this.parsedQuery.getMatchFunction();
834 	return matchFunc ? matchFunc(item) : null;
835 };
836 
837 /**
838  * Returns true if the query has a folder-related term with the given value.
839  * 
840  * @param 	{string}	path		a folder path (optional)
841  */
842 ZmSearch.prototype.hasFolderTerm =
843 function(path) {
844 	return this.parsedQuery && this.parsedQuery.hasTerm(["in", "under"], path);
845 };
846 
847 /**
848  * Replaces the old folder path with the new folder path in the query string, if found.
849  * 
850  * @param	{string}	oldPath		the old folder path
851  * @param	{string}	newPath		the new folder path
852  * 
853  * @return	{boolean}	true if replacement was performed
854  */
855 ZmSearch.prototype.replaceFolderTerm =
856 function(oldPath, newPath) {
857 	if (!this.parsedQuery) {
858 		return this.query;
859 	}
860 	var newQuery = this.parsedQuery.replaceTerm(["in", "under"], oldPath, newPath);
861 	if (newQuery) {
862 		this.query = newQuery;
863 	}
864 	return Boolean(newQuery);
865 };
866 
867 /**
868  * Returns true if the query has a tag term with the given value.
869  * 
870  * @param 	{string}	tagName		a tag name (optional)
871  */
872 ZmSearch.prototype.hasTagTerm =
873 function(tagName) {
874 	return this.parsedQuery && this.parsedQuery.hasTerm("tag", tagName);
875 };
876 
877 /**
878  * Replaces the old tag name with the new tag name in the query string, if found.
879  * 
880  * @param	{string}	oldName		the old tag name
881  * @param	{string}	newName		the new tag name
882  * 
883  * @return	{boolean}	true if replacement was performed
884  */
885 ZmSearch.prototype.replaceTagTerm =
886 function(oldName, newName) {
887 	if (!this.parsedQuery) {
888 		return this.query;
889 	}
890 	var newQuery = this.parsedQuery.replaceTerm("tag", oldName, newName);
891 	if (newQuery) {
892 		this.query = newQuery;
893 	}
894 	return Boolean(newQuery);
895 };
896 
897 /**
898  * Returns true if the query has a term related to unread status.
899  */
900 ZmSearch.prototype.hasUnreadTerm =
901 function() {
902 	return (this.parsedQuery && (this.parsedQuery.hasTerm("is", "read") ||
903 								 this.parsedQuery.hasTerm("is", "unread")));
904 };
905 
906 /**
907  * Returns true if the query has the term "is:anywhere".
908  */
909 ZmSearch.prototype.isAnywhere =
910 function() {
911 	return (this.parsedQuery && this.parsedQuery.hasTerm("is", "anywhere"));
912 };
913 
914 /**
915  * Returns true if the query has a "content" term.
916  */
917 ZmSearch.prototype.hasContentTerm =
918 function() {
919 	return (this.parsedQuery && this.parsedQuery.hasTerm("content"));
920 };
921 
922 /**
923  * Returns true if the query has just one term, and it's a folder or tag term.
924  */
925 ZmSearch.prototype.isSimple =
926 function() {
927 	var pq = this.parsedQuery;
928 	if (pq && (pq.getNumTokens() == 1)) {
929 		return pq.hasTerm(["in", "inid", "tag"]);
930 	}
931 	return false;
932 };
933 
934 ZmSearch.prototype.getTokens =
935 function() {
936 	return this.parsedQuery && this.parsedQuery.getTokens();
937 };
938 
939 ZmSearch.prototype._setProperties =
940 function() {
941 	var props = this.parsedQuery && this.parsedQuery.getProperties();
942 	for (var key in props) {
943 		this[key] = props[key];
944 	}
945 };
946 
947 
948 
949 
950 
951 /**
952  * This class is a parsed representation of a query string. It parses the string into tokens.
953  * A token is a paren, a conditional operator, or a search term (which has an operator and an
954  * argument). The query string is assumed to be valid.
955  * 
956  * Compound terms such as "in:(inbox or sent)" will be exploded into multiple terms.
957  * 
958  * @param	{string}	query		a query string
959  * 
960  * TODO: handle "field[lastName]" and "#lastName"
961  */
962 ZmParsedQuery = function(query) {
963 
964 	this.hasOrTerm = false;
965 	this._tokens = this._parse(AjxStringUtil.trim(query, true));
966 
967 	// preconditions for flags
968 	if (!ZmParsedQuery.IS_VALUE_PRECONDITION) {
969 		ZmParsedQuery.IS_VALUE_PRECONDITION = {};
970 		ZmParsedQuery.IS_VALUE_PRECONDITION['flagged']      = ZmSetting.FLAGGING_ENABLED;
971 		ZmParsedQuery.IS_VALUE_PRECONDITION['unflagged']    = ZmSetting.FLAGGING_ENABLED;
972 	}
973 };
974 
975 ZmParsedQuery.prototype.isZmParsedQuery = true;
976 ZmParsedQuery.prototype.toString = function() { return "ZmParsedQuery"; };
977 
978 ZmParsedQuery.TERM	= "TERM";	// search operator such as "in"
979 ZmParsedQuery.COND	= "COND";	// AND OR NOT
980 ZmParsedQuery.GROUP	= "GROUP";	// ( or )
981 
982 ZmParsedQuery.OP_CONTENT	= "content";
983 
984 ZmParsedQuery.OP_LIST = [
985 	"content", "subject", "msgid", "envto", "envfrom", "contact", "to", "from", "cc", "tofrom", 
986 	"tocc", "fromcc", "tofromcc", "in", "under", "inid", "underid", "has", "filename", "type", 
987 	"attachment", "is", "date", "mdate", "day", "week", "month", "year", "after", "before", 
988 	"size", "bigger", "larger", "smaller", "tag", "priority", "message", "my", "modseq", "conv", 
989 	"conv-count", "conv-minm", "conv-maxm", "conv-start", "conv-end", "appt-start", "appt-end", "author", "title", "keywords", 
990 	"company", "metadata", "item", "sort"
991 ];
992 ZmParsedQuery.IS_OP		= AjxUtil.arrayAsHash(ZmParsedQuery.OP_LIST);
993 
994 // valid arguments for the search term "is:"
995 ZmParsedQuery.IS_VALUES = [	"unread", "read", "flagged", "unflagged",
996 							"draft", "sent", "received", "replied", "unreplied", "forwarded", "unforwarded",
997 							"invite",
998 							"solo",
999 							"tome", "fromme", "ccme", "tofromme", "toccme", "fromccme", "tofromccme",
1000 							"local", "remote", "anywhere" ];
1001 
1002 // ops that can appear more than once in a query
1003 ZmParsedQuery.MULTIPLE = {};
1004 ZmParsedQuery.MULTIPLE["to"]			= true;
1005 ZmParsedQuery.MULTIPLE["is"]			= true;
1006 ZmParsedQuery.MULTIPLE["has"]			= true;
1007 ZmParsedQuery.MULTIPLE["tag"]			= true;
1008 ZmParsedQuery.MULTIPLE["appt-start"]	= true;
1009 ZmParsedQuery.MULTIPLE["appt-end"]		= true;
1010 ZmParsedQuery.MULTIPLE["type"]			= true;
1011 
1012 ZmParsedQuery.isMultiple =
1013 function(term) {
1014 	return Boolean(term && ZmParsedQuery.MULTIPLE[term.op]);
1015 };
1016 
1017 // ops that are mutually exclusive
1018 ZmParsedQuery.EXCLUDE = {};
1019 ZmParsedQuery.EXCLUDE["before"]	= ["date"];
1020 ZmParsedQuery.EXCLUDE["after"]	= ["date"];
1021 
1022 // values that are mutually exclusive - list value implies full multi-way exclusivity
1023 ZmParsedQuery.EXCLUDE["is"]					= {};
1024 ZmParsedQuery.EXCLUDE["is"]["read"]			= ["unread"];
1025 ZmParsedQuery.EXCLUDE["is"]["flagged"]		= ["unflagged"];
1026 ZmParsedQuery.EXCLUDE["is"]["sent"]			= ["received"];
1027 ZmParsedQuery.EXCLUDE["is"]["replied"]		= ["unreplied"];
1028 ZmParsedQuery.EXCLUDE["is"]["forwarded"]	= ["unforwarded"];
1029 ZmParsedQuery.EXCLUDE["is"]["local"]		= ["remote", "anywhere"];
1030 ZmParsedQuery.EXCLUDE["is"]["tome"]			= ["tofromme", "toccme", "tofromccme"];
1031 ZmParsedQuery.EXCLUDE["is"]["fromme"]		= ["tofromme", "fromccme", "tofromccme"];
1032 ZmParsedQuery.EXCLUDE["is"]["ccme"]			= ["toccme", "fromccme", "tofromccme"];
1033 
1034 ZmParsedQuery._createExcludeMap =
1035 function(excludes) {
1036 
1037 	var excludeMap = {};
1038 	for (var key in excludes) {
1039 		var value = excludes[key];
1040 		if (AjxUtil.isArray1(value)) {
1041 			value.push(key);
1042 			ZmParsedQuery._permuteExcludeMap(excludeMap, value);
1043 		}
1044 		else {
1045 			for (var key1 in value) {
1046 				var value1 = excludes[key][key1];
1047 				value1.push(key1);
1048 				ZmParsedQuery._permuteExcludeMap(excludeMap, AjxUtil.map(value1,
1049 						function(val) {
1050 							return new ZmSearchToken(key, val).toString();
1051 						}));
1052 			}
1053 		}
1054 	}
1055 	return excludeMap;
1056 };
1057 
1058 // makes each possible pair in the list exclusive
1059 ZmParsedQuery._permuteExcludeMap =
1060 function(excludeMap, list) {
1061 	if (list.length < 2) { return; }
1062 	for (var i = 0; i < list.length - 1; i++) {
1063 		var a = list[i];
1064 		for (var j = i + 1; j < list.length; j++) {
1065 			var b = list[j];
1066 			excludeMap[a] = excludeMap[a] || {};
1067 			excludeMap[b] = excludeMap[b] || {};
1068 			excludeMap[a][b] = true;
1069 			excludeMap[b][a] = true;
1070 		}
1071 	}
1072 };
1073 
1074 /**
1075  * Returns true if the given search terms should not appear in the same query.
1076  * 
1077  * @param {ZmSearchToken}	termA	search term
1078  * @param {ZmSearchToken}	termB	search term
1079  */
1080 ZmParsedQuery.areExclusive =
1081 function(termA, termB) {
1082 	if (!termA || !termB) { return false; }
1083 	var map = ZmParsedQuery.EXCLUDE_MAP;
1084 	if (!map) {
1085 		map = ZmParsedQuery.EXCLUDE_MAP = ZmParsedQuery._createExcludeMap(ZmParsedQuery.EXCLUDE);
1086 	}
1087 	var opA = termA.op, opB = termB.op;
1088 	var strA = termA.toString(), strB = termB.toString();
1089 	return Boolean((map[opA] && map[opA][opB]) || (map[opB] && map[opB][opA]) ||
1090 				   (map[strA] && map[strA][strB]) || (map[strB] && map[strB][strA]));
1091 };
1092 
1093 // conditional ops
1094 ZmParsedQuery.COND_AND		= "and"
1095 ZmParsedQuery.COND_OR		= "or";
1096 ZmParsedQuery.COND_NOT		= "not";
1097 ZmParsedQuery.GROUP_OPEN	= "(";
1098 ZmParsedQuery.GROUP_CLOSE	= ")";
1099 
1100 // JS version of conditional
1101 ZmParsedQuery.COND_OP = {};
1102 ZmParsedQuery.COND_OP[ZmParsedQuery.COND_AND]	= " && ";
1103 ZmParsedQuery.COND_OP[ZmParsedQuery.COND_OR]	= " || ";
1104 ZmParsedQuery.COND_OP[ZmParsedQuery.COND_NOT]	= " !";
1105 
1106 // word separators
1107 ZmParsedQuery.EOW_LIST	= [" ", ":", ZmParsedQuery.GROUP_OPEN, ZmParsedQuery.GROUP_CLOSE];
1108 ZmParsedQuery.IS_EOW	= AjxUtil.arrayAsHash(ZmParsedQuery.EOW_LIST);
1109 
1110 // map is:xxx to item properties
1111 ZmParsedQuery.FLAG = {};
1112 ZmParsedQuery.FLAG["unread"]		= "item.isUnread";
1113 ZmParsedQuery.FLAG["read"]			= "!item.isUnread";
1114 ZmParsedQuery.FLAG["flagged"]		= "item.isFlagged";
1115 ZmParsedQuery.FLAG["unflagged"]		= "!item.isFlagged";
1116 ZmParsedQuery.FLAG["forwarded"]		= "item.isForwarded";
1117 ZmParsedQuery.FLAG["unforwarded"]	= "!item.isForwarded";
1118 ZmParsedQuery.FLAG["sent"]			= "item.isSent";
1119 ZmParsedQuery.FLAG["draft"]			= "item.isDraft";
1120 ZmParsedQuery.FLAG["replied"]		= "item.isReplied";
1121 ZmParsedQuery.FLAG["unreplied"]		= "!item.isReplied";
1122 
1123 ZmParsedQuery.prototype._parse =
1124 function(query) {
1125 
1126 	function getQuotedStr(str, pos, q) {
1127 		var q = q || str.charAt(pos);
1128 		pos++;
1129 		var done = false, ch, quoted = "";
1130 		while (pos < str.length && !done) {
1131 			ch = str.charAt(pos);
1132 			if (ch == q) {
1133 				done = true;
1134 			} else {
1135 				quoted += ch;
1136 				pos++;
1137 			}
1138 		}
1139 
1140 		return done ? {str:quoted, pos:pos + 1} : null;
1141 	}
1142 	
1143 	function skipSpace(str, pos) {
1144 		while (pos < str.length && str.charAt(pos) == " ") {
1145 			pos++;
1146 		}
1147 		return pos;
1148 	}
1149 	
1150 	function fail(reason, query) {
1151 		DBG.println(AjxDebug.DBG1, "ZmParsedQuery failure: " + reason + "; query: [" + query + "]");
1152 		this.parseFailed = reason;
1153 		return tokens;		
1154 	}
1155 
1156 	var len = query.length;
1157 	var tokens = [], ch, lastCh, op, word = "", isEow = false, endOk = true, compound = 0, numParens = 0;
1158 	var pos = skipSpace(query, 0);
1159 	while (pos < len) {
1160 		lastCh = (ch != " ") ? ch : lastCh;
1161 		ch = query.charAt(pos);
1162 		isEow = ZmParsedQuery.IS_EOW[ch];
1163 
1164 		if (ch == ":") {
1165 			if (ZmParsedQuery.IS_OP[word]) {
1166 				op = word;
1167 			} else {
1168 				return fail("unrecognized op '" + word + "'", query);
1169 			}
1170 			word = "";
1171 			pos = skipSpace(query, pos + 1);
1172 			continue;
1173 		}
1174 
1175 		if (isEow) {
1176 			var lcWord = word.toLowerCase();
1177 			var isCondOp = !!ZmParsedQuery.COND_OP[lcWord];
1178 			if (op && word && !(isCondOp && compound > 0)) {
1179 				tokens.push(new ZmSearchToken(op, lcWord));
1180 				if (compound == 0) {
1181 					op = "";
1182 				}
1183 				word = "";
1184 				endOk = true;
1185 			} else if (!op || (op && compound > 0)) {
1186 				if (isCondOp) {
1187 					tokens.push(new ZmSearchToken(lcWord));
1188 					endOk = false;
1189 					if (lcWord == ZmParsedQuery.COND_OR) {
1190 						this.hasOrTerm = true;
1191 					}
1192 				} else if (word) {
1193 					tokens.push(new ZmSearchToken(ZmParsedQuery.OP_CONTENT, word));
1194 				}
1195 				word = "";
1196 			}
1197 		}
1198 
1199 		if (ch == '"') {
1200 			var results = getQuotedStr(query, pos);
1201 			if (results) {
1202 				word = results.str;
1203 				pos = results.pos;
1204 			} else {
1205 				return fail("improper use of quotes", query);
1206 			}
1207 		} else if (ch == ZmParsedQuery.GROUP_OPEN) {
1208 			var done = false;
1209 			if (compound > 0) {
1210 				compound++;
1211 			}
1212 			else if (lastCh == ":") {
1213 				compound = 1;
1214 				// see if parens are being used as secondary quoting mechanism by looking for and/or
1215 				var inside = query.substr(pos, query.indexOf(ZmParsedQuery.GROUP_CLOSE, pos + 1));
1216 				inside = inside && inside.toLowerCase();
1217 				if (inside && (inside.indexOf(" " + ZmParsedQuery.COND_OR + " ") == -1) &&
1218 							  (inside.indexOf(" " + ZmParsedQuery.COND_AND + " ") == -1)) {
1219 					var results = getQuotedStr(query, pos, ZmParsedQuery.GROUP_CLOSE);
1220 					if (results) {
1221 						word = results.str;
1222 						pos = results.pos;
1223 						compound = 0;
1224 					} else {
1225 						return fail("improper use of paren-based quoting", query);
1226 					}
1227 					done = true;
1228 				}
1229 			}
1230 			if (!done) {
1231 				tokens.push(new ZmSearchToken(ch));
1232 				numParens++;
1233 			}
1234 			pos = skipSpace(query, pos + 1);
1235 		} else if (ch == ZmParsedQuery.GROUP_CLOSE) {
1236 			if (compound > 0) {
1237 				compound--;
1238 			}
1239 			if (compound == 0) {
1240 				op = "";
1241 			}
1242 			tokens.push(new ZmSearchToken(ch));
1243 			pos = skipSpace(query, pos + 1);
1244 		} else if (ch == "-" && !word && !op) {
1245 			tokens.push(new ZmSearchToken(ZmParsedQuery.COND_NOT));
1246 			pos = skipSpace(query, pos + 1);
1247 			endOk = false;
1248 		} else {
1249 			if (ch != " ") {
1250 				word += ch;
1251 			}
1252 			pos++;
1253 		}
1254 	}
1255 
1256 	// check for term at end
1257 	if ((pos >= query.length) && op && word) {
1258 		tokens.push(new ZmSearchToken(op, word));
1259 		endOk = true;
1260 	} else if (!op && word) {
1261 		tokens.push(new ZmSearchToken(word));
1262 	}
1263 	
1264 	// remove unnecessary enclosing parens from when a single compound term is expanded, for example when
1265 	// "subject:(foo bar)" is expanded into "(subject:foo subject:bar)"
1266 	if (tokens.length >= 3 && numParens == 1 && tokens[0].op == ZmParsedQuery.GROUP_OPEN &&
1267 			tokens[tokens.length - 1].op == ZmParsedQuery.GROUP_CLOSE) {
1268 		tokens.shift();
1269 		tokens.pop();
1270 	}
1271 
1272 	if (!endOk) {
1273 		return fail("unexpected end of query", query);
1274 	}
1275 	
1276 	return tokens;
1277 };
1278 
1279 ZmParsedQuery.prototype.getTokens =
1280 function() {
1281 	return this._tokens;
1282 };
1283 
1284 ZmParsedQuery.prototype.getNumTokens =
1285 function() {
1286 	return this._tokens ? this._tokens.length : 0;
1287 };
1288 
1289 ZmParsedQuery.prototype.getProperties =
1290 function() {
1291 	
1292 	var props = {};
1293 	for (var i = 0, len = this._tokens.length; i < len; i++) {
1294 		var t = this._tokens[i];
1295 		if (t.type == ZmParsedQuery.TERM) {
1296 			var prev = i > 0 ? this._tokens[i-1] : null;
1297 			if (!((prev && prev.op == ZmParsedQuery.COND_NOT) || this.hasOrTerm)) {
1298 				if ((t.op == "in" || t.op == "inid") ) {
1299 					this.folderId = props.folderId = (t.op == "in") ? this._getFolderId(t.arg) : t.arg;
1300 				} else if (t.op == "tag") {
1301 					// TODO: make sure there's only one tag term?
1302 					this.tagId = props.tagId = this._getTagId(t.arg, true);
1303 				}
1304 			}
1305 		}
1306 	}
1307 	return props;
1308 };
1309 
1310 /**
1311  * Returns a function based on the parsed query. The function is passed an item (msg or conv) and returns
1312  * true if the item matches the search.
1313  * 
1314  * @return {Function}	the match function
1315  */
1316 ZmParsedQuery.prototype.getMatchFunction =
1317 function() {
1318 	
1319 	if (this._matchFunction) {
1320 		return this._matchFunction;
1321 	}
1322 	if (this.parseFailed || this.hasTerm(ZmParsedQuery.OP_CONTENT)) {
1323 		return null;
1324 	}
1325 	
1326 	var folderId, tagId;
1327 	var func = ["return Boolean("];
1328 	for (var i = 0, len = this._tokens.length; i < len; i++) {
1329 		var t = this._tokens[i];
1330 		if (t.type === ZmParsedQuery.TERM) {
1331 			if (t.op === "in" || t.op === "inid") {
1332 				folderId = (t.op === "in") ? this._getFolderId(t.arg) : t.arg;
1333 				if (folderId) {
1334 					func.push("((item.type === ZmItem.CONV) ? item.folders && item.folders['" + folderId +"'] : item.folderId === '" + folderId + "')");
1335 				}
1336 			}
1337 			else if (t.op === "tag") {
1338 				tagId = this._getTagId(t.arg, true);
1339 				if (tagId) {
1340 					func.push("item.hasTag('" + t.arg + "')");
1341 				}
1342 			}
1343 			else if (t.op === "is") {
1344 				var test = ZmParsedQuery.FLAG[t.arg];
1345 				if (test) {
1346 					func.push(test);
1347 				}
1348 			}
1349 			else if (t.op === 'has' && t.arg === 'attachment') {
1350 				func.push("item.hasAttach");
1351 			}
1352 			else {
1353 				// search had a term we don't know how to match
1354 				return null;
1355 			}
1356 			var next = this._tokens[i + 1];
1357 			if (next && (next.type == ZmParsedQuery.TERM || next == ZmParsedQuery.COND_OP[ZmParsedQuery.COND_NOT] || next == ZmParsedQuery.GROUP_CLOSE)) {
1358 				func.push(ZmParsedQuery.COND_OP[ZmParsedQuery.COND_AND]);
1359 			}
1360 		}
1361 		else if (t.type === ZmParsedQuery.COND) {
1362 			func.push(ZmParsedQuery.COND_OP[t.op]);
1363 		}
1364 		else if (t.type === ZmParsedQuery.GROUP) {
1365 			func.push(t.op);
1366 		}
1367 	}
1368 	func.push(")");
1369 
1370 	// the way multi-account searches are done, we set the queryHint *only* so
1371 	// set the folderId if it exists for simple multi-account searches
1372 	// TODO: multi-acct part seems wrong; search with many folders joined by OR would incorrectly set folderId to last folder
1373 	var isMultiAccountSearch = (appCtxt.multiAccounts && this.isMultiAccount() && !this.query && this.queryHint);
1374 	if (!this.hasOrTerm || isMultiAccountSearch) {
1375 		this.folderId = folderId;
1376 		this.tagId = tagId;
1377 	}
1378 	
1379 	try {
1380 		this._matchFunction = new Function("item", func.join(""));
1381 	} catch(ex) {}
1382 	
1383 	return this._matchFunction;
1384 };
1385 
1386 /**
1387  * Returns a query string that should be logically equivalent to the original query.
1388  */
1389 ZmParsedQuery.prototype.createQuery =
1390 function() {
1391 	var terms = [];
1392 	for (var i = 0, len = this._tokens.length; i < len; i++) {
1393 		terms.push(this._tokens[i].toString());
1394 	}
1395 	return terms.join(" ");
1396 };
1397 
1398 // Returns the fully-qualified ID for the given folder path.
1399 ZmParsedQuery.prototype._getFolderId =
1400 function(path) {
1401 	// first check if it's a system folder (name in query string may not match actual name)
1402 	var folderId = ZmFolder.QUERY_ID[path];
1403 
1404 	var accountName = this.accountName;
1405 	if (!accountName) {
1406 		var active = appCtxt.getActiveAccount();
1407 		accountName = active ? active.name : appCtxt.accountList.mainAccount;
1408 	}
1409 
1410 	// now check all folders by name
1411 	if (!folderId) {
1412 		var account = accountName && appCtxt.accountList.getAccountByName(accountName);
1413 		var folders = appCtxt.getFolderTree(account);
1414 		var folder = folders ? folders.getByPath(path, true) : null;
1415 		if (folder) {
1416 			folderId = folder.id;
1417 		}
1418 	}
1419 
1420 	if (accountName) {
1421 		folderId = ZmOrganizer.getSystemId(folderId, appCtxt.accountList.getAccountByName(accountName));
1422 	}
1423 
1424 	return folderId;
1425 };
1426 
1427 // Returns the ID for the given tag name.
1428 ZmParsedQuery.prototype._getTagId =
1429 function(name, normalized) {
1430 	var tagTree = appCtxt.getTagTree();
1431 	if (tagTree) {
1432 		var tag = tagTree.getByName(name.toLowerCase());
1433 		if (tag) {
1434 			return normalized ? tag.nId : tag.id;
1435 		}
1436 	}
1437 	return null;
1438 };
1439 
1440 /**
1441  * Gets the given term with the given argument. Case-insensitive. Returns the first term found.
1442  * 
1443  * @param	{array}		opList		list of ops 
1444  * @param	{string}	value		argument value (optional)
1445  * 
1446  * @return	{object}	a token object, or null
1447  */
1448 ZmParsedQuery.prototype.getTerm =
1449 function(opList, value) {
1450 	var opHash = AjxUtil.arrayAsHash(opList);
1451 	var lcValue = value && value.toLowerCase();
1452 	for (var i = 0, len = this._tokens.length; i < len; i++) {
1453 		var t = this._tokens[i];
1454 		var lcArg = t.arg && t.arg.toLowerCase();
1455 		if (t.type == ZmParsedQuery.TERM && opHash[t.op] && (!value || lcArg == lcValue)) {
1456 			return t;
1457 		}
1458 	}
1459 	return null;
1460 };
1461 
1462 /**
1463  * Returns true if the query contains the given term with the given argument. Case-insensitive.
1464  * 
1465  * @param	{array}		opList		list of ops 
1466  * @param	{string}	value		argument value (optional)
1467  * 
1468  * @return	{boolean}	true if the query contains the given term with the given argument
1469  */
1470 ZmParsedQuery.prototype.hasTerm =
1471 function(opList, value) {
1472 	return Boolean(this.getTerm(opList, value));
1473 };
1474 
1475 /**
1476  * Replaces the argument within the query for the given ops, if found. Case-insensitive. Replaces
1477  * only the first match.
1478  * 
1479  * @param	{array}		opList		list of ops 
1480  * @param	{string}	oldValue	the old argument
1481  * @param	{string}	newValue	the new argument
1482  * 
1483  * @return	{string}	a new query string (if the old argument was found and replaced), or the empty string
1484  */
1485 ZmParsedQuery.prototype.replaceTerm =
1486 function(opList, oldValue, newValue) {
1487 	var lcValue = oldValue && oldValue.toLowerCase();
1488 	var opHash = AjxUtil.arrayAsHash(opList);
1489 	if (oldValue && newValue) {
1490 		for (var i = 0, len = this._tokens.length; i < len; i++) {
1491 			var t = this._tokens[i];
1492 			var lcArg = t.arg && t.arg.toLowerCase();
1493 			if (t.type == ZmParsedQuery.TERM && opHash[t.op] && (lcArg == lcValue)) {
1494 				t.arg = newValue;
1495 				return this.createQuery();
1496 			}
1497 		}
1498 	}
1499 	return "";
1500 };
1501 
1502 /**
1503  * This class represents one unit of a search query. That may be a search term ("is:unread"),
1504  * and conditional operator (AND, OR, NOT), or a grouping operator (left or right paren).
1505  * 
1506  * @param {string}	op		operator
1507  * @param {string}	arg		argument part of search term
1508  */
1509 ZmSearchToken = function(op, arg) {
1510 	
1511 	if (op && arguments.length == 1) {
1512 		var parts = op.split(":");
1513 		op = parts[0];
1514 		arg = parts[1];
1515 	}
1516 	
1517 	this.op = op;
1518 	this.arg = arg;
1519 	if (ZmParsedQuery.IS_OP[op] && arg) {
1520 		this.type = ZmParsedQuery.TERM;
1521 	}
1522 	else if (op && ZmParsedQuery.COND_OP[op.toLowerCase()]) {
1523 		this.type = ZmParsedQuery.COND;
1524 		this.op = op.toLowerCase();
1525 	}
1526 	else if (op == ZmParsedQuery.GROUP_OPEN || op == ZmParsedQuery.GROUP_CLOSE) {
1527 		this.type = ZmParsedQuery.GROUP;
1528 	} else if (op) {
1529 		this.type = ZmParsedQuery.TERM;
1530 		this.op = ZmParsedQuery.OP_CONTENT;
1531 		this.arg = op;
1532 	}
1533 };
1534 
1535 ZmSearchToken.prototype.isZmSearchToken = true;
1536 
1537 /**
1538  * Returns the string version of this token.
1539  * 
1540  * @param {boolean}		force		if true, return "and" instead of an empty string ("and" is implied)
1541  */
1542 ZmSearchToken.prototype.toString =
1543 function(force) {
1544 	if (this.type == ZmParsedQuery.TERM) {
1545 		var arg = this.arg;
1546 		if (this.op == ZmParsedQuery.OP_CONTENT) {
1547 			return /\W/.test(arg) ? '"' + arg.replace(/"/g, '\\"') + '"' : arg;
1548 		}
1549 		else {
1550 			// quote arg if it has any spaces and is not already quoted
1551 			arg = (arg && (arg.indexOf('"') !== 0) && arg.indexOf(" ") != -1) ? '"' + arg + '"' : arg;
1552 			return [this.op, arg].join(":");
1553 		}
1554 	}
1555 	else {
1556 		return (!force && this.op == ZmParsedQuery.COND_AND) ? "" : this.op;
1557 	}
1558 };
1559