mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-27 12:36:14 +01:00
search service implementation with lunr
This commit is contained in:
parent
4ca7a9709e
commit
b724448081
12
package-lock.json
generated
12
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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[]>;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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> {
|
||||
|
@ -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 + '*';
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user