EGF and FFG exports

This commit is contained in:
Claude Brisson
2024-01-01 12:26:40 +01:00
parent 18edd16d6c
commit b261e56807
10 changed files with 218 additions and 54 deletions

View File

@@ -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<ID, Double>()
private fun exportToEGFFormat(tournament: Tournament<*>, lines: List<Json.Object>, criteria: List<Criterion>, 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<Json.Object>, 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")
}

View File

@@ -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 {
}
</ByePlayer>
<TournamentParameterSet>
<GeneralParameterSet bInternet="${tournament.online}" basicTime="${tournament.timeSystem.mainTime}" beginDate="${tournament.startDate}" canByoYomiTime="${tournament.timeSystem.byoyomi}" complementaryTimeSystem="${when(tournament.timeSystem.type) {
<GeneralParameterSet bInternet="${tournament.online}" basicTime="${tournament.timeSystem.mainTime / 60}" beginDate="${tournament.startDate}" canByoYomiTime="${tournament.timeSystem.byoyomi}" complementaryTimeSystem="${when(tournament.timeSystem.type) {
TimeSystem.TimeSystemType.SUDDEN_DEATH -> "SUDDENDEATH"
TimeSystem.TimeSystemType.STANDARD -> "STDBYOYOMI"
TimeSystem.TimeSystemType.JAPANESE -> "STDBYOYOMI"
TimeSystem.TimeSystemType.CANADIAN -> "CANBYOYOMI"
TimeSystem.TimeSystemType.FISCHER -> "FISCHER"
} }" director="" endDate="${tournament.endDate}" fischerTime="${tournament.timeSystem.increment}" genCountNotPlayedGamesAsHalfPoint="false" genMMBar="${

View File

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

View File

@@ -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,

View File

@@ -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"
)

View File

@@ -153,11 +153,6 @@
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-datetime-jvm</artifactId>
<version>0.4.0</version>
</dependency>
<!-- servlets and mail APIs -->
<dependency>
<groupId>jakarta.servlet</groupId>

View File

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

View File

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

View File

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

View File

@@ -101,9 +101,9 @@
</div>
<div class="popup-footer">
<div class="form-actions">
<button class="ui gray floating cancel button">Cancel</button>
<button class="ui blue floating button">EGF</button>
<button class="ui blue floating button">FFG</button>
<button type="button" class="ui gray floating cancel button">Cancel</button>
<button type="button" class="ui blue floating publish-egf button">EGF</button>
<button type="button" class="ui blue floating publish-ffg button">FFG</button>
</div>
</div>
</div>