diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt new file mode 100644 index 0000000..cc1cd89 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt @@ -0,0 +1,391 @@ +package com.nisemoe.nise.controller + +import com.nisemoe.generated.tables.references.BEATMAPS +import com.nisemoe.generated.tables.references.SCORES +import com.nisemoe.generated.tables.references.USERS +import com.nisemoe.nise.Format +import org.jooq.Condition +import org.jooq.DSLContext +import org.jooq.Field +import org.jooq.OrderField +import org.jooq.impl.DSL +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import kotlin.math.roundToInt + +@RestController +class SearchController( + private val dslContext: DSLContext +) { + + data class SearchResponse( + val scores: List, + + val totalResults: Int? = null + ) + + data class SearchResponseEntry( + // User fields + val user_id: Long?, + val user_username: String?, + val user_join_date: String?, + val user_country: String?, + val user_country_rank: Long?, + val user_rank: Long?, + val user_pp_raw: Double?, + val user_accuracy: Double?, + val user_playcount: Long?, + val user_total_score: Long?, + val user_ranked_score: Long?, + val user_seconds_played: Long?, + val user_count_300: Long?, + val user_count_100: Long?, + val user_count_50: Long?, + val user_count_miss: Int?, + + // Score fields + val id: Int?, + val beatmap_id: Int?, + val count_300: Int?, + val count_100: Int?, + val count_50: Int?, + val count_miss: Int?, + val date: String?, + val max_combo: Int?, + val mods: Int?, + val perfect: Boolean?, + val pp: Double?, + val rank: String?, + val replay_id: Long?, + val score: Long?, + val ur: Double?, + val frametime: Double?, + val edge_hits: Int?, + val snaps: Int?, + val adjusted_ur: Double?, + val mean_error: Double?, + val error_variance: Double?, + val error_standard_deviation: Double?, + val minimum_error: Double?, + val maximum_error: Double?, + val error_range: Double?, + val error_coefficient_of_variation: Double?, + val error_kurtosis: Double?, + val error_skewness: Double?, + val keypresses_median_adjusted: Double?, + val keypresses_standard_deviation_adjusted: Double?, + val sliderend_release_median_adjusted: Double?, + val sliderend_release_standard_deviation_adjusted: Double?, + + // Beatmap fields + val beatmap_artist: String?, + val beatmap_beatmapset_id: Int?, + val beatmap_creator: String?, + val beatmap_source: String?, + val beatmap_star_rating: Double?, + val beatmap_title: String?, + val beatmap_version: String? + ) + + data class SearchRequest( + val queries: List, + val sorting: SearchSorting + ) + + data class SearchSorting( + val field: String, + val order: String + ) + + data class SearchQuery( + val logicalOperator: String, + val predicates: List + ) + + data class SearchPredicate( + val field: SearchField, + val operator: String, + val value: String + ) + + data class SearchField( + val name: String, + val type: String + ) + + @PostMapping("search") + fun doSearch( + @RequestBody request: SearchRequest, + @RequestHeader("X-NISE-API") apiVersion: String + ): ResponseEntity { + if (apiVersion.isBlank()) + return ResponseEntity.badRequest().build() + + var baseQuery = DSL.noCondition() + for (query in request.queries.filter { it.predicates.isNotEmpty() }) { + var condition = buildCondition(query.predicates[0]) // Start with the first predicate + for (i in 1 until query.predicates.size) { + val nextPredicate = query.predicates[i] + condition = when (query.logicalOperator.lowercase()) { + "and" -> condition.and(buildCondition(nextPredicate)) + "or" -> condition.or(buildCondition(nextPredicate)) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + baseQuery = baseQuery.and(condition) + } + + val results = dslContext.select( + // User fields + USERS.USERNAME, + USERS.USER_ID, + USERS.JOIN_DATE, + USERS.COUNTRY, + USERS.COUNTRY_RANK, + USERS.RANK, + USERS.PP_RAW, + USERS.ACCURACY, + USERS.PLAYCOUNT, + USERS.TOTAL_SCORE, + USERS.RANKED_SCORE, + USERS.SECONDS_PLAYED, + USERS.COUNT_300, + USERS.COUNT_100, + USERS.COUNT_50, + + // Scores fields + SCORES.ID, + SCORES.BEATMAP_ID, + SCORES.COUNT_300, + SCORES.COUNT_100, + SCORES.COUNT_50, + SCORES.COUNT_MISS, + SCORES.DATE, + SCORES.MAX_COMBO, + SCORES.MODS, + SCORES.PERFECT, + SCORES.PP, + SCORES.RANK, + SCORES.REPLAY_ID, + SCORES.SCORE, + SCORES.UR, + SCORES.FRAMETIME, + SCORES.EDGE_HITS, + SCORES.SNAPS, + SCORES.ADJUSTED_UR, + SCORES.MEAN_ERROR, + SCORES.ERROR_VARIANCE, + SCORES.ERROR_STANDARD_DEVIATION, + SCORES.MINIMUM_ERROR, + SCORES.MAXIMUM_ERROR, + SCORES.ERROR_RANGE, + SCORES.ERROR_COEFFICIENT_OF_VARIATION, + SCORES.ERROR_KURTOSIS, + SCORES.ERROR_SKEWNESS, + SCORES.KEYPRESSES_MEDIAN_ADJUSTED, + SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, + SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, + SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, + + // Beatmaps fields + BEATMAPS.ARTIST, + BEATMAPS.BEATMAPSET_ID, + BEATMAPS.CREATOR, + BEATMAPS.SOURCE, + BEATMAPS.STAR_RATING, + BEATMAPS.TITLE, + BEATMAPS.VERSION + ) + .from(SCORES) + .join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID)) + .join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID)) + .where(baseQuery) + .apply { + if (request.sorting.field.isNotBlank()) + orderBy(buildSorting(request.sorting)) + } + .limit(50) + .fetch() + + val response = SearchResponse( + scores = results.map { + SearchResponseEntry( + // User fields + user_id = it.get(SCORES.USER_ID), + user_username = it.get(USERS.USERNAME), + user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) }, + user_country = it.get(USERS.COUNTRY), + user_country_rank = it.get(USERS.COUNTRY_RANK), + user_rank = it.get(USERS.RANK), + user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(), + user_accuracy = it.get(USERS.ACCURACY), + user_playcount = it.get(USERS.PLAYCOUNT), + user_total_score = it.get(USERS.TOTAL_SCORE), + user_ranked_score = it.get(USERS.RANKED_SCORE), + user_seconds_played = it.get(USERS.SECONDS_PLAYED), + user_count_300 = it.get(USERS.COUNT_300), + user_count_100 = it.get(USERS.COUNT_100), + user_count_50 = it.get(USERS.COUNT_50), + user_count_miss = it.get(SCORES.COUNT_MISS), + + // Score fields + id = it.get(SCORES.ID), + beatmap_id = it.get(SCORES.BEATMAP_ID), + count_300 = it.get(SCORES.COUNT_300), + count_100 = it.get(SCORES.COUNT_100), + count_50 = it.get(SCORES.COUNT_50), + count_miss = it.get(SCORES.COUNT_MISS), + date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) }, + max_combo = it.get(SCORES.MAX_COMBO), + mods = it.get(SCORES.MODS), + perfect = it.get(SCORES.PERFECT), + pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(), + rank = it.get(SCORES.RANK), + replay_id = it.get(SCORES.REPLAY_ID), + score = it.get(SCORES.SCORE), + ur = it.get(SCORES.UR), + frametime = it.get(SCORES.FRAMETIME), + edge_hits = it.get(SCORES.EDGE_HITS), + snaps = it.get(SCORES.SNAPS), + adjusted_ur = it.get(SCORES.ADJUSTED_UR), + mean_error = it.get(SCORES.MEAN_ERROR), + error_variance = it.get(SCORES.ERROR_VARIANCE), + error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION), + minimum_error = it.get(SCORES.MINIMUM_ERROR), + maximum_error = it.get(SCORES.MAXIMUM_ERROR), + error_range = it.get(SCORES.ERROR_RANGE), + error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION), + error_kurtosis = it.get(SCORES.ERROR_KURTOSIS), + error_skewness = it.get(SCORES.ERROR_SKEWNESS), + keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED), + keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED), + sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED), + sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED), + + // Beatmap fields + beatmap_artist = it.get(BEATMAPS.ARTIST), + beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID), + beatmap_creator = it.get(BEATMAPS.CREATOR), + beatmap_source = it.get(BEATMAPS.SOURCE), + beatmap_star_rating = it.get(BEATMAPS.STAR_RATING), + beatmap_title = it.get(BEATMAPS.TITLE), + beatmap_version = it.get(BEATMAPS.VERSION) + ) + } + ) + + return ResponseEntity.ok(response) + } + + private fun buildSorting(sorting: SearchSorting): OrderField<*> { + val field = mapPredicateFieldToDatabaseField(sorting.field) + return when (sorting.order.lowercase()) { + "asc" -> field.asc() + "desc" -> field.desc() + else -> throw IllegalArgumentException("Invalid sorting order") + } + } + + private fun buildCondition(predicate: SearchPredicate): Condition { + val field = mapPredicateFieldToDatabaseField(predicate.field.name) + return when (predicate.field.type.lowercase()) { + "number" -> buildNumberCondition(field as Field, predicate.operator, predicate.value.toDouble()) + "string" -> buildStringCondition(field as Field, predicate.operator, predicate.value) + else -> throw IllegalArgumentException("Invalid field type") + } + } + + private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> { + return when (predicateName.lowercase()) { + // User fields + "user_id" -> USERS.USER_ID + "user_username" -> USERS.USERNAME + "user_join_date" -> USERS.JOIN_DATE + "user_country" -> USERS.COUNTRY + "user_country_rank" -> USERS.COUNTRY_RANK + "user_rank" -> USERS.RANK + "user_pp_raw" -> USERS.PP_RAW + "user_accuracy" -> USERS.ACCURACY + "user_playcount" -> USERS.PLAYCOUNT + "user_total_score" -> USERS.TOTAL_SCORE + "user_ranked_score" -> USERS.RANKED_SCORE + "user_seconds_played" -> USERS.SECONDS_PLAYED + "user_count_300" -> USERS.COUNT_300 + "user_count_100" -> USERS.COUNT_100 + "user_count_50" -> USERS.COUNT_50 + + // Score fields + "id" -> SCORES.ID + "beatmap_id" -> SCORES.BEATMAP_ID + "count_300" -> SCORES.COUNT_300 + "count_100" -> SCORES.COUNT_100 + "count_50" -> SCORES.COUNT_50 + "count_miss" -> SCORES.COUNT_MISS + "date" -> SCORES.DATE + "max_combo" -> SCORES.MAX_COMBO + "mods" -> SCORES.MODS + "perfect" -> SCORES.PERFECT + "pp" -> SCORES.PP + "rank" -> SCORES.RANK + "replay_id" -> SCORES.REPLAY_ID + "score" -> SCORES.SCORE + "ur" -> SCORES.UR + "frametime" -> SCORES.FRAMETIME + "edge_hits" -> SCORES.EDGE_HITS + "snaps" -> SCORES.SNAPS + "adjusted_ur" -> SCORES.ADJUSTED_UR + "mean_error" -> SCORES.MEAN_ERROR + "error_variance" -> SCORES.ERROR_VARIANCE + "error_standard_deviation" -> SCORES.ERROR_STANDARD_DEVIATION + "minimum_error" -> SCORES.MINIMUM_ERROR + "maximum_error" -> SCORES.MAXIMUM_ERROR + "error_range" -> SCORES.ERROR_RANGE + "error_coefficient_of_variation" -> SCORES.ERROR_COEFFICIENT_OF_VARIATION + "error_kurtosis" -> SCORES.ERROR_KURTOSIS + "error_skewness" -> SCORES.ERROR_SKEWNESS + "keypresses_median_adjusted" -> SCORES.KEYPRESSES_MEDIAN_ADJUSTED + "keypresses_standard_deviation_adjusted" -> SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED + "sliderend_release_median_adjusted" -> SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED + "sliderend_release_standard_deviation_adjusted" -> SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED + + // Beatmap fields + "beatmap_artist" -> BEATMAPS.ARTIST + "beatmap_beatmapset_id" -> BEATMAPS.BEATMAPSET_ID + "beatmap_creator" -> BEATMAPS.CREATOR + "beatmap_source" -> BEATMAPS.SOURCE + "beatmap_star_rating" -> BEATMAPS.STAR_RATING + "beatmap_title" -> BEATMAPS.TITLE + "beatmap_version" -> BEATMAPS.VERSION + else -> throw IllegalArgumentException("Invalid field name: $predicateName") + } + } + + private fun buildNumberCondition(field: Field, operator: String, value: Double): Condition { + return when (operator) { + "=" -> field.eq(value) + ">" -> field.gt(value) + "<" -> field.lt(value) + ">=" -> field.ge(value) + "<=" -> field.le(value) + "!=" -> field.ne(value) + else -> throw IllegalArgumentException("Invalid operator") + } + } + + private fun buildStringCondition(field: Field, operator: String, value: String): Condition { + return when (operator.lowercase()) { + "=" -> field.eq(value) + "contains" -> field.containsIgnoreCase(value) + "like" -> field.likeIgnoreCase( + // Escape special characters for LIKE if needed + value.replace("%", "\\%").replace("_", "\\_") + ) + + else -> throw IllegalArgumentException("Invalid operator") + } + } + +} \ No newline at end of file diff --git a/nise-frontend/src/app/app-routing.module.ts b/nise-frontend/src/app/app-routing.module.ts index dfcb920..b67c0c9 100644 --- a/nise-frontend/src/app/app-routing.module.ts +++ b/nise-frontend/src/app/app-routing.module.ts @@ -6,6 +6,7 @@ import {ViewSimilarReplaysComponent} from "./view-similar-replays/view-similar-r import {ViewScoreComponent} from "./view-score/view-score.component"; import {ViewUserComponent} from "./view-user/view-user.component"; import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component"; +import {SearchComponent} from "./search/search.component"; const routes: Routes = [ {path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'}, @@ -16,6 +17,7 @@ const routes: Routes = [ {path: 'u/:userId', component: ViewUserComponent}, {path: 's/:replayId', component: ViewScoreComponent}, + {path: 'search', component: SearchComponent}, {path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent}, {path: '**', component: HomeComponent, title: '/nise.moe/'}, diff --git a/nise-frontend/src/app/search/search.component.css b/nise-frontend/src/app/search/search.component.css new file mode 100644 index 0000000..e1f93cc --- /dev/null +++ b/nise-frontend/src/app/search/search.component.css @@ -0,0 +1,17 @@ +.search-container { + /* your styles for the container */ +} + +.scrollable-table { + overflow-x: auto; /* Enables horizontal scrolling */ + width: 100%; /* Adjust as needed */ +} + +.table-border { + border: 1px solid rgba(179, 184, 195, 0.1); +} + +.table-border th, .table-border td { + padding: 2px; + border: 1px solid rgba(179, 184, 195, 0.1); +} diff --git a/nise-frontend/src/app/search/search.component.html b/nise-frontend/src/app/search/search.component.html new file mode 100644 index 0000000..1bcac44 --- /dev/null +++ b/nise-frontend/src/app/search/search.component.html @@ -0,0 +1,80 @@ +
+

/k/ - Advanced Search

+ + + +
+ Table columns + +
+ {{ category }} + +
+ +
+
+
+
+ +
+ +
+ +
+ +
+ sorting + + + + +
+ +
+ +
+ + +
+ + + + + + + + + + + +
+ {{ column.short_name }} +
+ + {{ getValue(entry, column.name) | number }} + + + {{ countryCodeToFlag(getValue(entry, column.name)) }} + + + + + + {{ getValue(entry, column.name) }} + +
+
+
+ +
diff --git a/nise-frontend/src/app/search/search.component.ts b/nise-frontend/src/app/search/search.component.ts new file mode 100644 index 0000000..7d1146e --- /dev/null +++ b/nise-frontend/src/app/search/search.component.ts @@ -0,0 +1,214 @@ +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} from "../format"; +import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component"; +import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.component"; + +interface SchemaField { + name: string; + short_name: string; + category: 'user' | 'score' | 'beatmap'; + type: 'number' | 'string' | 'flag' | 'grade' | 'boolean'; + active: boolean; + description: string; +} + +interface SearchResponse { + scores: SearchResponseEntry[]; +} + +interface Sorting { + field: string; + order: 'ASC' | 'DESC'; +} + +interface SearchResponseEntry { + // User fields + user_id?: number; + user_username?: string; + user_join_date?: string; + user_country?: string; + user_country_rank?: number; + user_rank?: number; + user_pp_raw?: number; + user_accuracy?: number; + user_playcount?: number; + user_total_score?: number; + user_ranked_score?: number; + user_seconds_played?: number; + user_count_300?: number; + user_count_100?: number; + user_count_50?: number; + user_count_miss?: number; + + // Score fields + replay_id?: number; + date?: string; + beatmap_id?: number; + pp?: number; + frametime?: number; + ur?: number; + + // Beatmap fields + beatmap_artist?: string; + beatmap_beatmapset_id?: number; + beatmap_creator?: string; + beatmap_source?: string; + beatmap_star_rating?: number; + beatmap_title?: string; + beatmap_version?: string; +} + +@Component({ + selector: 'app-search', + standalone: true, + imports: [ + ReactiveFormsModule, + NgForOf, + FormsModule, + JsonPipe, + NgIf, + DecimalPipe, + OsuGradeComponent, + QueryBuilderComponent + ], + templateUrl: './search.component.html', + styleUrl: './search.component.css' +}) +export class SearchComponent implements OnInit { + + constructor(private httpClient: HttpClient) { } + + response: SearchResponse | null = null; + + fields: SchemaField[] = [ + // User fields + { name: "user_id", short_name: "ID", category: "user", type: "number", active: true, description: "unique identifier for a user" }, + { name: "user_username", short_name: "Username", category: "user", type: "string", active: true, description: "user's name" }, + { name: "user_join_date", short_name: "Join Date", category: "user", type: "string", active: true, description: "when the user joined" }, + { name: "user_country", short_name: "Country", category: "user", type: "flag", active: true, description: "user's country flag" }, + { name: "user_country_rank", short_name: "Country Rank", category: "user", type: "number", active: true, description: "ranking within user's country" }, + { name: "user_rank", short_name: "Rank", category: "user", type: "number", active: true, description: "global ranking" }, + { name: "user_pp_raw", short_name: "User PP", category: "user", type: "number", active: true, description: "performance points" }, + { name: "user_accuracy", short_name: "User Accuracy", category: "user", type: "number", active: true, description: "hit accuracy percentage" }, + { name: "user_playcount", short_name: "Playcount", category: "user", type: "number", active: true, description: "total plays" }, + { name: "user_total_score", short_name: "Total Score", category: "user", type: "number", active: true, description: "cumulative score" }, + { name: "user_ranked_score", short_name: "Ranked Score", category: "user", type: "number", active: true, description: "score from ranked maps" }, + { name: "user_seconds_played", short_name: "Play Time", category: "user", type: "number", active: true, description: "total play time in seconds" }, + { name: "user_count_300", short_name: "300s", category: "user", type: "number", active: true, description: "number of 300 hits" }, + { name: "user_count_100", short_name: "100s", category: "user", type: "number", active: true, description: "number of 100 hits" }, + { name: "user_count_50", short_name: "50s", category: "user", type: "number", active: true, description: "number of 50 hits" }, + { name: "user_count_miss", short_name: "Misses", category: "user", type: "number", active: true, description: "missed hits" }, + + // Score fields + { name: "beatmap_id", short_name: "Beatmap ID", category: "score", type: "number", active: true, description: "identifies the beatmap" }, + { name: "count_300", short_name: "300s", category: "score", type: "number", active: true, description: "number of 300 hits in score" }, + { name: "count_100", short_name: "100s", category: "score", type: "number", active: true, description: "number of 100 hits in score" }, + { name: "count_50", short_name: "50s", category: "score", type: "number", active: true, description: "number of 50 hits in score" }, + { name: "count_miss", short_name: "Misses", category: "score", type: "number", active: true, description: "missed hits in score" }, + { name: "date", short_name: "Date", category: "score", type: "string", active: true, description: "when score was achieved" }, + { name: "max_combo", short_name: "Max Combo", category: "score", type: "number", active: true, description: "highest combo in score" }, + { name: "mods", short_name: "Mods", category: "score", type: "number", active: true, description: "game modifiers used" }, + { name: "perfect", short_name: "Perfect", category: "score", type: "boolean", active: true, description: "if score is a full combo" }, + { name: "pp", short_name: "Score PP", category: "score", type: "number", active: true, description: "performance points for score" }, + { name: "rank", short_name: "Rank", category: "score", type: "grade", active: true, description: "score grade" }, + { name: "replay_id", short_name: "Replay ID", category: "score", type: "number", active: true, description: "identifier for replay" }, + { name: "score", short_name: "Score", category: "score", type: "number", active: true, description: "score value" }, + { name: "ur", short_name: "UR", category: "score", type: "number", active: true, description: "unstable rate" }, + { name: "frametime", short_name: "Frame Time", category: "score", type: "number", active: true, description: "average frame time during play" }, + { name: "edge_hits", short_name: "Edge Hits", category: "score", type: "number", active: true, description: "hits at the edge of hit window" }, + { name: "snaps", short_name: "Snaps", category: "score", type: "number", active: true, description: "rapid cursor movements" }, + { name: "adjusted_ur", short_name: "Adj. UR", category: "score", type: "number", active: true, description: "adjusted unstable rate" }, + { name: "mean_error", short_name: "Mean Error", category: "score", type: "number", active: true, description: "average timing error" }, + { name: 'error_variance', short_name: 'Error Var.', category: 'score', type: 'number', active: true, description: 'variability of error in scores' }, + { name: 'error_standard_deviation', short_name: 'Error SD', category: 'score', type: 'number', active: true, description: 'standard deviation of error' }, + { name: 'minimum_error', short_name: 'Min Error', category: 'score', type: 'number', active: true, description: 'smallest error recorded' }, + { name: 'maximum_error', short_name: 'Max Error', category: 'score', type: 'number', active: true, description: 'largest error recorded' }, + { name: 'error_range', short_name: 'Error Range', category: 'score', type: 'number', active: true, description: 'range between min and max error' }, + { name: 'error_coefficient_of_variation', short_name: 'Error CV', category: 'score', type: 'number', active: true, description: 'relative variability of error' }, + { name: 'error_kurtosis', short_name: 'Kurtosis', category: 'score', type: 'number', active: true, description: 'peakedness of error distribution' }, + { name: 'error_skewness', short_name: 'Skewness', category: 'score', type: 'number', active: true, description: 'asymmetry of error distribution' }, + { name: 'keypresses_median_adjusted', short_name: 'KP Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted keypresses' }, + { name: 'keypresses_standard_deviation_adjusted', short_name: 'KP std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted keypresses' }, + { name: 'sliderend_release_median_adjusted', short_name: 'Sliderend Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted sliderend releases' }, + { name: 'sliderend_release_standard_deviation_adjusted', short_name: 'Sliderend std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted sliderend releases' }, + + // Beatmap fields + { name: 'beatmap_artist', short_name: 'Artist', category: 'beatmap', type: 'string', active: true, description: 'artist of the beatmap' }, + { name: 'beatmap_beatmapset_id', short_name: 'Set ID', category: 'beatmap', type: 'number', active: true, description: 'id of the beatmap set' }, + { name: 'beatmap_creator', short_name: 'Creator', category: 'beatmap', type: 'string', active: true, description: 'creator of the beatmap' }, + { name: 'beatmap_source', short_name: 'Source', category: 'beatmap', type: 'string', active: true, description: 'source of the beatmap music' }, + { name: 'beatmap_star_rating', short_name: 'Stars', category: 'beatmap', type: 'number', active: true, description: '(★) difficulty rating of the beatmap' }, + { name: 'beatmap_title', short_name: 'Title', category: 'beatmap', type: 'string', active: true, description: 'title of the beatmap' }, + { name: 'beatmap_version', short_name: 'Version', category: 'beatmap', type: 'string', active: true, description: 'version or difficulty name of the beatmap' } + ]; + + sortingOrder: Sorting | null = null; + queries: Query[] | null = null; + + ngOnInit(): void { + const storedQueries = localStorage.getItem('search_queries'); + if (storedQueries) { + this.queries = JSON.parse(storedQueries); + } else { + this.queries = []; + } + + // Load active/inactive status from localStorage + const storedStatus = localStorage.getItem('columns_status'); + if (storedStatus) { + const statusMap = JSON.parse(storedStatus); + this.fields.forEach(field => { + if (statusMap.hasOwnProperty(field.name)) { + field.active = statusMap[field.name]; + } + }); + } + + this.sortingOrder = { + field: 'user_id', + order: 'ASC' + }; + } + + mapSchemaFieldsToFields(): Field[] { + return this.fields.map(field => { + return { + name: field.name, + type: field.type, + }; + }); + } + + saveColumnsStatusToLocalStorage(): void { + const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => { + acc[field.name] = field.active; + return acc; + }, {}); + + localStorage.setItem('columns_status', JSON.stringify(statusMap)); + } + + search(): void { + const body = { + queries: this.queries, + sorting: this.sortingOrder + } + this.httpClient.post(`${environment.apiUrl}/search`, body).subscribe(response => { + this.response = response; + }); + // this.updateLocalStorage(); + } + + // Add this method to the SearchComponent class + getValue(entry: SearchResponseEntry, columnName: string): any { + return entry[columnName as keyof SearchResponseEntry]; + } + + + + protected readonly countryCodeToFlag = countryCodeToFlag; +} diff --git a/nise-frontend/src/corelib/components/query-builder/query-builder.component.css b/nise-frontend/src/corelib/components/query-builder/query-builder.component.css new file mode 100644 index 0000000..69a6b89 --- /dev/null +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.css @@ -0,0 +1,26 @@ +.query { + padding: 10px; + margin-bottom: 10px; +} + +.logical-operator-toggle button { + /* your styles for the logical operator buttons */ +} + +.logical-operator-toggle button.active { + /* your styles for the active state */ +} + +.predicate { + display: flex; + align-items: center; + margin-bottom: 5px; +} + +.predicate select, .predicate input, .predicate button { + margin-right: 5px; +} + +.predicate button { + /* style it to look more like a close button */ +} diff --git a/nise-frontend/src/corelib/components/query-builder/query-builder.component.html b/nise-frontend/src/corelib/components/query-builder/query-builder.component.html new file mode 100644 index 0000000..1829c99 --- /dev/null +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.html @@ -0,0 +1,33 @@ +
+ Query builder +
+ Predicate #{{ i + 1 }} +
+ + +
+ +
+ + + + + + +
+ +
+ +
diff --git a/nise-frontend/src/corelib/components/query-builder/query-builder.component.ts b/nise-frontend/src/corelib/components/query-builder/query-builder.component.ts new file mode 100644 index 0000000..8e9298d --- /dev/null +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.ts @@ -0,0 +1,81 @@ +import {Component, Input} from '@angular/core'; +import {FormsModule} from "@angular/forms"; +import {NgForOf, NgIf} from "@angular/common"; + +export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean'; +export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!='; + +export interface Field { + name: string; + type: FieldType; +} + +export interface Predicate { + field: Field | null; + operator: OperatorType | null; + value: string | number | null; +} + +export interface Query { + predicates: Predicate[]; + logicalOperator: 'AND' | 'OR'; +} + +@Component({ + selector: 'app-query-builder', + standalone: true, + imports: [ + FormsModule, + NgForOf, + NgIf + ], + templateUrl: './query-builder.component.html', + styleUrl: './query-builder.component.css' +}) +export class QueryBuilderComponent { + + @Input() queries: Query[] = []; + @Input() fields: Field[] = []; + + addPredicate(queryIndex: number): void { + const newPredicate: Predicate = { field: null, operator: null, value: null}; + this.queries[queryIndex].predicates.push(newPredicate); + } + + onFieldChange(predicate: Predicate, selectedField: any): void { + predicate.field = selectedField; + predicate.operator = this.getOperators(selectedField.type)[0]; + } + + getOperators(fieldType: FieldType | undefined): OperatorType[] { + switch (fieldType) { + case 'number': + return ['=', '>', '<', '>=', '<=', '!=']; + case 'string': + return ['=', 'contains', 'like']; + default: + return []; + } + } + + removePredicate(queryIndex: number, predicateIndex: number): void { + if(!this.queries) { + return; + } + + this.queries[queryIndex].predicates.splice(predicateIndex, 1); + } + + addQuery(): void { + this.queries.push({ + predicates: [], + logicalOperator: 'AND' + }); + } + + private updateLocalStorage(): void { + console.warn('Updating local storage'); + localStorage.setItem('search_queries', JSON.stringify(this.queries)); + } + +}