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