/* 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); }, };