/* 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 = [ "GlodaAttributeDBDef", "GlodaAccount", "GlodaConversation", "GlodaFolder", "GlodaMessage", "GlodaContact", "GlodaIdentity", "GlodaAttachment", ]; const { GlodaConstants } = ChromeUtils.import( "resource:///modules/gloda/GlodaConstants.jsm" ); const { MailServices } = ChromeUtils.import( "resource:///modules/MailServices.jsm" ); var LOG = console.createInstance({ prefix: "gloda.datamodel", maxLogLevel: "Warn", maxLogLevelPref: "gloda.loglevel", }); /** * @class Represents a gloda attribute definition's DB form. This class * stores the information in the database relating to this attribute * definition. Access its attrDef attribute to get at the really juicy data. * This main interesting thing this class does is serve as the keeper of the * mapping from parameters to attribute ids in the database if this is a * parameterized attribute. */ function GlodaAttributeDBDef( aDatastore, aID, aCompoundName, aAttrType, aPluginName, aAttrName ) { // _datastore is now set on the prototype by GlodaDatastore this._id = aID; this._compoundName = aCompoundName; this._attrType = aAttrType; this._pluginName = aPluginName; this._attrName = aAttrName; this.attrDef = null; /** Map parameter values to the underlying database id. */ this._parameterBindings = {}; } GlodaAttributeDBDef.prototype = { // set by GlodaDatastore _datastore: null, get id() { return this._id; }, get attributeName() { return this._attrName; }, get parameterBindings() { return this._parameterBindings; }, /** * Bind a parameter value to the attribute definition, allowing use of the * attribute-parameter as an attribute. * * @returns */ bindParameter(aValue) { // people probably shouldn't call us with null, but handle it if (aValue == null) { return this._id; } if (aValue in this._parameterBindings) { return this._parameterBindings[aValue]; } // no database entry exists if we are here, so we must create it... let id = this._datastore._createAttributeDef( this._attrType, this._pluginName, this._attrName, aValue ); this._parameterBindings[aValue] = id; this._datastore.reportBinding(id, this, aValue); return id; }, /** * Given a list of values, return a list (regardless of plurality) of * database-ready [attribute id, value] tuples. This is intended to be used * to directly convert the value of a property on an object that corresponds * to a bound attribute. * * @param {Array} aInstanceValues An array of instance values regardless of * whether or not the attribute is singular. */ convertValuesToDBAttributes(aInstanceValues) { let nounDef = this.attrDef.objectNounDef; let dbAttributes = []; if (nounDef.usesParameter) { for (let instanceValue of aInstanceValues) { let [param, dbValue] = nounDef.toParamAndValue(instanceValue); dbAttributes.push([this.bindParameter(param), dbValue]); } } else if ("toParamAndValue" in nounDef) { // Not generating any attributes is ok. This basically means the noun is // just an informative property on the Gloda Message and has no real // indexing purposes. for (let instanceValue of aInstanceValues) { dbAttributes.push([ this._id, nounDef.toParamAndValue(instanceValue)[1], ]); } } return dbAttributes; }, toString() { return this._compoundName; }, }; var GlodaHasAttributesMixIn = { *enumerateAttributes() { let nounDef = this.NOUN_DEF; for (let key in this) { let value = this[key]; let attrDef = nounDef.attribsByBoundName[key]; // we expect to not have attributes for underscore prefixed values (those // are managed by the instance's logic. we also want to not explode // should someone crap other values in there, we get both birds with this // one stone. if (attrDef === undefined) { continue; } if (attrDef.singular) { // ignore attributes with null values if (value != null) { yield [attrDef, [value]]; } } else if (value.length) { // ignore attributes with no values yield [attrDef, value]; } } }, domContribute(aDomNode) { let nounDef = this.NOUN_DEF; for (let attrName in nounDef.domExposeAttribsByBoundName) { let attr = nounDef.domExposeAttribsByBoundName[attrName]; if (this[attrName]) { aDomNode.setAttribute(attr.domExpose, this[attrName]); } } }, }; function MixIn(aConstructor, aMixIn) { let proto = aConstructor.prototype; for (let [name, func] of Object.entries(aMixIn)) { if (name.startsWith("get_")) { proto.__defineGetter__(name.substring(4), func); } else { proto[name] = func; } } } /** * @class A gloda wrapper around nsIMsgIncomingServer. */ function GlodaAccount(aIncomingServer) { this._incomingServer = aIncomingServer; } GlodaAccount.prototype = { NOUN_ID: 106, get id() { return this._incomingServer.key; }, get name() { return this._incomingServer.prettyName; }, get incomingServer() { return this._incomingServer; }, toString() { return "Account: " + this.id; }, toLocaleString() { return this.name; }, }; /** * @class A gloda conversation (thread) exists so that messages can belong. */ function GlodaConversation( aDatastore, aID, aSubject, aOldestMessageDate, aNewestMessageDate ) { // _datastore is now set on the prototype by GlodaDatastore this._id = aID; this._subject = aSubject; this._oldestMessageDate = aOldestMessageDate; this._newestMessageDate = aNewestMessageDate; } GlodaConversation.prototype = { NOUN_ID: GlodaConstants.NOUN_CONVERSATION, // set by GlodaDatastore _datastore: null, get id() { return this._id; }, get subject() { return this._subject; }, get oldestMessageDate() { return this._oldestMessageDate; }, get newestMessageDate() { return this._newestMessageDate; }, getMessagesCollection(aListener, aData) { let query = new GlodaMessage.prototype.NOUN_DEF.queryClass(); query.conversation(this._id).orderBy("date"); return query.getCollection(aListener, aData); }, toString() { return "Conversation:" + this._id; }, toLocaleString() { return this._subject; }, }; function GlodaFolder( aDatastore, aID, aURI, aDirtyStatus, aPrettyName, aIndexingPriority ) { // _datastore is now set by GlodaDatastore this._id = aID; this._uri = aURI; this._dirtyStatus = aDirtyStatus; this._prettyName = aPrettyName; this._account = null; this._activeIndexing = false; this._indexingPriority = aIndexingPriority; this._deleted = false; this._compacting = false; } GlodaFolder.prototype = { NOUN_ID: GlodaConstants.NOUN_FOLDER, // set by GlodaDatastore _datastore: null, /** The folder is believed to be up-to-date */ kFolderClean: 0, /** The folder has some un-indexed or dirty messages */ kFolderDirty: 1, /** The folder needs to be entirely re-indexed, regardless of the flags on * the messages in the folder. This state will be downgraded to dirty */ kFolderFilthy: 2, _kFolderDirtyStatusMask: 0x7, /** * The (local) folder has been compacted and all of its message keys are * potentially incorrect. This is not a possible state for IMAP folders * because their message keys are based on UIDs rather than offsets into * the mbox file. */ _kFolderCompactedFlag: 0x8, /** The folder should never be indexed. */ kIndexingNeverPriority: -1, /** The lowest priority assigned to a folder. */ kIndexingLowestPriority: 0, /** The highest priority assigned to a folder. */ kIndexingHighestPriority: 100, /** The indexing priority for a folder if no other priority is assigned. */ kIndexingDefaultPriority: 20, /** Folders marked check new are slightly more important I guess. */ kIndexingCheckNewPriority: 30, /** Favorite folders are more interesting to the user, presumably. */ kIndexingFavoritePriority: 40, /** The indexing priority for inboxes. */ kIndexingInboxPriority: 50, /** The indexing priority for sent mail folders. */ kIndexingSentMailPriority: 60, get id() { return this._id; }, get uri() { return this._uri; }, get dirtyStatus() { return this._dirtyStatus & this._kFolderDirtyStatusMask; }, /** * Mark a folder as dirty if it was clean. Do nothing if it was already dirty * or filthy. For use by GlodaMsgIndexer only. And maybe rkent and his * marvelous extensions. */ _ensureFolderDirty() { if (this.dirtyStatus == this.kFolderClean) { this._dirtyStatus = (this.kFolderDirty & this._kFolderDirtyStatusMask) | (this._dirtyStatus & ~this._kFolderDirtyStatusMask); this._datastore.updateFolderDirtyStatus(this); } }, /** * Definitely for use only by GlodaMsgIndexer to downgrade the dirty status of * a folder. */ _downgradeDirtyStatus(aNewStatus) { if (this.dirtyStatus != aNewStatus) { this._dirtyStatus = (aNewStatus & this._kFolderDirtyStatusMask) | (this._dirtyStatus & ~this._kFolderDirtyStatusMask); this._datastore.updateFolderDirtyStatus(this); } }, /** * Indicate whether this folder is currently being compacted. The * |GlodaMsgIndexer| keeps this in-memory-only value up-to-date. */ get compacting() { return this._compacting; }, /** * Set whether this folder is currently being compacted. This is really only * for the |GlodaMsgIndexer| to set. */ set compacting(aCompacting) { this._compacting = aCompacting; }, /** * Indicate whether this folder was compacted and has not yet been * compaction processed. */ get compacted() { return Boolean(this._dirtyStatus & this._kFolderCompactedFlag); }, /** * For use only by GlodaMsgIndexer to set/clear the compaction state of this * folder. */ _setCompactedState(aCompacted) { if (this.compacted != aCompacted) { if (aCompacted) { this._dirtyStatus |= this._kFolderCompactedFlag; } else { this._dirtyStatus &= ~this._kFolderCompactedFlag; } this._datastore.updateFolderDirtyStatus(this); } }, get name() { return this._prettyName; }, toString() { return "Folder:" + this._id; }, toLocaleString() { let xpcomFolder = this.getXPCOMFolder(this.kActivityFolderOnlyNoData); if (!xpcomFolder) { return this._prettyName; } return ( xpcomFolder.prettyName + " (" + xpcomFolder.rootFolder.prettyName + ")" ); }, get indexingPriority() { return this._indexingPriority; }, /** We are going to index this folder. */ kActivityIndexing: 0, /** Asking for the folder to perform header retrievals. */ kActivityHeaderRetrieval: 1, /** We only want the folder for its metadata but are not going to open it. */ kActivityFolderOnlyNoData: 2, /** Is this folder known to be actively used for indexing? */ _activeIndexing: false, /** Get our indexing status. */ get indexing() { return this._activeIndexing; }, /** * Set our indexing status. Normally, this will be enabled through passing * an activity type of kActivityIndexing (which will set us), but we will * still need to be explicitly disabled by the indexing code. * When disabling indexing, we will call forgetFolderIfUnused to take care of * shutting things down. * We are not responsible for committing changes to the message database! * That is on you! */ set indexing(aIndexing) { this._activeIndexing = aIndexing; }, /** * Retrieve the nsIMsgFolder instance corresponding to this folder, providing * an explanation of why you are requesting it for tracking/cleanup purposes. * * @param aActivity One of the kActivity* constants. If you pass * kActivityIndexing, we will set indexing for you, but you will need to * clear it when you are done. * @returns The nsIMsgFolder if available, null on failure. */ getXPCOMFolder(aActivity) { switch (aActivity) { case this.kActivityIndexing: // mark us as indexing, but don't bother with live tracking. we do // that independently and only for header retrieval. this.indexing = true; break; case this.kActivityHeaderRetrieval: case this.kActivityFolderOnlyNoData: // we don't have to do anything here. break; } return MailServices.folderLookup.getFolderForURL(this.uri); }, /** * Retrieve a GlodaAccount instance corresponding to this folder. * * @returns The GlodaAccount instance. */ getAccount() { if (!this._account) { let msgFolder = this.getXPCOMFolder(this.kActivityFolderOnlyNoData); this._account = new GlodaAccount(msgFolder.server); } return this._account; }, }; /** * @class A message representation. */ function GlodaMessage( aDatastore, aID, aFolderID, aMessageKey, aConversationID, aConversation, aDate, aHeaderMessageID, aDeleted, aJsonText, aNotability, aSubject, aIndexedBodyText, aAttachmentNames ) { // _datastore is now set on the prototype by GlodaDatastore this._id = aID; this._folderID = aFolderID; this._messageKey = aMessageKey; this._conversationID = aConversationID; this._conversation = aConversation; this._date = aDate; this._headerMessageID = aHeaderMessageID; this._jsonText = aJsonText; this._notability = aNotability; this._subject = aSubject; this._indexedBodyText = aIndexedBodyText; this._attachmentNames = aAttachmentNames; // only set _deleted if we're deleted, otherwise the undefined does our // speaking for us. if (aDeleted) { this._deleted = aDeleted; } } GlodaMessage.prototype = { NOUN_ID: GlodaConstants.NOUN_MESSAGE, // set by GlodaDatastore _datastore: null, get id() { return this._id; }, get folderID() { return this._folderID; }, get messageKey() { return this._messageKey; }, get conversationID() { return this._conversationID; }, // conversation is special get headerMessageID() { return this._headerMessageID; }, get notability() { return this._notability; }, set notability(aNotability) { this._notability = aNotability; }, get subject() { return this._subject; }, get indexedBodyText() { return this._indexedBodyText; }, get attachmentNames() { return this._attachmentNames; }, get date() { return this._date; }, set date(aNewDate) { this._date = aNewDate; }, get folder() { // XXX due to a deletion bug it is currently possible to get in a state // where we have an illegal folderID value. This will result in an // exception. As a workaround, let's just return null in that case. try { if (this._folderID != null) { return this._datastore._mapFolderID(this._folderID); } } catch (ex) {} return null; }, get folderURI() { // XXX just like for folder, handle mapping failures and return null try { if (this._folderID != null) { return this._datastore._mapFolderID(this._folderID).uri; } } catch (ex) {} return null; }, get account() { // XXX due to a deletion bug it is currently possible to get in a state // where we have an illegal folderID value. This will result in an // exception. As a workaround, let's just return null in that case. try { if (this._folderID == null) { return null; } let folder = this._datastore._mapFolderID(this._folderID); return folder.getAccount(); } catch (ex) {} return null; }, get conversation() { return this._conversation; }, toString() { // uh, this is a tough one... return "Message:" + this._id; }, _clone() { return new GlodaMessage( /* datastore */ null, this._id, this._folderID, this._messageKey, this._conversationID, this._conversation, this._date, this._headerMessageID, "_deleted" in this ? this._deleted : undefined, "_jsonText" in this ? this._jsonText : undefined, this._notability, this._subject, this._indexedBodyText, this._attachmentNames ); }, /** * Provide a means of propagating changed values on our clone back to * ourselves. This is required because of an object identity trick gloda * does; when indexing an already existing object, all mutations happen on * a clone of the existing object so that */ _declone(aOther) { if ("_content" in aOther) { this._content = aOther._content; } // The _indexedAuthor/_indexedRecipients fields don't get updated on // fulltext update so we don't need to propagate. this._indexedBodyText = aOther._indexedBodyText; this._attachmentNames = aOther._attachmentNames; }, /** * Mark this message as a ghost. Ghosts are characterized by having no folder * id and no message key. They also are not deleted or they would be of * absolutely no use to us. * * These changes are suitable for persistence. */ _ghost() { this._folderID = null; this._messageKey = null; if ("_deleted" in this) { delete this._deleted; } }, /** * Are we a ghost (which implies not deleted)? We are not a ghost if we have * a definite folder location (we may not know our message key in the case * of IMAP moves not fully completed) and are not deleted. */ get _isGhost() { return this._folderID == null && !this._isDeleted; }, /** * If we were dead, un-dead us. */ _ensureNotDeleted() { if ("_deleted" in this) { delete this._deleted; } }, /** * Are we deleted? This is private because deleted gloda messages are not * visible to non-core-gloda code. */ get _isDeleted() { return "_deleted" in this && this._deleted; }, /** * Trash this message's in-memory representation because it should no longer * be reachable by any code. The database record is gone, it's not coming * back. */ _objectPurgedMakeYourselfUnpleasant() { this._id = null; this._folderID = null; this._messageKey = null; this._conversationID = null; this._conversation = null; this.date = null; this._headerMessageID = null; }, /** * Return the underlying nsIMsgDBHdr from the folder storage for this, or * null if the message does not exist for one reason or another. We may log * to our logger in the failure cases. * * This method no longer caches the result, so if you need to hold onto it, * hold onto it. * * In the process of retrieving the underlying message header, we may have to * open the message header database associated with the folder. This may * result in blocking while the load happens, so you may want to try and find * an alternate way to initiate the load before calling us. * We provide hinting to the GlodaDatastore via the GlodaFolder so that it * knows when it's a good time for it to go and detach from the database. * * @returns The nsIMsgDBHdr associated with this message if available, null on * failure. */ get folderMessage() { if (this._folderID === null || this._messageKey === null) { return null; } // XXX like for folder and folderURI, return null if we can't map the folder let glodaFolder; try { glodaFolder = this._datastore._mapFolderID(this._folderID); } catch (ex) { return null; } let folder = glodaFolder.getXPCOMFolder( glodaFolder.kActivityHeaderRetrieval ); if (folder) { let folderMessage; try { folderMessage = folder.GetMessageHeader(this._messageKey); } catch (ex) { folderMessage = null; } if (folderMessage !== null) { // verify the message-id header matches what we expect... if (folderMessage.messageId != this._headerMessageID) { LOG.info( "Message with message key " + this._messageKey + " in folder '" + folder.URI + "' does not match expected " + "header! (" + this._headerMessageID + " expected, got " + folderMessage.messageId + ")" ); folderMessage = null; } } return folderMessage; } // this only gets logged if things have gone very wrong. we used to throw // here, but it's unlikely our caller can do anything more meaningful than // treating this as a disappeared message. LOG.info( "Unable to locate folder message for: " + this._folderID + ":" + this._messageKey ); return null; }, get folderMessageURI() { let folderMessage = this.folderMessage; if (folderMessage) { return folderMessage.folder.getUriForMsg(folderMessage); } return null; }, }; MixIn(GlodaMessage, GlodaHasAttributesMixIn); /** * @class Contacts correspond to people (one per person), and may own multiple * identities (e-mail address, IM account, etc.) */ function GlodaContact( aDatastore, aID, aDirectoryUUID, aContactUUID, aName, aPopularity, aFrecency, aJsonText ) { // _datastore set on the prototype by GlodaDatastore this._id = aID; this._directoryUUID = aDirectoryUUID; this._contactUUID = aContactUUID; this._name = aName; this._popularity = aPopularity; this._frecency = aFrecency; if (aJsonText) { this._jsonText = aJsonText; } this._identities = null; } GlodaContact.prototype = { NOUN_ID: GlodaConstants.NOUN_CONTACT, // set by GlodaDatastore _datastore: null, get id() { return this._id; }, get directoryUUID() { return this._directoryUUID; }, get contactUUID() { return this._contactUUID; }, get name() { return this._name; }, set name(aName) { this._name = aName; }, get popularity() { return this._popularity; }, set popularity(aPopularity) { this._popularity = aPopularity; this.dirty = true; }, get frecency() { return this._frecency; }, set frecency(aFrecency) { this._frecency = aFrecency; this.dirty = true; }, get identities() { return this._identities; }, toString() { return "Contact:" + this._id; }, get accessibleLabel() { return "Contact: " + this._name; }, _clone() { return new GlodaContact( /* datastore */ null, this._id, this._directoryUUID, this._contactUUID, this._name, this._popularity, this._frecency ); }, }; MixIn(GlodaContact, GlodaHasAttributesMixIn); /** * @class A specific means of communication for a contact. */ function GlodaIdentity( aDatastore, aID, aContactID, aContact, aKind, aValue, aDescription, aIsRelay ) { // _datastore set on the prototype by GlodaDatastore this._id = aID; this._contactID = aContactID; this._contact = aContact; this._kind = aKind; this._value = aValue; this._description = aDescription; this._isRelay = aIsRelay; // Cached indication of whether there is an address book card for this // identity. We keep this up-to-date via address book listener // notifications in |GlodaABIndexer|. this._hasAddressBookCard = undefined; } GlodaIdentity.prototype = { NOUN_ID: GlodaConstants.NOUN_IDENTITY, // set by GlodaDatastore _datastore: null, get id() { return this._id; }, get contactID() { return this._contactID; }, get contact() { return this._contact; }, get kind() { return this._kind; }, get value() { return this._value; }, get description() { return this._description; }, get isRelay() { return this._isRelay; }, get uniqueValue() { return this._kind + "@" + this._value; }, toString() { return "Identity:" + this._kind + ":" + this._value; }, toLocaleString() { if (this.contact.name == this.value) { return this.value; } return this.contact.name + " : " + this.value; }, get abCard() { // for our purposes, the address book only speaks email if (this._kind != "email") { return false; } let card = MailServices.ab.cardForEmailAddress(this._value); this._hasAddressBookCard = card != null; return card; }, /** * Indicates whether we have an address book card for this identity. This * value is cached once looked-up and kept up-to-date by |GlodaABIndexer| * and its notifications. */ get inAddressBook() { if (this._hasAddressBookCard !== undefined) { return this._hasAddressBookCard; } return (this.abCard && true) || false; }, }; /** * An attachment, with as much information as we can gather on it */ function GlodaAttachment( aGlodaMessage, aName, aContentType, aSize, aPart, aExternalUrl, aIsExternal ) { // _datastore set on the prototype by GlodaDatastore this._glodaMessage = aGlodaMessage; this._name = aName; this._contentType = aContentType; this._size = aSize; this._part = aPart; this._externalUrl = aExternalUrl; this._isExternal = aIsExternal; } GlodaAttachment.prototype = { NOUN_ID: GlodaConstants.NOUN_ATTACHMENT, // set by GlodaDatastore get name() { return this._name; }, get contentType() { return this._contentType; }, get size() { return this._size; }, get url() { if (this.isExternal) { return this._externalUrl; } let uri = this._glodaMessage.folderMessageURI; if (!uri) { throw new Error( "The message doesn't exist anymore, unable to rebuild attachment URL" ); } let msgService = MailServices.messageServiceFromURI(uri); let neckoURL = msgService.getUrlForUri(uri); let url = neckoURL.spec; let hasParamAlready = url.match(/\?[a-z]+=[^\/]+$/); let sep = hasParamAlready ? "&" : "?"; return ( url + sep + "part=" + this._part + "&filename=" + encodeURIComponent(this._name) ); }, get isExternal() { return this._isExternal; }, toString() { return "attachment: " + this._name + ":" + this._contentType; }, };