mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-30 13:03:53 +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": {
|
"@types/lunr": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.1.6.tgz",
|
||||||
"integrity": "sha512-esk3CG25hRtHsVHm+LOjiSFYdw8be3uIY653WUwR43Bro914HSimPgPpqgajkhTJ0awK3RQfaIxP7zvbtCpcyg==",
|
"integrity": "sha512-Bz6fUhX1llTa7ygQJN3ttoVkkrpW7xxSEP7D7OYFO/FCBKqKqruRUZtJzTtYA0GkQX13lxU5u+8LuCviJlAXkQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
@ -4570,9 +4570,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lunr": {
|
"lunr": {
|
||||||
"version": "2.1.6",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.1.tgz",
|
||||||
"integrity": "sha512-ydJpB8CX8cZ/VE+KMaYaFcZ6+o2LruM6NG76VXdflYTgluvVemz1lW4anE+pyBbLvxJHZdvD1Jy/fOqdzAEJog=="
|
"integrity": "sha1-ETYWorYC3cEJMqe/ik5uV+v+zfI="
|
||||||
},
|
},
|
||||||
"make-dir": {
|
"make-dir": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
"@types/form-data": "^2.2.1",
|
"@types/form-data": "^2.2.1",
|
||||||
"@types/jasmine": "^2.8.2",
|
"@types/jasmine": "^2.8.2",
|
||||||
"@types/lowdb": "^1.0.1",
|
"@types/lowdb": "^1.0.1",
|
||||||
"@types/lunr": "2.1.5",
|
"@types/lunr": "^2.1.6",
|
||||||
"@types/node": "8.0.19",
|
"@types/node": "8.0.19",
|
||||||
"@types/node-fetch": "^1.6.9",
|
"@types/node-fetch": "^1.6.9",
|
||||||
"@types/node-forge": "0.7.1",
|
"@types/node-forge": "0.7.1",
|
||||||
@ -75,7 +75,7 @@
|
|||||||
"form-data": "2.3.2",
|
"form-data": "2.3.2",
|
||||||
"keytar": "4.2.1",
|
"keytar": "4.2.1",
|
||||||
"lowdb": "1.0.0",
|
"lowdb": "1.0.0",
|
||||||
"lunr": "2.1.6",
|
"lunr": "2.3.1",
|
||||||
"node-fetch": "2.1.2",
|
"node-fetch": "2.1.2",
|
||||||
"node-forge": "0.7.1",
|
"node-forge": "0.7.1",
|
||||||
"papaparse": "4.3.5",
|
"papaparse": "4.3.5",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { CipherView } from '../models/view/cipherView';
|
import { CipherView } from '../models/view/cipherView';
|
||||||
|
|
||||||
export abstract class SearchService {
|
export abstract class SearchService {
|
||||||
|
clearIndex: () => void;
|
||||||
indexCiphers: () => Promise<void>;
|
indexCiphers: () => Promise<void>;
|
||||||
searchCiphers: (query: string) => Promise<CipherView[]>;
|
searchCiphers: (query: string, filter?: (cipher: CipherView) => boolean) => Promise<CipherView[]>;
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { CipherService } from '../../abstractions/cipher.service';
|
import { SearchService } from '../../abstractions/search.service';
|
||||||
|
|
||||||
import { CipherView } from '../../models/view/cipherView';
|
import { CipherView } from '../../models/view/cipherView';
|
||||||
|
|
||||||
@ -23,11 +23,12 @@ export class CiphersComponent {
|
|||||||
protected allCiphers: CipherView[] = [];
|
protected allCiphers: CipherView[] = [];
|
||||||
protected filter: (cipher: CipherView) => boolean = null;
|
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) {
|
async load(filter: (cipher: CipherView) => boolean = null) {
|
||||||
this.allCiphers = await this.cipherService.getAllDecrypted();
|
await this.applyFilter(filter);
|
||||||
this.applyFilter(filter);
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,13 +38,18 @@ export class CiphersComponent {
|
|||||||
await this.load(this.filter);
|
await this.load(this.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
if (this.filter == null) {
|
await this.search(0);
|
||||||
this.ciphers = this.allCiphers;
|
}
|
||||||
} else {
|
|
||||||
this.ciphers = this.allCiphers.filter(this.filter);
|
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) {
|
selectCipher(cipher: CipherView) {
|
||||||
|
@ -78,10 +78,6 @@ export class CipherView implements View {
|
|||||||
return this.fields && this.fields.length > 0;
|
return this.fields && this.fields.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get login_username(): string {
|
|
||||||
return this.login != null ? this.login.username : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get passwordRevisionDisplayDate(): Date {
|
get passwordRevisionDisplayDate(): Date {
|
||||||
if (this.login == null) {
|
if (this.login == null) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -38,6 +38,7 @@ import { CipherService as CipherServiceAbstraction } from '../abstractions/ciphe
|
|||||||
import { CryptoService } from '../abstractions/crypto.service';
|
import { CryptoService } from '../abstractions/crypto.service';
|
||||||
import { I18nService } from '../abstractions/i18n.service';
|
import { I18nService } from '../abstractions/i18n.service';
|
||||||
import { PlatformUtilsService } from '../abstractions/platformUtils.service';
|
import { PlatformUtilsService } from '../abstractions/platformUtils.service';
|
||||||
|
import { SearchService } from '../abstractions/search.service';
|
||||||
import { SettingsService } from '../abstractions/settings.service';
|
import { SettingsService } from '../abstractions/settings.service';
|
||||||
import { StorageService } from '../abstractions/storage.service';
|
import { StorageService } from '../abstractions/storage.service';
|
||||||
import { UserService } from '../abstractions/user.service';
|
import { UserService } from '../abstractions/user.service';
|
||||||
@ -51,12 +52,25 @@ const Keys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class CipherService implements CipherServiceAbstraction {
|
export class CipherService implements CipherServiceAbstraction {
|
||||||
decryptedCipherCache: CipherView[];
|
// tslint:disable-next-line
|
||||||
|
_decryptedCipherCache: CipherView[];
|
||||||
|
|
||||||
constructor(private cryptoService: CryptoService, private userService: UserService,
|
constructor(private cryptoService: CryptoService, private userService: UserService,
|
||||||
private settingsService: SettingsService, private apiService: ApiService,
|
private settingsService: SettingsService, private apiService: ApiService,
|
||||||
private storageService: StorageService, private i18nService: I18nService,
|
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 {
|
clearCache(): void {
|
||||||
@ -591,7 +605,7 @@ export class CipherService implements CipherServiceAbstraction {
|
|||||||
|
|
||||||
async clear(userId: string): Promise<any> {
|
async clear(userId: string): Promise<any> {
|
||||||
await this.storageService.remove(Keys.ciphersPrefix + userId);
|
await this.storageService.remove(Keys.ciphersPrefix + userId);
|
||||||
this.decryptedCipherCache = null;
|
this.clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveManyWithServer(ids: string[], folderId: string): Promise<any> {
|
async moveManyWithServer(ids: string[], folderId: string): Promise<any> {
|
||||||
|
@ -3,58 +3,152 @@ import * as lunr from 'lunr';
|
|||||||
import { CipherView } from '../models/view/cipherView';
|
import { CipherView } from '../models/view/cipherView';
|
||||||
|
|
||||||
import { CipherService } from '../abstractions/cipher.service';
|
import { CipherService } from '../abstractions/cipher.service';
|
||||||
|
import { PlatformUtilsService } from '../abstractions/platformUtils.service';
|
||||||
import { SearchService as SearchServiceAbstraction } from '../abstractions/search.service';
|
import { SearchService as SearchServiceAbstraction } from '../abstractions/search.service';
|
||||||
|
|
||||||
export class SearchService implements SearchServiceAbstraction {
|
import { DeviceType } from '../enums/deviceType';
|
||||||
private index: lunr.Index;
|
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> {
|
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();
|
const builder = new lunr.Builder();
|
||||||
builder.ref('id');
|
builder.ref('id');
|
||||||
builder.field('name');
|
(builder as any).field('shortId', { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
|
||||||
builder.field('subTitle');
|
(builder as any).field('name', { boost: 10 });
|
||||||
|
(builder as any).field('subTitle', { boost: 5 });
|
||||||
builder.field('notes');
|
builder.field('notes');
|
||||||
builder.field('login_username');
|
(builder as any).field('login.username', {
|
||||||
builder.field('login_uri');
|
extractor: (c: CipherView) => c.login != null ? c.login.username : null,
|
||||||
|
|
||||||
const ciphers = await this.cipherService.getAllDecrypted();
|
|
||||||
ciphers.forEach((c) => {
|
|
||||||
builder.add(c);
|
|
||||||
});
|
});
|
||||||
|
(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.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[] = [];
|
const results: CipherView[] = [];
|
||||||
if (this.index == null) {
|
if (query != null) {
|
||||||
return results;
|
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>();
|
const ciphersMap = new Map<string, CipherView>();
|
||||||
ciphers.forEach((c) => {
|
ciphers.forEach((c) => ciphersMap.set(c.id, c));
|
||||||
ciphersMap.set(c.id, c);
|
|
||||||
});
|
|
||||||
|
|
||||||
query = this.transformQuery(query);
|
let searchResults: lunr.Index.Result[] = null;
|
||||||
const searchResults = this.index.search(query);
|
const isQueryString = query != null && query.length > 1 && query.indexOf('>') === 0;
|
||||||
searchResults.forEach((r) => {
|
if (isQueryString) {
|
||||||
if (ciphersMap.has(r.ref)) {
|
try {
|
||||||
results.push(ciphersMap.get(r.ref));
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformQuery(query: string) {
|
|
||||||
if (query.indexOf('>') === 0) {
|
|
||||||
return query.substr(1);
|
|
||||||
}
|
|
||||||
return '*' + query + '*';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user