6 Commits

Author SHA1 Message Date
755f5e5c24 (front) tsconfig use strict template 2021-08-22 22:31:53 +02:00
f40ee2b6d2 Suggester - Autocomplete Serch
Merge branch 'poc/suggester/ng-bootstrap' into dev
2021-08-22 17:39:30 +02:00
d011d5738f (front) Fix top buttons 2021-08-22 17:39:17 +02:00
e5de38b9d9 (front) Create 'To Sort' component
With a specific ELS Services to query element in a specific folder

TODO: Parameterize folder
2021-08-22 17:39:13 +02:00
f37ce410eb (front) Improve search result stype
Template to show results with glyphicon

(cherry picked from commit 550d8e7b343dc262be87182ae13f231ccc995b55)

Improve style
Stealing CSS from the demo site

(cherry picked from commit 75bebac0ac6080a0210d2b156a14f8a0f70067db)
2021-08-22 17:34:56 +02:00
124a30ad8c (front) Suggester with ng-bootstrap
Really "complex" search method, but yea, it's work :s

(cherry picked from commit 0789a92b0a9153b6112ad39b1f61090bdbeab197)
2021-08-22 17:33:33 +02:00
20 changed files with 941 additions and 316 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,15 +19,17 @@
"@angular/platform-browser": "~11.0.4",
"@angular/platform-browser-dynamic": "~11.0.4",
"@angular/router": "~11.0.4",
"rxjs": "~6.6.0",
"zone.js": "~0.10.2",
"@ng-bootstrap/ng-bootstrap": "^9.1.3",
"bootstrap": "^3.3.7",
"tslib": "^2.0.0"
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1100.4",
"@angular/cli": "~11.0.4",
"@angular/compiler-cli": "~11.0.4",
"@angular/localize": "^11.0.4",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",

View File

