/* 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( "" + 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);