From b261e568072b38b2161d889a32db21fc5a2f4d6e Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Mon, 1 Jan 2024 12:26:40 +0100 Subject: [PATCH] EGF and FFG exports --- .../jeudego/pairgoth/api/StandingsHandler.kt | 168 +++++++++++++++--- .../org/jeudego/pairgoth/ext/OpenGotha.kt | 16 +- .../org/jeudego/pairgoth/model/TimeSystem.kt | 14 +- .../org/jeudego/pairgoth/model/Tournament.kt | 10 +- .../org/jeudego/pairgoth/server/ApiServlet.kt | 8 +- view-webapp/pom.xml | 5 - view-webapp/src/main/sass/tour.scss | 9 + view-webapp/src/main/webapp/js/api.js | 13 +- .../src/main/webapp/js/tour-standings.inc.js | 23 +++ .../src/main/webapp/tour-standings.inc.html | 6 +- 10 files changed, 218 insertions(+), 54 deletions(-) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/StandingsHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/StandingsHandler.kt index b664685..d4a7480 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/StandingsHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/StandingsHandler.kt @@ -3,21 +3,27 @@ package org.jeudego.pairgoth.api import com.republicate.kson.Json import com.republicate.kson.toJsonArray import org.jeudego.pairgoth.model.Criterion +import org.jeudego.pairgoth.model.Criterion.* +import org.jeudego.pairgoth.model.Game.Result.* +import org.jeudego.pairgoth.model.ID import org.jeudego.pairgoth.model.MacMahon import org.jeudego.pairgoth.model.Pairable import org.jeudego.pairgoth.model.PairingType +import org.jeudego.pairgoth.model.Tournament +import org.jeudego.pairgoth.model.adjustedTime +import org.jeudego.pairgoth.model.displayRank +import org.jeudego.pairgoth.model.getID import org.jeudego.pairgoth.model.historyBefore import org.jeudego.pairgoth.pairing.HistoryHelper import org.jeudego.pairgoth.pairing.solver.MacMahonSolver +import java.io.PrintWriter +import java.time.format.DateTimeFormatter import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import kotlin.math.max import kotlin.math.min - -import org.jeudego.pairgoth.model.Criterion.* -import org.jeudego.pairgoth.model.Game.Result.* -import org.jeudego.pairgoth.model.ID -import org.jeudego.pairgoth.model.getID +import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.* +import java.text.DecimalFormat object StandingsHandler: PairgothApiHandler { override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { @@ -83,7 +89,7 @@ object StandingsHandler: PairgothApiHandler { for (crit in criteria) { player[crit.first] = crit.second[player.getID()] ?: 0.0 } - player["results"] = Json.MutableArray(List(round) { "=0" }) + player["results"] = Json.MutableArray(List(round) { "0=" }) } val sortedPairables = pairables.sortedWith { left, right -> for (crit in criteria) { @@ -114,38 +120,156 @@ object StandingsHandler: PairgothApiHandler { val blackNum = black?.getInt("num") ?: 0 val whiteColor = if (black == null) "" else "w" val blackColor = if (white == null) "" else "b" - val handicap = if (game.handicap == 0) "" else "/h${game.handicap}" + val handicap = if (game.handicap == 0) "" else "${game.handicap}" assert(white != null || black != null) if (white != null) { val mark = when (game.result) { UNKNOWN -> "?" - BLACK -> "-" - WHITE -> "+" - JIGO -> "=" - CANCELLED -> "X" - BOTHWIN -> "++" - BOTHLOOSE -> "--" + BLACK, BOTHLOOSE -> "-" + WHITE, BOTHWIN -> "+" + JIGO, CANCELLED -> "=" } val results = white.getArray("results") as Json.MutableArray - results[r - 1] = "$whiteColor$mark$blackNum$handicap" + results[r - 1] = + if (blackNum == 0) "0$mark" + else "$blackNum$mark/$whiteColor$handicap" } if (black != null) { val mark = when (game.result) { UNKNOWN -> "?" - BLACK -> "+" - WHITE -> "-" - JIGO -> "=" - CANCELLED -> "X" - BOTHWIN -> "++" - BOTHLOOSE -> "--" + BLACK, BOTHWIN -> "+" + WHITE, BOTHLOOSE -> "-" + JIGO, CANCELLED -> "=" } val results = black.getArray("results") as Json.MutableArray - results[r - 1] = "$blackColor$mark$whiteNum$handicap" + results[r - 1] = + if (whiteNum == 0) "0$mark" + else "$whiteNum$mark/$blackColor$handicap" } } } - return sortedPairables.toJsonArray() + val accept = request.getHeader("Accept")?.substringBefore(";") + return when(accept) { + "application/json" -> sortedPairables.toJsonArray() + "application/egf" -> { + exportToEGFFormat(tournament, sortedPairables, neededCriteria, response.writer) + return null + } + "application/ffg" -> { + exportToFFGFormat(tournament, sortedPairables, response.writer) + return null + } + else -> ApiHandler.badRequest("invalid Accept header: $accept") + } } val nullMap = mapOf() + + private fun exportToEGFFormat(tournament: Tournament<*>, lines: List, criteria: List, writer: PrintWriter) { + val mainTime = tournament.timeSystem.mainTime + val adjustedTime = tournament.timeSystem.adjustedTime() + val egfClass = + if (tournament.online) { + when (tournament.timeSystem.type) { + FISCHER -> + if (mainTime >= 1800 && adjustedTime >= 3000) "D" + else "X" + else -> + if (mainTime >= 2400 && adjustedTime >= 3000) "D" + else "X" + } + } else { + when (tournament.timeSystem.type) { + FISCHER -> + if (mainTime >= 2700 && adjustedTime >= 4500) "A" + else if (mainTime >= 1800 && adjustedTime >= 3000) "B" + else if (mainTime >= 1200 && adjustedTime >= 1800) "C" + else "X" + else -> + if (mainTime >= 3600 && adjustedTime >= 4500) "A" + else if (mainTime >= 2400 && adjustedTime >= 3000) "B" + else if (mainTime >= 1500 && adjustedTime >= 1800) "C" + else "X" + } + } + val ret = +""" +; CL[${egfClass}] +; EV[${tournament.name}] +; PC[${tournament.country.lowercase()},${tournament.location}] +; DT[${tournament.startDate},${tournament.endDate}] +; HA[${ + if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}" + else "h9" + +}] +; KM[${tournament.komi}] +; TM[${tournament.timeSystem.adjustedTime() / 60}] +; CM[Generated by Pairgoth v0.1] +; +; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") } +${ + lines.joinToString("\n") { player -> + "${ + player.getString("num")!!.padStart(4, ' ') + } ${ + "${player.getString("name")} ${player.getString("firstname")}".padEnd(30, ' ').take(30) + } ${ + displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ') + } ${ + player.getString("country")!!.uppercase() + } ${ + (player.getString("club") ?: "").padStart(4).take(4) + } ${ + criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') } + } ${ + player.getArray("results")!!.map { + (it as String).padStart(8, ' ') + }.joinToString(" ") + }" + } +} +""" + writer.println(ret) + } + + private fun exportToFFGFormat(tournament: Tournament<*>, lines: List, writer: PrintWriter) { + // let's try in UTF-8 + val ret = +""";name=${tournament.shortName} +;date=${frDate.format(tournament.startDate)} +;vill=${tournament.location}${if (tournament.online) "(online)" else ""} +;comm=${tournament.name} +;prog=Pairgoth v0.1 +;time=${tournament.timeSystem.mainTime / 60} +;ta=${tournament.timeSystem.adjustedTime() / 60} +;size=${tournament.gobanSize} +;komi=${tournament.komi} +; +;Num Nom Prenom Niv Licence Club +${ + lines.joinToString("\n") { player -> + "${ + player.getString("num")!!.padStart(4, ' ') + } ${ + "${player.getString("name")} ${player.getString("firstname")}".padEnd(24, ' ').take(24) + } ${ + displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ') + } ${ + player.getString("ffg") ?: " " + } ${ + (player.getString("club") ?: "").padStart(6).take(6) + } ${ + player.getArray("results")!!.joinToString(" ") { + (it as String).replace("/", "").replace(Regex("(?<=[bw])$"), "0").padStart(7, ' ') + } + }" + } +} +""" + writer.println(ret) + } + + private val numFormat = DecimalFormat("###0.#") + private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt index b8ae348..cd74f6e 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/OpenGotha.kt @@ -2,7 +2,7 @@ package org.jeudego.pairgoth.ext import jakarta.xml.bind.JAXBContext import jakarta.xml.bind.JAXBElement -import kotlinx.datetime.LocalDate +import java.time.LocalDate import org.jeudego.pairgoth.model.* import org.jeudego.pairgoth.opengotha.TournamentType import org.jeudego.pairgoth.opengotha.ObjectFactory @@ -13,7 +13,7 @@ import javax.xml.datatype.XMLGregorianCalendar import kotlin.math.roundToInt private const val MILLISECONDS_PER_DAY = 86400000 -fun XMLGregorianCalendar.toLocalDate() = LocalDate(year, month, day) +fun XMLGregorianCalendar.toLocalDate() = LocalDate.of(year, month, day) object OpenGotha { @@ -114,10 +114,10 @@ object OpenGotha { location = genParams.location, online = genParams.isBInternet ?: false, timeSystem = when (genParams.complementaryTimeSystem) { - "SUDDENDEATH" -> SuddenDeath(genParams.basicTime) - "STDBYOYOMI" -> StandardByoyomi(genParams.basicTime, genParams.stdByoYomiTime, 1) // no periods? - "CANBYOYOMI" -> CanadianByoyomi(genParams.basicTime, genParams.canByoYomiTime, genParams.nbMovesCanTime) - "FISCHER" -> FischerTime(genParams.basicTime, genParams.fischerTime) + "SUDDENDEATH" -> SuddenDeath(genParams.basicTime * 60) + "STDBYOYOMI" -> StandardByoyomi(genParams.basicTime * 60, genParams.stdByoYomiTime, 1) // no periods? + "CANBYOYOMI" -> CanadianByoyomi(genParams.basicTime * 60, genParams.canByoYomiTime, genParams.nbMovesCanTime) + "FISCHER" -> FischerTime(genParams.basicTime * 60, genParams.fischerTime) else -> throw Error("missing byoyomi type") }, pairing = when (handParams.hdCeiling) { @@ -269,9 +269,9 @@ object OpenGotha { } - "STDBYOYOMI" + TimeSystem.TimeSystemType.JAPANESE -> "STDBYOYOMI" TimeSystem.TimeSystemType.CANADIAN -> "CANBYOYOMI" TimeSystem.TimeSystemType.FISCHER -> "FISCHER" } }" director="" endDate="${tournament.endDate}" fischerTime="${tournament.timeSystem.increment}" genCountNotPlayedGamesAsHalfPoint="false" genMMBar="${ diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt index 9d3e252..f4e9682 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt @@ -1,7 +1,6 @@ package org.jeudego.pairgoth.model import com.republicate.kson.Json -import org.jeudego.pairgoth.api.ApiHandler import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.* @@ -15,7 +14,7 @@ data class TimeSystem( val stones: Int ) { companion object {} - enum class TimeSystemType { CANADIAN, STANDARD, FISCHER, SUDDEN_DEATH } + enum class TimeSystemType { CANADIAN, JAPANESE, FISCHER, SUDDEN_DEATH } } fun CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int) = @@ -30,7 +29,7 @@ fun CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int) = fun StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int) = TimeSystem( - type = STANDARD, + type = JAPANESE, mainTime = mainTime, increment = 0, byoyomi = byoyomi, @@ -86,9 +85,16 @@ fun TimeSystem.Companion.fromJson(json: Json.Object) = fun TimeSystem.toJson() = when (type) { TimeSystem.TimeSystemType.CANADIAN -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones) - TimeSystem.TimeSystemType.STANDARD -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods) + TimeSystem.TimeSystemType.JAPANESE -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods) TimeSystem.TimeSystemType.FISCHER -> if (maxTime == Int.MAX_VALUE) Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment) else Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment, "maxTime" to maxTime) TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("type" to type.name, "mainTime" to mainTime) } + +fun TimeSystem.adjustedTime() = when (type) { + TimeSystem.TimeSystemType.CANADIAN -> mainTime + 60 * byoyomi / stones + TimeSystem.TimeSystemType.JAPANESE -> mainTime + 45 * byoyomi + TimeSystem.TimeSystemType.FISCHER -> mainTime + 120 * increment + TimeSystem.TimeSystemType.SUDDEN_DEATH -> mainTime +} diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt index b380dd6..d610218 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt @@ -2,7 +2,7 @@ package org.jeudego.pairgoth.model import com.republicate.kson.Json import com.republicate.kson.toJsonArray -import kotlinx.datetime.LocalDate +import java.time.LocalDate import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.pairing.solver.MacMahonSolver import org.jeudego.pairgoth.pairing.solver.SwissSolver @@ -169,8 +169,8 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n type = type, name = json.getString("name") ?: default?.name ?: badRequest("missing name"), shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"), - startDate = json.getLocalDate("startDate") ?: default?.startDate ?: badRequest("missing startDate"), - endDate = json.getLocalDate("endDate") ?: default?.endDate ?: badRequest("missing endDate"), + startDate = json.getString("startDate")?.let { LocalDate.parse(it) } ?: default?.startDate ?: badRequest("missing startDate"), + endDate = json.getString("endDate")?.let { LocalDate.parse(it) } ?: default?.endDate ?: badRequest("missing endDate"), country = json.getString("country") ?: default?.country ?: badRequest("missing country"), location = json.getString("location") ?: default?.location ?: badRequest("missing location"), online = json.getBoolean("online") ?: default?.online ?: false, @@ -187,8 +187,8 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n type = type, name = json.getString("name") ?: default?.name ?: badRequest("missing name"), shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"), - startDate = json.getLocalDate("startDate") ?: default?.startDate ?: badRequest("missing startDate"), - endDate = json.getLocalDate("endDate") ?: default?.endDate ?: badRequest("missing endDate"), + startDate = json.getString("startDate")?.let { LocalDate.parse(it) } ?: default?.startDate ?: badRequest("missing startDate"), + endDate = json.getString("endDate")?.let { LocalDate.parse(it) } ?: default?.endDate ?: badRequest("missing endDate"), country = json.getString("country") ?: default?.country ?: badRequest("missing country"), location = json.getString("location") ?: default?.location ?: badRequest("missing location"), online = json.getBoolean("online") ?: default?.online ?: false, diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt index 5c38a21..723cae9 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt @@ -218,8 +218,12 @@ class ApiServlet: HttpServlet() { "Missing 'Accept' header" ) // CB TODO 1) a reference to a specific API call at this point is a code smell. - // 2) there will e other content types: .tou, .h9, .html - if (!isJson(accept) && (!isXml(accept) || !request.requestURI.matches(Regex("/api/tour/\\d+")))) throw ApiException( + // 2) there will be other content types: .tou, .h9, .html + if (!isJson(accept) && + (!isXml(accept) || !request.requestURI.matches(Regex("/api/tour/\\d+"))) && + (accept != "application/ffg" && accept != "application/egf" || !request.requestURI.matches(Regex("/api/tour/\\d+/standings/\\d+"))) + + ) throw ApiException( HttpServletResponse.SC_BAD_REQUEST, "Invalid 'Accept' header" ) diff --git a/view-webapp/pom.xml b/view-webapp/pom.xml index 6e51093..b4add5d 100644 --- a/view-webapp/pom.xml +++ b/view-webapp/pom.xml @@ -153,11 +153,6 @@ kotlin-reflect ${kotlin.version} - - org.jetbrains.kotlinx - kotlinx-datetime-jvm - 0.4.0 - jakarta.servlet diff --git a/view-webapp/src/main/sass/tour.scss b/view-webapp/src/main/sass/tour.scss index 96479a0..1e0230f 100644 --- a/view-webapp/src/main/sass/tour.scss +++ b/view-webapp/src/main/sass/tour.scss @@ -54,6 +54,11 @@ /* registration section */ + #players-list { + max-width: 95vw; + overflow-x: auto; + } + #player { &.create { .edition { @@ -280,4 +285,8 @@ justify-content: space-around; } } + #standings-container { + max-width: 95vw; + overflow-x: auto; + } } diff --git a/view-webapp/src/main/webapp/js/api.js b/view-webapp/src/main/webapp/js/api.js index eaac9b7..0fd329f 100644 --- a/view-webapp/src/main/webapp/js/api.js +++ b/view-webapp/src/main/webapp/js/api.js @@ -9,13 +9,16 @@ const apiVersion = '1.0'; // .catch(err => { ... }); const base = '/api/'; -let headers = function() { +let headers = function(withJson) { let ret = { - "Content-Type": "application/json; charset=utf-8", - "Accept-Version": apiVersion, - "Accept": "application/json", - "X-Browser-Key": store('browserKey') + 'Accept-Version': apiVersion, + 'Accept': 'application/json', + 'X-Browser-Key': store('browserKey') }; + if (typeof(withJson) === 'undefined') withJson = true; + if (withJson) { + ret['Content-Type'] = 'application/json'; + } let accessToken = store('accessToken'); if (accessToken) { ret['Authorization'] = `Bearer ${accessToken}`; diff --git a/view-webapp/src/main/webapp/js/tour-standings.inc.js b/view-webapp/src/main/webapp/js/tour-standings.inc.js index d9f74e9..9c0b6d6 100644 --- a/view-webapp/src/main/webapp/js/tour-standings.inc.js +++ b/view-webapp/src/main/webapp/js/tour-standings.inc.js @@ -1,3 +1,20 @@ +function publish(format, extension) { + let form = $('#tournament-infos')[0]; + let shortName = form.val('shortName'); + let hdrs = headers(); + hdrs['Accept'] = `application/${format}` + fetch(`api/tour/${tour_id}/standings/${activeRound}`, { + headers: hdrs + }).then(resp => { + if (resp.ok) return resp.text() + else throw "publish error" + }).then(txt => { + let blob = new Blob(['\uFEFF', txt.trim()], {type: 'plain/text;charset=utf-8'}); + downloadFile(blob, `${shortName}.${extension}`); + close_modal(); + }).catch(err => showError(err)); +} + onLoad(() => { $('.criterium').on('click', e => { let alreadyOpen = e.target.closest('select'); @@ -45,4 +62,10 @@ onLoad(() => { $('#publish-modal').on('click', e => { close_modal(); }); + $('.publish-ffg').on('click', e => { + publish('ffg', 'tou'); + }); + $('.publish-egf').on('click', e => { + publish('egf', 'h9'); + }); }); diff --git a/view-webapp/src/main/webapp/tour-standings.inc.html b/view-webapp/src/main/webapp/tour-standings.inc.html index 5824408..9194f5c 100644 --- a/view-webapp/src/main/webapp/tour-standings.inc.html +++ b/view-webapp/src/main/webapp/tour-standings.inc.html @@ -101,9 +101,9 @@