@@ -27,7 +27,7 @@ export class AlbumsComponent implements OnInit {
this.loadData();
}
private editQuery(field: string, value: string, type: query_edit_type): void {
private editQuery(field: string, value: Album, type: query_edit_type): void {
// TODO Move this method to a service
if (value[field] instanceof Array) {
value[field] = value[field][0]
@@ -49,12 +49,12 @@ export class AlbumsComponent implements OnInit {
this.queryEdited = true;
}
exlude(field: string, value: string): void {
exlude(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.exclude)
this.loadData()
}
select(field: string, value: string): void {
select(field: string, value: Album): void {
this.editQuery(field, value, query_edit_type.select)
this.loadData()
}

View File

@@ -7,6 +7,7 @@ import { ArtistComponent } from './artist/artist.component';
import { GenreComponent } from './genre/genre.component';
import { TopPlayedComponent } from './top-played/top-played.component';
import { AlbumsComponent } from './albums/albums.component';
import { ToSortComponent } from './to-sort/to-sort.component';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
@@ -15,7 +16,8 @@ const routes: Routes = [
{ path: 'album', component: AlbumsComponent },
{ path: 'artist/:name', component: ArtistComponent },
{ path: 'genre/:name', component: GenreComponent },
{ path: 'top-played', component: TopPlayedComponent }
{ path: 'top-played', component: TopPlayedComponent },
{ path: 'to-sort', component: ToSortComponent }
];
@NgModule({

View File

@@ -36,7 +36,8 @@ nav a.active {
/* FIXME Code Repetition */
.btn-top {
display: inline-block;
display: flex;
flex-direction: column;
position: fixed;
z-index: 1;
-webkit-transition: opacity .3s 0s, visibility 0s 0s;

View File

@@ -1,5 +1,8 @@
<nav>
<a class="btn-top" routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a><br />
<a class="btn-top" routerLink="/album" routerLinkActive="active"><span class="glyphicon glyphicon-cd"></span></a>
<div class="btn-top">
<a routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a><br />
<a routerLink="/album" routerLinkActive="active"><span class="glyphicon glyphicon-cd"></span></a>
<a routerLink="/to-sort" routerLinkActive="active"><span class="glyphicon glyphicon-sort"></span></a>
</div>
</nav>
<router-outlet></router-outlet>

View File

@@ -22,13 +22,17 @@ import { SortByPipe } from './pipes/sort-by.pipe';
import { ConvertSizeToStringPipe } from './pipes/convert-size-to-string.pipe';
import { RoundPipe } from './pipes/round.pipe';
import { AlbumsComponent } from './albums/albums.component';
import { ToSortComponent } from './to-sort/to-sort.component';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
FormsModule,
AppRoutingModule
AppRoutingModule,
NgbModule
],
declarations: [
AppComponent,
@@ -43,7 +47,8 @@ import { AlbumsComponent } from './albums/albums.component';
SortByPipe,
ConvertSizeToStringPipe,
TopPlayedComponent,
RoundPipe
RoundPipe,
ToSortComponent
],
providers: [
ElsService,

View File

@@ -34,5 +34,44 @@
border-radius: 50%;
background-color: #9b59b6;
margin: 0 auto;
}
/* Style for autocompletion */
/* Stolen from https://ng-bootstrap.github.io/#/components/typeahead/examples */
::ng-deep .dropdown-item {
display: block;
width: 100%;
padding: .25rem 1.5rem;
clear: both;
font-weight: 400;
color: #212529;
text-align: inherit;
white-space: nowrap;
background-color: transparent;
border: 0
}
::ng-deep .dropdown-item:focus,
::ng-deep .dropdown-item:hover {
color: #16181b;
text-decoration: none;
background-color: #f8f9fa
}
::ng-deep .dropdown-item.active,
::ng-deep .dropdown-item:active {
color: #fff;
text-decoration: none;
background-color: #007bff
}
::ng-deep .dropdown-item.disabled,
::ng-deep .dropdown-item:disabled {
color: #6c757d;
pointer-events: none;
background-color: transparent
}
::ng-deep .dropdown-menu.show {
display: block
}

View File

@@ -77,6 +77,14 @@
</div>
</div>
<ng-template #rt let-r="result" let-t="term">
<!-- glyphicon glyphicon-cd -->
<span *ngIf="r.type == 'artist'" class="glyphicon glyphicon-user"></span>
<span *ngIf="r.type == 'album'" class="glyphicon glyphicon-cd"></span>
&nbsp; <ngb-highlight [result]="r.name" [term]="t"></ngb-highlight>
</ng-template>
<form class="navbar-form">
<div class="form-group" style="display:inline;">
<div class="input-group" style="display:table;">
@@ -85,13 +93,10 @@
</span>
<input class="form-control" type="text" name="search" placeholder="Search Here" autocomplete="off" autofocus="autofocus"
id="searchSuggest"
list="dynmicUserIds"
[(ngModel)]="searchTerm"
(keyup)="onSearchChange()"
(change)="onSearchSelected($event)">
<datalist id="dynmicUserIds">
<option *ngFor="let item of suggested" [value]="item.name" [label]="item.type" [id]="item">{{item}}</option>
</datalist>
[ngbTypeahead]="search"
[resultTemplate]="rt"
(selectItem)="onSelectSearch($event)">
</div>
</div>
</form>

View File

@@ -4,16 +4,16 @@ import { Router } from '@angular/router';
import { ElsService } from './../els.service';
import { Song } from './../model/song';
import { Bucket } from './../model/bucket';
import { Album } from './../model/album';
import { Artist } from './../model/artist';
import { Suggested } from '../model/suggested';
import {Observable, of, OperatorFunction} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, map, tap, switchMap} from 'rxjs/operators';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
totalTime = 0;
totalSize = 0;
@@ -119,18 +119,26 @@ export class DashboardComponent implements OnInit {
});
}
onSearchChange() {
this.elsService.getSuggest(this.searchTerm).subscribe(result => this.suggested = result);
onSelectSearch($event) {
this.route.navigate(['/' + $event.item.type + '/' + $event.item.name])
}
onSearchSelected($event) {
let selected = $event.target.value
// FIXME Not possible to get good element (just value)
// Need to use a plugin to do this correctly
this.suggested.forEach(element => {
if (element.name == selected) {
this.route.navigate(['/' + element.type + '/' + element.name])
}
});
}
searching = false;
searchFailed = false;
search: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) =>
text$.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => this.searching = true),
switchMap(term =>
this.elsService.getSuggest(term).pipe(
tap(() => this.searchFailed = false),
catchError(() => {
this.searchFailed = true;
return of([]);
}))
),
tap(() => this.searching = false)
)
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ElsSortService } from './els-sort.service';
describe('ElsSortService', () => {
let service: ElsSortService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ElsSortService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,147 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ElsService } from './els.service';
import { Album } from './model/album';
import { Bucket } from './model/bucket';
import { Song } from './model/song';
@Injectable({
providedIn: 'root'
})
export class ElsSortService extends ElsService {
constructor(protected http: HttpClient) {
super(http);
}
getTime(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Total Time'}
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getTime()'));
}
getSize(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
aggs: {
sum_time: {
sum: { field: 'Size' }
}
},
'size': 0
}), {headers: this.headers})
.toPromise()
.then(res => res.aggregations.sum_time.value as number)
.catch(error => this.handleError(error, 'getSize()'));
}
getCountSong(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountSong()'));
}
getCountNeverListenSong(): Promise<number> {
return this.http
.post<any>(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_COUNT,
JSON.stringify({
'query': {
'bool': {
'must_not': [
{
'exists': { 'field': 'Play Count'}
}, {
term: {
"Location.tree": "/F:/Musique"
} }
]
}
}
}), {headers: this.headers})
.toPromise()
.then(res => res.count as number)
.catch(error => this.handleError(error, 'getCountNeverListenSong()'));
}
getAlbums(): Observable<Bucket[]> {
return this.http
.post(this.elsUrl + ElsService.SONG_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
"Location.tree": "/F:/Musique"
}
}
]
}
},
'size': 0,
'aggs': {
'albums' : {
'terms': {
'field' : 'Album.raw',
'order': { '_term': 'asc' },
'size': 50
}
}
}
}), {headers: this.headers})
.pipe(
map(res => this.responseAggregationToBucket(res, "albums")),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
}

