diff options
Diffstat (limited to 'comm/mail/modules/SearchSpec.jsm')
-rw-r--r-- | comm/mail/modules/SearchSpec.jsm | 562 |
1 files changed, 562 insertions, 0 deletions
diff --git a/comm/mail/modules/SearchSpec.jsm b/comm/mail/modules/SearchSpec.jsm new file mode 100644 index 0000000000..50bbfbaa64 --- /dev/null +++ b/comm/mail/modules/SearchSpec.jsm @@ -0,0 +1,562 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SearchSpec"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +/** + * Wrapper abstraction around a view's search session. This is basically a + * friend class of FolderDisplayWidget and is privy to some of its internals. + */ +function SearchSpec(aViewWrapper) { + this.owner = aViewWrapper; + + this._viewTerms = null; + this._virtualFolderTerms = null; + this._userTerms = null; + + this._session = null; + this._sessionListener = null; + this._listenersRegistered = false; + + this._onlineSearch = false; +} +SearchSpec.prototype = { + /** + * Clone this SearchSpec; intended to be used by DBViewWrapper.clone(). + */ + clone(aViewWrapper) { + let doppel = new SearchSpec(aViewWrapper); + + // we can just copy the terms since we never mutate them + doppel._viewTerms = this._viewTerms; + doppel._virtualFolderTerms = this._virtualFolderTerms; + doppel._userTerms = this._userTerms; + + // _session can stay null + // no listener is required, so we can keep _sessionListener and + // _listenersRegistered at their default values + + return doppel; + }, + + get hasSearchTerms() { + return this._viewTerms || this._virtualFolderTerms || this._userTerms; + }, + + get hasOnlyVirtualTerms() { + return this._virtualFolderTerms && !this._viewTerms && !this._userTerms; + }, + + /** + * On-demand creation of the nsIMsgSearchSession. Automatically creates a + * SearchSpecListener at the same time and registers it as a listener. The + * DBViewWrapper is responsible for adding (and removing) the db view + * as a listener. + * + * Code should only access this attribute when it wants to manipulate the + * session. Callers should use hasSearchTerms if they want to determine if + * a search session is required. + */ + get session() { + if (this._session == null) { + this._session = Cc[ + "@mozilla.org/messenger/searchSession;1" + ].createInstance(Ci.nsIMsgSearchSession); + } + return this._session; + }, + + /** + * (Potentially) add the db view as a search listener and kick off the search. + * We only do that if we have search terms. The intent is to allow you to + * call this all the time, even if you don't need to. + * DBViewWrapper._applyViewChanges used to handle a lot more of this, but our + * need to make sure that the session listener gets added after the DBView + * caused us to introduce this method. (We want the DB View's OnDone method + * to run before our listener, as it may do important work.) + */ + associateView(aDBView) { + if (this.hasSearchTerms) { + this.updateSession(); + + if (this.owner.isSynthetic) { + this.owner._syntheticView.search(new FilteringSyntheticListener(this)); + } else { + if (!this._sessionListener) { + this._sessionListener = new SearchSpecListener(this); + } + + this.session.registerListener( + aDBView, + Ci.nsIMsgSearchSession.allNotifications + ); + aDBView.searchSession = this._session; + this._session.registerListener( + this._sessionListener, + Ci.nsIMsgSearchSession.onNewSearch | + Ci.nsIMsgSearchSession.onSearchDone + ); + this._listenersRegistered = true; + + this.owner.searching = true; + this.session.search(this.owner.listener.msgWindow); + } + } else if (this.owner.isSynthetic) { + // If it's synthetic but we have no search terms, hook the output of the + // synthetic view directly up to the search nsIMsgDBView. + let owner = this.owner; + owner.searching = true; + this.owner._syntheticView.search( + aDBView.QueryInterface(Ci.nsIMsgSearchNotify), + function () { + owner.searching = false; + } + ); + } + }, + /** + * Stop any active search and stop the db view being a search listener (if it + * is one). + */ + dissociateView(aDBView) { + // If we are currently searching, interrupt the search. This will + // immediately notify the listeners that the search is done with and + // clear the searching flag for us. + if (this.owner.searching) { + if (this.owner.isSynthetic) { + this.owner._syntheticView.abortSearch(); + } else { + this.session.interruptSearch(); + } + } + + if (this._listenersRegistered) { + this._session.unregisterListener(this._sessionListener); + this._session.unregisterListener(aDBView); + aDBView.searchSession = null; + this._listenersRegistered = false; + } + }, + + /** + * Given a list of terms, mutate them so that they form a single boolean + * group. + * + * @param aTerms The search terms + * @param aCloneTerms Do we need to clone the terms? + */ + _flattenGroupifyTerms(aTerms, aCloneTerms) { + let iTerm = 0, + term; + let outTerms = aCloneTerms ? [] : aTerms; + for (term of aTerms) { + if (aCloneTerms) { + let cloneTerm = this.session.createTerm(); + cloneTerm.value = term.value; + cloneTerm.attrib = term.attrib; + cloneTerm.arbitraryHeader = term.arbitraryHeader; + cloneTerm.hdrProperty = term.hdrProperty; + cloneTerm.customId = term.customId; + cloneTerm.op = term.op; + cloneTerm.booleanAnd = term.booleanAnd; + cloneTerm.matchAll = term.matchAll; + term = cloneTerm; + outTerms.push(term); + } + if (iTerm == 0) { + term.beginsGrouping = true; + term.endsGrouping = false; + term.booleanAnd = true; + } else { + term.beginsGrouping = false; + term.endsGrouping = false; + } + iTerm++; + } + if (term) { + term.endsGrouping = true; + } + + return outTerms; + }, + + /** + * Normalize the provided list of terms so that all of the 'groups' in it are + * ANDed together. If any OR clauses are detected outside of a group, we + * defer to |_flattenGroupifyTerms| to force the terms to be bundled up into + * a single group, maintaining the booleanAnd state of terms. + * + * This particular logic is desired because it allows the quick filter bar to + * produce interesting and useful filters. + * + * @param aTerms The search terms + * @param aCloneTerms Do we need to clone the terms? + */ + _groupifyTerms(aTerms, aCloneTerms) { + let term; + let outTerms = aCloneTerms ? [] : aTerms; + let inGroup = false; + for (term of aTerms) { + // If we're in a group, all that is forbidden is the creation of new + // groups. + if (inGroup) { + if (term.beginsGrouping) { + // forbidden! + return this._flattenGroupifyTerms(aTerms, aCloneTerms); + } else if (term.endsGrouping) { + inGroup = false; + } + } else { + // If we're not in a group, the boolean must be AND. It's okay for a group + // to start. + // If it's not an AND then it needs to be in a group and we use the other + // function to take care of it. (This function can't back up...) + if (!term.booleanAnd) { + return this._flattenGroupifyTerms(aTerms, aCloneTerms); + } + + inGroup = term.beginsGrouping; + } + + if (aCloneTerms) { + let cloneTerm = this.session.createTerm(); + cloneTerm.attrib = term.attrib; + cloneTerm.value = term.value; + cloneTerm.arbitraryHeader = term.arbitraryHeader; + cloneTerm.hdrProperty = term.hdrProperty; + cloneTerm.customId = term.customId; + cloneTerm.op = term.op; + cloneTerm.booleanAnd = term.booleanAnd; + cloneTerm.matchAll = term.matchAll; + cloneTerm.beginsGrouping = term.beginsGrouping; + cloneTerm.endsGrouping = term.endsGrouping; + term = cloneTerm; + outTerms.push(term); + } + } + + return outTerms; + }, + + /** + * Set search terms that are defined by the 'view', which translates to that + * weird combo-box that lets you view your unread messages, messages by tag, + * messages that aren't deleted, etc. + * + * @param aViewTerms The list of terms. We take ownership and mutate it. + */ + set viewTerms(aViewTerms) { + if (aViewTerms) { + this._viewTerms = this._groupifyTerms(aViewTerms); + } else if (this._viewTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._viewTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the view terms currently in effect. Do not mutate this. + */ + get viewTerms() { + return this._viewTerms; + }, + /** + * Set search terms that are defined by the 'virtual folder' definition. This + * could also be thought of as the 'saved search' part of a saved search. + * + * @param aVirtualFolderTerms The list of terms. We make our own copy and + * do not mutate yours. + */ + set virtualFolderTerms(aVirtualFolderTerms) { + if (aVirtualFolderTerms) { + // we need to clone virtual folder terms because they are pulled from a + // persistent location rather than created on demand + this._virtualFolderTerms = this._groupifyTerms(aVirtualFolderTerms, true); + } else if (this._virtualFolderTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._virtualFolderTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the Virtual folder terms currently in effect. Do not mutate this. + */ + get virtualFolderTerms() { + return this._virtualFolderTerms; + }, + + /** + * Set the terms that the user is explicitly searching on. These will be + * augmented with the 'context' search terms potentially provided by + * viewTerms and virtualFolderTerms. + * + * @param aUserTerms The list of terms. We take ownership and mutate it. + */ + set userTerms(aUserTerms) { + if (aUserTerms) { + this._userTerms = this._groupifyTerms(aUserTerms); + } else if (this._userTerms === null) { + // If they are nulling out already null values, do not apply view changes! + return; + } else { + this._userTerms = null; + } + this.owner._applyViewChanges(); + }, + /** + * @returns the user terms currently in effect as set via the |userTerms| + * attribute or via the |quickSearch| method. Do not mutate this. + */ + get userTerms() { + return this._userTerms; + }, + + clear() { + if (this.hasSearchTerms) { + this._viewTerms = null; + this._virtualFolderTerms = null; + this._userTerms = null; + this.owner._applyViewChanges(); + } + }, + + get onlineSearch() { + return this._onlineSearch; + }, + /** + * Virtual folders have a concept of 'online search' which affects the logic + * in updateSession that builds our search scopes. If onlineSearch is false, + * then when displaying the virtual folder unaffected by mail views or quick + * searches, we will most definitely perform an offline search. If + * onlineSearch is true, we will perform an online search only for folders + * which are not available offline and for which the server is configured + * to have an online 'searchScope'. + * When mail views or quick searches are in effect our search is always + * offline unless the only way to satisfy the needs of the constraints is an + * online search (read: the message body is required but not available + * offline.) + */ + set onlineSearch(aOnlineSearch) { + this._onlineSearch = aOnlineSearch; + }, + + /** + * Populate the search session using viewTerms, virtualFolderTerms, and + * userTerms. The way this works is that each of the 'context' sets of + * terms gets wrapped into a group which is boolean anded together with + * everything else. + */ + updateSession() { + let session = this.session; + + // clear out our current terms and scope + session.searchTerms = []; + session.clearScopes(); + + // -- apply terms + if (this._virtualFolderTerms) { + for (let term of this._virtualFolderTerms) { + session.appendTerm(term); + } + } + + if (this._viewTerms) { + for (let term of this._viewTerms) { + session.appendTerm(term); + } + } + + if (this._userTerms) { + for (let term of this._userTerms) { + session.appendTerm(term); + } + } + + // -- apply scopes + // If it is a synthetic view, create a single bogus scope so that we can use + // MatchHdr. + if (this.owner.isSynthetic) { + // We don't want to pass in a folder, and we don't want to use the + // allSearchableGroups scope, so we cheat and use AddDirectoryScopeTerm. + session.addDirectoryScopeTerm(Ci.nsMsgSearchScope.offlineMail); + return; + } + + let filtering = this._userTerms != null || this._viewTerms != null; + let validityManager = Cc[ + "@mozilla.org/mail/search/validityManager;1" + ].getService(Ci.nsIMsgSearchValidityManager); + for (let folder of this.owner._underlyingFolders) { + // we do not need to check isServer here because _underlyingFolders + // filtered it out when it was initialized. + + let scope; + let serverScope = folder.server.searchScope; + // If we're offline, or this is a local folder, or there's no separate + // online scope, use server scope. + if ( + Services.io.offline || + serverScope == Ci.nsMsgSearchScope.offlineMail || + folder instanceof Ci.nsIMsgLocalMailFolder + ) { + scope = serverScope; + } else { + // we need to test the validity in online and offline tables + let onlineValidityTable = validityManager.getTable(serverScope); + + let offlineScope; + if (folder.flags & Ci.nsMsgFolderFlags.Offline) { + offlineScope = Ci.nsMsgSearchScope.offlineMail; + } else { + // The onlineManual table is used for local search when there is no + // body available. + offlineScope = Ci.nsMsgSearchScope.onlineManual; + } + + let offlineValidityTable = validityManager.getTable(offlineScope); + let offlineAvailable = true; + let onlineAvailable = true; + for (let term of session.searchTerms) { + if (!term.matchAll) { + // for custom terms, we need to getAvailable from the custom term + if (term.attrib == Ci.nsMsgSearchAttrib.Custom) { + let customTerm = MailServices.filters.getCustomTerm( + term.customId + ); + if (customTerm) { + offlineAvailable = customTerm.getAvailable( + offlineScope, + term.op + ); + onlineAvailable = customTerm.getAvailable(serverScope, term.op); + } else { + // maybe an extension with a custom term was unloaded? + console.error( + "Custom search term " + term.customId + " missing" + ); + } + } else { + if (!offlineValidityTable.getAvailable(term.attrib, term.op)) { + offlineAvailable = false; + } + if (!onlineValidityTable.getAvailable(term.attrib, term.op)) { + onlineAvailable = false; + } + } + } + } + // If both scopes work, honor the onlineSearch request, for saved search folders (!filtering) + // and the search dialog (!displayedFolder). + // If only one works, use it. Otherwise, default to offline + if (onlineAvailable && offlineAvailable) { + scope = + (!filtering || !this.owner.displayedFolder) && this.onlineSearch + ? serverScope + : offlineScope; + } else if (onlineAvailable) { + scope = serverScope; + } else { + scope = offlineScope; + } + } + session.addScopeTerm(scope, folder); + } + }, + + prettyStringOfSearchTerms(aSearchTerms) { + if (aSearchTerms == null) { + return " (none)\n"; + } + + let s = ""; + + for (let term of aSearchTerms) { + s += " " + term.termAsString + "\n"; + } + + return s; + }, + + prettyString() { + let s = " Search Terms:\n"; + s += " Virtual Folder Terms:\n"; + s += this.prettyStringOfSearchTerms(this._virtualFolderTerms); + s += " View Terms:\n"; + s += this.prettyStringOfSearchTerms(this._viewTerms); + s += " User Terms:\n"; + s += this.prettyStringOfSearchTerms(this._userTerms); + s += " Scope (Folders):\n"; + for (let folder of this.owner._underlyingFolders) { + s += " " + folder.prettyName + "\n"; + } + return s; + }, +}; + +/** + * A simple nsIMsgSearchNotify listener that only listens for search start/stop + * so that it can tell the DBViewWrapper when the search has completed. + */ +function SearchSpecListener(aSearchSpec) { + this.searchSpec = aSearchSpec; +} +SearchSpecListener.prototype = { + onNewSearch() { + // searching should already be true by the time this happens. if it's not, + // it means some code is poking at the search session. bad! + if (!this.searchSpec.owner.searching) { + console.error("Search originated from unknown initiator! Confusion!"); + this.searchSpec.owner.searching = true; + } + }, + + onSearchHit(aMsgHdr, aFolder) { + // this method is never invoked! + }, + + onSearchDone(aStatus) { + this.searchSpec.owner.searching = false; + }, +}; + +/** + * Pretend to implement the nsIMsgSearchNotify interface, checking all matches + * we are given against the search session on the search spec. If they pass, + * relay them to the underlying db view, otherwise quietly eat them. + * This is what allows us to use mail-views and quick searches against + * gloda-backed searches. + */ +function FilteringSyntheticListener(aSearchSpec) { + this.searchSpec = aSearchSpec; + this.session = this.searchSpec.session; + this.dbView = this.searchSpec.owner.dbView.QueryInterface( + Ci.nsIMsgSearchNotify + ); +} +FilteringSyntheticListener.prototype = { + onNewSearch() { + this.searchSpec.owner.searching = true; + this.dbView.onNewSearch(); + }, + onSearchHit(aMsgHdr, aFolder) { + // We don't need to worry about msgDatabase opening the database. + // It is (obviously) already open, and presumably gloda is already on the + // hook to perform the cleanup (assuming gloda is backing this search). + if (this.session.MatchHdr(aMsgHdr, aFolder.msgDatabase)) { + this.dbView.onSearchHit(aMsgHdr, aFolder); + } + }, + onSearchDone(aStatus) { + this.searchSpec.owner.searching = false; + this.dbView.onSearchDone(aStatus); + }, +}; |