search service implementation with lunr

This commit is contained in:
Kyle Spearrin 2018-08-13 09:42:52 -04:00
parent 4ca7a9709e
commit b724448081
7 changed files with 169 additions and 58 deletions

12
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -1,6 +1,7 @@
import { CipherView } from '../models/view/cipherView';
export abstract class SearchService {
clearIndex: () => void;
indexCiphers: () => Promise<void>;
searchCiphers: (query: string) => Promise<CipherView[]>;
searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean) => Promise<CipherView[]>;
}

View File

@ -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) {

View File

@ -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;

View File

@ -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<any> {
await this.storageService.remove(Keys.ciphersPrefix + userId);
this.decryptedCipherCache = null;
this.clearCache();
}
async moveManyWithServer(ids: string[], folderId: string): Promise<any> {

View File

@ -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<void> {
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<CipherView[]> {
async searchCiphers(query: string, filter: (cipher: CipherView) => boolean = null):
Promise<CipherView[]> {
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<string, CipherView>();
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 + '*';
}
}