View File

@@ -339,7 +339,7 @@ export class ElsService {
// TODO Take in consideration "sum_other_doc_count"
}
getArtistFromAlbumName(albumname: string): Observable<Album[]> {
getArtistFromAlbumName(albumname: string): Observable<Album[]> { // TODO Rename ?
return this.http
.post<any>(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
@@ -436,7 +436,7 @@ export class ElsService {
*
* @param res Response to process
*/
private responseToSongs(res: any): Song[] {
protected responseToSongs(res: any): Song[] {
const result: Array<Song> = [];
res.hits.hits.forEach((hit) => {
result.push(hit._source);
@@ -474,7 +474,7 @@ export class ElsService {
* @param res Response to process
* @param name Name of aggregation
*/
private responseAggregationToBucket(res: any, name: string): Bucket[] {
protected responseAggregationToBucket(res: any, name: string): Bucket[] {
const result: Array<Bucket> = [];
res.aggregations[name].buckets.forEach((bucket) => {
result.push(bucket);

View File

@@ -1,8 +1,4 @@
export class Suggested {
name: string;
type: string;
public toString() : string {
return `${this.name} (${this.type})`;
}
}

View File

@@ -0,0 +1,38 @@
.panel-stat {
min-height: 102px;
}
.panel-green {
background-color: #16a085;
color:white;
}
.panel-yellow {
background-color: #f1c40f;
color:white;
}
.panel-red {
background-color: #d9534f;
color:white;
}
.panel-purple {
background-color: #9b59b6;
color:white;
}
.panel-blue {
background-color: #3498db;
color:white;
}
.stats_icon {
font-size: 3em;
}
.circle {
border-radius: 50%;
background-color: #9b59b6;
margin: 0 auto;
}

View File

@@ -0,0 +1,130 @@
<div class="container">
<h1>To sort Songs - </h1>
<br />
<div class="row cardAdmin">
<div class="col-lg-3 col-md-3">
<div class="panel panel-blue">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-time stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!totalTime"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="totalTime">{{totalTime | convertMs}}</h3>
</div>
<div><br>Total time ({{totalTime | convertMoreExact}})</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-hdd stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!totalSize"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="totalSize">{{totalSize | convertSizeToString}}</h3>
</div>
<div><br>Total size</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-yellow">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-play stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!trackCountSong"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="trackCountSong">{{trackCountSong}}</h3>
</div>
<div><br>Total Songs</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-3">
<div class="panel panel-red">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<span class="glyphicon glyphicon-stop stats_icon"></span>
</div>
<div class="col-xs-9 text-right">
<div>
<h3 *ngIf="!neverListenSong"><span class="glyphicon glyphicon-refresh loading"></span></h3>
<h3 *ngIf="neverListenSong">{{neverListenSong}}</h3>
</div>
<div><br>Never list. songs (~{{neverListenSong / trackCountSong * 100 | round}}%)</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-12">
<table class="table table-striped" style="white-space: nowrap;">
<thead>
<tr>
<th>Album</th>
<th>Track Count</th>
<th>Artist/Album Artist</th>
<th>Play Count</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let album of toSortAlbum">
<td>
<a [routerLink]="['/album', album.key]">{{album.key}}</a>&nbsp;
</td>
<td>{{albums[album.key]['Track Count']}}</td>
<ng-template [ngIf]="albums[album.key]['Album Artist']" [ngIfElse]="artistSection">
<td>
<a [routerLink]="['/artist', albums[album.key]['Album Artist']]">{{albums[album.key]['Album Artist']}}</a>&nbsp;
</td>
</ng-template>
<ng-template #artistSection>
<td>
<a [routerLink]="['/artist', albums[album.key].Artist[0]]">{{albums[album.key].Artist}}</a>&nbsp;
</td>
</ng-template>
<ng-template #artistSection>
<td>
<a [routerLink]="['/artist', albums[album.key].Artist[0]]">{{albums[album.key].Artist}}</a>&nbsp;
</td>
</ng-template>
<td>
{{albums[album.key]['Play Count']}} ({{albums[album.key]['Play Count']/albums[album.key]['Track Count'] | number:'1.0-0'}}/songs)
</td>
<td class="star" [title]="(albums[album.key]['Album Rating Computed']?'Computed Rating: ':'Rating: ') + albums[album.key]['Album Rating']">
<span *ngFor="let item of numberToArray(albums[album.key]['Album Rating'], 20)">
<span class="glyphicon" [ngClass]="albums[album.key]['Album Rating Computed']?'glyphicon-star-empty':'glyphicon-star'"></span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToSortComponent } from './to-sort.component';
describe('ToSortComponent', () => {
let component: ToSortComponent;
let fixture: ComponentFixture<ToSortComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ToSortComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ToSortComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,89 @@
import { Component, OnInit } from '@angular/core';
import { ElsSortService } from '../els-sort.service';
import { Album } from '../model/album';
import { Bucket } from '../model/bucket';
import { Song } from '../model/song';
import { Utils } from '../utils';
@Component({
selector: 'app-to-sort',
templateUrl: './to-sort.component.html',
styleUrls: ['./to-sort.component.css']
})
export class ToSortComponent implements OnInit {
numberToArray = Utils.numberToArray;
// Stats var
totalTime = 0;
totalSize = 0;
trackCountSong = 0;
neverListenSong = 0;
// Global var
toSortAlbum: Bucket[] = [];
albums = []
constructor(private elsService: ElsSortService) { }
ngOnInit(): void {
// **** GET STATS ****//
this.elsService.getTime().then(result => this.totalTime = result);
this.elsService.getSize().then(result => this.totalSize = result);
this.elsService.getCountSong().then(result => this.trackCountSong = result);
this.elsService.getCountNeverListenSong().then(result => this.neverListenSong = result);
// **** GET ALBUMS ****//
const tmpToSortAlbums: Bucket[] = [];
this.elsService.getAlbums().subscribe(buckets => {
buckets.forEach(bucket => {
if (tmpToSortAlbums.length === 0) {
tmpToSortAlbums.push(bucket);
} else {
let found = false;
tmpToSortAlbums.forEach(element => {
if (element.key === bucket.key) {
element.doc_count += bucket.doc_count;
found = true;
}
});
if (!found) {
tmpToSortAlbums.push(bucket);
}
}
});
this.toSortAlbum = tmpToSortAlbums;
this.toSortAlbum.forEach(bucket => this.getAlbum(bucket));
// console.log(this.toSortAlbum)
// console.log(this.albums)
});
}
private getAlbum(albumBucket: Bucket) {
// For each bucket.key (album name), get Album document
// Use track count to compare
this.elsService.getArtistFromAlbumName(albumBucket.key).subscribe(albums => {
// Identification of the good album
let goodAlbum;
if (albums.length > 1) {
// More than one result for an album name: search good by track count
albums.forEach(album => {
if (album['Track Count'] === albumBucket.doc_count) {
goodAlbum = album;
}
});
} else {
// Just one result for album name
goodAlbum = albums[0];
}
// TODO Crap security if no good album found
if (goodAlbum == undefined) {
goodAlbum = albums[0]
}
this.albums[albumBucket.key] = goodAlbum;
});
}
}

View File

@@ -1,3 +1,7 @@
/***************************************************************************************************
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
*/
import '@angular/localize/init';
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.

View File

@@ -16,5 +16,8 @@
"es2018",
"dom"
]
},
"angularCompilerOptions": {
"strictTemplates": true
}
}