import {Component, OnInit} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from "@angular/forms"; import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common"; import { HttpClient } from "@angular/common/http"; import {environment} from "../../environments/environment"; import {countryCodeToFlag, formatDuration} from "../format"; import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component"; import { FieldType, Operator, Query, QueryBuilderComponent } from "../../corelib/components/query-builder/query-builder.component"; import {ActivatedRoute, RouterLink} from "@angular/router"; import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe"; import {DownloadFilesService} from "../../corelib/service/download-files.service"; import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component"; import {Title} from "@angular/platform-browser"; export interface SchemaField { name: string; shortName: string; category: 'user' | 'score' | 'beatmap' | 'metrics'; type: 'number' | 'string' | 'flag' | 'grade' | 'boolean' | 'datetime' | 'playtime'; validOperators: Operator[]; active: boolean; description: string; } interface SchemaResponse { fields: SchemaField[]; } interface SearchResponse { results: any[]; pagination: SearchPagination; } interface SearchPagination { currentPage: number; pageSize: number; totalResults: number; totalPages: number; } interface Sorting { field: string; order: 'ASC' | 'DESC'; } @Component({ selector: 'app-search', standalone: true, imports: [ ReactiveFormsModule, NgForOf, FormsModule, JsonPipe, NgIf, DecimalPipe, OsuGradeComponent, QueryBuilderComponent, RouterLink, CalculatePageRangePipe, CuteLoadingComponent ], templateUrl: './search.component.html', styleUrl: './search.component.css' }) export class SearchComponent implements OnInit { schemaUrl!: string; searchUrl!: string; pageTitle!: string; localStorageKey!: string; searchType!: string; constructor(private httpClient: HttpClient, private title: Title, private route: ActivatedRoute, public downloadFilesService: DownloadFilesService) { } currentSchemaVersion = 2 isError = false; isLoading = false; isLoadingSchema = true; response: SearchResponse | null = null; fields: SchemaField[] = []; sortingOrder: Sorting | null = null; queries: Query[] | null = null; ngOnInit(): void { this.route.data.subscribe(data => { const searchType = data['searchType']; this.searchType = searchType; if (searchType === 'user') { this.schemaUrl = `${environment.apiUrl}/search-user/schema`; this.searchUrl = `${environment.apiUrl}/search-user`; this.pageTitle = '/m/ - user search'; this.localStorageKey = 'user_search_settings'; } else { this.schemaUrl = `${environment.apiUrl}/search/schema`; this.searchUrl = `${environment.apiUrl}/search`; this.pageTitle = '/k/ - score search'; this.localStorageKey = 'search_settings'; } this.title.setTitle(this.pageTitle); this.isLoadingSchema = true; this.httpClient.get(this.schemaUrl).subscribe({ next: (response) => { this.fields = response.fields; this.fields.forEach(field => { field.validOperators = this.getOperators(field.type); }) this.loadPreviousFromLocalStorage(); this.isLoadingSchema = false; }, error: () => { alert('Error fetching schema'); } }); }); } hasFieldsInCategory(category: string): boolean { return this.fields.some(field => field.category === category); } getOperators(fieldType: FieldType | undefined): Operator[] { switch (fieldType) { case 'number': return ['=', '>', '<', '>=', '<=', '!='] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'any'}) as Operator); case 'string': return ['=', 'contains', 'like'] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'any'}) as Operator); case 'boolean': return ['=', '!='] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'boolean'}) as Operator); case 'flag': return ['=', '!='] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'flag'}) as Operator); case 'grade': return ['=', '!='] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'grade'}) as Operator); case 'datetime': return ['before', 'after'] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'datetime'}) as Operator); case 'playtime': return ['>', '<', '>=', '<='] .map((operatorType: String) => ({operatorType: operatorType, acceptsValues: 'any'}) as Operator); default: return []; } } private loadPreviousFromLocalStorage(): void { const storedQueries = localStorage.getItem(this.localStorageKey); let parsedQueries = storedQueries ? JSON.parse(storedQueries) : null; if (parsedQueries && this.verifySchema(parsedQueries)) { this.queries = parsedQueries.queries; this.sortingOrder = parsedQueries.sortingOrder; this.fields.forEach(field => { field.active = parsedQueries.columns[field.name] ?? field.active; }); } else { localStorage.removeItem(this.localStorageKey); this.queries = []; this.sortingOrder = { field: 'user_id', order: 'ASC' }; } } deselectEntireFieldCategory(categoryName: string): void { this.fields.forEach(field => { if (field.category === categoryName) { field.active = false; } }); this.saveSettingsToLocalStorage(); } selectEntireFieldCategory(categoryName: string): void { this.fields.forEach(field => { if (field.category === categoryName) { field.active = true; } }); this.saveSettingsToLocalStorage(); } private serializeSettings() { return { queries: this.queries, sortingOrder: this.sortingOrder, columns: this.getColumnSettings(), schemaVersion: this.currentSchemaVersion }; } private getColumnSettings() { return this.fields.reduce<{ [key: string]: boolean }>((acc, field) => { acc[field.name] = field.active; return acc; }, {}); } onSortingFieldChange(event: Event): void { const selectElement = event.target as HTMLSelectElement; if(!selectElement) { return; } if(this.sortingOrder === null) { this.sortingOrder = { field: 'user_id', order: 'ASC' }; } this.sortingOrder.field = selectElement.value; } saveSettingsToLocalStorage(): void { const settings = this.serializeSettings(); localStorage.setItem(this.localStorageKey, JSON.stringify(settings)); } exportForApi(): void { if(!this.queries) { return; } const body = { queries: this.queries.map(query => ({ ...query, predicates: query.predicates.map(predicate => ({ field: { name: predicate.field!!.name, type: predicate.field!!.type }, operator: predicate.operator, value: predicate.value })) })), sorting: this.sortingOrder, page: 1 }; // Copy to cliboard navigator.clipboard.writeText(JSON.stringify(body)).then(() => { alert('Copied to clipboard'); }, () => { alert('Error copying to clipboard'); }); } exportSettings(): void { const settings = this.serializeSettings(); this.downloadFilesService.downloadJSON(settings as any, 'nise-settings'); } importSettings(json: any): void { if (this.verifySchema(json)) { this.queries = json.queries; this.sortingOrder = json.sortingOrder; this.fields.forEach(field => { if (json.columns.hasOwnProperty(field.name)) { field.active = json.columns[field.name]; } }); } else { alert('Invalid settings file.'); } } getColumns(): string[] { return this.fields.map(field => field.name); } uploadSettingsFile(event: any): void { const file = event.target.files[0]; if (file) { const fileReader = new FileReader(); fileReader.onload = (e) => { try { const json = JSON.parse(fileReader.result as string); this.importSettings(json); } catch (error) { console.error('Error parsing JSON', error); } }; fileReader.readAsText(file); } } verifySchema(json: any): boolean { if(!('schemaVersion' in json) || json.schemaVersion < this.currentSchemaVersion) { return false; } return 'queries' in json && 'sortingOrder' in json && 'columns' in json; } search(pageNumber: number = 1): void { this.isLoading = true; this.isError = false; this.response = null; const body = { queries: this.queries, sorting: this.sortingOrder, page: pageNumber } this.httpClient.post(this.searchUrl, body) .subscribe({ next: (response) => { this.response = response; this.isLoading = false; }, error: () => { this.isError = true; this.isLoading = false; } }); this.saveSettingsToLocalStorage(); } getValue(entry: any, columnName: string): any { if (entry.hasOwnProperty(columnName)) { return entry[columnName]; } else { return null; } } getLink(entry: any): any { if(this.searchType === 'user') { return "/u/" + this.getValue(entry, 'username'); } else { return "/s/" + this.getValue(entry, 'replay_id'); } } protected readonly countryCodeToFlag = countryCodeToFlag; protected readonly Math = Math; protected readonly formatDuration = formatDuration; }