summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm')
-rw-r--r--comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm576
1 files changed, 576 insertions, 0 deletions
diff --git a/comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm b/comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm
new file mode 100644
index 0000000000..98f67eadda
--- /dev/null
+++ b/comm/mailnews/db/gloda/components/GlodaAutoComplete.jsm
@@ -0,0 +1,576 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * glautocomp.js decides which autocomplete item type to
+ * use when one enters text in global search box. There are
+ * following types of autocomplete item: gloda-contact-chunk-richlistitem,
+ * gloda-fulltext-all-richlistitem, gloda-fulltext-single-richlistitem, gloda-multi-richlistitem,
+ * gloda-single-identity-richlistitem, gloda-single-tag-richlistitem.
+ */
+
+var EXPORTED_SYMBOLS = ["GlodaAutoComplete"];
+
+const { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+
+var Gloda = null;
+var MultiSuffixTree = null;
+var TagNoun = null;
+var FreeTagNoun = null;
+
+function ResultRowFullText(aItem, words, typeForStyle) {
+ this.item = aItem;
+ this.words = words;
+ this.typeForStyle = "gloda-fulltext-" + typeForStyle + "-richlistitem";
+}
+ResultRowFullText.prototype = {
+ multi: false,
+ fullText: true,
+};
+
+function ResultRowSingle(aItem, aCriteriaType, aCriteria, aExplicitNounID) {
+ this.nounID = aExplicitNounID || aItem.NOUN_ID;
+ this.nounDef = Gloda._nounIDToDef[this.nounID];
+ this.criteriaType = aCriteriaType;
+ this.criteria = aCriteria;
+ this.item = aItem;
+ this.typeForStyle = "gloda-single-" + this.nounDef.name + "-richlistitem";
+}
+ResultRowSingle.prototype = {
+ multi: false,
+ fullText: false,
+};
+
+function ResultRowMulti(aNounID, aCriteriaType, aCriteria, aQuery) {
+ this.nounID = aNounID;
+ this.nounDef = Gloda._nounIDToDef[aNounID];
+ this.criteriaType = aCriteriaType;
+ this.criteria = aCriteria;
+ this.collection = aQuery.getCollection(this);
+ this.collection.becomeExplicit();
+ this.renderer = null;
+}
+ResultRowMulti.prototype = {
+ multi: true,
+ typeForStyle: "gloda-multi-richlistitem",
+ fullText: false,
+ onItemsAdded(aItems) {
+ if (this.renderer) {
+ for (let [, item] of aItems.entries()) {
+ this.renderer.renderItem(item);
+ }
+ }
+ },
+ onItemsModified(aItems) {},
+ onItemsRemoved(aItems) {},
+ onQueryCompleted() {},
+};
+
+function nsAutoCompleteGlodaResult(aListener, aCompleter, aString) {
+ this.listener = aListener;
+ this.completer = aCompleter;
+ this.searchString = aString;
+ this._results = [];
+ this._pendingCount = 0;
+ this._problem = false;
+ // Track whether we have reported anything to the complete controller so
+ // that we know not to send notifications to it during calls to addRows
+ // prior to that point.
+ this._initiallyReported = false;
+
+ this.wrappedJSObject = this;
+}
+nsAutoCompleteGlodaResult.prototype = {
+ getObjectAt(aIndex) {
+ return this._results[aIndex] || null;
+ },
+ markPending(aCompleter) {
+ this._pendingCount++;
+ },
+ markCompleted(aCompleter) {
+ if (--this._pendingCount == 0 && this.active) {
+ this.listener.onSearchResult(this.completer, this);
+ }
+ },
+ announceYourself() {
+ this._initiallyReported = true;
+ this.listener.onSearchResult(this.completer, this);
+ },
+ addRows(aRows) {
+ if (!aRows.length) {
+ return;
+ }
+ this._results.push.apply(this._results, aRows);
+ if (this._initiallyReported && this.active) {
+ this.listener.onSearchResult(this.completer, this);
+ }
+ },
+ // ==== nsIAutoCompleteResult
+ searchString: null,
+ get searchResult() {
+ if (this._problem) {
+ return Ci.nsIAutoCompleteResult.RESULT_FAILURE;
+ }
+ if (this._results.length) {
+ return !this._pendingCount
+ ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
+ : Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING;
+ }
+ return !this._pendingCount
+ ? Ci.nsIAutoCompleteResult.RESULT_NOMATCH
+ : Ci.nsIAutoCompleteResult.RESULT_NOMATCH_ONGOING;
+ },
+ active: false,
+ defaultIndex: -1,
+ errorDescription: null,
+ get matchCount() {
+ return this._results === null ? 0 : this._results.length;
+ },
+ // this is the lower text, (shows the url in firefox)
+ // we try and show the contact's name here.
+ getValueAt(aIndex) {
+ let thing = this._results[aIndex];
+ return thing.name || thing.value || thing.subject || null;
+ },
+ getLabelAt(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+ // rich uses this to be the "title". it is the upper text
+ // we try and show the identity here.
+ getCommentAt(aIndex) {
+ let thing = this._results[aIndex];
+ if (thing.value) {
+ // identity
+ return thing.contact.name;
+ }
+ return thing.name || thing.subject;
+ },
+ // rich uses this to be the "type"
+ getStyleAt(aIndex) {
+ let row = this._results[aIndex];
+ return row.typeForStyle;
+ },
+ // rich uses this to be the icon
+ getImageAt(aIndex) {
+ let thing = this._results[aIndex];
+ if (!thing.value) {
+ return null;
+ }
+
+ return ""; // we don't want to use gravatars as is.
+ /*
+ let md5hash = GlodaUtils.md5HashString(thing.value);
+ let gravURL = "http://www.gravatar.com/avatar/" + md5hash +
+ "?d=identicon&s=32&r=g";
+ return gravURL;
+ */
+ },
+ getFinalCompleteValueAt(aIndex) {
+ return this.getValueAt(aIndex);
+ },
+ removeValueAt() {},
+ _stop() {},
+};
+
+var MAX_POPULAR_CONTACTS = 200;
+
+/**
+ * Complete contacts/identities based on name/email. Instant phase is based on
+ * a suffix-tree built of popular contacts/identities. Delayed phase relies
+ * on a LIKE search of all known contacts.
+ */
+function ContactIdentityCompleter() {
+ // get all the contacts
+ let contactQuery = Gloda.newQuery(GlodaConstants.NOUN_CONTACT);
+ contactQuery.orderBy("-popularity").limit(MAX_POPULAR_CONTACTS);
+ this.contactCollection = contactQuery.getCollection(this, null);
+ this.contactCollection.becomeExplicit();
+}
+ContactIdentityCompleter.prototype = {
+ _popularitySorter(a, b) {
+ return b.popularity - a.popularity;
+ },
+ complete(aResult, aString) {
+ if (aString.length < 3) {
+ // In CJK, first name or last name is sometime used as 1 character only.
+ // So we allow autocompleted search even if 1 character.
+ //
+ // [U+3041 - U+9FFF ... Full-width Katakana, Hiragana
+ // and CJK Ideograph
+ // [U+AC00 - U+D7FF ... Hangul
+ // [U+F900 - U+FFDC ... CJK compatibility ideograph
+ if (!aString.match(/[\u3041-\u9fff\uac00-\ud7ff\uf900-\uffdc]/)) {
+ return false;
+ }
+ }
+
+ let matches;
+ if (this.suffixTree) {
+ matches = this.suffixTree.findMatches(aString.toLowerCase());
+ } else {
+ matches = [];
+ }
+
+ // let's filter out duplicates due to identity/contact double-hits by
+ // establishing a map based on the contact id for these guys.
+ // let's also favor identities as we do it, because that gets us the
+ // most accurate gravat, potentially
+ let contactToThing = {};
+ for (let iMatch = 0; iMatch < matches.length; iMatch++) {
+ let thing = matches[iMatch];
+ if (
+ thing.NOUN_ID == GlodaConstants.NOUN_CONTACT &&
+ !(thing.id in contactToThing)
+ ) {
+ contactToThing[thing.id] = thing;
+ } else if (thing.NOUN_ID == GlodaConstants.NOUN_IDENTITY) {
+ contactToThing[thing.contactID] = thing;
+ }
+ }
+ // and since we can now map from contacts down to identities, map contacts
+ // to the first identity for them that we find...
+ matches = Object.keys(contactToThing)
+ .map(id => contactToThing[id])
+ .map(val =>
+ val.NOUN_ID == GlodaConstants.NOUN_IDENTITY ? val : val.identities[0]
+ );
+
+ let rows = matches.map(
+ match => new ResultRowSingle(match, "text", aResult.searchString)
+ );
+ aResult.addRows(rows);
+
+ // - match against database contacts / identities
+ let pending = { contactToThing, pendingCount: 2 };
+
+ let contactQuery = Gloda.newQuery(GlodaConstants.NOUN_CONTACT);
+ contactQuery.nameLike(
+ contactQuery.WILDCARD,
+ aString,
+ contactQuery.WILDCARD
+ );
+ pending.contactColl = contactQuery.getCollection(this, aResult);
+ pending.contactColl.becomeExplicit();
+
+ let identityQuery = Gloda.newQuery(GlodaConstants.NOUN_IDENTITY);
+ identityQuery
+ .kind("email")
+ .valueLike(identityQuery.WILDCARD, aString, identityQuery.WILDCARD);
+ pending.identityColl = identityQuery.getCollection(this, aResult);
+ pending.identityColl.becomeExplicit();
+
+ aResult._contactCompleterPending = pending;
+
+ return true;
+ },
+ onItemsAdded(aItems, aCollection) {},
+ onItemsModified(aItems, aCollection) {},
+ onItemsRemoved(aItems, aCollection) {},
+ onQueryCompleted(aCollection) {
+ // handle the initial setup case...
+ if (aCollection.data == null) {
+ // cheat and explicitly add our own contact...
+ if (
+ Gloda.myContact &&
+ !(Gloda.myContact.id in this.contactCollection._idMap)
+ ) {
+ this.contactCollection._onItemsAdded([Gloda.myContact]);
+ }
+
+ // the set of identities owned by the contacts is automatically loaded as part
+ // of the contact loading...
+ // (but only if we actually have any contacts)
+ this.identityCollection =
+ this.contactCollection.subCollections[GlodaConstants.NOUN_IDENTITY];
+
+ let contactNames = this.contactCollection.items.map(
+ c => c.name.replace(" ", "").toLowerCase() || "x"
+ );
+ // if we had no contacts, we will have no identity collection!
+ let identityMails;
+ if (this.identityCollection) {
+ identityMails = this.identityCollection.items.map(i =>
+ i.value.toLowerCase()
+ );
+ }
+
+ // The suffix tree takes two parallel lists; the first contains strings
+ // while the second contains objects that correspond to those strings.
+ // In the degenerate case where identityCollection does not exist, it will
+ // be undefined. Calling concat with an argument of undefined simply
+ // duplicates the list we called concat on, and is thus harmless. Our
+ // use of && on identityCollection allows its undefined value to be
+ // passed through to concat. identityMails will likewise be undefined.
+ this.suffixTree = new MultiSuffixTree(
+ contactNames.concat(identityMails),
+ this.contactCollection.items.concat(
+ this.identityCollection && this.identityCollection.items
+ )
+ );
+
+ return;
+ }
+
+ // handle the completion case
+ let result = aCollection.data;
+ let pending = result._contactCompleterPending;
+
+ if (--pending.pendingCount == 0) {
+ let possibleDudes = [];
+
+ let contactToThing = pending.contactToThing;
+
+ let items;
+
+ // check identities first because they are better than contacts in terms
+ // of display
+ items = pending.identityColl.items;
+ for (let iIdentity = 0; iIdentity < items.length; iIdentity++) {
+ let identity = items[iIdentity];
+ if (!(identity.contactID in contactToThing)) {
+ contactToThing[identity.contactID] = identity;
+ possibleDudes.push(identity);
+ // augment the identity with its contact's popularity
+ identity.popularity = identity.contact.popularity;
+ }
+ }
+ items = pending.contactColl.items;
+ for (let iContact = 0; iContact < items.length; iContact++) {
+ let contact = items[iContact];
+ if (!(contact.id in contactToThing)) {
+ contactToThing[contact.id] = contact;
+ possibleDudes.push(contact.identities[0]);
+ }
+ }
+
+ // sort in order of descending popularity
+ possibleDudes.sort(this._popularitySorter);
+ let rows = possibleDudes.map(
+ dude => new ResultRowSingle(dude, "text", result.searchString)
+ );
+ result.addRows(rows);
+ result.markCompleted(this);
+
+ // the collections no longer care about the result, make it clear.
+ delete pending.identityColl.data;
+ delete pending.contactColl.data;
+ // the result object no longer needs us or our data
+ delete result._contactCompleterPending;
+ }
+ },
+};
+
+/**
+ * Complete tags that are used on contacts.
+ */
+function ContactTagCompleter() {
+ FreeTagNoun.populateKnownFreeTags();
+ this._buildSuffixTree();
+ FreeTagNoun.addListener(this);
+}
+ContactTagCompleter.prototype = {
+ _buildSuffixTree() {
+ let tagNames = [],
+ tags = [];
+ for (let [tagName, tag] of Object.entries(FreeTagNoun.knownFreeTags)) {
+ tagNames.push(tagName.toLowerCase());
+ tags.push(tag);
+ }
+ this._suffixTree = new MultiSuffixTree(tagNames, tags);
+ this._suffixTreeDirty = false;
+ },
+ onFreeTagAdded(aTag) {
+ this._suffixTreeDirty = true;
+ },
+ complete(aResult, aString) {
+ // now is not the best time to do this; have onFreeTagAdded use a timer.
+ if (this._suffixTreeDirty) {
+ this._buildSuffixTree();
+ }
+
+ if (aString.length < 2) {
+ // No async mechanism that will add new rows.
+ return false;
+ }
+
+ let tags = this._suffixTree.findMatches(aString.toLowerCase());
+ let rows = [];
+ for (let tag of tags) {
+ let query = Gloda.newQuery(GlodaConstants.NOUN_CONTACT);
+ query.freeTags(tag);
+ let resRow = new ResultRowMulti(
+ GlodaConstants.NOUN_CONTACT,
+ "tag",
+ tag.name,
+ query
+ );
+ rows.push(resRow);
+ }
+ aResult.addRows(rows);
+
+ return false; // no async mechanism that will add new rows
+ },
+};
+
+/**
+ * Complete tags that are used on messages
+ */
+function MessageTagCompleter() {
+ this._buildSuffixTree();
+}
+MessageTagCompleter.prototype = {
+ _buildSuffixTree() {
+ let tagNames = [],
+ tags = [];
+ let tagArray = TagNoun.getAllTags();
+ for (let iTag = 0; iTag < tagArray.length; iTag++) {
+ let tag = tagArray[iTag];
+ tagNames.push(tag.tag.toLowerCase());
+ tags.push(tag);
+ }
+ this._suffixTree = new MultiSuffixTree(tagNames, tags);
+ this._suffixTreeDirty = false;
+ },
+ complete(aResult, aString) {
+ if (aString.length < 2) {
+ return false;
+ }
+
+ let tags = this._suffixTree.findMatches(aString.toLowerCase());
+ let rows = [];
+ for (let tag of tags) {
+ let resRow = new ResultRowSingle(tag, "tag", tag.tag, TagNoun.id);
+ rows.push(resRow);
+ }
+ aResult.addRows(rows);
+
+ return false; // no async mechanism that will add new rows
+ },
+};
+
+/**
+ * Complete with helpful hints about full-text search
+ */
+function FullTextCompleter() {}
+FullTextCompleter.prototype = {
+ complete(aResult, aSearchString) {
+ if (aSearchString.length < 4) {
+ return false;
+ }
+ // We use code very similar to that in GlodaMsgSearcher.jsm, except that we
+ // need to detect when we found phrases, as well as strip commas.
+ aSearchString = aSearchString.trim();
+ let terms = [];
+ let phraseFound = false;
+ while (aSearchString) {
+ let term = "";
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf(aSearchString[0], 1);
+ // eat the quote if it has no friend
+ if (endIndex == -1) {
+ aSearchString = aSearchString.substring(1);
+ continue;
+ }
+ phraseFound = true;
+ term = aSearchString.substring(1, endIndex).trim();
+ if (term) {
+ terms.push(term);
+ }
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let spaceIndex = aSearchString.indexOf(" ");
+ if (spaceIndex == -1) {
+ terms.push(aSearchString.replace(/,/g, ""));
+ break;
+ }
+
+ term = aSearchString.substring(0, spaceIndex).replace(/,/g, "");
+ if (term) {
+ terms.push(term);
+ }
+ aSearchString = aSearchString.substring(spaceIndex + 1);
+ }
+
+ if (terms.length == 1 && !phraseFound) {
+ aResult.addRows([new ResultRowFullText(aSearchString, terms, "single")]);
+ } else {
+ aResult.addRows([new ResultRowFullText(aSearchString, terms, "all")]);
+ }
+
+ return false; // no async mechanism that will add new rows
+ },
+};
+
+function GlodaAutoComplete() {
+ this.wrappedJSObject = this;
+ try {
+ // set up our awesome globals!
+ if (Gloda === null) {
+ let loadNS = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaPublic.jsm"
+ );
+ Gloda = loadNS.Gloda;
+
+ loadNS = ChromeUtils.import("resource:///modules/gloda/GlodaUtils.jsm");
+ loadNS = ChromeUtils.import("resource:///modules/gloda/SuffixTree.jsm");
+ MultiSuffixTree = loadNS.MultiSuffixTree;
+ loadNS = ChromeUtils.import("resource:///modules/gloda/NounTag.jsm");
+ TagNoun = loadNS.TagNoun;
+ loadNS = ChromeUtils.import("resource:///modules/gloda/NounFreetag.jsm");
+ FreeTagNoun = loadNS.FreeTagNoun;
+ }
+
+ this.completers = [];
+ this.curResult = null;
+
+ this.completers.push(new FullTextCompleter()); // not async.
+ this.completers.push(new ContactIdentityCompleter()); // potentially async.
+ this.completers.push(new ContactTagCompleter()); // not async.
+ this.completers.push(new MessageTagCompleter()); // not async.
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+GlodaAutoComplete.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
+
+ startSearch(aString, aParam, aResult, aListener) {
+ try {
+ let result = new nsAutoCompleteGlodaResult(aListener, this, aString);
+ // save this for hacky access to the search. I somewhat suspect we simply
+ // should not be using the formal autocomplete mechanism at all.
+ // Used in glodacomplete.xml.
+ this.curResult = result;
+
+ // Guard against late async results being sent.
+ this.curResult.active = true;
+
+ if (aParam == "global") {
+ for (let completer of this.completers) {
+ // they will return true if they have something pending.
+ if (completer.complete(result, aString)) {
+ result.markPending(completer);
+ }
+ }
+ // } else {
+ // It'd be nice to do autocomplete in the quicksearch modes based
+ // on the specific values for that mode in the current view.
+ // But we don't do that yet.
+ }
+
+ result.announceYourself();
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ stopSearch() {
+ this.curResult.active = false;
+ },
+};