Improvements in advanced search

This commit is contained in:
nise.moe 2024-02-24 14:59:17 +01:00
parent 9e15e6bcc0
commit 7014dfdfb5
17 changed files with 610 additions and 196 deletions

View File

@ -4,10 +4,13 @@ import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format import com.nisemoe.nise.Format
import com.nisemoe.nise.service.AuthService
import org.jooq.Condition import org.jooq.Condition
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Field import org.jooq.Field
import org.jooq.OrderField import org.jooq.OrderField
import org.jooq.Record
import org.jooq.Result
import org.jooq.impl.DSL import org.jooq.impl.DSL
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@ -18,15 +21,32 @@ import kotlin.math.roundToInt
@RestController @RestController
class SearchController( 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( data class SearchResponse(
val scores: List<SearchResponseEntry>, val scores: List<SearchResponseEntry>,
val pagination: SearchResponsePagination
val totalResults: Int? = null
) )
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( data class SearchResponseEntry(
// User fields // User fields
val user_id: Long?, val user_id: Long?,
@ -92,7 +112,8 @@ class SearchController(
data class SearchRequest( data class SearchRequest(
val queries: List<SearchQuery>, val queries: List<SearchQuery>,
val sorting: SearchSorting val sorting: SearchSorting,
val page: Int
) )
data class SearchSorting( data class SearchSorting(
@ -102,7 +123,8 @@ class SearchController(
data class SearchQuery( data class SearchQuery(
val logicalOperator: String, val logicalOperator: String,
val predicates: List<SearchPredicate> val predicates: List<SearchPredicate>,
val childQueries: List<SearchQuery>?
) )
data class SearchPredicate( data class SearchPredicate(
@ -117,24 +139,18 @@ class SearchController(
) )
@PostMapping("search") @PostMapping("search")
fun doSearch( fun doSearch(@RequestBody request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
@RequestBody request: SearchRequest, if(!authService.isAdmin())
@RequestHeader("X-NISE-API") apiVersion: String return ResponseEntity.status(401).build()
): ResponseEntity<SearchResponse> {
if (apiVersion.isBlank()) if (apiVersion.isBlank())
return ResponseEntity.badRequest().build() return ResponseEntity.badRequest().build()
// TODO: Validation
var baseQuery = DSL.noCondition() var baseQuery = DSL.noCondition()
for (query in request.queries.filter { it.predicates.isNotEmpty() }) { for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
var condition = buildCondition(query.predicates[0]) // Start with the first predicate val condition = buildCondition(query)
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) baseQuery = baseQuery.and(condition)
} }
@ -207,79 +223,123 @@ class SearchController(
if (request.sorting.field.isNotBlank()) if (request.sorting.field.isNotBlank())
orderBy(buildSorting(request.sorting)) orderBy(buildSorting(request.sorting))
} }
.limit(50) .offset((request.page - 1) * RESULTS_PER_PAGE)
.limit(RESULTS_PER_PAGE)
.fetch() .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( val response = SearchResponse(
scores = results.map { scores = mapRecordToScores(results),
SearchResponseEntry( pagination = SearchResponsePagination(
// User fields currentPage = request.page,
user_id = it.get(SCORES.USER_ID), pageSize = RESULTS_PER_PAGE,
user_username = it.get(USERS.USERNAME), totalResults = totalResults
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) return ResponseEntity.ok(response)
} }
private fun mapRecordToScores(results: Result<Record>): MutableList<SearchResponseEntry> =
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<*> { private fun buildSorting(sorting: SearchSorting): OrderField<*> {
val field = mapPredicateFieldToDatabaseField(sorting.field) val field = mapPredicateFieldToDatabaseField(sorting.field)
return when (sorting.order.lowercase()) { 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) val field = mapPredicateFieldToDatabaseField(predicate.field.name)
return when (predicate.field.type.lowercase()) { return when (predicate.field.type.lowercase()) {
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble()) "number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble())

View File

@ -1,8 +1,10 @@
package com.nisemoe.nise.database package com.nisemoe.nise.database
import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
import com.nisemoe.generated.tables.records.ScoresRecord 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.*
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.AuthService

View File

@ -38,10 +38,10 @@ class JudgementCompressionTest {
@Test @Test
fun compressionIntegrityTest() { 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) .from(SCORES)
.where(SCORES.REPLAY.isNotNull) .where(SCORES.REPLAY.isNotNull)
.limit(100) .limit(500)
.fetchInto(ScoresRecord::class.java) .fetchInto(ScoresRecord::class.java)
for(score in scores) { for(score in scores) {
@ -60,6 +60,7 @@ class JudgementCompressionTest {
val compressedData = compressJudgements.serialize(result.judgements) 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("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)) this.logger.info("Compressed (Brotli) size: {} bytes", String.format("%,d", compressedData.size))

View File

@ -24,6 +24,8 @@
"ng2-charts": "^5.0.4", "ng2-charts": "^5.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zone.js": "^0.14.3" "zone.js": "^0.14.3"
}, },
"devDependencies": { "devDependencies": {
@ -32,6 +34,7 @@
"@angular/compiler-cli": "^17.0.9", "@angular/compiler-cli": "^17.0.9",
"@angular/localize": "^17.1.1", "@angular/localize": "^17.1.1",
"@types/jasmine": "~4.3.0", "@types/jasmine": "~4.3.0",
"@types/uuid": "^9.0.8",
"jasmine-core": "~4.6.0", "jasmine-core": "~4.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
@ -3845,6 +3848,12 @@
"@types/node": "*" "@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": { "node_modules/@types/ws": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
@ -4100,6 +4109,14 @@
"node": ">=8.9.0" "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": { "node_modules/agent-base": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", "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": { "node_modules/chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -4963,6 +4992,14 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -5267,6 +5304,17 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/critters": {
"version": "0.0.20", "version": "0.0.20",
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz", "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz",
@ -6405,6 +6453,14 @@
"node": ">= 0.6" "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": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -7141,9 +7197,9 @@
} }
}, },
"node_modules/ip": { "node_modules/ip": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
"dev": true "dev": true
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
@ -10943,6 +10999,17 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true "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": { "node_modules/ssri": {
"version": "10.0.5", "version": "10.0.5",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
@ -11586,7 +11653,6 @@
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"peer": true,
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
@ -12035,6 +12101,22 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"dev": true "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": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -26,6 +26,8 @@
"ng2-charts": "^5.0.4", "ng2-charts": "^5.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"uuid": "^9.0.1",
"xlsx": "^0.18.5",
"zone.js": "^0.14.3" "zone.js": "^0.14.3"
}, },
"devDependencies": { "devDependencies": {
@ -34,6 +36,7 @@
"@angular/compiler-cli": "^17.0.9", "@angular/compiler-cli": "^17.0.9",
"@angular/localize": "^17.1.1", "@angular/localize": "^17.1.1",
"@types/jasmine": "~4.3.0", "@types/jasmine": "~4.3.0",
"@types/uuid": "^9.0.8",
"jasmine-core": "~4.6.0", "jasmine-core": "~4.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",

View File

@ -15,3 +15,12 @@
padding: 2px; padding: 2px;
border: 1px solid rgba(179, 184, 195, 0.1); border: 1px solid rgba(179, 184, 195, 0.1);
} }
.score-entry {
cursor: pointer;
}
.score-entry:hover {
background-color: rgba(179, 184, 195, 0.15);
}

View File

@ -1,8 +1,6 @@
<div class="main term"> <div class="main term">
<h1><span class="board">/k/</span> - Advanced Search</h1> <h1><span class="board">/k/</span> - Advanced Search</h1>
<!-- <pre>{{ queries | json }}</pre>-->
<fieldset> <fieldset>
<legend>Table columns</legend> <legend>Table columns</legend>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']"> <ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
@ -29,7 +27,11 @@
<legend>sorting</legend> <legend>sorting</legend>
<select> <select>
<option *ngFor="let field of fields" [value]="field.name" (click)="this.sortingOrder.field = field.name">{{ field.name }}</option> <option *ngFor="let field of fields"
[value]="field.name"
(click)="this.sortingOrder.field = field.name"
[selected]="field.name == this.sortingOrder.field"
>{{ field.name }}</option>
</select> </select>
<label> <label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" /> <input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
@ -42,10 +44,31 @@
</fieldset> </fieldset>
<div class="text-center"> <div class="text-center">
<button (click)="search()" class="mb-2 mt-2">Search</button> <div class="text-center mb-2 mt-2">
<button (click)="exportSettings()">Export Query</button>
<button (click)="fileInput.click()" style="margin-left: 5px">Import Query</button>
<input type="file" #fileInput style="display: none" (change)="importSettings($event)" accept=".json">
</div>
<button (click)="search()" class="mb-2" style="font-size: 18px">Search</button>
</div> </div>
<ng-container *ngIf="this.isLoading">
<div class="text-center">
<p>Loading...</p>
</div>
</ng-container>
<ng-container *ngIf="response"> <ng-container *ngIf="response">
<fieldset class="mb-2">
<legend>tools</legend>
<div class="text-center">
<button (click)="this.downloadFilesService.downloadCSV(response.scores)">Download .csv</button>
<button (click)="this.downloadFilesService.downloadJSON(response.scores)">Download .json</button>
<button (click)="this.downloadFilesService.downloadXLSX(response.scores)">Download .xslx</button>
</div>
</fieldset>
<div class="scrollable-table"> <div class="scrollable-table">
<table class="table-border"> <table class="table-border">
<thead> <thead>
@ -56,7 +79,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let entry of response.scores"> <tr *ngFor="let entry of response.scores" class="score-entry" [routerLink]="['/s/' + entry.replay_id]">
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px"> <td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
<ng-container *ngIf="column.type == 'number'"> <ng-container *ngIf="column.type == 'number'">
{{ getValue(entry, column.name) | number }} {{ getValue(entry, column.name) | number }}
@ -67,6 +90,14 @@
<ng-container *ngIf="column.type == 'grade'"> <ng-container *ngIf="column.type == 'grade'">
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade> <app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
</ng-container> </ng-container>
<ng-container *ngIf="column.type == 'boolean'">
<ng-container *ngIf="getValue(entry, column.name) == true">
</ng-container>
<ng-container *ngIf="getValue(entry, column.name) == false">
</ng-container>
</ng-container>
<ng-container *ngIf="column.type == 'string'"> <ng-container *ngIf="column.type == 'string'">
{{ getValue(entry, column.name) }} {{ getValue(entry, column.name) }}
</ng-container> </ng-container>
@ -75,6 +106,26 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="text-center mt-2">
<p>Total results: {{ response.pagination.totalResults | number }}</p>
<p>Page: {{ response.pagination.currentPage | number }} / {{ response.pagination.totalPages | number }}</p>
<div class="mb-2">
<button *ngIf="response.pagination.currentPage > 5" (click)="this.search(1)" style="margin-right: 5px">1</button>
<span *ngIf="response.pagination.currentPage > 6">... </span>
<button *ngFor="let page of [].constructor(Math.min(response.pagination.totalPages, 10)) | calculatePageRange:response.pagination.currentPage:response.pagination.totalPages; let i = index"
(click)="this.search(page)"
[disabled]="page == response.pagination.currentPage"
style="margin-right: 5px">
{{ page }}
</button>
<span *ngIf="response.pagination.currentPage < response.pagination.totalPages - 5">... </span>
<button *ngIf="response.pagination.currentPage < response.pagination.totalPages - 4" (click)="this.search(response.pagination.totalPages)" style="margin-right: 5px">{{ response.pagination.totalPages }}</button>
</div>
<button (click)="this.search(response.pagination.currentPage - 1)" [disabled]="response.pagination.currentPage == 1">← Previous</button>
<button (click)="this.search(response.pagination.currentPage + 1)" [disabled]="response.pagination.currentPage == response.pagination.totalPages" style="margin-left: 5px">Next →</button>
</div>
</ng-container> </ng-container>
</div> </div>

View File

@ -6,6 +6,10 @@ import {environment} from "../../environments/environment";
import {countryCodeToFlag} from "../format"; import {countryCodeToFlag} from "../format";
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component"; import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.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 { interface SchemaField {
name: string; name: string;
@ -18,6 +22,14 @@ interface SchemaField {
interface SearchResponse { interface SearchResponse {
scores: SearchResponseEntry[]; scores: SearchResponseEntry[];
pagination: SearchPagination;
}
interface SearchPagination {
currentPage: number;
pageSize: number;
totalResults: number;
totalPages: number;
} }
interface Sorting { interface Sorting {
@ -73,15 +85,18 @@ interface SearchResponseEntry {
NgIf, NgIf,
DecimalPipe, DecimalPipe,
OsuGradeComponent, OsuGradeComponent,
QueryBuilderComponent QueryBuilderComponent,
RouterLink,
CalculatePageRangePipe
], ],
templateUrl: './search.component.html', templateUrl: './search.component.html',
styleUrl: './search.component.css' styleUrl: './search.component.css'
}) })
export class SearchComponent implements OnInit { export class SearchComponent implements OnInit {
constructor(private httpClient: HttpClient) { } constructor(private httpClient: HttpClient, public downloadFilesService: DownloadFilesService) { }
isLoading = false;
response: SearchResponse | null = null; response: SearchResponse | null = null;
fields: SchemaField[] = [ 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 { saveColumnsStatusToLocalStorage(): void {
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => { const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
acc[field.name] = field.active; acc[field.name] = field.active;
@ -192,15 +246,31 @@ export class SearchComponent implements OnInit {
localStorage.setItem('columns_status', JSON.stringify(statusMap)); localStorage.setItem('columns_status', JSON.stringify(statusMap));
} }
search(): void { search(pageNumber: number = 1): void {
this.isLoading = true;
const body = { const body = {
queries: this.queries, queries: this.queries,
sorting: this.sortingOrder sorting: this.sortingOrder,
page: pageNumber
} }
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).subscribe(response => { this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).pipe(
this.response = response; 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 // Add this method to the SearchComponent class
@ -208,7 +278,6 @@ export class SearchComponent implements OnInit {
return entry[columnName as keyof SearchResponseEntry]; return entry[columnName as keyof SearchResponseEntry];
} }
protected readonly countryCodeToFlag = countryCodeToFlag; protected readonly countryCodeToFlag = countryCodeToFlag;
protected readonly Math = Math;
} }

View File

@ -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);
}
}

View File

@ -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 */
}

View File

@ -1,33 +1,7 @@
<fieldset> <fieldset>
<legend>Query builder</legend> <legend>Query Builder</legend>
<fieldset *ngFor="let query of queries; let i = index" class="query">
<legend>Predicate #{{ i + 1 }}</legend>
<div class="logical-operator-toggle">
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="AND" />
AND
</label>
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="OR" />
OR
</label>
</div>
<div *ngFor="let predicate of query.predicates; let j = index" class="predicate">
<select>
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
</select>
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator">
{{ operator }}
</option>
</select>
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field">
<button (click)="removePredicate(i, j)">X</button>
</div>
<button (click)="addPredicate(i)">+ Rule</button>
</fieldset>
<button (click)="addQuery()">+ Predicate</button> <button (click)="addQuery()">+ Predicate</button>
<div *ngFor="let query of queries; let i = index">
<app-query [query]="query" [fields]="fields" (removeQuery)="removeQuery(i)" (queryChanged)="queryChanged()"></app-query>
</div>
</fieldset> </fieldset>

View File

@ -1,6 +1,7 @@
import {Component, Input} from '@angular/core'; import {Component, Input} from '@angular/core';
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {NgForOf, NgIf} from "@angular/common"; import {NgForOf, NgIf} from "@angular/common";
import {QueryComponent} from "../query/query.component";
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean'; export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!='; export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
@ -19,6 +20,7 @@ export interface Predicate {
export interface Query { export interface Query {
predicates: Predicate[]; predicates: Predicate[];
logicalOperator: 'AND' | 'OR'; logicalOperator: 'AND' | 'OR';
childQueries?: Query[]; // Optional property for sub-queries
} }
@Component({ @Component({
@ -27,7 +29,8 @@ export interface Query {
imports: [ imports: [
FormsModule, FormsModule,
NgForOf, NgForOf,
NgIf NgIf,
QueryComponent
], ],
templateUrl: './query-builder.component.html', templateUrl: './query-builder.component.html',
styleUrl: './query-builder.component.css' styleUrl: './query-builder.component.css'
@ -37,35 +40,6 @@ export class QueryBuilderComponent {
@Input() queries: Query[] = []; @Input() queries: Query[] = [];
@Input() fields: Field[] = []; @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 { addQuery(): void {
this.queries.push({ this.queries.push({
predicates: [], predicates: [],
@ -73,9 +47,12 @@ export class QueryBuilderComponent {
}); });
} }
private updateLocalStorage(): void { removeQuery(queryIndex: number): void {
console.warn('Updating local storage'); this.queries.splice(queryIndex, 1);
localStorage.setItem('search_queries', JSON.stringify(this.queries)); }
queryChanged(): void {
console.log(this.queries);
} }
} }

View File

@ -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 */
}

View File

@ -0,0 +1,37 @@
<fieldset class="query">
<legend>Predicate <button (click)="removeQuery.emit()">Delete</button></legend>
<div class="logical-operator-toggle">
<label>
<input type="radio" name="logicalOperator-{{ queryId }}" [(ngModel)]="query.logicalOperator" value="AND" (change)="this.queryChanged.emit()"/>
AND
</label>
<label>
<input type="radio" name="logicalOperator-{{ queryId }}" [(ngModel)]="query.logicalOperator" value="OR" (change)="this.queryChanged.emit()"/>
OR
</label>
</div>
<div *ngFor="let predicate of query.predicates; let i = index">
<select style="max-width: 40%">
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
</select>
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator" (change)="this.queryChanged.emit()">
{{ operator }}
</option>
</select>
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field" (change)="this.queryChanged.emit()">
<button (click)="removePredicate(i)">X</button>
</div>
<button (click)="addPredicate()" style="margin-top: 5px">+ Rule</button>
<div *ngFor="let childQuery of query.childQueries; let i = index">
<app-query [query]="childQuery" [fields]="fields" (removeQuery)="removeSubQuery(i)" (queryChanged)="queryChanged.emit()"></app-query>
</div>
<button (click)="addSubQuery()">+ Sub-Predicate</button>
</fieldset>

View File

@ -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<void>();
@Output() queryChanged = new EventEmitter<void>();
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();
}
}

View File

@ -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');
}
}

View File

@ -74,7 +74,7 @@ export class UserService {
} }
clearCurrentUserFromLocalStorage() { clearCurrentUserFromLocalStorage() {
localStorage.clear(); localStorage.setItem('currentUser', '');
document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'SESSION=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
} }