1 /*
  2  * ***** BEGIN LICENSE BLOCK *****
  3  * Zimbra Collaboration Suite Web Client
  4  * Copyright (C) 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) 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. All Rights Reserved.
 21  * ***** END LICENSE BLOCK *****
 22  */
 23 
 24 /**
 25  * @overview
 26  *
 27  * This file defines authentication.
 28  *
 29  */
 30 
 31 /**
 32  * Creates and initializes support for server-based autocomplete.
 33  * @class
 34  * This class manages auto-completion via <code><AutoCompleteRequest></code> calls to the server. Currently limited
 35  * to matching against only one type among people, locations, and equipment.
 36  *
 37  * @author Conrad Damon
 38  */
 39 ZmAutocomplete = function(params) {
 40 
 41 	if (arguments.length == 0) {
 42 		return;
 43 	}
 44 
 45 	if (appCtxt.get(ZmSetting.CONTACTS_ENABLED)) {
 46 		var listener = this._settingChangeListener.bind(this);
 47 		var settings = [ZmSetting.GAL_AUTOCOMPLETE, ZmSetting.AUTOCOMPLETE_SHARE, ZmSetting.AUTOCOMPLETE_SHARED_ADDR_BOOKS];
 48 		for (var i = 0; i < settings.length; i++) {
 49 			appCtxt.getSettings().getSetting(settings[i]).addChangeListener(listener);
 50 		}
 51 	}
 52 };
 53 
 54 // choices for text in the returned match object
 55 ZmAutocomplete.AC_VALUE_FULL = "fullAddress";
 56 ZmAutocomplete.AC_VALUE_EMAIL = "email";
 57 ZmAutocomplete.AC_VALUE_NAME = "name";
 58 
 59 // request control
 60 ZmAutocomplete.AC_TIMEOUT = 20;	// autocomplete timeout (in seconds)
 61 
 62 // result types
 63 ZmAutocomplete.AC_TYPE_CONTACT = "contact";
 64 ZmAutocomplete.AC_TYPE_GAL = "gal";
 65 ZmAutocomplete.AC_TYPE_TABLE = "rankingTable";
 66 
 67 ZmAutocomplete.AC_TYPE_UNKNOWN = "unknown";
 68 ZmAutocomplete.AC_TYPE_LOCATION = "Location";	// same as ZmResource.ATTR_LOCATION
 69 ZmAutocomplete.AC_TYPE_EQUIPMENT = "Equipment";	// same as ZmResource.ATTR_EQUIPMENT
 70 
 71 // icons
 72 ZmAutocomplete.AC_ICON = {};
 73 ZmAutocomplete.AC_ICON[ZmAutocomplete.AC_TYPE_CONTACT] = "Contact";
 74 ZmAutocomplete.AC_ICON[ZmAutocomplete.AC_TYPE_GAL] = "GALContact";
 75 ZmAutocomplete.AC_ICON[ZmAutocomplete.AC_TYPE_LOCATION] = "Location";
 76 ZmAutocomplete.AC_ICON[ZmAutocomplete.AC_TYPE_EQUIPMENT] = "Resource";
 77 
 78 // cache control
 79 ZmAutocomplete.GAL_RESULTS_TTL = 900000;	// time-to-live for cached GAL autocomplete results (msec)
 80 
 81 /**
 82  * Returns a string representation of the object.
 83  *
 84  * @return		{String}		a string representation of the object
 85  */
 86 ZmAutocomplete.prototype.toString =
 87 		function() {
 88 			return "ZmAutocomplete";
 89 		};
 90 
 91 /**
 92  * Returns a list of matching contacts for a given string. The first name, last
 93  * name, full name, first/last name, and email addresses are matched against.
 94  *
 95  * @param {String}					str				the string to match against
 96  * @param {closure}					callback		the callback to run with results
 97  * @param {ZmAutocompleteListView}	aclv			the needed to show wait msg
 98  * @param {ZmZimbraAccount}			account			the account to fetch cached items from
 99  * @param {Hash}					options			additional options:
100  * @param {constant}				 type			 type of result to match; default is {@link ZmAutocomplete.AC_TYPE_CONTACT}; other valid values are for location or equipment
101  * @param {Boolean}					needItem		 if <code>true</code>, return a {@link ZmItem} as part of match result
102  * @param {Boolean}					supportForget	allow user to reset ranking for a contact (defaults to true)
103  */
104 ZmAutocomplete.prototype.autocompleteMatch =
105 		function(str, callback, aclv, options, account, autocompleteType) {
106 
107 			str = str.toLowerCase().replace(/"/g, '');
108 			this._curAcStr = str;
109 			DBG.println("ac", "begin autocomplete for " + str);
110 
111 			var acType = (options && (options.acType || options.type)) || ZmAutocomplete.AC_TYPE_CONTACT;
112 
113 			var list = this._checkCache(str, acType, account);
114 			if (!str || (list !== null)) {
115 				callback(list);
116 			}
117 			else {
118 				aclv.setWaiting(true, str);
119 				return this._doSearch(str, aclv, options, acType, callback, account, autocompleteType);
120 			}
121 		};
122 
123 ZmAutocomplete.prototype._doSearch =
124 		function(str, aclv, options, acType, callback, account, autocompleteType) {
125 
126 			var params = {query:str, isAutocompleteSearch:true};
127 			if (acType != ZmAutocomplete.AC_TYPE_CONTACT) {
128 				params.isGalAutocompleteSearch = true;
129 				params.isAutocompleteSearch = false;
130 				params.limit = params.limit * 2;
131 				var searchType = ((acType === ZmAutocomplete.AC_TYPE_LOCATION) || (acType === ZmAutocomplete.AC_TYPE_EQUIPMENT)) ?  ZmItem.RESOURCE : ZmItem.CONTACT;
132 				params.types = AjxVector.fromArray([searchType]);
133 				params.galType = params.galType || ZmSearch.GAL_RESOURCE;
134 				DBG.println("ac", "AutoCompleteGalRequest: " + str);
135 			} else {
136 				DBG.println("ac", "AutoCompleteRequest: " + str);
137 			}
138 			params.accountName = account && account.name;
139 
140 			var search = new ZmSearch(params);
141 			var searchParams = {
142 				callback:		this._handleResponseDoAutocomplete.bind(this, str, aclv, options, acType, callback, account),
143 				errorCallback:	this._handleErrorDoAutocomplete.bind(this, str, aclv),
144 				timeout:		ZmAutocomplete.AC_TIMEOUT,
145 				noBusyOverlay:	true
146 			};
147 			if (autocompleteType) {
148 				searchParams.autocompleteType = autocompleteType;
149 			}
150             searchParams.offlineCallback = this._handleOfflineDoAutocomplete.bind(this, str, search, searchParams.callback);
151 			return search.execute(searchParams);
152 		};
153 
154 /**
155  * @private
156  */
157 ZmAutocomplete.prototype._handleResponseDoAutocomplete =
158 		function(str, aclv, options, acType, callback, account, result) {
159 
160 			DBG.println("ac", "got response for " + str);
161 			aclv.setWaiting(false);
162 
163 			var resultList, gotContacts = false, hasGal = false;
164 			var resp = result.getResponse();
165 			if (resp && resp.search && resp.search.isGalAutocompleteSearch) {
166 				var cl = resp.getResults(resp.type);
167 				resultList = (cl && cl.getArray()) || [];
168 				gotContacts = hasGal = true;
169 			} else {
170 				resultList = resp._respEl.match || [];
171 			}
172 
173 			DBG.println("ac", resultList.length + " matches");
174 
175 			var list = [];
176 			for (var i = 0; i < resultList.length; i++) {
177 				var match = new ZmAutocompleteMatch(resultList[i], options, gotContacts, str);
178 				if (match.acType == acType) {
179 					if (options.excludeGroups && match.isGroup) continue;
180 					if (match.type == ZmAutocomplete.AC_TYPE_GAL) {
181 						hasGal = true;
182 					}
183 					list.push(match);
184 				}
185 			}
186 			var complete = !(resp && resp.getAttribute("more"));
187 
188 			// we assume the results from the server are sorted by ranking
189 			callback(list);
190 			this._cacheResults(str, acType, list, hasGal, complete && resp._respEl.canBeCached, null, account);
191 		};
192 
193 /**
194  * Handle timeout.
195  *
196  * @private
197  */
198 ZmAutocomplete.prototype._handleErrorDoAutocomplete =
199 		function(str, aclv, ex) {
200 			DBG.println("ac", "error on request for " + str + ": " + ex.toString());
201 			aclv.setWaiting(false);
202 			appCtxt.setStatusMsg({msg:ZmMsg.autocompleteFailed, level:ZmStatusView.LEVEL_WARNING});
203 
204 			return true;
205 		};
206 
207 /**
208  * @private
209  */
210 ZmAutocomplete.prototype._handleOfflineDoAutocomplete =
211 function(str, search, callback) {
212     if (str) {
213         var autoCompleteCallback = this._handleOfflineResponseDoAutocomplete.bind(this, search, callback);
214         ZmOfflineDB.searchContactsForAutoComplete(str, autoCompleteCallback);
215     }
216 };
217 
218 ZmAutocomplete.prototype._handleOfflineResponseDoAutocomplete =
219 function(search, callback, result) {
220     var match = [];
221     result.forEach(function(contact) {
222         var attrs = contact._attrs;
223 		if (attrs) {
224 			var obj = {
225 				id : contact.id,
226 				l : contact.l
227 			};
228 			if (attrs.fullName) {
229 				var fullName = attrs.fullName;
230 			}
231 			else {
232 				var fullName = [];
233 				if (attrs.firstName) {
234 					fullName.push(attrs.firstName);
235 				}
236 				if (attrs.middleName) {
237 					fullName.push(attrs.middleName);
238 				}
239 				if (attrs.lastName) {
240 					fullName.push(attrs.lastName);
241 				}
242 				fullName = fullName.join(" ");
243 			}
244 			if (attrs.email) {
245 				obj.email = '"' + fullName + '" <' + attrs.email + '>';
246 			}
247 			else if (attrs.type === "group") {
248 				obj.display = fullName;
249 				obj.type = ZmAutocomplete.AC_TYPE_CONTACT;
250 				obj.exp = true;
251 				obj.isGroup = true;
252 			}
253 			match.push(obj);
254 		}
255     });
256     if (callback) {
257         var zmSearchResult = new ZmSearchResult(search);
258         var response = {
259             match : match
260         };
261         zmSearchResult.set(response);
262         var zmCsfeResult = new ZmCsfeResult(zmSearchResult);
263         callback(zmCsfeResult);
264     }
265 };
266 
267 /**
268  * Sort auto-complete list by ranking scores.
269  *
270  * @param	{ZmAutocomplete}	a		the auto-complete list
271  * @param	{ZmAutocomplete}	b		the auto-complete list
272  * @return	{int}	0 if the lists match; 1 if "a" is before "b"; -1 if "b" is before "a"
273  */
274 ZmAutocomplete.acSortCompare =
275 		function(a, b) {
276 			var aScore = (a && a.score) || 0;
277 			var bScore = (b && b.score) || 0;
278 			return (aScore > bScore) ? 1 : (aScore < bScore) ? -1 : 0;
279 		};
280 
281 /**
282  * Checks if the given string is a valid email.
283  *
284  * @param {String}	str		a string
285  * @return	{Boolean}	<code>true</code> if a valid email
286  */
287 ZmAutocomplete.prototype.isComplete =
288 		function(str) {
289 			return AjxEmailAddress.isValid(str);
290 		};
291 
292 /**
293  * Asks the server to drop an address from the ranking table.
294  *
295  * @param {string}	addr		email address
296  * @param {closure}	callback	callback to run after response
297  */
298 ZmAutocomplete.prototype.forget =
299 		function(addr, callback) {
300 
301 			var jsonObj = {RankingActionRequest:{_jsns:"urn:zimbraMail"}};
302 			jsonObj.RankingActionRequest.action = {op:"delete", email:addr};
303 			var respCallback = this._handleResponseForget.bind(this, callback);
304 			var aCtxt = appCtxt.isChildWindow ? parentAppCtxt : appCtxt;
305 			aCtxt.getRequestMgr().sendRequest({jsonObj:jsonObj, asyncMode:true, callback:respCallback});
306 		};
307 
308 ZmAutocomplete.prototype._handleResponseForget =
309 		function(callback) {
310 			appCtxt.clearAutocompleteCache(ZmAutocomplete.AC_TYPE_CONTACT);
311 			if (appCtxt.isChildWindow) {
312 				parentAppCtxt.clearAutocompleteCache(ZmAutocomplete.AC_TYPE_CONTACT);
313 			}
314 			if (callback) {
315 				callback();
316 			}
317 		};
318 
319 /**
320  * Expands a contact which is a DL and returns a list of its members.
321  *
322  * @param {ZmContact}	contact		DL contact
323  * @param {int}			offset		member to start with (in case we're paging a large DL)
324  * @param {closure}		callback	callback to run with results
325  */
326 ZmAutocomplete.prototype.expandDL =
327 		function(contact, offset, callback) {
328 
329 			var respCallback = this._handleResponseExpandDL.bind(this, contact, callback);
330 			contact.getDLMembers(offset, null, respCallback);
331 		};
332 
333 ZmAutocomplete.prototype._handleResponseExpandDL =
334 		function(contact, callback, result) {
335 
336 			var list = result.list;
337 			var matches = [];
338 			if (list && list.length) {
339 				for (var i = 0, len = list.length; i < len; i++) {
340 					var addr = list[i];
341 					var match = {};
342 					match.type = ZmAutocomplete.AC_TYPE_GAL;
343 					match.email = addr;
344 					match.isGroup = result.isDL[addr];
345 					matches.push(new ZmAutocompleteMatch(match, null, false, contact && contact.str));
346 				}
347 			}
348 			if (callback) {
349 				callback(matches);
350 			}
351 		};
352 
353 /**
354  * @param acType		[constant]			type of result to match
355  * @param str			[string]			string to match against
356  * @param account		[ZmZimbraAccount]*	account to check cache against
357  * @param create		[boolean]			if <code>true</code>, create a cache if none found
358  *
359  * @private
360  */
361 ZmAutocomplete.prototype._getCache =
362 		function(acType, str, account, create) {
363 			var context = AjxEnv.isIE ? window.appCtxt : window.parentAppCtxt || window.appCtxt;
364 			return context.getAutocompleteCache(acType, str, account, create);
365 		};
366 
367 /**
368  * @param str			[string]			string to match against
369  * @param acType		[constant]			type of result to match
370  * @param list			[array]				list of matches
371  * @param hasGal		[boolean]*			if true, list includes GAL results
372  * @param cacheable		[boolean]*			server indication of cacheability
373  * @param baseCache		[hash]*				cache that is superset of this one
374  * @param account		[ZmZimbraAccount]*	account to check cache against
375  *
376  * @private
377  */
378 ZmAutocomplete.prototype._cacheResults =
379 		function(str, acType, list, hasGal, cacheable, baseCache, account) {
380 
381 			var cache = this._getCache(acType, str, account, true);
382 			cache.list = list;
383 			// we always cache; flag below indicates whether we can do forward matching
384 			cache.cacheable = (baseCache && baseCache.cacheable) || cacheable;
385 			if (hasGal) {
386 				cache.ts = (baseCache && baseCache.ts) || (new Date()).getTime();
387 			}
388 		};
389 
390 /**
391  * @private
392  *
393  * TODO: substring result matching for multiple tokens, eg "tim d"
394  */
395 ZmAutocomplete.prototype._checkCache =
396 		function(str, acType, account) {
397 
398 			// check cache for results for this exact string
399 			var cache = this._getCachedResults(str, acType, null, account);
400 			var list = cache && cache.list;
401 			if (list !== null) {
402 				return list;
403 			}
404 			if (str.length <= 1) {
405 				return null;
406 			}
407 
408 			// bug 58913: don't do client-side substring matching since the server matches on
409 			// fields that are not returned in the results
410 			return null;
411 
412 			// forward matching: if we have complete results for a beginning part of this
413 			// string, we can cull those down to results for this string
414 			var tmp = str;
415 			while (tmp && !list) {
416 				tmp = tmp.slice(0, -1); // remove last character
417 				DBG.println("ac", "checking cache for " + tmp);
418 				cache = this._getCachedResults(tmp, acType, true, account);
419 				list = cache && cache.list;
420 				if (list && list.length == 0) {
421 					// substring had no matches, so this string has none
422 					DBG.println("ac", "Found empty results for substring " + tmp);
423 					return list;
424 				}
425 			}
426 
427 			var list1 = [];
428 			if (list && list.length) {
429 				// found a substring that we've already done matching for, so we just need
430 				// to narrow those results
431 				DBG.println("ac", "working forward from '" + tmp + "'");
432 				// test each of the substring's matches to see if it also matches this string
433 				for (var i = 0; i < list.length; i++) {
434 					var match = list[i];
435 					if (match.matches(str)) {
436 						list1.push(match);
437 					}
438 				}
439 			} else {
440 				return null;
441 			}
442 
443 			this._cacheResults(str, acType, list1, false, false, cache, account);
444 
445 			return list1;
446 		};
447 
448 /**
449  * See if we have cached results for the given string. If the cached results have a
450  * timestamp, we make sure they haven't expired.
451  *
452  * @param str				[string]			string to match against
453  * @param acType			[constant]			type of result to match
454  * @param checkCacheable	[boolean]			if true, make sure results are cacheable
455  * @param account			[ZmZimbraAccount]*	account to fetch cached results from
456  *
457  * @private
458  */
459 ZmAutocomplete.prototype._getCachedResults =
460 		function(str, acType, checkCacheable, account) {
461 
462 			var cache = this._getCache(acType, str, account);
463 			if (cache) {
464 				if (checkCacheable && (cache.cacheable === false)) {
465 					return null;
466 				}
467 				if (cache.ts) {
468 					var now = (new Date()).getTime();
469 					if (now > (cache.ts + ZmAutocomplete.GAL_RESULTS_TTL)) {
470 						return null;	// expired GAL results
471 					}
472 				}
473 				DBG.println("ac", "cache hit for " + str);
474 				return cache;
475 			} else {
476 				return null;
477 			}
478 		};
479 
480 /**
481  * Clears contact autocomplete cache on change to any related setting.
482  *
483  * @private
484  */
485 ZmAutocomplete.prototype._settingChangeListener =
486 		function(ev) {
487 			if (ev.type != ZmEvent.S_SETTING) {
488 				return;
489 			}
490 			var context = AjxEnv.isIE ? window.appCtxt : window.parentAppCtxt || window.appCtxt;
491 			context.clearAutocompleteCache(ZmAutocomplete.AC_TYPE_CONTACT);
492 		};
493 
494 
495 /**
496  * Creates an auto-complete match.
497  * @class
498  * This class represents an auto-complete result, with fields for the caller to look at, and fields to
499  * help with further matching.
500  *
501  * @param {Object}	match		the JSON match object or a {@link ZmContact} object
502  * @param {Object}	options		the matching options
503  * @param {Boolean}	isContact	if <code>true</code>, provided match is a {@link ZmContact}
504  */
505 ZmAutocompleteMatch = function(match, options, isContact, str) {
506 	// TODO: figure out how to minimize loading of calendar code
507 	AjxDispatcher.require(["MailCore", "CalendarCore"]);
508 	if (!match) {
509 		return;
510 	}
511 	this.type = match.type;
512 	this.str = str;
513 	var ac = window.parentAppCtxt || window.appCtxt;
514 	if (isContact) {
515 		this.text = this.name = match.getFullName();
516 		this.email = match.getEmail();
517 		this.item = match;
518 		this.type = ZmContact.getAttr(match, ZmResource && ZmResource.F_type || "zimbraCalResType") || ZmAutocomplete.AC_TYPE_GAL;
519 		this.fullAddress = (new AjxEmailAddress(this.email, null, this.text)).toString(); //bug:60789 formated the email and name to get fullAddress
520 	} else {
521 		this.isGroup = Boolean(match.isGroup);
522 		this.isDL = (this.isGroup && this.type == ZmAutocomplete.AC_TYPE_GAL);
523 		if (this.isGroup && !this.isDL) {
524 			// Local contact group; emails need to be looked up by group member ids. 
525 			var contactGroup = ac.cacheGet(match.id);
526 			if (contactGroup && contactGroup.isLoaded) {
527 				this.setContactGroupMembers(match.id);
528 			}
529 			else {
530 				//not a contact group that is in cache.  we'll need to deref it
531 				this.needDerefGroup = true;
532 				this.groupId = match.id;
533 			}
534 			this.name = match.display;
535 			this.text = AjxStringUtil.htmlEncode(match.display) || this.email;
536 			this.icon = "Group";
537 		} else {
538 			// Local contact, GAL contact, or distribution list
539 			var email = AjxEmailAddress.parse(match.email);
540 			if (email) {
541 				this.email = email.getAddress();
542 				if (this.type == ZmAutocomplete.AC_TYPE_CONTACT) {
543 					var contactList = AjxDispatcher.run("GetContacts");
544 					var contact = contactList && contactList.getById(match.id);
545 					var displayName = contact && contact.getFullNameForDisplay(false);
546 
547 					this.fullAddress = "\"" + displayName + "\" <" + email.getAddress() + ">";
548 					this.name = displayName;
549 					this.text = AjxStringUtil.htmlEncode(this.fullAddress);
550 				} else {
551 					this.fullAddress = email.toString();
552 					this.name = email.getName();
553 					this.text = AjxStringUtil.htmlEncode(match.email);
554 				}
555 			} else {
556 				this.email = match.email;
557 				this.text = AjxStringUtil.htmlEncode(match.email);
558 			}
559 			if (options && options.needItem && window.ZmContact) {
560 				this.item = new ZmContact(null);
561 				this.item.initFromEmail(email || match.email);
562 			}
563 			this.icon = this.isDL ? "Group" : ZmAutocomplete.AC_ICON[match.type];
564 			this.canExpand = this.isDL && match.exp;
565 			ac.setIsExpandableDL(this.email, this.canExpand);
566 		}
567 	}
568 	this.score = (match.ranking && parseInt(match.ranking)) || 0;
569 	this.icon = this.icon || ZmAutocomplete.AC_ICON[ZmAutocomplete.AC_TYPE_CONTACT];
570 	this.acType = (this.type == ZmAutocomplete.AC_TYPE_LOCATION || this.type == ZmAutocomplete.AC_TYPE_EQUIPMENT)
571 			? this.type : ZmAutocomplete.AC_TYPE_CONTACT;
572 	if (this.type == ZmAutocomplete.AC_TYPE_LOCATION || this.type == ZmAutocomplete.AC_TYPE_EQUIPMENT) {
573 		this.icon = ZmAutocomplete.AC_ICON[this.type];
574 	}
575 };
576 
577 /**
578  * Sets the email & fullAddress properties of a contact group
579  * @param groupId {String} contact group id to lookup from cache
580  * @param callback {AjxCallback} callback to be run
581  */
582 ZmAutocompleteMatch.prototype.setContactGroupMembers =
583 		function(groupId, callback) {
584 			var ac = window.parentAppCtxt || window.appCtxt;
585 			var contactGroup = ac.cacheGet(groupId);
586 			if (contactGroup) {
587 				var groups = contactGroup.getGroupMembers();
588 				var addresses = (groups && groups.good && groups.good.getArray()) || [];
589 				var emails = [], addrs = [];
590 				for (var i = 0; i < addresses.length; i++) {
591 					var addr = addresses[i];
592 					emails.push(addr.getAddress());
593 					addrs.push(addr.toString());
594 				}
595 				this.email = emails.join(AjxEmailAddress.SEPARATOR);
596 				this.fullAddress = addrs.join(AjxEmailAddress.SEPARATOR);
597 			}
598 			if (callback) {
599 				callback.run();
600 			}
601 		};
602 
603 /**
604  * Returns a string representation of the object.
605  *
606  * @return		{String}		a string representation of the object
607  */
608 ZmAutocompleteMatch.prototype.toString =
609 		function() {
610 			return "ZmAutocompleteMatch";
611 		};
612 
613 /**
614  * Matches the given string to this auto-complete result.
615  *
616  * @param {String}	str		the string
617  * @return	{Boolean}	<code>true</code> if the given string matches this result
618  */
619 ZmAutocompleteMatch.prototype.matches =
620 		function(str) {
621 			if (this.name && !this._nameParsed) {
622 				var parts = this.name.split(/\s+/, 3);
623 				var firstName = parts[0];
624 				this._lastName = parts[parts.length - 1];
625 				this._firstLast = [firstName, this._lastName].join(" ");
626 				this._nameParsed = true;
627 			}
628 
629 			var fields = [this.email, this.name, this._lastName, this._firstLast];
630 			for (var i = 0; i < fields.length; i++) {
631 				var f = fields[i] && fields[i].toLowerCase();
632 				if (f && (f.indexOf(str) == 0)) {
633 					return true;
634 				}
635 			}
636 			return false;
637 		};
638 
639 /**
640  * Creates a search auto-complete.
641  * @class
642  * This class supports auto-complete for our query language. Each search operator that is supported has an associated handler.
643  * A handler is a hash which contains the info needed for auto-complete. A handler can have the following properties:
644  *
645  * <ul>
646  * <li><b>listType</b> - A handler needs a list of objects to autocomplete against. By default, that list is
647  *						 identified by the operator. If more than one operator uses the same list, their handlers
648  *						 should use this property to identify the list.</li>
649  * <li><b>loader</b> - Function that populates the list of objects. Lists used by more than one operator provide
650  *						 their loader separately.</li>
651  * <li><b>text</b> - Function that returns a string value of data, to autocomplete against and to display in the
652  *						 autocomplete list.</li>
653  * <li><b>icon</b> - Function that returns an icon to display in the autocomplete list.</li>
654  * <li><b>matchText</b> - Function that returns a string to place in the input when the item is selected. Defaults to
655  *						 the 'op:' plus the value of the 'text' attribute.</li>
656  * <li><b>quoteMatch</b> - If <code>true</code>, the text that goes into matchText will be place in double quotes.</li>
657  * </ul>
658  *
659  */
660 ZmSearchAutocomplete = function() {
661 
662 	this._op = {};
663 	this._list = {};
664 	this._loadFunc = {};
665 
666 	var params = {
667 		loader:		this._loadTags,
668 		text:		function(o) {
669 			return o.getName(false, null, true, true);
670 		},
671 		icon:		function(o) {
672 			return o.getIconWithColor();
673 		},
674 		matchText:	function(o) {
675 			return o.createQuery();
676 		}
677 	};
678 	this._registerHandler("tag", params);
679 
680 	params = {
681 		listType:	ZmId.ORG_FOLDER,
682 		text:		function(o) {
683 			return o.getPath(false, false, null, true, false);
684 		},
685 		icon:		function(o) {
686 			return o.getIconWithColor();
687 		},
688 		matchText:	function(o) {
689 			return o.createQuery();
690 		}
691 	};
692 	this._loadFunc[ZmId.ORG_FOLDER] = this._loadFolders;
693 	this._registerHandler("in", params);
694 	params.matchText = function(o) {
695 		return "under:" + '"' + o.getPath() + '"';
696 	};
697 	this._registerHandler("under", params);
698 
699 	params = { loader:		this._loadFlags };
700 	this._registerHandler("is", params);
701 
702 	params = {
703 		loader:		this._loadObjects,
704 		icon:		function(o) {
705 			return ZmSearchAutocomplete.ICON[o];
706 		}
707 	};
708 	this._registerHandler("has", params);
709 
710 	this._loadFunc[ZmId.ITEM_ATT] = this._loadTypes;
711 	params = {listType:		ZmId.ITEM_ATT,
712 		text:			function(o) {
713 			return o.desc;
714 		},
715 		icon:			function(o) {
716 			return o.image;
717 		},
718 		matchText:	function(o) {
719 			return "type:" + (o.query || o.type);
720 		},
721 		quoteMatch:	true
722 	};
723 	this._registerHandler("type", params);
724 	params = {listType:		ZmId.ITEM_ATT,
725 		text:			function(o) {
726 			return o.desc;
727 		},
728 		icon:			function(o) {
729 			return o.image;
730 		},
731 		matchText:	function(o) {
732 			return "attachment:" + (o.query || o.type);
733 		},
734 		quoteMatch:	true
735 	};
736 	this._registerHandler("attachment", params);
737 
738 	params = {
739 		loader:		this._loadCommands
740 	};
741 	this._registerHandler("set", params);
742 
743 	var folderTree = appCtxt.getFolderTree();
744 	if (folderTree) {
745 		folderTree.addChangeListener(this._folderTreeChangeListener.bind(this));
746 	}
747 	var tagTree = appCtxt.getTagTree();
748 	if (tagTree) {
749 		tagTree.addChangeListener(this._tagTreeChangeListener.bind(this));
750 	}
751 };
752 
753 ZmSearchAutocomplete.prototype.isZmSearchAutocomplete = true;
754 ZmSearchAutocomplete.prototype.toString = function() {
755 	return "ZmSearchAutocomplete";
756 };
757 
758 ZmSearchAutocomplete.ICON = {};
759 ZmSearchAutocomplete.ICON["attachment"] = "Attachment";
760 ZmSearchAutocomplete.ICON["phone"] = "Telephone";
761 ZmSearchAutocomplete.ICON["url"] = "URL";
762 
763 /**
764  * @private
765  */
766 ZmSearchAutocomplete.prototype._registerHandler = function(op, params) {
767 
768 	var loadFunc = params.loader || this._loadFunc[params.listType];
769 	this._op[op] = {
770 		loader:     loadFunc.bind(this),
771 		text:       params.text, icon:params.icon,
772 		listType:   params.listType || op, matchText:params.matchText || params.text,
773 		quoteMatch: params.quoteMatch
774 	};
775 };
776 
777 /**
778  * Returns a list of matches for a given query operator.
779  *
780  * @param {String}					str			the string to match against
781  * @param {closure}					callback	the callback to run with results
782  * @param {ZmAutocompleteListView}	aclv		needed to show wait msg
783  * @param {Hash}					options		a hash of additional options
784  */
785 ZmSearchAutocomplete.prototype.autocompleteMatch = function(str, callback, aclv, options) {
786 
787 	if (ZmSearchAutocomplete._ignoreNextKey) {
788 		ZmSearchAutocomplete._ignoreNextKey = false;
789 		return;
790 	}
791 
792 	str = str.toLowerCase().replace(/"/g, '');
793 
794 	var idx = str.lastIndexOf(" ");
795 	if (idx != -1 && idx <= str.length) {
796 		str = str.substr(idx + 1);
797 	}
798 	var m = str.match(/\b-?\$?([a-z]+):/);
799 	if (!(m && m.length)) {
800 		callback();
801 		return;
802 	}
803 
804 	var op = m[1];
805 	var opHash = this._op[op];
806 	if (!opHash) {
807 		callback();
808 		return;
809 	}
810 	var list = this._list[opHash.listType];
811 	if (list) {
812 		callback(this._getMatches(op, str));
813 	} else {
814 		var respCallback = this._handleResponseLoad.bind(this, op, str, callback);
815 		this._list[opHash.listType] = [];
816 		opHash.loader(opHash.listType, respCallback);
817 	}
818 };
819 
820 // TODO - some validation of search ops and args
821 ZmSearchAutocomplete.prototype.isComplete = function(str, returnStr) {
822 
823 	var pq = new ZmParsedQuery(str);
824 	var tokens = pq.getTokens();
825 	if (!pq.parseFailed && tokens && (tokens.length == 1)) {
826 		return returnStr ? tokens[0].toString() : true;
827 	}
828 	else {
829 		return false;
830 	}
831 };
832 
833 ZmSearchAutocomplete.prototype.getAddedBubbleClass = function(str) {
834 
835 	var pq = new ZmParsedQuery(str);
836 	var tokens = pq.getTokens();
837 	return (!pq.parseFailed && tokens && (tokens.length == 1) && tokens[0].type);
838 };
839 
840 /**
841  * @private
842  */
843 ZmSearchAutocomplete.prototype._getMatches = function(op, str) {
844 
845 	var opHash = this._op[op];
846 	var results = [], app;
847 	var list = this._list[opHash.listType];
848 	var rest = str.substr(str.indexOf(":") + 1);
849 	if (opHash.listType == ZmId.ORG_FOLDER) {
850 		rest = rest.replace(/^\//, "");	// remove leading slash in folder path
851 		app = appCtxt.getCurrentAppName();
852 		if (!ZmApp.ORGANIZER[app]) {
853 			app = null;
854 		}
855 	}
856 	for (var i = 0, len = list.length; i < len; i++) {
857 		var o = list[i];
858 		var text = opHash.text ? opHash.text(o) : o;
859 		var test = text.toLowerCase();
860 		if (app && ZmOrganizer.APP[o.type] != app) {
861 			continue;
862 		}
863 		if (!rest || (test.indexOf(rest) == 0)) {
864 			var matchText = opHash.matchText ? opHash.matchText(o) :
865 					opHash.quoteMatch ? [op, ":", '"', text, '"'].join("") :
866 							[op, ":", text].join("");
867 			matchText = str.replace(op + ":" + rest, matchText);
868 			results.push({text:			text,
869 				icon:			opHash.icon ? opHash.icon(o) : null,
870 				matchText:	matchText,
871 				exactMatch:	(test.length == rest.length)});
872 		}
873 	}
874 
875 	// no need to show list of one item that is same as what was typed
876 	if (results.length == 1 && results[0].exactMatch) {
877 		results = [];
878 	}
879 
880 	return results;
881 };
882 
883 /**
884  * @private
885  */
886 ZmSearchAutocomplete.prototype._handleResponseLoad = function(op, str, callback) {
887 	callback(this._getMatches(op, str));
888 };
889 
890 /**
891  * @private
892  */
893 ZmSearchAutocomplete.prototype._loadTags = function(listType, callback) {
894 
895 	var list = this._list[listType];
896 	var tags = appCtxt.getTagTree().asList();
897 	for (var i = 0, len = tags.length; i < len; i++) {
898 		var tag = tags[i];
899 		if (tag.id != ZmOrganizer.ID_ROOT) {
900 			list.push(tag);
901 		}
902 	}
903 	list.sort(ZmTag.sortCompare);
904 	if (callback) {
905 		callback();
906 	}
907 };
908 
909 /**
910  * @private
911  */
912 ZmSearchAutocomplete.prototype._loadFolders = function(listType, callback) {
913 
914 	var list = this._list[listType];
915 	var folderTree = appCtxt.getFolderTree();
916 	var folders = folderTree ? folderTree.asList({includeRemote:true}) : [];
917 	for (var i = 0, len = folders.length; i < len; i++) {
918 		var folder = folders[i];
919 		if (folder.id !== ZmOrganizer.ID_ROOT && !ZmFolder.HIDE_ID[folder.id] && folder.id !== ZmFolder.ID_DLS) {
920 			list.push(folder);
921 		}
922 	}
923 	list.sort(ZmFolder.sortComparePath);
924 	if (callback) {
925 		callback();
926 	}
927 };
928 
929 /**
930  * @private
931  */
932 ZmSearchAutocomplete.prototype._loadFlags = function(listType, callback) {
933 
934 	var flags = AjxUtil.filter(ZmParsedQuery.IS_VALUES, function(flag) {
935 		return appCtxt.checkPrecondition(ZmParsedQuery.IS_VALUE_PRECONDITION[flag]);
936 	});
937 	this._list[listType] = flags.sort();
938 	if (callback) {
939 		callback();
940 	}
941 };
942 
943 /**
944  * @private
945  */
946 ZmSearchAutocomplete.prototype._loadObjects = function(listType, callback) {
947 
948 	var list = this._list[listType];
949 	list.push("attachment");
950 	var idxZimlets = appCtxt.getZimletMgr().getIndexedZimlets();
951 	if (idxZimlets.length) {
952 		for (var i = 0; i < idxZimlets.length; i++) {
953 			list.push(idxZimlets[i].keyword);
954 		}
955 	}
956 	list.sort();
957 	if (callback) {
958 		callback();
959 	}
960 };
961 
962 /**
963  * @private
964  */
965 ZmSearchAutocomplete.prototype._loadTypes = function(listType, callback) {
966 
967 	AjxDispatcher.require("Extras");
968 	var attachTypeList = new ZmAttachmentTypeList();
969 	var respCallback = this._handleResponseLoadTypes.bind(this, attachTypeList, listType, callback);
970 	attachTypeList.load(respCallback);
971 };
972 
973 /**
974  * @private
975  */
976 ZmSearchAutocomplete.prototype._handleResponseLoadTypes = function(attachTypeList, listType, callback) {
977 
978 	this._list[listType] = attachTypeList.getAttachments();
979 	if (callback) {
980 		callback();
981 	}
982 };
983 
984 /**
985  * @private
986  */
987 ZmSearchAutocomplete.prototype._loadCommands = function(listType, callback) {
988 
989 	var list = this._list[listType];
990 	for (var funcName in ZmClientCmdHandler.prototype) {
991 		if (funcName.indexOf("execute_") == 0) {
992 			list.push(funcName.substr(8));
993 		}
994 	}
995 	list.sort();
996 	if (callback) {
997 		callback();
998 	}
999 };
1000 
1001 /**
1002  * @private
1003  */
1004 ZmSearchAutocomplete.prototype._folderTreeChangeListener = function(ev) {
1005 
1006 	var fields = ev.getDetail("fields");
1007 	if (ev.event == ZmEvent.E_DELETE || ev.event == ZmEvent.E_CREATE || ev.event == ZmEvent.E_MOVE ||
1008 			((ev.event == ZmEvent.E_MODIFY) && fields && fields[ZmOrganizer.F_NAME])) {
1009 
1010 		var listType = ZmId.ORG_FOLDER;
1011 		if (this._list[listType]) {
1012 			this._list[listType] = [];
1013 			this._loadFolders(listType);
1014 		}
1015 	}
1016 };
1017 
1018 /**
1019  * @private
1020  */
1021 ZmSearchAutocomplete.prototype._tagTreeChangeListener = function(ev) {
1022 
1023 	var fields = ev.getDetail("fields");
1024 	if (ev.event == ZmEvent.E_DELETE || ev.event == ZmEvent.E_CREATE || ev.event == ZmEvent.E_MOVE ||
1025 			((ev.event == ZmEvent.E_MODIFY) && fields && fields[ZmOrganizer.F_NAME])) {
1026 
1027 		var listType = "tag";
1028 		if (this._list[listType]) {
1029 			this._list[listType] = [];
1030 			this._loadTags(listType);
1031 		}
1032 	}
1033 };
1034 
1035 /**
1036  * Creates a people search auto-complete.
1037  * @class
1038  * This class supports auto-complete for searching the GAL and the user's
1039  * personal contacts.
1040  */
1041 ZmPeopleSearchAutocomplete = function() {
1042 	// no need to call ctor
1043 	//	this._acRequests = {};
1044 };
1045 
1046 ZmPeopleSearchAutocomplete.prototype = new ZmAutocomplete;
1047 ZmPeopleSearchAutocomplete.prototype.constructor = ZmPeopleSearchAutocomplete;
1048 
1049 ZmPeopleSearchAutocomplete.prototype.toString = function() { return "ZmPeopleSearchAutocomplete"; };
1050 
1051 ZmPeopleSearchAutocomplete.prototype._doSearch = function(str, aclv, options, acType, callback, account) {
1052 
1053 	var params = {
1054 		query: str,
1055 		types: AjxVector.fromArray([ZmItem.CONTACT]),
1056 		sortBy: ZmSearch.NAME_ASC,
1057 		contactSource: ZmId.SEARCH_GAL,
1058 		accountName: account && account.name
1059 	};
1060 
1061 	var search = new ZmSearch(params);
1062 
1063 	var searchParams = {
1064 		callback:		this._handleResponseDoAutocomplete.bind(this, str, aclv, options, acType, callback, account),
1065 		errorCallback:	this._handleErrorDoAutocomplete.bind(this, str, aclv),
1066 		timeout:		ZmAutocomplete.AC_TIMEOUT,
1067 		noBusyOverlay:	true
1068 	};
1069 	return search.execute(searchParams);
1070 };
1071 
1072 /**
1073  * @private
1074  */
1075 ZmPeopleSearchAutocomplete.prototype._handleResponseDoAutocomplete = function(str, aclv, options, acType, callback, account, result) {
1076 
1077 	// if we get back results for other than the current string, ignore them
1078 	if (str != this._curAcStr) {
1079 		return;
1080 	}
1081 
1082 	var resp = result.getResponse();
1083 	var cl = resp.getResults(ZmItem.CONTACT);
1084 	var resultList = (cl && cl.getArray()) || [];
1085 	var list = [];
1086 
1087 	for (var i = 0; i < resultList.length; i++) {
1088 		var match = new ZmAutocompleteMatch(resultList[i], options, true);
1089 		list.push(match);
1090 	}
1091 	var complete = !(resp && resp.getAttribute("more"));
1092 
1093 	// we assume the results from the server are sorted by ranking
1094 	callback(list);
1095 	this._cacheResults(str, acType, list, true, complete && resp._respEl.canBeCached, null, account);
1096 };
1097