1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2024-11-03 21:04:03 +08:00

adding minimal autocomplete

This commit is contained in:
Patrik J. Braun 2021-03-20 14:37:56 +01:00
parent 9f9dbe0c51
commit 8afd49c588
9 changed files with 157 additions and 26 deletions

View File

@ -1,7 +1,7 @@
import {SearchQueryTypes} from './SearchQueryDTO'; import {SearchQueryTypes} from './SearchQueryDTO';
export class AutoCompleteItem { export class AutoCompleteItem {
constructor(public text: string, public type: SearchQueryTypes) { constructor(public text: string, public type: SearchQueryTypes = null) {
} }
equals(other: AutoCompleteItem) { equals(other: AutoCompleteItem) {

View File

@ -95,6 +95,8 @@ import {CSRFInterceptor} from './model/network/helper/csrf.interceptor';
import {SettingsEntryComponent} from './ui/settings/_abstract/settings-entry/settings-entry.component'; import {SettingsEntryComponent} from './ui/settings/_abstract/settings-entry/settings-entry.component';
import {GallerySearchQueryEntryComponent} from './ui/gallery/search/query-enrty/query-entry.search.gallery.component'; import {GallerySearchQueryEntryComponent} from './ui/gallery/search/query-enrty/query-entry.search.gallery.component';
import {StringifySearchQuery} from './pipes/StringifySearchQuery'; import {StringifySearchQuery} from './pipes/StringifySearchQuery';
import {AutoCompleteService} from './ui/gallery/search/autocomplete.service';
import {SearchQueryParserService} from './ui/gallery/search/search-query-parser.service';
@Injectable() @Injectable()
@ -231,6 +233,8 @@ export function translationsFactory(locale: string) {
GalleryCacheService, GalleryCacheService,
GalleryService, GalleryService,
MapService, MapService,
SearchQueryParserService,
AutoCompleteService,
AuthenticationService, AuthenticationService,
ThumbnailLoaderService, ThumbnailLoaderService,
ThumbnailManagerService, ThumbnailManagerService,

View File

@ -1,11 +1,16 @@
import {Pipe, PipeTransform} from '@angular/core'; import {Pipe, PipeTransform} from '@angular/core';
import {SearchQueryDTO} from '../../../common/entities/SearchQueryDTO'; import {SearchQueryDTO} from '../../../common/entities/SearchQueryDTO';
import {SearchQueryParserService} from '../ui/gallery/search/search-query-parser.service';
@Pipe({name: 'searchQuery'}) @Pipe({name: 'searchQuery'})
export class StringifySearchQuery implements PipeTransform { export class StringifySearchQuery implements PipeTransform {
constructor(
private _searchQueryParserService: SearchQueryParserService) {
}
transform(query: SearchQueryDTO): string { transform(query: SearchQueryDTO): string {
return SearchQueryDTO.stringify(query); return this._searchQueryParserService.stringify(query);
} }
} }

View File

@ -11,6 +11,7 @@ import {Subscription} from 'rxjs';
import {SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../common/entities/SearchQueryDTO'; import {SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../common/entities/SearchQueryDTO';
import {ActivatedRoute, Params} from '@angular/router'; import {ActivatedRoute, Params} from '@angular/router';
import {QueryParams} from '../../../../../common/QueryParams'; import {QueryParams} from '../../../../../common/QueryParams';
import {SearchQueryParserService} from '../search/search-query-parser.service';
@Component({ @Component({
@ -34,6 +35,7 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy {
constructor(public _galleryService: GalleryService, constructor(public _galleryService: GalleryService,
private _notification: NotificationService, private _notification: NotificationService,
private _searchQueryParserService: SearchQueryParserService,
public i18n: I18n, public i18n: I18n,
private _route: ActivatedRoute, private _route: ActivatedRoute,
private modalService: BsModalService) { private modalService: BsModalService) {
@ -57,7 +59,7 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy {
validateRawSearchText() { validateRawSearchText() {
try { try {
this.searchQueryDTO = SearchQueryDTO.parse(this.rawSearchText); this.searchQueryDTO = this._searchQueryParserService.parse(this.rawSearchText);
this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery); this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -65,7 +67,7 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy {
} }
onQueryChange() { onQueryChange() {
this.rawSearchText = SearchQueryDTO.stringify(this.searchQueryDTO); this.rawSearchText = this._searchQueryParserService.stringify(this.searchQueryDTO);
this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery); this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery);
} }

View File

@ -2,23 +2,47 @@ import {Injectable} from '@angular/core';
import {NetworkService} from '../../../model/network/network.service'; import {NetworkService} from '../../../model/network/network.service';
import {AutoCompleteItem} from '../../../../../common/entities/AutoCompleteItem'; import {AutoCompleteItem} from '../../../../../common/entities/AutoCompleteItem';
import {GalleryCacheService} from '../cache.gallery.service'; import {GalleryCacheService} from '../cache.gallery.service';
import {SearchQueryParserService} from './search-query-parser.service';
import {BehaviorSubject} from 'rxjs';
@Injectable() @Injectable()
export class AutoCompleteService { export class AutoCompleteService {
constructor(private _networkService: NetworkService, constructor(private _networkService: NetworkService,
private galleryCacheService: GalleryCacheService) { private _searchQueryParserService: SearchQueryParserService,
private _galleryCacheService: GalleryCacheService) {
} }
public async autoComplete(text: string): Promise<Array<AutoCompleteItem>> { public autoComplete(text: string): BehaviorSubject<AutoCompleteItem[]> {
let items: Array<AutoCompleteItem> = this.galleryCacheService.getAutoComplete(text); const items: BehaviorSubject<AutoCompleteItem[]> = new BehaviorSubject(
if (items == null) { this.sortResults(text, this.getQueryKeywords(text)));
items = await this._networkService.getJson<Array<AutoCompleteItem>>('/autocomplete/' + text); const cached = this._galleryCacheService.getAutoComplete(text);
this.galleryCacheService.setAutoComplete(text, items); if (cached == null) {
this._networkService.getJson<AutoCompleteItem[]>('/autocomplete/' + text).then(ret => {
this._galleryCacheService.setAutoComplete(text, ret);
items.next(this.sortResults(text, ret.concat(items.value)));
});
} }
return items; return items;
} }
private sortResults(text: string, items: AutoCompleteItem[]) {
return items.sort((a, b) => {
if ((a.text.startsWith(text) && b.text.startsWith(text)) ||
(!a.text.startsWith(text) && !b.text.startsWith(text))) {
return a.text.localeCompare(b.text);
} else if (a.text.startsWith(text)) {
return -1;
}
return 1;
});
}
private getQueryKeywords(text: string) {
return Object.values(this._searchQueryParserService.keywords)
.filter(key => key.startsWith(text))
.map(key => new AutoCompleteItem(key + ':'));
}
} }

View File

@ -0,0 +1,46 @@
import {Injectable} from '@angular/core';
import {QueryKeywords, SearchQueryParser} from '../../../../../common/SearchQueryParser';
import {SearchQueryDTO} from '../../../../../common/entities/SearchQueryDTO';
@Injectable()
export class SearchQueryParserService {
public readonly keywords: QueryKeywords = {
NSomeOf: '-of',
and: 'and',
caption: 'caption',
directory: 'directory',
file_name: 'file-name',
from: 'from',
keyword: 'keyword',
landscape: 'landscape',
maxRating: 'max-rating',
maxResolution: 'max-resolution',
minRating: 'min-rating',
minResolution: 'min-resolution',
or: 'or',
orientation: 'orientation',
person: 'person',
portrait: 'portrait',
position: 'position',
someOf: 'some-of',
to: 'to',
kmFrom: 'km-from'
};
private readonly parser: SearchQueryParser;
constructor() {
this.parser = new SearchQueryParser(this.keywords);
}
public parse(str: string): SearchQueryDTO {
return this.parser.parse(str);
}
stringify(query: SearchQueryDTO): string {
return this.parser.stringify(query);
}
}

View File

@ -36,9 +36,19 @@
text-decoration: none; text-decoration: none;
} }
#srch-term { .search-text {
border-bottom-left-radius: 0; border: 0;
border-bottom-width: 0; z-index: 6;
width: 500px;
background: transparent;
}
.search-hint {
width: 500px;
background: white;
z-index: 1;
position: absolute;
border: 0;
} }
form { form {

View File

@ -1,7 +1,7 @@
<form class="navbar-form" role="search" #SearchForm="ngForm"> <form class="navbar-form" role="search" #SearchForm="ngForm">
<div class="input-group"> <div class="input-group">
<input type="text" <input type="text"
class="form-control " class="form-control search-text"
i18n-placeholder i18n-placeholder
placeholder="Search" placeholder="Search"
(keyup)="onSearchChange($event)" (keyup)="onSearchChange($event)"
@ -10,12 +10,24 @@
[(ngModel)]="rawSearchText" [(ngModel)]="rawSearchText"
(ngModelChange)="validateRawSearchText()" (ngModelChange)="validateRawSearchText()"
(keydown.enter)="Search()" (keydown.enter)="Search()"
(keydown.arrowRight)="applyHint($event)"
#name="ngModel" #name="ngModel"
size="30" size="30"
ngControl="search" ngControl="search"
name="srch-term" name="srch-term"
id="srch-term" id="srch-term"
autocomplete="off"> autocomplete="off">
<input type="text"
class="form-control search-hint"
[ngModel]="SearchHint"
i18n-placeholder
placeholder="Search"
disabled
value="test hint"
size="30"
name="srch-term-hint"
id="srch-term-hint"
autocomplete="off">
<div class="autocomplete-list" *ngIf="autoCompleteItems.length > 0" <div class="autocomplete-list" *ngIf="autoCompleteItems.length > 0"
@ -62,7 +74,7 @@
<div class="modal-body"> <div class="modal-body">
<form #searchPanelForm="ngForm" class="form-horizontal"> <form #searchPanelForm="ngForm" class="form-horizontal">
<input type="text" <input type="text"
class="form-control" class="form-control search-text"
i18n-placeholder i18n-placeholder
placeholder="Search" placeholder="Search"
[(ngModel)]="rawSearchText" [(ngModel)]="rawSearchText"

View File

@ -3,13 +3,14 @@ import {AutoCompleteService} from './autocomplete.service';
import {AutoCompleteItem} from '../../../../../common/entities/AutoCompleteItem'; import {AutoCompleteItem} from '../../../../../common/entities/AutoCompleteItem';
import {ActivatedRoute, Params, Router, RouterLink} from '@angular/router'; import {ActivatedRoute, Params, Router, RouterLink} from '@angular/router';
import {GalleryService} from '../gallery.service'; import {GalleryService} from '../gallery.service';
import {Subscription} from 'rxjs'; import {BehaviorSubject, Subscription} from 'rxjs';
import {Config} from '../../../../../common/config/public/Config'; import {Config} from '../../../../../common/config/public/Config';
import {NavigationService} from '../../../model/navigation.service'; import {NavigationService} from '../../../model/navigation.service';
import {QueryParams} from '../../../../../common/QueryParams'; import {QueryParams} from '../../../../../common/QueryParams';
import {MetadataSearchQueryTypes, SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../common/entities/SearchQueryDTO'; import {MetadataSearchQueryTypes, SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../common/entities/SearchQueryDTO';
import {BsModalService} from 'ngx-bootstrap/modal'; import {BsModalService} from 'ngx-bootstrap/modal';
import {BsModalRef} from 'ngx-bootstrap/modal/bs-modal-ref.service'; import {BsModalRef} from 'ngx-bootstrap/modal/bs-modal-ref.service';
import {SearchQueryParserService} from './search-query-parser.service';
@Component({ @Component({
selector: 'app-gallery-search', selector: 'app-gallery-search',
@ -31,8 +32,10 @@ export class GallerySearchComponent implements OnDestroy {
lastInstantSearch: '' lastInstantSearch: ''
}; };
private readonly subscription: Subscription = null; private readonly subscription: Subscription = null;
private autocompleteItems: BehaviorSubject<AutoCompleteItem[]>;
constructor(private _autoCompleteService: AutoCompleteService, constructor(private _autoCompleteService: AutoCompleteService,
private _searchQueryParserService: SearchQueryParserService,
private _galleryService: GalleryService, private _galleryService: GalleryService,
private navigationService: NavigationService, private navigationService: NavigationService,
private _route: ActivatedRoute, private _route: ActivatedRoute,
@ -54,6 +57,17 @@ export class GallerySearchComponent implements OnDestroy {
}); });
} }
get SearchHint() {
if (!this.autocompleteItems ||
!this.autocompleteItems.value || this.autocompleteItems.value.length === 0) {
return this.rawSearchText;
}
const searchText = this.getAutocompleteToken();
if (this.autocompleteItems.value[0].text.startsWith(searchText)) {
return this.rawSearchText + this.autocompleteItems.value[0].text.substr(searchText.length);
}
return this.rawSearchText;
}
get HTMLSearchQuery() { get HTMLSearchQuery() {
return JSON.stringify(this.searchQueryDTO); return JSON.stringify(this.searchQueryDTO);
@ -66,15 +80,21 @@ export class GallerySearchComponent implements OnDestroy {
} }
} }
getAutocompleteToken(): string {
if (this.rawSearchText.trim().length === 0) {
return '';
}
const tokens = this.rawSearchText.split(' ');
return tokens[tokens.length - 1];
}
onSearchChange(event: KeyboardEvent) { onSearchChange(event: KeyboardEvent) {
const searchText = this.getAutocompleteToken();
const searchText = (<HTMLInputElement>event.target).value.trim();
if (Config.Client.Search.AutoComplete.enabled && if (Config.Client.Search.AutoComplete.enabled &&
this.cache.lastAutocomplete !== searchText) { this.cache.lastAutocomplete !== searchText) {
this.cache.lastAutocomplete = searchText; this.cache.lastAutocomplete = searchText;
// this.autocomplete(searchText).catch(console.error); this.autocomplete(searchText).catch(console.error);
} }
} }
@ -109,12 +129,12 @@ export class GallerySearchComponent implements OnDestroy {
} }
onQueryChange() { onQueryChange() {
this.rawSearchText = SearchQueryDTO.stringify(this.searchQueryDTO); this.rawSearchText = this._searchQueryParserService.stringify(this.searchQueryDTO);
} }
validateRawSearchText() { validateRawSearchText() {
try { try {
this.searchQueryDTO = SearchQueryDTO.parse(this.rawSearchText); this.searchQueryDTO = this._searchQueryParserService.parse(this.rawSearchText);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@ -124,6 +144,13 @@ export class GallerySearchComponent implements OnDestroy {
this.router.navigate(['/search', this.HTMLSearchQuery]).catch(console.error); this.router.navigate(['/search', this.HTMLSearchQuery]).catch(console.error);
} }
applyHint($event: any) {
if ($event.target.selectionStart !== this.rawSearchText.length) {
return;
}
this.rawSearchText = this.SearchHint;
}
private emptyAutoComplete() { private emptyAutoComplete() {
this.autoCompleteItems = []; this.autoCompleteItems = [];
} }
@ -132,15 +159,16 @@ export class GallerySearchComponent implements OnDestroy {
if (!Config.Client.Search.AutoComplete.enabled) { if (!Config.Client.Search.AutoComplete.enabled) {
return; return;
} }
if (searchText.trim() === '.') {
if (searchText.trim().length === 0 ||
searchText.trim() === '.') {
return; return;
} }
if (searchText.trim().length > 0) { if (searchText.trim().length > 0) {
try { try {
const items = await this._autoCompleteService.autoComplete(searchText); this.autocompleteItems = this._autoCompleteService.autoComplete(searchText);
this.showSuggestions(items, searchText);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }