diff options
Diffstat (limited to 'comm/mail/components/im/modules/index_im.sys.mjs')
-rw-r--r-- | comm/mail/components/im/modules/index_im.sys.mjs | 928 |
1 files changed, 928 insertions, 0 deletions
diff --git a/comm/mail/components/im/modules/index_im.sys.mjs b/comm/mail/components/im/modules/index_im.sys.mjs new file mode 100644 index 0000000000..bcea54e1ea --- /dev/null +++ b/comm/mail/components/im/modules/index_im.sys.mjs @@ -0,0 +1,928 @@ +/* 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/. */ + +var CC = Components.Constructor; + +const { Gloda } = ChromeUtils.import( + "resource:///modules/gloda/GlodaPublic.jsm" +); +const { GlodaAccount } = ChromeUtils.import( + "resource:///modules/gloda/GlodaDataModel.jsm" +); +const { GlodaConstants } = ChromeUtils.import( + "resource:///modules/gloda/GlodaConstants.jsm" +); +const { GlodaIndexer, IndexingJob } = ChromeUtils.import( + "resource:///modules/gloda/GlodaIndexer.jsm" +); +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "GlodaDatastore", + "resource:///modules/gloda/GlodaDatastore.jsm" +); + +var kCacheFileName = "indexedFiles.json"; + +var FileInputStream = CC( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); +var ScriptableInputStream = CC( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" +); + +// kIndexingDelay is how long we wait from the point of scheduling an indexing +// job to actually carrying it out. +var kIndexingDelay = 5000; // in milliseconds + +XPCOMUtils.defineLazyGetter(lazy, "MailFolder", () => + Cc["@mozilla.org/mail/folder-factory;1?name=mailbox"].createInstance( + Ci.nsIMsgFolder + ) +); + +var gIMAccounts = {}; + +function GlodaIMConversation(aTitle, aTime, aPath, aContent) { + // grokNounItem from Gloda.jsm puts automatically the values of all + // JS properties in the jsonAttributes magic attribute, except if + // they start with _, so we put the values in _-prefixed properties, + // and have getters in the prototype. + this._title = aTitle; + this._time = aTime; + this._path = aPath; + this._content = aContent; +} +GlodaIMConversation.prototype = { + get title() { + return this._title; + }, + get time() { + return this._time; + }, + get path() { + return this._path; + }, + get content() { + return this._content; + }, + + // for glodaFacetBindings.xml compatibility (pretend we are a message object) + get account() { + let [protocol, username] = this._path.split("/", 2); + + let cacheName = protocol + "/" + username; + if (cacheName in gIMAccounts) { + return gIMAccounts[cacheName]; + } + + // Find the nsIIncomingServer for the current imIAccount. + for (let account of MailServices.accounts.accounts) { + let incomingServer = account.incomingServer; + if (!incomingServer || incomingServer.type != "im") { + continue; + } + let imAccount = incomingServer.wrappedJSObject.imAccount; + if ( + imAccount.protocol.normalizedName == protocol && + imAccount.normalizedName == username + ) { + return (gIMAccounts[cacheName] = new GlodaAccount(incomingServer)); + } + } + // The IM conversation is probably for an account that no longer exists. + return null; + }, + get subject() { + return this._title; + }, + get date() { + return new Date(this._time * 1000); + }, + get involves() { + return GlodaConstants.IGNORE_FACET; + }, + _recipients: null, + get recipients() { + if (!this._recipients) { + this._recipients = [{ contact: { name: this._path.split("/", 2)[1] } }]; + } + return this._recipients; + }, + _from: null, + get from() { + if (!this._from) { + let from = ""; + let account = this.account; + if (account) { + from = account.incomingServer.wrappedJSObject.imAccount.protocol.name; + } + this._from = { value: "", contact: { name: from } }; + } + return this._from; + }, + get tags() { + return []; + }, + get starred() { + return false; + }, + get attachmentNames() { + return null; + }, + get indexedBodyText() { + return this._content; + }, + get read() { + return true; + }, + get folder() { + return GlodaConstants.IGNORE_FACET; + }, + + // for glodaFacetView.js _removeDupes + get headerMessageID() { + return this.id; + }, +}; + +// FIXME +var WidgetProvider = { + providerName: "widget", + *process() { + // XXX What is this supposed to do? + yield GlodaConstants.kWorkDone; + }, +}; + +var IMConversationNoun = { + name: "im-conversation", + clazz: GlodaIMConversation, + allowsArbitraryAttrs: true, + tableName: "imConversations", + schema: { + columns: [ + ["id", "INTEGER PRIMARY KEY"], + ["title", "STRING"], + ["time", "NUMBER"], + ["path", "STRING"], + ], + fulltextColumns: [["content", "STRING"]], + }, +}; +Gloda.defineNoun(IMConversationNoun); + +// Needs to be set after calling defineNoun, otherwise it's replaced +// by GlodaDatabind.jsm' implementation. +IMConversationNoun.objFromRow = function (aRow) { + // Row columns are: + // 0 id + // 1 title + // 2 time + // 3 path + // 4 jsonAttributes + // 5 content + // 6 offsets + let conv = new GlodaIMConversation( + aRow.getString(1), + aRow.getInt64(2), + aRow.getString(3), + aRow.getString(5) + ); + conv.id = aRow.getInt64(0); // handleResult will keep only our first result + // if the id property isn't set. + return conv; +}; + +var EXT_NAME = "im"; + +// --- special (on-row) attributes +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "time", + singular: true, + special: GlodaConstants.kSpecialColumn, + specialColumnName: "time", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_NUMBER, + canQuery: true, +}); +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "title", + singular: true, + special: GlodaConstants.kSpecialString, + specialColumnName: "title", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_STRING, + canQuery: true, +}); +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "path", + singular: true, + special: GlodaConstants.kSpecialString, + specialColumnName: "path", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_STRING, + canQuery: true, +}); + +// --- fulltext attributes +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrFundamental, + attributeName: "content", + singular: true, + special: GlodaConstants.kSpecialFulltext, + specialColumnName: "content", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_FULLTEXT, + canQuery: true, +}); + +// -- fulltext search helper +// fulltextMatches. Match over message subject, body, and attachments +// @testpoint gloda.noun.message.attr.fulltextMatches +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrDerived, + attributeName: "fulltextMatches", + singular: true, + special: GlodaConstants.kSpecialFulltext, + specialColumnName: "imConversationsText", + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_FULLTEXT, +}); +// For Facet.jsm DateFaceter +Gloda.defineAttribute({ + provider: WidgetProvider, + extensionName: EXT_NAME, + attributeType: GlodaConstants.kAttrDerived, + attributeName: "date", + singular: true, + special: GlodaConstants.kSpecialColumn, + subjectNouns: [IMConversationNoun.id], + objectNoun: GlodaConstants.NOUN_NUMBER, + facet: { + type: "date", + }, + canQuery: true, +}); + +var GlodaIMIndexer = { + name: "index_im", + cacheVersion: 1, + enable() { + Services.obs.addObserver(this, "conversation-closed"); + Services.obs.addObserver(this, "new-ui-conversation"); + Services.obs.addObserver(this, "conversation-update-type"); + Services.obs.addObserver(this, "ui-conversation-closed"); + Services.obs.addObserver(this, "ui-conversation-replaced"); + + // The shutdown blocker ensures pending saves happen even if the app + // gets shut down before the timer fires. + if (this._shutdownBlockerAdded) { + return; + } + this._shutdownBlockerAdded = true; + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "GlodaIMIndexer cache save", + () => { + if (!this._cacheSaveTimer) { + return Promise.resolve(); + } + clearTimeout(this._cacheSaveTimer); + return this._saveCacheNow(); + } + ); + + this._knownFiles = {}; + + let dir = FileUtils.getFile("ProfD", ["logs"]); + if (!dir.exists() || !dir.isDirectory()) { + return; + } + let cacheFile = dir.clone(); + cacheFile.append(kCacheFileName); + if (!cacheFile.exists()) { + return; + } + + const PR_RDONLY = 0x01; + let fis = new FileInputStream( + cacheFile, + PR_RDONLY, + parseInt("0444", 8), + Ci.nsIFileInputStream.CLOSE_ON_EOF + ); + let sis = new ScriptableInputStream(fis); + let text = sis.read(sis.available()); + sis.close(); + + let data = JSON.parse(text); + + // Check to see if the Gloda datastore ID matches the one that we saved + // in the cache. If so, we can trust it. If not, that means that the + // cache is likely invalid now, so we ignore it (and eventually + // overwrite it). + if ( + "datastoreID" in data && + Gloda.datastoreID && + data.datastoreID === Gloda.datastoreID + ) { + // Ok, the cache's datastoreID matches the one we expected, so it's + // still valid. + this._knownFiles = data.knownFiles; + } + + this.cacheVersion = data.version; + + // If there was no version set on the cache, there is a chance that the index + // is affected by bug 1069845. fixEntriesWithAbsolutePaths() sets the version to 1. + if (!this.cacheVersion) { + this.fixEntriesWithAbsolutePaths(); + } + }, + disable() { + Services.obs.removeObserver(this, "conversation-closed"); + Services.obs.removeObserver(this, "new-ui-conversation"); + Services.obs.removeObserver(this, "conversation-update-type"); + Services.obs.removeObserver(this, "ui-conversation-closed"); + Services.obs.removeObserver(this, "ui-conversation-replaced"); + }, + + /* _knownFiles is a tree whose leaves are the last modified times of + * log files when they were last indexed. + * Each level of the tree is stored as an object. The root node is an + * object that maps a protocol name to an object representing the subtree + * for that protocol. The structure is: + * _knownFiles -> protoObj -> accountObj -> convObj + * The corresponding keys of the above objects are: + * protocol names -> account names -> conv names -> file names -> last modified time + * convObj maps ALL previously indexed log files of a chat buddy or MUC to + * their last modified times. Note that gloda knows nothing about log grouping + * done by logger.js. + */ + _knownFiles: {}, + _cacheSaveTimer: null, + _shutdownBlockerAdded: false, + _scheduleCacheSave() { + if (this._cacheSaveTimer) { + return; + } + this._cacheSaveTimer = setTimeout(this._saveCacheNow, 5000); + }, + _saveCacheNow() { + GlodaIMIndexer._cacheSaveTimer = null; + + let data = { + knownFiles: GlodaIMIndexer._knownFiles, + datastoreID: Gloda.datastoreID, + version: GlodaIMIndexer.cacheVersion, + }; + + // Asynchronously copy the data to the file. + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "logs", + kCacheFileName + ); + return IOUtils.writeJSON(path, data, { + tmpPath: path + ".tmp", + }).catch(aError => console.error("Failed to write cache file: " + aError)); + }, + + _knownConversations: {}, + // Promise queue for indexing jobs. The next indexing job is queued using this + // promise's then() to ensure we only load logs for one conv at a time. + _indexingJobPromise: null, + // Maps a conv id to the function that resolves the promise representing the + // ongoing indexing job on it. This is called from indexIMConversation when it + // finishes and will trigger the next queued indexing job. + _indexingJobCallbacks: new Map(), + + _scheduleIndexingJob(aConversation) { + let convId = aConversation.id; + + // If we've already scheduled this conversation to be indexed, let's + // not repeat. + if (!(convId in this._knownConversations)) { + this._knownConversations[convId] = { + id: convId, + scheduledIndex: null, + logFileCount: null, + convObj: {}, + }; + } + + if (!this._knownConversations[convId].scheduledIndex) { + // Ok, let's schedule the job. + this._knownConversations[convId].scheduledIndex = setTimeout( + this._beginIndexingJob.bind(this, aConversation), + kIndexingDelay + ); + } + }, + + _beginIndexingJob(aConversation) { + let convId = aConversation.id; + + // In the event that we're triggering this indexing job manually, without + // bothering to schedule it (for example, when a conversation is closed), + // we give the conversation an entry in _knownConversations, which would + // normally have been done in _scheduleIndexingJob. + if (!(convId in this._knownConversations)) { + this._knownConversations[convId] = { + id: convId, + scheduledIndex: null, + logFileCount: null, + convObj: {}, + }; + } + + let conv = this._knownConversations[convId]; + (async () => { + // We need to get the log files every time, because a new log file might + // have been started since we last got them. + let logFiles = await IMServices.logs.getLogPathsForConversation( + aConversation + ); + if (!logFiles || !logFiles.length) { + // No log files exist yet, nothing to do! + return; + } + + if (conv.logFileCount == undefined) { + // We initialize the _knownFiles tree path for the current files below in + // case it doesn't already exist. + let folder = PathUtils.parent(logFiles[0]); + let convName = PathUtils.filename(folder); + folder = PathUtils.parent(folder); + let accountName = PathUtils.filename(folder); + folder = PathUtils.parent(folder); + let protoName = PathUtils.filename(folder); + if ( + !Object.prototype.hasOwnProperty.call(this._knownFiles, protoName) + ) { + this._knownFiles[protoName] = {}; + } + let protoObj = this._knownFiles[protoName]; + if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) { + protoObj[accountName] = {}; + } + let accountObj = protoObj[accountName]; + if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) { + accountObj[convName] = {}; + } + + // convObj is the penultimate level of the tree, + // maps file name -> last modified time + conv.convObj = accountObj[convName]; + conv.logFileCount = 0; + } + + // The last log file in the array is the one currently being written to. + // When new log files are started, we want to finish indexing the previous + // one as well as index the new ones. The index of the previous one is + // conv.logFiles.length - 1, so we slice from there. This gives us all new + // log files even if there are multiple new ones. + let currentLogFiles = + conv.logFileCount > 1 + ? logFiles.slice(conv.logFileCount - 1) + : logFiles; + for (let logFile of currentLogFiles) { + let fileName = PathUtils.filename(logFile); + let lastModifiedTime = (await IOUtils.stat(logFile)).lastModified; + if ( + Object.prototype.hasOwnProperty.call(conv.convObj, fileName) && + conv.convObj[fileName] == lastModifiedTime + ) { + // The file hasn't changed since we last indexed it, so we're done. + continue; + } + + if (this._indexingJobPromise) { + await this._indexingJobPromise; + } + this._indexingJobPromise = new Promise(aResolve => { + this._indexingJobCallbacks.set(convId, aResolve); + }); + + let job = new IndexingJob("indexIMConversation", null); + job.conversation = conv; + job.path = logFile; + job.lastModifiedTime = lastModifiedTime; + GlodaIndexer.indexJob(job); + } + conv.logFileCount = logFiles.length; + })().catch(console.error); + + // Now clear the job, so we can index in the future. + this._knownConversations[convId].scheduledIndex = null; + }, + + observe(aSubject, aTopic, aData) { + if ( + aTopic == "new-ui-conversation" || + aTopic == "conversation-update-type" + ) { + // Add ourselves to the ui-conversation's list of observers for the + // unread-message-count-changed notification. + // For this notification, aSubject is the ui-conversation that is opened. + aSubject.addObserver(this); + return; + } + + if ( + aTopic == "ui-conversation-closed" || + aTopic == "ui-conversation-replaced" + ) { + aSubject.removeObserver(this); + return; + } + + if (aTopic == "unread-message-count-changed") { + // We get this notification by attaching observers to conversations + // directly (see the new-ui-conversation handler for when we attach). + if (aSubject.unreadIncomingMessageCount == 0) { + // The unread message count changed to 0, meaning that a conversation + // that had been in the background and receiving messages was suddenly + // moved to the foreground and displayed to the user. We schedule an + // indexing job on this conversation now, since we want to index messages + // that the user has seen. + this._scheduleIndexingJob(aSubject.target); + } + return; + } + + if (aTopic == "conversation-closed") { + let convId = aSubject.id; + // If there's a scheduled indexing job, cancel it, because we're going + // to index now. + if ( + convId in this._knownConversations && + this._knownConversations[convId].scheduledIndex != null + ) { + clearTimeout(this._knownConversations[convId].scheduledIndex); + } + + this._beginIndexingJob(aSubject); + delete this._knownConversations[convId]; + return; + } + + if (aTopic == "new-text" && !aSubject.noLog) { + // Ok, some new text is about to be put into a conversation. For this + // notification, aSubject is a prplIMessage. + let conv = aSubject.conversation; + let uiConv = IMServices.conversations.getUIConversation(conv); + + // We only want to schedule an indexing job if this message is + // immediately visible to the user. We figure this out by finding + // the unread message count on the associated UIConversation for this + // message. If the unread count is 0, we know that the message has been + // displayed to the user. + if (uiConv.unreadIncomingMessageCount == 0) { + this._scheduleIndexingJob(conv); + } + } + }, + + /* If there is an existing gloda conversation for the given path, + * find its id. + */ + _getIdFromPath(aPath) { + let selectStatement = lazy.GlodaDatastore._createAsyncStatement( + "SELECT id FROM imConversations WHERE path = ?1" + ); + selectStatement.bindByIndex(0, aPath); + let id; + return new Promise((resolve, reject) => { + selectStatement.executeAsync({ + handleResult: aResultSet => { + let row = aResultSet.getNextRow(); + if (!row) { + return; + } + if (id || aResultSet.getNextRow()) { + console.error( + "Warning: found more than one gloda conv id for " + aPath + "\n" + ); + } + id = id || row.getInt64(0); // We use the first found id. + }, + handleError: aError => + console.error("Error finding gloda id from path:\n" + aError), + handleCompletion: () => { + resolve(id); + }, + }); + }); + }, + + // Get the path of a log file relative to the logs directory - the last 4 + // components of the path. + _getRelativePath(aLogPath) { + return PathUtils.split(aLogPath).slice(-4).join("/"); + }, + + /** + * @param {object} aCache - An object mapping file names to their last + * modified times at the time they were last indexed. The value for the file + * currently being indexed is updated to the aLastModifiedTime parameter's + * value once indexing is complete. + * @param {GlodaIMConversation} [aGlodaConv] - An optional in-out param that + * lets the caller save and reuse the GlodaIMConversation instance created + * when the conversation is indexed the first time. After a conversation is + * indexed for the first time, the GlodaIMConversation instance has its id + * property set to the row id of the conversation in the database. This id + * is required to later update the conversation in the database, so the + * caller dealing with ongoing conversation has to provide the aGlodaConv + * parameter, while the caller dealing with old conversations doesn't care. + */ + async indexIMConversation( + aCallbackHandle, + aLogPath, + aLastModifiedTime, + aCache, + aGlodaConv + ) { + let log = await IMServices.logs.getLogFromFile(aLogPath); + let logConv = await log.getConversation(); + + // Ignore corrupted log files. + if (!logConv) { + return GlodaConstants.kWorkDone; + } + + let fileName = PathUtils.filename(aLogPath); + let messages = logConv + .getMessages() + // Some messages returned, e.g. sessionstart messages, + // may have the noLog flag set. Ignore these. + .filter(m => !m.noLog); + let content = []; + while (messages.length > 0) { + await new Promise(resolve => { + ChromeUtils.idleDispatch(timing => { + while (timing.timeRemaining() > 5 && messages.length > 0) { + let m = messages.shift(); + let who = m.alias || m.who; + // Messages like topic change notifications may not have a source. + let prefix = who ? who + ": " : ""; + content.push( + prefix + + lazy.MailFolder.convertMsgSnippetToPlainText( + "<!DOCTYPE html>" + m.message + ) + ); + } + resolve(); + }); + }); + } + content = content.join("\n\n"); + let glodaConv; + if (aGlodaConv && aGlodaConv.value) { + glodaConv = aGlodaConv.value; + glodaConv._content = content; + } else { + let relativePath = this._getRelativePath(aLogPath); + glodaConv = new GlodaIMConversation( + logConv.title, + log.time, + relativePath, + content + ); + // If we've indexed this file before, we need the id of the existing + // gloda conversation so that the existing entry gets updated. This can + // happen if the log sweep detects that the last messages in an open + // chat were not in fact indexed before that session was shut down. + let id = await this._getIdFromPath(relativePath); + if (id) { + glodaConv.id = id; + } + if (aGlodaConv) { + aGlodaConv.value = glodaConv; + } + } + + if (!aCache) { + throw new Error("indexIMConversation called without aCache parameter."); + } + let isNew = + !Object.prototype.hasOwnProperty.call(aCache, fileName) && !glodaConv.id; + let rv = aCallbackHandle.pushAndGo( + Gloda.grokNounItem(glodaConv, {}, true, isNew, aCallbackHandle) + ); + + if (!aLastModifiedTime) { + console.error( + "indexIMConversation called without lastModifiedTime parameter." + ); + } + aCache[fileName] = aLastModifiedTime || 1; + this._scheduleCacheSave(); + + return rv; + }, + + *_worker_indexIMConversation(aJob, aCallbackHandle) { + let glodaConv = {}; + let existingGlodaConv = aJob.conversation.glodaConv; + if ( + existingGlodaConv && + existingGlodaConv.path == this._getRelativePath(aJob.path) + ) { + glodaConv.value = aJob.conversation.glodaConv; + } + + // indexIMConversation may initiate an async grokNounItem sub-job. + this.indexIMConversation( + aCallbackHandle, + aJob.path, + aJob.lastModifiedTime, + aJob.conversation.convObj, + glodaConv + ).then(() => GlodaIndexer.callbackDriver()); + // Tell the Indexer that we're doing async indexing. We'll be left alone + // until callbackDriver() is called above. + yield GlodaConstants.kWorkAsync; + + // Resolve the promise for this job. + this._indexingJobCallbacks.get(aJob.conversation.id)(); + this._indexingJobCallbacks.delete(aJob.conversation.id); + this._indexingJobPromise = null; + aJob.conversation.indexPending = false; + aJob.conversation.glodaConv = glodaConv.value; + yield GlodaConstants.kWorkDone; + }, + + *_worker_logsFolderSweep(aJob) { + let dir = FileUtils.getFile("ProfD", ["logs"]); + if (!dir.exists() || !dir.isDirectory()) { + // If the folder does not exist, then we are done. + yield GlodaConstants.kWorkDone; + } + + // Sweep the logs directory for log files, adding any new entries to the + // _knownFiles tree as we traverse. + for (let proto of dir.directoryEntries) { + if (!proto.isDirectory()) { + continue; + } + let protoName = proto.leafName; + if (!Object.prototype.hasOwnProperty.call(this._knownFiles, protoName)) { + this._knownFiles[protoName] = {}; + } + let protoObj = this._knownFiles[protoName]; + let accounts = proto.directoryEntries; + for (let account of accounts) { + if (!account.isDirectory()) { + continue; + } + let accountName = account.leafName; + if (!Object.prototype.hasOwnProperty.call(protoObj, accountName)) { + protoObj[accountName] = {}; + } + let accountObj = protoObj[accountName]; + for (let conv of account.directoryEntries) { + let convName = conv.leafName; + if (!conv.isDirectory() || convName == ".system") { + continue; + } + if (!Object.prototype.hasOwnProperty.call(accountObj, convName)) { + accountObj[convName] = {}; + } + let job = new IndexingJob("convFolderSweep", null); + job.folder = conv; + job.convObj = accountObj[convName]; + GlodaIndexer.indexJob(job); + } + } + } + + yield GlodaConstants.kWorkDone; + }, + + *_worker_convFolderSweep(aJob, aCallbackHandle) { + let folder = aJob.folder; + + for (let file of folder.directoryEntries) { + let fileName = file.leafName; + if ( + !file.isFile() || + !file.isReadable() || + !fileName.endsWith(".json") || + (Object.prototype.hasOwnProperty.call(aJob.convObj, fileName) && + aJob.convObj[fileName] == file.lastModifiedTime) + ) { + continue; + } + // indexIMConversation may initiate an async grokNounItem sub-job. + this.indexIMConversation( + aCallbackHandle, + file.path, + file.lastModifiedTime, + aJob.convObj + ).then(() => GlodaIndexer.callbackDriver()); + // Tell the Indexer that we're doing async indexing. We'll be left alone + // until callbackDriver() is called above. + yield GlodaConstants.kWorkAsync; + } + yield GlodaConstants.kWorkDone; + }, + + get workers() { + return [ + ["indexIMConversation", { worker: this._worker_indexIMConversation }], + ["logsFolderSweep", { worker: this._worker_logsFolderSweep }], + ["convFolderSweep", { worker: this._worker_convFolderSweep }], + ]; + }, + + initialSweep() { + let job = new IndexingJob("logsFolderSweep", null); + GlodaIndexer.indexJob(job); + }, + + // Due to bug 1069845, some logs were indexed against their full paths instead + // of their path relative to the logs directory. These entries are updated to + // use relative paths below. + fixEntriesWithAbsolutePaths() { + let store = lazy.GlodaDatastore; + let selectStatement = store._createAsyncStatement( + "SELECT id, path FROM imConversations" + ); + let updateStatement = store._createAsyncStatement( + "UPDATE imConversations SET path = ?1 WHERE id = ?2" + ); + + store._beginTransaction(); + selectStatement.executeAsync({ + handleResult: aResultSet => { + let row; + while ((row = aResultSet.getNextRow())) { + // If the path has more than 4 components, it is not relative to + // the logs folder. Update it to use only the last 4 components. + // The absolute paths were stored as OS-specific paths, so we split + // them with PathUtils.split(). It's a safe assumption that nobody + // ported their profile folder to a different OS since the regression, + // so this should work. + let pathComponents = PathUtils.split(row.getString(1)); + if (pathComponents.length > 4) { + updateStatement.bindByIndex(1, row.getInt64(0)); // id + updateStatement.bindByIndex(0, pathComponents.slice(-4).join("/")); // Last 4 path components + updateStatement.executeAsync({ + handleResult: () => {}, + handleError: aError => + console.error("Error updating bad entry:\n" + aError), + handleCompletion: () => {}, + }); + } + } + }, + + handleError: aError => + console.error("Error looking for bad entries:\n" + aError), + + handleCompletion: () => { + store.runPostCommit(() => { + this.cacheVersion = 1; + this._scheduleCacheSave(); + }); + store._commitTransaction(); + }, + }); + }, +}; + +GlodaIndexer.registerIndexer(GlodaIMIndexer); |