diff --git a/package-lock.json b/package-lock.json index 90c0ac7057a..c63ec413075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,9 +115,9 @@ } }, "@types/lunr": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.1.5.tgz", - "integrity": "sha512-esk3CG25hRtHsVHm+LOjiSFYdw8be3uIY653WUwR43Bro914HSimPgPpqgajkhTJ0awK3RQfaIxP7zvbtCpcyg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.1.6.tgz", + "integrity": "sha512-Bz6fUhX1llTa7ygQJN3ttoVkkrpW7xxSEP7D7OYFO/FCBKqKqruRUZtJzTtYA0GkQX13lxU5u+8LuCviJlAXkQ==", "dev": true }, "@types/node": { @@ -4570,9 +4570,9 @@ } }, "lunr": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.1.6.tgz", - "integrity": "sha512-ydJpB8CX8cZ/VE+KMaYaFcZ6+o2LruM6NG76VXdflYTgluvVemz1lW4anE+pyBbLvxJHZdvD1Jy/fOqdzAEJog==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.1.tgz", + "integrity": "sha1-ETYWorYC3cEJMqe/ik5uV+v+zfI=" }, "make-dir": { "version": "1.3.0", diff --git a/package.json b/package.json index c34ba06efc4..df787ee65e0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@types/form-data": "^2.2.1", "@types/jasmine": "^2.8.2", "@types/lowdb": "^1.0.1", - "@types/lunr": "2.1.5", + "@types/lunr": "^2.1.6", "@types/node": "8.0.19", "@types/node-fetch": "^1.6.9", "@types/node-forge": "0.7.1", @@ -75,7 +75,7 @@ "form-data": "2.3.2", "keytar": "4.2.1", "lowdb": "1.0.0", - "lunr": "2.1.6", + "lunr": "2.3.1", "node-fetch": "2.1.2", "node-forge": "0.7.1", "papaparse": "4.3.5", diff --git a/src/abstractions/search.service.ts b/src/abstractions/search.service.ts index c5983e2427f..1a4aa75e4b8 100644 --- a/src/abstractions/search.service.ts +++ b/src/abstractions/search.service.ts @@ -1,6 +1,7 @@ import { CipherView } from '../models/view/cipherView'; export abstract class SearchService { + clearIndex: () => void; indexCiphers: () => Promise; - searchCiphers: (query: string) => Promise; + searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean) => Promise; } diff --git a/src/angular/components/ciphers.component.ts b/src/angular/components/ciphers.component.ts index fea70ce2804..4ce7f85b6e9 100644 --- a/src/angular/components/ciphers.component.ts +++ b/src/angular/components/ciphers.component.ts @@ -4,7 +4,7 @@ import { Output, } from '@angular/core'; -import { CipherService } from '../../abstractions/cipher.service'; +import { SearchService } from '../../abstractions/search.service'; import { CipherView } from '../../models/view/cipherView'; @@ -23,11 +23,12 @@ export class CiphersComponent { protected allCiphers: CipherView[] = []; protected filter: (cipher: CipherView) => boolean = null; - constructor(protected cipherService: CipherService) { } + private searchTimeout: any = null; + + constructor(protected searchService: SearchService) { } async load(filter: (cipher: CipherView) => boolean = null) { - this.allCiphers = await this.cipherService.getAllDecrypted(); - this.applyFilter(filter); + await this.applyFilter(filter); this.loaded = true; } @@ -37,13 +38,18 @@ export class CiphersComponent { await this.load(this.filter); } - applyFilter(filter: (cipher: CipherView) => boolean = null) { + async applyFilter(filter: (cipher: CipherView) => boolean = null) { this.filter = filter; - if (this.filter == null) { - this.ciphers = this.allCiphers; - } else { - this.ciphers = this.allCiphers.filter(this.filter); + await this.search(0); + } + + search(timeout: number = 0) { + if (this.searchTimeout != null) { + clearTimeout(this.searchTimeout); } + this.searchTimeout = setTimeout(async () => { + this.ciphers = await this.searchService.searchCiphers(this.searchText, this.filter); + }, timeout); } selectCipher(cipher: CipherView) { diff --git a/src/models/view/cipherView.ts b/src/models/view/cipherView.ts index c9ef1300480..5c69728c1c3 100644 --- a/src/models/view/cipherView.ts +++ b/src/models/view/cipherView.ts @@ -78,10 +78,6 @@ export class CipherView implements View { return this.fields && this.fields.length > 0; } - get login_username(): string { - return this.login != null ? this.login.username : null; - } - get passwordRevisionDisplayDate(): Date { if (this.login == null) { return null; diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index 6fb6c3ab5ed..73f4ba0d7fc 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -38,6 +38,7 @@ import { CipherService as CipherServiceAbstraction } from '../abstractions/ciphe import { CryptoService } from '../abstractions/crypto.service'; import { I18nService } from '../abstractions/i18n.service'; import { PlatformUtilsService } from '../abstractions/platformUtils.service'; +import { SearchService } from '../abstractions/search.service'; import { SettingsService } from '../abstractions/settings.service'; import { StorageService } from '../abstractions/storage.service'; import { UserService } from '../abstractions/user.service'; @@ -51,12 +52,25 @@ const Keys = { }; export class CipherService implements CipherServiceAbstraction { - decryptedCipherCache: CipherView[]; + // tslint:disable-next-line + _decryptedCipherCache: CipherView[]; constructor(private cryptoService: CryptoService, private userService: UserService, private settingsService: SettingsService, private apiService: ApiService, private storageService: StorageService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService) { + private platformUtilsService: PlatformUtilsService, private searchService: () => SearchService) { + } + + get decryptedCipherCache() { + return this._decryptedCipherCache; + } + set decryptedCipherCache(value: CipherView[]) { + this._decryptedCipherCache = value; + if (value == null) { + this.searchService().clearIndex(); + } else { + this.searchService().indexCiphers(); + } } clearCache(): void { @@ -591,7 +605,7 @@ export class CipherService implements CipherServiceAbstraction { async clear(userId: string): Promise { await this.storageService.remove(Keys.ciphersPrefix + userId); - this.decryptedCipherCache = null; + this.clearCache(); } async moveManyWithServer(ids: string[], folderId: string): Promise { diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 1307d0db742..8aa5562807b 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -3,58 +3,152 @@ import * as lunr from 'lunr'; import { CipherView } from '../models/view/cipherView'; import { CipherService } from '../abstractions/cipher.service'; +import { PlatformUtilsService } from '../abstractions/platformUtils.service'; import { SearchService as SearchServiceAbstraction } from '../abstractions/search.service'; -export class SearchService implements SearchServiceAbstraction { - private index: lunr.Index; +import { DeviceType } from '../enums/deviceType'; +import { FieldType } from '../enums/fieldType'; - constructor(private cipherService: CipherService) { +export class SearchService implements SearchServiceAbstraction { + private indexing = false; + private index: lunr.Index = null; + private onlySearchName = false; + + constructor(private cipherService: CipherService, platformUtilsService: PlatformUtilsService) { + this.onlySearchName = platformUtilsService.getDevice() === DeviceType.EdgeExtension; + } + + clearIndex(): void { + this.index = null; } async indexCiphers(): Promise { + if (this.indexing) { + return; + } + // tslint:disable-next-line + console.time('search indexing'); + this.indexing = true; + this.index = null; const builder = new lunr.Builder(); builder.ref('id'); - builder.field('name'); - builder.field('subTitle'); + (builder as any).field('shortId', { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) }); + (builder as any).field('name', { boost: 10 }); + (builder as any).field('subTitle', { boost: 5 }); builder.field('notes'); - builder.field('login_username'); - builder.field('login_uri'); - - const ciphers = await this.cipherService.getAllDecrypted(); - ciphers.forEach((c) => { - builder.add(c); + (builder as any).field('login.username', { + extractor: (c: CipherView) => c.login != null ? c.login.username : null, }); - + (builder as any).field('login.uris', { + boost: 2, + extractor: (c: CipherView) => c.login == null || !c.login.hasUris ? null : + c.login.uris.filter((u) => u.hostname != null).map((u) => u.hostname), + }); + (builder as any).field('fields', { + extractor: (c: CipherView) => { + if (!c.hasFields) { + return null; + } + const fields = c.fields.filter((f) => f.type === FieldType.Text).map((f) => { + let field = ''; + if (f.name != null) { + field += f.name; + } + if (f.value != null) { + if (field !== '') { + field += ' '; + } + field += f.value; + } + return field; + }); + return fields.filter((f) => f.trim() !== ''); + }, + }); + (builder as any).field('attachments', { + extractor: (c: CipherView) => !c.hasAttachments ? null : c.attachments.map((a) => a.fileName), + }); + const ciphers = await this.cipherService.getAllDecrypted(); + ciphers.forEach((c) => builder.add(c)); this.index = builder.build(); + this.indexing = false; + // tslint:disable-next-line + console.timeEnd('search indexing'); } - async searchCiphers(query: string): Promise { + async searchCiphers(query: string, filter: (cipher: CipherView) => boolean = null): + Promise { const results: CipherView[] = []; - if (this.index == null) { - return results; + if (query != null) { + query = query.trim().toLowerCase(); + } + if (query === '') { + query = null; + } + + let ciphers = await this.cipherService.getAllDecrypted(); + if (filter != null) { + ciphers = ciphers.filter(filter); + } + + if (query == null || (this.index == null && query.length < 2)) { + return ciphers; + } + + if (this.index == null) { + // Fall back to basic search if index is not available + return ciphers.filter((c) => { + if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { + return true; + } + if (this.onlySearchName) { + return false; + } + if (query.length >= 8 && c.id.startsWith(query)) { + return true; + } + if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) { + return true; + } + if (c.login && c.login.uri != null && c.login.uri.toLowerCase().indexOf(query) > -1) { + return true; + } + return false; + }); } - const ciphers = await this.cipherService.getAllDecrypted(); const ciphersMap = new Map(); - ciphers.forEach((c) => { - ciphersMap.set(c.id, c); - }); + ciphers.forEach((c) => ciphersMap.set(c.id, c)); - query = this.transformQuery(query); - const searchResults = this.index.search(query); - searchResults.forEach((r) => { - if (ciphersMap.has(r.ref)) { - results.push(ciphersMap.get(r.ref)); - } - }); + let searchResults: lunr.Index.Result[] = null; + const isQueryString = query != null && query.length > 1 && query.indexOf('>') === 0; + if (isQueryString) { + try { + searchResults = this.index.search(query.substr(1)); + } catch { } + } else { + // tslint:disable-next-line + const soWild = lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING; + searchResults = this.index.query((q) => { + q.term(query, { fields: ['name'], wildcard: soWild }); + q.term(query, { fields: ['subTitle'], wildcard: soWild }); + q.term(query, { fields: ['login.uris'], wildcard: soWild }); + lunr.tokenizer(query).forEach((token) => { + q.term(token.toString(), {}); + }); + }); + } + if (searchResults != null) { + searchResults.forEach((r) => { + if (ciphersMap.has(r.ref)) { + results.push(ciphersMap.get(r.ref)); + } + }); + } + if (results != null) { + results.sort(this.cipherService.getLocaleSortingFunction()); + } return results; } - - private transformQuery(query: string) { - if (query.indexOf('>') === 0) { - return query.substr(1); - } - return '*' + query + '*'; - } }