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 index cc1cd89..9ffc526 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt @@ -4,10 +4,13 @@ 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 com.nisemoe.nise.service.AuthService import org.jooq.Condition import org.jooq.DSLContext import org.jooq.Field import org.jooq.OrderField +import org.jooq.Record +import org.jooq.Result import org.jooq.impl.DSL import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -18,15 +21,32 @@ import kotlin.math.roundToInt @RestController class SearchController( - private val dslContext: DSLContext + private val dslContext: DSLContext, + private val authService: AuthService, ) { + companion object { + + const val RESULTS_PER_PAGE = 50 + + } + data class SearchResponse( val scores: List, - - val totalResults: Int? = null + val pagination: SearchResponsePagination ) + data class SearchResponsePagination( + val currentPage: Int, + val pageSize: Int, + val totalResults: Int + ) { + + val totalPages: Int + get() = if (totalResults % pageSize == 0) totalResults / pageSize else totalResults / pageSize + 1 + + } + data class SearchResponseEntry( // User fields val user_id: Long?, @@ -92,7 +112,8 @@ class SearchController( data class SearchRequest( val queries: List, - val sorting: SearchSorting + val sorting: SearchSorting, + val page: Int ) data class SearchSorting( @@ -102,7 +123,8 @@ class SearchController( data class SearchQuery( val logicalOperator: String, - val predicates: List + val predicates: List, + val childQueries: List? ) data class SearchPredicate( @@ -117,24 +139,18 @@ class SearchController( ) @PostMapping("search") - fun doSearch( - @RequestBody request: SearchRequest, - @RequestHeader("X-NISE-API") apiVersion: String - ): ResponseEntity { + fun doSearch(@RequestBody request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity { + if(!authService.isAdmin()) + return ResponseEntity.status(401).build() + if (apiVersion.isBlank()) return ResponseEntity.badRequest().build() + // TODO: Validation + 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") - } - } + val condition = buildCondition(query) baseQuery = baseQuery.and(condition) } @@ -207,79 +223,123 @@ class SearchController( if (request.sorting.field.isNotBlank()) orderBy(buildSorting(request.sorting)) } - .limit(50) + .offset((request.page - 1) * RESULTS_PER_PAGE) + .limit(RESULTS_PER_PAGE) .fetch() + // Get total results + val totalResults = dslContext.selectCount() + .from(SCORES) + .join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID)) + .join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID)) + .where(baseQuery) + .fetchOne(0, Int::class.java) ?: 0 + 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) - ) - } + scores = mapRecordToScores(results), + pagination = SearchResponsePagination( + currentPage = request.page, + pageSize = RESULTS_PER_PAGE, + totalResults = totalResults + ) ) return ResponseEntity.ok(response) } + private fun mapRecordToScores(results: Result): MutableList = + 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) + ) + } + + fun buildCondition(query: SearchQuery): Condition { + // Handle base predicates + var baseCondition = buildPredicateCondition(query.predicates.first()) + query.predicates.drop(1).forEach { predicate -> + baseCondition = when (query.logicalOperator.lowercase()) { + "and" -> baseCondition.and(buildPredicateCondition(predicate)) + "or" -> baseCondition.or(buildPredicateCondition(predicate)) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + + // Handle child queries + if(query.childQueries.isNullOrEmpty()) + return baseCondition + + query.childQueries.forEach { childQuery -> + val childCondition = buildCondition(childQuery) // Recursively build condition for child queries + baseCondition = when (query.logicalOperator.lowercase()) { + "and" -> baseCondition.and(childCondition) + "or" -> baseCondition.or(childCondition) + else -> throw IllegalArgumentException("Invalid logical operator") + } + } + + return baseCondition + } + private fun buildSorting(sorting: SearchSorting): OrderField<*> { val field = mapPredicateFieldToDatabaseField(sorting.field) return when (sorting.order.lowercase()) { @@ -289,7 +349,7 @@ class SearchController( } } - private fun buildCondition(predicate: SearchPredicate): Condition { + private fun buildPredicateCondition(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()) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index d4e0665..c6dd58c 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt @@ -1,8 +1,10 @@ package com.nisemoe.nise.database -import com.nisemoe.generated.tables.records.ScoresJudgementsRecord import com.nisemoe.generated.tables.records.ScoresRecord -import com.nisemoe.generated.tables.references.* +import com.nisemoe.generated.tables.references.BEATMAPS +import com.nisemoe.generated.tables.references.SCORES +import com.nisemoe.generated.tables.references.SCORES_SIMILARITY +import com.nisemoe.generated.tables.references.USERS import com.nisemoe.nise.* import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.service.AuthService diff --git a/nise-backend/src/test/kotlin/com/nisemoe/nise/scheduler/JudgementCompressionTest.kt b/nise-backend/src/test/kotlin/com/nisemoe/nise/scheduler/JudgementCompressionTest.kt index 012bcf4..8dbe737 100644 --- a/nise-backend/src/test/kotlin/com/nisemoe/nise/scheduler/JudgementCompressionTest.kt +++ b/nise-backend/src/test/kotlin/com/nisemoe/nise/scheduler/JudgementCompressionTest.kt @@ -38,10 +38,10 @@ class JudgementCompressionTest { @Test fun compressionIntegrityTest() { - val scores = dslContext.select(SCORES.REPLAY, SCORES.BEATMAP_ID, SCORES.MODS) + val scores = dslContext.select(SCORES.REPLAY, SCORES.BEATMAP_ID, SCORES.MODS, SCORES.REPLAY_ID) .from(SCORES) .where(SCORES.REPLAY.isNotNull) - .limit(100) + .limit(500) .fetchInto(ScoresRecord::class.java) for(score in scores) { @@ -60,6 +60,7 @@ class JudgementCompressionTest { val compressedData = compressJudgements.serialize(result.judgements) + this.logger.info("replay_id = {}", score.replayId) this.logger.info("JSON size: {} bytes", String.format("%,d", Json.encodeToString(CircleguardService.ReplayResponse.serializer(), result).length)) this.logger.info("Compressed (Brotli) size: {} bytes", String.format("%,d", compressedData.size)) diff --git a/nise-frontend/package-lock.json b/nise-frontend/package-lock.json index b7b27d4..e87c02a 100644 --- a/nise-frontend/package-lock.json +++ b/nise-frontend/package-lock.json @@ -24,6 +24,8 @@ "ng2-charts": "^5.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "uuid": "^9.0.1", + "xlsx": "^0.18.5", "zone.js": "^0.14.3" }, "devDependencies": { @@ -32,6 +34,7 @@ "@angular/compiler-cli": "^17.0.9", "@angular/localize": "^17.1.1", "@types/jasmine": "~4.3.0", + "@types/uuid": "^9.0.8", "jasmine-core": "~4.6.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -3845,6 +3848,12 @@ "@types/node": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -4100,6 +4109,14 @@ "node": ">=8.9.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -4758,6 +4775,18 @@ } ] }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4963,6 +4992,14 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -5267,6 +5304,17 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/critters": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz", @@ -6405,6 +6453,14 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7141,9 +7197,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -10943,6 +10999,17 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/ssri": { "version": "10.0.5", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", @@ -11586,7 +11653,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -12035,6 +12101,22 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -12160,6 +12242,26 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/nise-frontend/package.json b/nise-frontend/package.json index 4b3a034..1e3c2f9 100644 --- a/nise-frontend/package.json +++ b/nise-frontend/package.json @@ -26,6 +26,8 @@ "ng2-charts": "^5.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "uuid": "^9.0.1", + "xlsx": "^0.18.5", "zone.js": "^0.14.3" }, "devDependencies": { @@ -34,6 +36,7 @@ "@angular/compiler-cli": "^17.0.9", "@angular/localize": "^17.1.1", "@types/jasmine": "~4.3.0", + "@types/uuid": "^9.0.8", "jasmine-core": "~4.6.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/nise-frontend/src/app/search/search.component.css b/nise-frontend/src/app/search/search.component.css index e1f93cc..aaef020 100644 --- a/nise-frontend/src/app/search/search.component.css +++ b/nise-frontend/src/app/search/search.component.css @@ -15,3 +15,12 @@ padding: 2px; border: 1px solid rgba(179, 184, 195, 0.1); } + +.score-entry { + cursor: pointer; + +} + +.score-entry:hover { + background-color: rgba(179, 184, 195, 0.15); +} diff --git a/nise-frontend/src/app/search/search.component.html b/nise-frontend/src/app/search/search.component.html index 1bcac44..9aec323 100644 --- a/nise-frontend/src/app/search/search.component.html +++ b/nise-frontend/src/app/search/search.component.html @@ -1,8 +1,6 @@

/k/ - Advanced Search

- -
Table columns @@ -29,7 +27,11 @@ sorting
- +
+ + + +
+ + +
+ +
+

Loading...

+
+
+ +
+ tools +
+ + + +
+
@@ -56,7 +79,7 @@ - +
{{ getValue(entry, column.name) | number }} @@ -67,6 +90,14 @@ + + + ✓ + + + ✗ + + {{ getValue(entry, column.name) }} @@ -75,6 +106,26 @@
+ +
+

Total results: {{ response.pagination.totalResults | number }}

+

Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}

+
+ + ... + + ... + +
+ + +
+
diff --git a/nise-frontend/src/app/search/search.component.ts b/nise-frontend/src/app/search/search.component.ts index 7d1146e..49dfd3b 100644 --- a/nise-frontend/src/app/search/search.component.ts +++ b/nise-frontend/src/app/search/search.component.ts @@ -6,6 +6,10 @@ 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"; +import {RouterLink} from "@angular/router"; +import {CalculatePageRangePipe} from "../../corelib/calculate-page-range.pipe"; +import {DownloadFilesService} from "../../corelib/service/download-files.service"; +import {catchError, throwError} from "rxjs"; interface SchemaField { name: string; @@ -18,6 +22,14 @@ interface SchemaField { interface SearchResponse { scores: SearchResponseEntry[]; + pagination: SearchPagination; +} + +interface SearchPagination { + currentPage: number; + pageSize: number; + totalResults: number; + totalPages: number; } interface Sorting { @@ -73,15 +85,18 @@ interface SearchResponseEntry { NgIf, DecimalPipe, OsuGradeComponent, - QueryBuilderComponent + QueryBuilderComponent, + RouterLink, + CalculatePageRangePipe ], templateUrl: './search.component.html', styleUrl: './search.component.css' }) export class SearchComponent implements OnInit { - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, public downloadFilesService: DownloadFilesService) { } + isLoading = false; response: SearchResponse | null = null; fields: SchemaField[] = [ @@ -183,6 +198,45 @@ export class SearchComponent implements OnInit { }); } + updateLocalStorage(): void { + console.warn('Updating local storage'); + localStorage.setItem('search_queries', JSON.stringify(this.queries)); + } + + exportSettings(): void { + const settings = { + queries: this.queries, + sorting: this.sortingOrder + } as any; + this.downloadFilesService.downloadJSON(settings); + } + + importSettings(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); + if (this.verifySchema(json)) { + this.queries = json.queries; + this.sortingOrder = json.sorting; + } else { + console.error('Invalid file schema'); + } + } catch (error) { + console.error('Error parsing JSON', error); + } + }; + fileReader.readAsText(file); + } + } + + verifySchema(json: any): boolean { + // TODO: Implement schema verification logic here + return 'queries' in json && 'sorting' in json; + } + saveColumnsStatusToLocalStorage(): void { const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => { acc[field.name] = field.active; @@ -192,15 +246,31 @@ export class SearchComponent implements OnInit { localStorage.setItem('columns_status', JSON.stringify(statusMap)); } - search(): void { + search(pageNumber: number = 1): void { + this.isLoading = true; const body = { queries: this.queries, - sorting: this.sortingOrder + sorting: this.sortingOrder, + page: pageNumber } - this.httpClient.post(`${environment.apiUrl}/search`, body).subscribe(response => { - this.response = response; + this.httpClient.post(`${environment.apiUrl}/search`, body).pipe( + catchError(error => { + // Handle the error or rethrow + console.error('An error occurred:', error); + return throwError(() => new Error('An error occurred')); // Rethrow or return a new observable + }) + ).subscribe({ + next: (response) => { + this.response = response; + this.isLoading = false; + }, + error: (error) => { + // Handle subscription error + console.error('Subscription error:', error); + this.isLoading = false; + } }); - // this.updateLocalStorage(); + this.updateLocalStorage(); } // Add this method to the SearchComponent class @@ -208,7 +278,6 @@ export class SearchComponent implements OnInit { return entry[columnName as keyof SearchResponseEntry]; } - - protected readonly countryCodeToFlag = countryCodeToFlag; + protected readonly Math = Math; } diff --git a/nise-frontend/src/corelib/calculate-page-range.pipe.ts b/nise-frontend/src/corelib/calculate-page-range.pipe.ts new file mode 100644 index 0000000..6a809a2 --- /dev/null +++ b/nise-frontend/src/corelib/calculate-page-range.pipe.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + standalone: true, + name: 'calculatePageRange' +}) +export class CalculatePageRangePipe implements PipeTransform { + transform(totalPages: number, currentPage: number, totalPagesCount: number): number[] { + const visiblePages = 10; + let startPage = Math.max(currentPage - 5, 1); + let endPage = Math.min(startPage + visiblePages - 1, totalPagesCount); + + // Adjust the start and end if near the beginning or end + if (currentPage < 6) { + endPage = Math.min(visiblePages, totalPagesCount); + } else if (currentPage > totalPagesCount - 5) { + startPage = Math.max(totalPagesCount - visiblePages + 1, 1); + } + + // Generate the range of page numbers to display + return Array.from({ length: (endPage - startPage) + 1 }, (_, i) => startPage + i); + } +} 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 index 69a6b89..e69de29 100644 --- a/nise-frontend/src/corelib/components/query-builder/query-builder.component.css +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.css @@ -1,26 +0,0 @@ -.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 index 1829c99..e285d78 100644 --- a/nise-frontend/src/corelib/components/query-builder/query-builder.component.html +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.html @@ -1,33 +1,7 @@
- Query builder -
- Predicate #{{ i + 1 }} -
- - -
- -
- - - - - - -
- -
+ Query Builder +
+ +
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 index 8e9298d..ade06bb 100644 --- a/nise-frontend/src/corelib/components/query-builder/query-builder.component.ts +++ b/nise-frontend/src/corelib/components/query-builder/query-builder.component.ts @@ -1,6 +1,7 @@ import {Component, Input} from '@angular/core'; import {FormsModule} from "@angular/forms"; import {NgForOf, NgIf} from "@angular/common"; +import {QueryComponent} from "../query/query.component"; export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean'; export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!='; @@ -19,6 +20,7 @@ export interface Predicate { export interface Query { predicates: Predicate[]; logicalOperator: 'AND' | 'OR'; + childQueries?: Query[]; // Optional property for sub-queries } @Component({ @@ -27,7 +29,8 @@ export interface Query { imports: [ FormsModule, NgForOf, - NgIf + NgIf, + QueryComponent ], templateUrl: './query-builder.component.html', styleUrl: './query-builder.component.css' @@ -37,35 +40,6 @@ 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: [], @@ -73,9 +47,12 @@ export class QueryBuilderComponent { }); } - private updateLocalStorage(): void { - console.warn('Updating local storage'); - localStorage.setItem('search_queries', JSON.stringify(this.queries)); + removeQuery(queryIndex: number): void { + this.queries.splice(queryIndex, 1); + } + + queryChanged(): void { + console.log(this.queries); } } diff --git a/nise-frontend/src/corelib/components/query/query.component.css b/nise-frontend/src/corelib/components/query/query.component.css new file mode 100644 index 0000000..69a6b89 --- /dev/null +++ b/nise-frontend/src/corelib/components/query/query.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/query.component.html b/nise-frontend/src/corelib/components/query/query.component.html new file mode 100644 index 0000000..03ad5a4 --- /dev/null +++ b/nise-frontend/src/corelib/components/query/query.component.html @@ -0,0 +1,37 @@ +
+ Predicate + +
+ + +
+ +
+ + + + + + + + +
+ + +
+ +
+ +
diff --git a/nise-frontend/src/corelib/components/query/query.component.ts b/nise-frontend/src/corelib/components/query/query.component.ts new file mode 100644 index 0000000..fb72403 --- /dev/null +++ b/nise-frontend/src/corelib/components/query/query.component.ts @@ -0,0 +1,70 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Field, FieldType, OperatorType, Predicate, Query} from "../query-builder/query-builder.component"; +import {NgForOf} from "@angular/common"; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import { v4 as uuidv4 } from 'uuid'; + +@Component({ + selector: 'app-query', + standalone: true, + imports: [ + NgForOf, + ReactiveFormsModule, + FormsModule + ], + templateUrl: './query.component.html', + styleUrl: './query.component.css' +}) +export class QueryComponent { + + @Input() query!: Query; + @Input() fields!: Field[]; + + @Output() removeQuery = new EventEmitter(); + @Output() queryChanged = new EventEmitter(); + + queryId = uuidv4(); + + constructor() {} + + 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 []; + } + } + + addPredicate(): void { + this.query.predicates.push({ field: null, operator: null, value: null }); + this.queryChanged.emit(); + } + + removePredicate(index: number): void { + this.query.predicates.splice(index, 1); + this.queryChanged.emit(); + } + + addSubQuery(): void { + if (!this.query.childQueries) { + this.query.childQueries = []; + } + this.query.childQueries.push({ predicates: [], logicalOperator: 'AND' }); + this.queryChanged.emit(); + } + + removeSubQuery(index: number): void { + // @ts-ignore + this.query.childQueries.splice(index, 1); + this.queryChanged.emit(); + } + +} diff --git a/nise-frontend/src/corelib/service/download-files.service.ts b/nise-frontend/src/corelib/service/download-files.service.ts new file mode 100644 index 0000000..ac1aa13 --- /dev/null +++ b/nise-frontend/src/corelib/service/download-files.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from "xlsx"; + +@Injectable({ + providedIn: 'root' +}) +export class DownloadFilesService { + + downloadJSON(input: Object[]) { + const dataStr = JSON.stringify(input); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'data.json'; + link.click(); + } + + downloadCSV(input: Object[]) { + let csvData = input.map(row => Object.values(row).join(',')).join('\n'); + const blob = new Blob([csvData], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'data.csv'; + link.click(); + } + + downloadXLSX(input: Object[]) { + const ws: XLSX.WorkSheet = XLSX.utils.json_to_sheet(input); + const wb: XLSX.WorkBook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Data'); + XLSX.writeFile(wb, 'data.xlsx'); + } + +} diff --git a/nise-frontend/src/corelib/service/user.service.ts b/nise-frontend/src/corelib/service/user.service.ts index d8e4dc2..e6bf3f4 100644 --- a/nise-frontend/src/corelib/service/user.service.ts +++ b/nise-frontend/src/corelib/service/user.service.ts @@ -74,7 +74,7 @@ export class UserService { } clearCurrentUserFromLocalStorage() { - localStorage.clear(); + localStorage.setItem('currentUser', ''); document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; }