2016-04-01 05:58:16 +08:00
|
|
|
import _ from 'underscore'
|
|
|
|
import {
|
|
|
|
Utils,
|
|
|
|
Thread,
|
|
|
|
AccountStore,
|
|
|
|
DatabaseStore,
|
|
|
|
NylasSyncStatusStore,
|
|
|
|
QuotedHTMLTransformer,
|
|
|
|
} from 'nylas-exports'
|
|
|
|
|
|
|
|
const INDEX_SIZE = 10000
|
2016-04-08 00:55:10 +08:00
|
|
|
const MAX_INDEX_SIZE = 30000
|
2016-04-01 05:58:16 +08:00
|
|
|
const CHUNKS_PER_ACCOUNT = 10
|
|
|
|
const INDEXING_WAIT = 1000
|
|
|
|
const MESSAGE_BODY_LENGTH = 50000
|
2016-06-08 05:40:21 +08:00
|
|
|
const INDEX_VERSION = 1
|
2016-04-01 05:58:16 +08:00
|
|
|
|
2016-11-13 02:32:16 +08:00
|
|
|
class ThreadSearchIndexStore {
|
2016-04-01 05:58:16 +08:00
|
|
|
|
2016-04-06 04:27:07 +08:00
|
|
|
constructor() {
|
2016-04-01 05:58:16 +08:00
|
|
|
this.unsubscribers = []
|
2017-01-16 10:28:04 +08:00
|
|
|
this.indexer = null;
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
|
|
|
|
2017-01-16 10:28:04 +08:00
|
|
|
activate(indexer) {
|
|
|
|
this.indexer = indexer;
|
|
|
|
this.indexer.registerSearchableModel(Thread, (model) => this.updateThreadIndex(model));
|
|
|
|
|
|
|
|
const date = Date.now();
|
2017-01-10 02:50:54 +08:00
|
|
|
console.log('Thread Search: Initializing thread search index...')
|
2016-04-08 00:55:10 +08:00
|
|
|
|
2017-01-10 02:50:54 +08:00
|
|
|
this.accountIds = _.pluck(AccountStore.accounts(), 'id')
|
|
|
|
this.initializeIndex()
|
|
|
|
.then(() => {
|
|
|
|
NylasEnv.config.set('threadSearchIndexVersion', INDEX_VERSION)
|
|
|
|
return Promise.resolve()
|
|
|
|
})
|
|
|
|
.then(() => {
|
|
|
|
console.log(`Thread Search: Index built successfully in ${((Date.now() - date) / 1000)}s`)
|
|
|
|
this.unsubscribers = [
|
|
|
|
AccountStore.listen(this.onAccountsChanged),
|
|
|
|
DatabaseStore.listen(this.onDataChanged),
|
|
|
|
]
|
2016-04-01 05:58:16 +08:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2016-04-08 00:55:10 +08:00
|
|
|
/**
|
|
|
|
* We only want to build the entire index if:
|
|
|
|
* - It doesn't exist yet
|
|
|
|
* - It is too big
|
2016-09-29 02:03:41 +08:00
|
|
|
* - We bumped the index version
|
2016-04-08 00:55:10 +08:00
|
|
|
*
|
|
|
|
* Otherwise, we just want to index accounts that haven't been indexed yet.
|
|
|
|
* An account may not have been indexed if it is added and the app is closed
|
|
|
|
* before sync completes
|
|
|
|
*/
|
|
|
|
initializeIndex() {
|
2016-06-08 05:40:21 +08:00
|
|
|
if (NylasEnv.config.get('threadSearchIndexVersion') !== INDEX_VERSION) {
|
|
|
|
return this.clearIndex()
|
|
|
|
.then(() => this.buildIndex(this.accountIds))
|
|
|
|
}
|
|
|
|
|
2016-04-01 05:58:16 +08:00
|
|
|
return DatabaseStore.searchIndexSize(Thread)
|
|
|
|
.then((size) => {
|
2016-05-07 07:23:48 +08:00
|
|
|
console.log(`Thread Search: Current index size is ${(size || 0)} threads`)
|
2016-04-01 05:58:16 +08:00
|
|
|
if (!size || size >= MAX_INDEX_SIZE || size === 0) {
|
2016-04-08 00:55:10 +08:00
|
|
|
return this.clearIndex().thenReturn(this.accountIds)
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
2016-04-08 00:55:10 +08:00
|
|
|
return this.getUnindexedAccounts()
|
2016-04-01 05:58:16 +08:00
|
|
|
})
|
2016-06-08 05:40:21 +08:00
|
|
|
.then((accountIds) => this.buildIndex(accountIds))
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
|
|
|
|
2016-04-08 00:55:10 +08:00
|
|
|
/**
|
|
|
|
* When accounts change, we are only interested in knowing if an account has
|
|
|
|
* been added or removed
|
|
|
|
*
|
|
|
|
* - If an account has been added, we want to index its threads, but wait
|
|
|
|
* until that account has been successfully synced
|
|
|
|
*
|
|
|
|
* - If an account has been removed, we want to remove its threads from the
|
|
|
|
* index
|
|
|
|
*
|
|
|
|
* If the application is closed before sync is completed, the new account will
|
|
|
|
* be indexed via `initializeIndex`
|
|
|
|
*/
|
2016-11-13 02:32:16 +08:00
|
|
|
onAccountsChanged = () => {
|
2016-04-08 00:55:10 +08:00
|
|
|
_.defer(() => {
|
2017-01-10 02:50:54 +08:00
|
|
|
const latestIds = _.pluck(AccountStore.accounts(), 'id')
|
|
|
|
if (_.isEqual(this.accountIds, latestIds)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const date = Date.now()
|
|
|
|
console.log(`Thread Search: Updating thread search index for accounts ${latestIds}`)
|
2016-04-08 00:55:10 +08:00
|
|
|
|
2017-01-10 02:50:54 +08:00
|
|
|
const newIds = _.difference(latestIds, this.accountIds)
|
|
|
|
const removedIds = _.difference(this.accountIds, latestIds)
|
|
|
|
const promises = []
|
|
|
|
if (newIds.length > 0) {
|
|
|
|
promises.push(this.buildIndex(newIds))
|
|
|
|
}
|
2016-04-08 00:55:10 +08:00
|
|
|
|
2017-01-10 02:50:54 +08:00
|
|
|
if (removedIds.length > 0) {
|
|
|
|
promises.push(
|
|
|
|
Promise.all(removedIds.map(id => DatabaseStore.unindexModelsForAccount(id, Thread)))
|
|
|
|
)
|
|
|
|
}
|
|
|
|
this.accountIds = latestIds
|
|
|
|
Promise.all(promises)
|
|
|
|
.then(() => {
|
|
|
|
console.log(`Thread Search: Index updated successfully in ${((Date.now() - date) / 1000)}s`)
|
2016-04-08 00:55:10 +08:00
|
|
|
})
|
2016-04-01 05:58:16 +08:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2016-04-08 00:55:10 +08:00
|
|
|
/**
|
|
|
|
* When a thread gets updated we will update the search index with the data
|
|
|
|
* from that thread if the account it belongs to is not being currently
|
|
|
|
* synced.
|
|
|
|
*
|
|
|
|
* When the account is successfully synced, its threads will be added to the
|
|
|
|
* index either via `onAccountsChanged` or via `initializeIndex` when the app
|
|
|
|
* starts
|
|
|
|
*/
|
2016-11-13 02:32:16 +08:00
|
|
|
onDataChanged = (change) => {
|
2016-04-01 05:58:16 +08:00
|
|
|
if (change.objectClass !== Thread.name) {
|
|
|
|
return;
|
|
|
|
}
|
2016-04-08 00:55:10 +08:00
|
|
|
_.defer(() => {
|
|
|
|
const {objects, type} = change
|
2017-01-10 02:50:54 +08:00
|
|
|
const threads = objects;
|
2016-04-08 00:55:10 +08:00
|
|
|
|
|
|
|
let promises = []
|
|
|
|
if (type === 'persist') {
|
2017-01-16 10:28:04 +08:00
|
|
|
this.indexer.notifyHasIndexingToDo();
|
2016-04-08 00:55:10 +08:00
|
|
|
} else if (type === 'unpersist') {
|
2016-11-13 02:32:16 +08:00
|
|
|
promises = threads.map(this.unindexThread)
|
2016-04-08 00:55:10 +08:00
|
|
|
}
|
|
|
|
Promise.all(promises)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
buildIndex = (accountIds) => {
|
2016-06-08 05:40:21 +08:00
|
|
|
if (!accountIds || accountIds.length === 0) { return Promise.resolve() }
|
2016-04-08 00:55:10 +08:00
|
|
|
const sizePerAccount = Math.floor(INDEX_SIZE / accountIds.length)
|
|
|
|
return Promise.resolve(accountIds)
|
|
|
|
.each((accountId) => (
|
|
|
|
this.indexThreadsForAccount(accountId, sizePerAccount)
|
|
|
|
))
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
clearIndex() {
|
|
|
|
return (
|
|
|
|
DatabaseStore.dropSearchIndex(Thread)
|
|
|
|
.then(() => DatabaseStore.createSearchIndex(Thread))
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2016-04-08 00:55:10 +08:00
|
|
|
getUnindexedAccounts() {
|
|
|
|
return Promise.resolve(this.accountIds)
|
2016-09-29 02:03:41 +08:00
|
|
|
.filter((accId) => DatabaseStore.isIndexEmptyForAccount(accId, Thread))
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
indexThreadsForAccount(accountId, indexSize) {
|
|
|
|
const chunkSize = Math.floor(indexSize / CHUNKS_PER_ACCOUNT)
|
|
|
|
const chunks = Promise.resolve(_.times(CHUNKS_PER_ACCOUNT, () => chunkSize))
|
|
|
|
|
|
|
|
return chunks.each((size, idx) => {
|
|
|
|
return DatabaseStore.findAll(Thread)
|
|
|
|
.where({accountId})
|
|
|
|
.limit(size)
|
|
|
|
.offset(size * idx)
|
|
|
|
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
|
2016-11-17 09:46:07 +08:00
|
|
|
.background()
|
2016-04-01 05:58:16 +08:00
|
|
|
.then((threads) => {
|
|
|
|
return Promise.all(
|
2016-11-13 02:32:16 +08:00
|
|
|
threads.map(this.indexThread)
|
2016-04-01 05:58:16 +08:00
|
|
|
).then(() => {
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, INDEXING_WAIT))
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2016-11-13 02:32:16 +08:00
|
|
|
indexThread = (thread) => {
|
2016-04-01 05:58:16 +08:00
|
|
|
return (
|
|
|
|
this.getIndexData(thread)
|
|
|
|
.then((indexData) => (
|
|
|
|
DatabaseStore.indexModel(thread, indexData)
|
|
|
|
))
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2016-11-13 02:32:16 +08:00
|
|
|
updateThreadIndex = (thread) => {
|
2016-04-01 05:58:16 +08:00
|
|
|
return (
|
|
|
|
this.getIndexData(thread)
|
|
|
|
.then((indexData) => (
|
|
|
|
DatabaseStore.updateModelIndex(thread, indexData)
|
|
|
|
))
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2016-11-13 02:32:16 +08:00
|
|
|
unindexThread = (thread) => {
|
2016-04-08 00:55:10 +08:00
|
|
|
return DatabaseStore.unindexModel(thread)
|
|
|
|
}
|
|
|
|
|
2016-04-01 05:58:16 +08:00
|
|
|
getIndexData(thread) {
|
2017-01-18 04:34:15 +08:00
|
|
|
return thread.messages().then((messages) => {
|
|
|
|
return {
|
|
|
|
bodies: messages
|
|
|
|
.map(({body, snippet}) => (!_.isString(body) ? {snippet} : {body}))
|
|
|
|
.map(({body, snippet}) => (
|
|
|
|
snippet || Utils.extractTextFromHtml(body, {maxLength: MESSAGE_BODY_LENGTH}).replace(/(\s)+/g, ' ')
|
|
|
|
)).join(' '),
|
|
|
|
to: messages.map(({to, cc, bcc}) => (
|
|
|
|
_.uniq(to.concat(cc).concat(bcc).map(({name, email}) => `${name} ${email}`))
|
|
|
|
)).join(' '),
|
|
|
|
from: messages.map(({from}) => (
|
|
|
|
from.map(({name, email}) => `${name} ${email}`)
|
|
|
|
)).join(' '),
|
|
|
|
};
|
|
|
|
}).then(({bodies, to, from}) => {
|
|
|
|
const categories = (
|
|
|
|
thread.categories
|
|
|
|
.map(({displayName}) => displayName)
|
2016-09-29 02:03:41 +08:00
|
|
|
.join(' ')
|
2017-01-18 04:34:15 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
return {
|
|
|
|
categories: categories,
|
|
|
|
to_: to,
|
|
|
|
from_: from,
|
|
|
|
body: bodies,
|
|
|
|
subject: thread.subject,
|
|
|
|
};
|
2016-11-03 04:03:20 +08:00
|
|
|
});
|
2016-04-01 05:58:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
deactivate() {
|
|
|
|
this.unsubscribers.forEach(unsub => unsub())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-11-13 02:32:16 +08:00
|
|
|
export default new ThreadSearchIndexStore()
|