1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2025-01-14 14:43:17 +08:00

improving autocomplete #237

This commit is contained in:
Patrik J. Braun 2021-03-21 09:46:25 +01:00
parent 042b744a48
commit 24942b2ee1
5 changed files with 121 additions and 41 deletions

View File

@ -2,12 +2,12 @@ import {Injectable} from '@angular/core';
import {DirectoryDTO} from '../../../../common/entities/DirectoryDTO'; import {DirectoryDTO} from '../../../../common/entities/DirectoryDTO';
import {Utils} from '../../../../common/Utils'; import {Utils} from '../../../../common/Utils';
import {Config} from '../../../../common/config/public/Config'; import {Config} from '../../../../common/config/public/Config';
import {AutoCompleteItem, IAutoCompleteItem} from '../../../../common/entities/AutoCompleteItem'; import {IAutoCompleteItem} from '../../../../common/entities/AutoCompleteItem';
import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO'; import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO';
import {MediaDTO} from '../../../../common/entities/MediaDTO'; import {MediaDTO} from '../../../../common/entities/MediaDTO';
import {SortingMethods} from '../../../../common/entities/SortingMethods'; import {SortingMethods} from '../../../../common/entities/SortingMethods';
import {VersionService} from '../../model/version.service'; import {VersionService} from '../../model/version.service';
import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; import {SearchQueryDTO, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO';
interface CacheItem<T> { interface CacheItem<T> {
timestamp: number; timestamp: number;
@ -101,11 +101,11 @@ export class GalleryCacheService {
return null; return null;
} }
public getAutoComplete(text: string): IAutoCompleteItem[] { public getAutoComplete(text: string, type: SearchQueryTypes): IAutoCompleteItem[] {
if (Config.Client.Other.enableCache === false) { if (Config.Client.Other.enableCache === false) {
return null; return null;
} }
const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text; const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text + (type ? '_' + type : '');
const tmp = localStorage.getItem(key); const tmp = localStorage.getItem(key);
if (tmp != null) { if (tmp != null) {
const value: CacheItem<IAutoCompleteItem[]> = JSON.parse(tmp); const value: CacheItem<IAutoCompleteItem[]> = JSON.parse(tmp);
@ -118,16 +118,17 @@ export class GalleryCacheService {
return null; return null;
} }
public setAutoComplete(text: string, items: Array<IAutoCompleteItem>): void { public setAutoComplete(text: string, type: SearchQueryTypes, items: Array<IAutoCompleteItem>): void {
if (Config.Client.Other.enableCache === false) { if (Config.Client.Other.enableCache === false) {
return; return;
} }
const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text + (type ? '_' + type : '');
const tmp: CacheItem<Array<IAutoCompleteItem>> = { const tmp: CacheItem<Array<IAutoCompleteItem>> = {
timestamp: Date.now(), timestamp: Date.now(),
item: items item: items
}; };
try { try {
localStorage.setItem(GalleryCacheService.AUTO_COMPLETE_PREFIX + text, JSON.stringify(tmp)); localStorage.setItem(key, JSON.stringify(tmp));
} catch (e) { } catch (e) {
this.reset(); this.reset();
console.error(e); console.error(e);

View File

@ -5,11 +5,14 @@ import {GalleryCacheService} from '../cache.gallery.service';
import {SearchQueryParserService} from './search-query-parser.service'; import {SearchQueryParserService} from './search-query-parser.service';
import {BehaviorSubject} from 'rxjs'; import {BehaviorSubject} from 'rxjs';
import {SearchQueryTypes, TextSearchQueryTypes} from '../../../../../common/entities/SearchQueryDTO'; import {SearchQueryTypes, TextSearchQueryTypes} from '../../../../../common/entities/SearchQueryDTO';
import {QueryParams} from '../../../../../common/QueryParams';
@Injectable() @Injectable()
export class AutoCompleteService { export class AutoCompleteService {
private keywords: string[] = []; private keywords: string[] = [];
private relationKeywords: string[] = [];
private textSearchKeywordsMap: { [key: string]: SearchQueryTypes } = {};
constructor(private _networkService: NetworkService, constructor(private _networkService: NetworkService,
private _searchQueryParserService: SearchQueryParserService, private _searchQueryParserService: SearchQueryParserService,
@ -27,15 +30,37 @@ export class AutoCompleteService {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
this.keywords.push(i + this._searchQueryParserService.keywords.NSomeOf); this.keywords.push(i + this._searchQueryParserService.keywords.NSomeOf);
} }
TextSearchQueryTypes.forEach(t => {
this.textSearchKeywordsMap[(<any>this._searchQueryParserService.keywords)[SearchQueryTypes[t]]] = t;
});
} }
public autoComplete(text: string): BehaviorSubject<RenderableAutoCompleteItem[]> { public autoComplete(text: { current: string, prev: string }): BehaviorSubject<RenderableAutoCompleteItem[]> {
const items: BehaviorSubject<RenderableAutoCompleteItem[]> = new BehaviorSubject( const items: BehaviorSubject<RenderableAutoCompleteItem[]> = new BehaviorSubject(
this.sortResults(text, this.getQueryKeywords(text))); this.sortResults(text.current, this.getQueryKeywords(text)));
const cached = this._galleryCacheService.getAutoComplete(text);
const type = this.getTypeFromPrefix(text.current);
const searchText = this.getPrefixLessSearchText(text.current);
if (searchText === '') {
return items;
}
this.typedAutoComplete(searchText, type, items);
return items;
}
public typedAutoComplete(text: string, type: SearchQueryTypes,
items?: BehaviorSubject<RenderableAutoCompleteItem[]>): BehaviorSubject<RenderableAutoCompleteItem[]> {
items = items || new BehaviorSubject([]);
const cached = this._galleryCacheService.getAutoComplete(text, type);
if (cached == null) { if (cached == null) {
this._networkService.getJson<IAutoCompleteItem[]>('/autocomplete/' + text).then(ret => { const acParams: any = {};
this._galleryCacheService.setAutoComplete(text, ret); if (type) {
acParams[QueryParams.gallery.search.type] = type;
}
this._networkService.getJson<IAutoCompleteItem[]>('/autocomplete/' + text, acParams).then(ret => {
this._galleryCacheService.setAutoComplete(text, type, ret);
items.next(this.sortResults(text, ret.map(i => this.ACItemToRenderable(i)).concat(items.value))); items.next(this.sortResults(text, ret.map(i => this.ACItemToRenderable(i)).concat(items.value)));
}); });
} else { } else {
@ -44,6 +69,22 @@ export class AutoCompleteService {
return items; return items;
} }
private getTypeFromPrefix(text: string): SearchQueryTypes {
const tokens = text.split(':');
if (tokens.length !== 2) {
return null;
}
return this.textSearchKeywordsMap[tokens[0]] || null;
}
private getPrefixLessSearchText(text: string): string {
const tokens = text.split(':');
if (tokens.length !== 2) {
return text;
}
return tokens[1];
}
private ACItemToRenderable(item: IAutoCompleteItem): RenderableAutoCompleteItem { private ACItemToRenderable(item: IAutoCompleteItem): RenderableAutoCompleteItem {
if (!item.type) { if (!item.type) {
return {text: item.text, queryHint: item.text}; return {text: item.text, queryHint: item.text};
@ -52,7 +93,7 @@ export class AutoCompleteService {
return { return {
text: item.text, type: item.type, text: item.text, type: item.type,
queryHint: queryHint:
(<any>this._searchQueryParserService.keywords)[SearchQueryTypes[item.type]] + ':(' + item.text + ')' (<any>this._searchQueryParserService.keywords)[SearchQueryTypes[item.type]] + ':"' + item.text + '"'
}; };
} }
return { return {
@ -73,9 +114,20 @@ export class AutoCompleteService {
} }
private getQueryKeywords(text: string): RenderableAutoCompleteItem[] { private getQueryKeywords(text: { current: string, prev: string }): RenderableAutoCompleteItem[] {
// if empty, recommend "and"
if (text.current === '') {
if (text.prev !== this._searchQueryParserService.keywords.and) {
return [{
text: this._searchQueryParserService.keywords.and,
queryHint: this._searchQueryParserService.keywords.and
}];
} else {
return [];
}
}
return this.keywords return this.keywords
.filter(key => key.startsWith(text)) .filter(key => key.startsWith(text.current))
.map(key => ({ .map(key => ({
text: key, text: key,
queryHint: key queryHint: key

View File

@ -20,7 +20,7 @@
font-size: 17px; font-size: 17px;
} }
.autocomplete-item a { .autocomplete-item {
color: #333; color: #333;
padding: 0 20px; padding: 0 20px;
line-height: 1.42857143; line-height: 1.42857143;
@ -28,14 +28,11 @@
display: block; display: block;
} }
.autocomplete-item:hover { .autocomplete-item-selected {
background-color: #007bff; background-color: #007bff;
color: #FFF;
} }
.autocomplete-item:hover a {
color: #FFF;
text-decoration: none;
}
.search-text { .search-text {
border: 0; border: 0;

View File

@ -9,8 +9,10 @@
(focus)="onFocus()" (focus)="onFocus()"
[(ngModel)]="rawSearchText" [(ngModel)]="rawSearchText"
(ngModelChange)="validateRawSearchText()" (ngModelChange)="validateRawSearchText()"
(keydown.enter)="Search()" (keydown.enter)="OnEnter($event)"
(keydown.arrowRight)="applyHint($event)" (keydown.arrowRight)="applyHint($event)"
(keydown.arrowUp)="selectAutocompleteUp()"
(keydown.arrowDown)="selectAutocompleteDown()"
#name="ngModel" #name="ngModel"
size="30" size="30"
ngControl="search" ngControl="search"
@ -32,7 +34,11 @@
<div class="autocomplete-list" *ngIf="autoCompleteRenders.length > 0" <div class="autocomplete-list" *ngIf="autoCompleteRenders.length > 0"
(mouseover)="setMouseOverAutoComplete(true)" (mouseout)="setMouseOverAutoComplete(false)"> (mouseover)="setMouseOverAutoComplete(true)" (mouseout)="setMouseOverAutoComplete(false)">
<div class="autocomplete-item" (click)="applyAutoComplete(item)" *ngFor="let item of autoCompleteRenders"> <div class="autocomplete-item"
[ngClass]="{'autocomplete-item-selected':highlightedAutoCompleteItem == i}"
(mouseover)="setMouseOverAutoCompleteItem(i)"
(click)="applyAutoComplete(item)"
*ngFor="let item of autoCompleteRenders; let i = index">
<div> <div>
<span [ngSwitch]="item.type"> <span [ngSwitch]="item.type">
<span *ngSwitchCase="SearchQueryTypes.caption" class="oi oi-image"></span> <span *ngSwitchCase="SearchQueryTypes.caption" class="oi oi-image"></span>

View File

@ -26,6 +26,7 @@ export class GallerySearchComponent implements OnDestroy {
readonly SearchQueryTypes: typeof SearchQueryTypes; readonly SearchQueryTypes: typeof SearchQueryTypes;
modalRef: BsModalRef; modalRef: BsModalRef;
public readonly MetadataSearchQueryTypes: { value: string; key: SearchQueryTypes }[]; public readonly MetadataSearchQueryTypes: { value: string; key: SearchQueryTypes }[];
public highlightedAutoCompleteItem = 0;
private cache = { private cache = {
lastAutocomplete: '', lastAutocomplete: '',
lastInstantSearch: '' lastInstantSearch: ''
@ -62,11 +63,11 @@ export class GallerySearchComponent implements OnDestroy {
return this.rawSearchText; return this.rawSearchText;
} }
const searchText = this.getAutocompleteToken(); const searchText = this.getAutocompleteToken();
if (searchText === '') { if (searchText.current === '') {
return this.rawSearchText; return this.rawSearchText + this.autoCompleteItems.value[this.highlightedAutoCompleteItem].queryHint;
} }
if (this.autoCompleteItems.value[0].queryHint.startsWith(searchText)) { if (this.autoCompleteItems.value[0].queryHint.startsWith(searchText.current)) {
return this.rawSearchText + this.autoCompleteItems.value[0].queryHint.substr(searchText.length); return this.rawSearchText + this.autoCompleteItems.value[this.highlightedAutoCompleteItem].queryHint.substr(searchText.current.length);
} }
return this.rawSearchText; return this.rawSearchText;
} }
@ -82,20 +83,23 @@ export class GallerySearchComponent implements OnDestroy {
} }
} }
getAutocompleteToken(): string { getAutocompleteToken(): { current: string, prev: string } {
if (this.rawSearchText.trim().length === 0) { if (this.rawSearchText.trim().length === 0) {
return ''; return {current: '', prev: ''};
} }
const tokens = this.rawSearchText.split(' '); const tokens = this.rawSearchText.split(' ');
return tokens[tokens.length - 1]; return {
current: tokens[tokens.length - 1],
prev: (tokens.length > 2 ? tokens[tokens.length - 2] : '')
};
} }
onSearchChange(event: KeyboardEvent) { onSearchChange(event: KeyboardEvent) {
const searchText = this.getAutocompleteToken(); const searchText = this.getAutocompleteToken();
if (Config.Client.Search.AutoComplete.enabled && if (Config.Client.Search.AutoComplete.enabled &&
this.cache.lastAutocomplete !== searchText) { this.cache.lastAutocomplete !== searchText.current) {
this.cache.lastAutocomplete = searchText; this.cache.lastAutocomplete = searchText.current;
this.autocomplete(searchText).catch(console.error); this.autocomplete(searchText).catch(console.error);
} }
@ -157,35 +161,55 @@ export class GallerySearchComponent implements OnDestroy {
applyAutoComplete(item: AutoCompleteRenderItem) { applyAutoComplete(item: AutoCompleteRenderItem) {
const token = this.getAutocompleteToken(); const token = this.getAutocompleteToken();
this.rawSearchText = this.rawSearchText.substr(0, this.rawSearchText.length - token.length) this.rawSearchText = this.rawSearchText.substr(0, this.rawSearchText.length - token.current.length)
+ item.queryHint; + item.queryHint;
this.emptyAutoComplete(); this.emptyAutoComplete();
this.validateRawSearchText(); this.validateRawSearchText();
} }
setMouseOverAutoCompleteItem(i: number) {
this.highlightedAutoCompleteItem = i;
}
selectAutocompleteUp() {
if (this.highlightedAutoCompleteItem > 0) {
this.highlightedAutoCompleteItem--;
}
}
selectAutocompleteDown() {
if (this.autoCompleteItems &&
this.highlightedAutoCompleteItem < this.autoCompleteItems.value.length - 1) {
this.highlightedAutoCompleteItem++;
}
}
OnEnter($event: any) {
if (this.autoCompleteRenders.length === 0) {
this.Search();
return;
}
this.applyAutoComplete(this.autoCompleteRenders[this.highlightedAutoCompleteItem]);
}
private emptyAutoComplete() { private emptyAutoComplete() {
this.highlightedAutoCompleteItem = 0;
this.autoCompleteRenders = []; this.autoCompleteRenders = [];
} }
private async autocomplete(searchText: string) { private async autocomplete(searchText: { current: string, prev: string }) {
if (!Config.Client.Search.AutoComplete.enabled) { if (!Config.Client.Search.AutoComplete.enabled) {
return; return;
} }
if (searchText.trim().length === 0 || if (this.rawSearchText.trim().length > 0) { // are we searching for anything?
searchText.trim() === '.') {
return;
}
if (searchText.trim().length > 0) {
try { try {
if (this.autoCompleteItems) { if (this.autoCompleteItems) {
this.autoCompleteItems.unsubscribe(); this.autoCompleteItems.unsubscribe();
} }
this.autoCompleteItems = this._autoCompleteService.autoComplete(searchText); this.autoCompleteItems = this._autoCompleteService.autoComplete(searchText);
this.autoCompleteItems.subscribe(() => { this.autoCompleteItems.subscribe(() => {
this.showSuggestions(this.autoCompleteItems.value, searchText); this.showSuggestions(this.autoCompleteItems.value, searchText.current);
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);