Merge from webview2

This commit is contained in:
Claude Brisson
2023-12-24 00:10:03 +01:00
214 changed files with 11364 additions and 81708 deletions

View File

@@ -59,10 +59,20 @@
<configuration>
<scan>0</scan>
<httpConnector>
<port>8085</port>
<host>${pairgoth.api.host}</host>
<port>${pairgoth.api.port}</port>
</httpConnector>
<systemProperties>
<pairgoth.env>${pairgoth.env}</pairgoth.env>
<pairgoth.api.external.url>${pairgoth.api.external.url}</pairgoth.api.external.url>
<pairgoth.webapp.external.url>${pairgoth.webapp.external.url}</pairgoth.webapp.external.url>
<pairgoth.store>${pairgoth.store}</pairgoth.store>
<pairgoth.store.file.path>${pairgoth.store.file.path}</pairgoth.store.file.path>
<pairgoth.logger.level>${pairgoth.logger.level}</pairgoth.logger.level>
<pairgoth.logger.format>${pairgoth.logger.format}</pairgoth.logger.format>
</systemProperties>
<webApp>
<contextPath>/api/</contextPath>
<contextPath>${pairgoth.api.context}/</contextPath>
</webApp>
</configuration>
</plugin>
@@ -134,6 +144,12 @@
<artifactId>jakarta.mail</artifactId>
<version>1.6.7</version>
</dependency>
<!-- utils -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<!-- auth -->
<dependency>
<groupId>org.pac4j</groupId>

View File

@@ -1,7 +1,7 @@
package org.jeudego.pairgoth.api
import com.republicate.kson.Json
import org.jeudego.pairgoth.web.ApiException
import org.jeudego.pairgoth.server.ApiException
import org.slf4j.LoggerFactory
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

View File

@@ -3,7 +3,7 @@ package org.jeudego.pairgoth.api
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.web.Event
import org.jeudego.pairgoth.server.Event
import javax.servlet.http.HttpServletRequest
interface PairgothApiHandler: ApiHandler {

View File

@@ -6,7 +6,8 @@ import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.web.Event.*
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -15,10 +16,18 @@ object PairingHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played")
val playing = tournament.games(round).values.flatMap {
listOf(it.black, it.white)
}.toSet()
return tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }.map { it.id }.toJsonArray()
val unpairables = tournament.pairables.values.filter { it.skip.contains(round) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
val pairables = tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
val games = tournament.games(round).values
return Json.Object(
"games" to games.map { it.toJson() }.toCollection(Json.MutableArray()),
"pairables" to pairables,
"unpairables" to unpairables
)
}
override fun post(request: HttpServletRequest): Json {
@@ -54,9 +63,19 @@ object PairingHandler: PairgothApiHandler {
// only allow last round (if players have not been paired in the last round, it *may* be possible to be more laxist...)
if (round != tournament.lastRound()) badRequest("cannot edit pairings in other rounds but the last")
val payload = getObjectPayload(request)
val game = tournament.games(round)[payload.getInt("id")] ?: badRequest("invalid game id")
val gameId = payload.getInt("id") ?: badRequest("invalid game id")
val game = tournament.games(round)[gameId] ?: badRequest("invalid game id")
val playing = (tournament.games(round).values).filter { it.id != gameId }.flatMap {
listOf(it.black, it.white)
}.toSet()
game.black = payload.getID("b") ?: badRequest("missing black player id")
game.white = payload.getID("w") ?: badRequest("missing white player id")
val black = tournament.pairables[game.black] ?: badRequest("invalid black player id")
val white = tournament.pairables[game.black] ?: badRequest("invalid white player id")
if (black.skip.contains(round)) badRequest("black is not playing this round")
if (white.skip.contains(round)) badRequest("white is not playing this round")
if (playing.contains(black.id)) badRequest("black is already in another game")
if (playing.contains(white.id)) badRequest("white is already in another game")
if (payload.containsKey("h")) game.handicap = payload.getString("h")?.toIntOrNull() ?: badRequest("invalid handicap")
tournament.dispatchEvent(gameUpdated, Json.Object("round" to round, "game" to game.toJson()))
return Json.Object("success" to true)

View File

@@ -5,8 +5,8 @@ import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.Player
import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.web.Event
import org.jeudego.pairgoth.web.Event.*
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

View File

@@ -5,7 +5,7 @@ import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.Game
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.web.Event
import org.jeudego.pairgoth.server.Event
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

View File

@@ -4,8 +4,8 @@ import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.web.Event
import org.jeudego.pairgoth.web.Event.*
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

View File

@@ -10,9 +10,9 @@ import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.web.ApiServlet
import org.jeudego.pairgoth.web.Event
import org.jeudego.pairgoth.web.Event.*
import org.jeudego.pairgoth.server.ApiServlet
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import org.w3c.dom.Element
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -64,8 +64,7 @@ object TournamentHandler: PairgothApiHandler {
clear()
putAll(tournament.games(round))
}
Store.replaceTournament(updated)
tournament.dispatchEvent(tournamentUpdated, tournament.toJson())
updated.dispatchEvent(tournamentUpdated, updated.toJson())
return Json.Object("success" to true)
}

View File

@@ -151,6 +151,7 @@ object OpenGotha {
it.value.map { game ->
Game(
id = Store.nextGameId,
table = game.tableNumber,
black = canonicMap[game.blackPlayer] ?: throw Error("player not found: ${game.blackPlayer}"),
white = canonicMap[game.whitePlayer] ?: throw Error("player not found: ${game.whitePlayer}"),
handicap = game.handicap,

View File

@@ -6,6 +6,7 @@ import java.util.*
data class Game(
val id: ID,
var table: Int,
var white: ID,
var black: ID,
var handicap: Int = 0,
@@ -33,6 +34,7 @@ data class Game(
fun Game.toJson() = Json.Object(
"id" to id,
"t" to table,
"w" to white,
"b" to black,
"h" to handicap,
@@ -42,6 +44,7 @@ fun Game.toJson() = Json.Object(
fun Game.Companion.fromJson(json: Json.Object) = Game(
id = json.getID("id") ?: throw Error("missing game id"),
table = json.getInt("t") ?: throw Error("missing game table"),
white = json.getID("w") ?: throw Error("missing white player"),
black = json.getID("b") ?: throw Error("missing black player"),
handicap = json.getInt("h") ?: 0,

View File

@@ -3,6 +3,7 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.store.Store
import java.util.*
// Pairable
@@ -51,6 +52,13 @@ fun Pairable.Companion.parseRank(rankStr: String): Int {
// Player
enum class DatabaseId {
AGA,
EGF,
FFG;
val key get() = this.name.lowercase(Locale.ROOT)
}
class Player(
id: ID,
name: String,
@@ -62,7 +70,7 @@ class Player(
): Pairable(id, name, rating, rank) {
companion object
// used to store external IDs ("FFG" => FFG ID, "EGF" => EGF PIN, "AGA" => AGA ID ...)
val externalIds = mutableMapOf<String, String>()
val externalIds = mutableMapOf<DatabaseId, String>()
override fun toJson(): Json.Object = Json.MutableObject(
"id" to id,
"name" to name,
@@ -71,8 +79,11 @@ class Player(
"rank" to rank,
"country" to country,
"club" to club
).also {
if (skip.isNotEmpty()) it["skip"] = Json.Array(skip)
).also { json ->
if (skip.isNotEmpty()) json["skip"] = Json.Array(skip)
externalIds.forEach { (dbid, id) ->
json[dbid.key] = id
}
}
override fun nameSeed(separator: String): String {
return name + separator + firstname
@@ -92,4 +103,9 @@ fun Player.Companion.fromJson(json: Json.Object, default: Player? = null) = Play
json.getArray("skip")?.let {
if (it.isNotEmpty()) player.skip.addAll(it.map { id -> (id as Number).toInt() })
}
DatabaseId.values().forEach { dbid ->
json.getString(dbid.key)?.let { id ->
player.externalIds[dbid] = id
}
}
}

View File

@@ -49,7 +49,7 @@ data class MainCritParams(
val additionalPlacementCritSystem1: Criterion = Criterion.RATING,
val additionalPlacementCritSystem2: Criterion = Criterion.NONE,
) {
enum class DrawUpDown {TOP, MIDDLE, BOTTOM}
enum class DrawUpDown { TOP, MIDDLE, BOTTOM }
enum class SeedMethod { SPLIT_AND_FOLD, SPLIT_AND_RANDOM, SPLIT_AND_SLIP }
companion object {
const val MAX_CATEGORIES_WEIGHT = 20000000000000.0 // 2e13
@@ -185,7 +185,7 @@ class MacMahon(
handicap = HandicapParams(
weight = MainCritParams.MAX_SCORE_WEIGHT, // TODO - contradictory with the comment above (not used anyway ?!)
useMMS = true,
rankThreshold = 0,
rankThreshold = -20,
ceiling = 9
)
),

View File

@@ -3,7 +3,7 @@ package org.jeudego.pairgoth.oauth
// In progress
import com.republicate.kson.Json
import org.jeudego.pairgoth.web.WebappManager
import org.jeudego.pairgoth.server.WebappManager
//import com.republicate.modality.util.AESCryptograph
//import com.republicate.modality.util.Cryptograph
import org.apache.commons.codec.binary.Base64
@@ -21,7 +21,7 @@ abstract class OAuthHelper {
protected get() = WebappManager.getMandatoryProperty("oauth." + name + ".secret")
protected val redirectURI: String?
protected get() = try {
val uri: String = WebappManager.Companion.getProperty("webapp.url") + "/oauth.html"
val uri: String = WebappManager.getProperty("webapp.external.url") + "/oauth.html"
URLEncoder.encode(uri, "UTF-8")
} catch (uee: UnsupportedEncodingException) {
logger.error("could not encode redirect URI", uee)

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.*
import java.util.*
abstract class BasePairingHelper(
history: List<List<Game>>, // History of all games played for each round
@@ -131,4 +132,11 @@ abstract class BasePairingHelper(
open fun nameSort(p: Pairable, q: Pairable): Int {
return if (p.name > q.name) 1 else -1
}
val tables = history.mapTo(mutableListOf()) { games ->
games.map { it.table }.fold(BitSet()) { acc, table ->
acc.set(table)
acc
}
}
}

View File

@@ -28,7 +28,7 @@ sealed class BaseSolver(
companion object {
val rand = Random(/* seed from properties - TODO */)
val DEBUG_EXPORT_WEIGHT = true
val DEBUG_EXPORT_WEIGHT = false
var byePlayers: MutableList<Pairable> = mutableListOf()
}
@@ -123,7 +123,7 @@ sealed class BaseSolver(
var result = sorted.flatMap { games(white = it[0], black = it[1]) }
// add game for ByePlayer
if (chosenByePlayer != ByePlayer) result += Game(id = Store.nextGameId, white = ByePlayer.id, black = chosenByePlayer.id, result = Game.Result.fromSymbol('b'))
if (chosenByePlayer != ByePlayer) result += Game(id = Store.nextGameId, table = 0, white = ByePlayer.id, black = chosenByePlayer.id, result = Game.Result.fromSymbol('b'))
if (DEBUG_EXPORT_WEIGHT) {
//println("DUDD debug")
@@ -535,7 +535,9 @@ sealed class BaseSolver(
open fun games(black: Pairable, white: Pairable): List<Game> {
// CB TODO team of individuals pairing
return listOf(Game(id = Store.nextGameId, black = black.id, white = white.id, handicap = pairing.handicap.handicap(black, white), drawnUpDown = white.group-black.group))
val usedTables = tables.getOrNull(round - 1) ?: BitSet().also { tables.add(it) }
val table = if (black.id == 0 || white.id == 0) 0 else usedTables.nextClearBit(1)
usedTables.set(table)
return listOf(Game(id = Store.nextGameId, table = table, black = black.id, white = white.id, handicap = pairing.handicap.handicap(white, black), drawnUpDown = white.group-black.group))
}
}

View File

@@ -0,0 +1,36 @@
package org.jeudego.pairgoth.server
import com.republicate.kson.Json
import java.io.IOException
class ApiException : IOException {
var code: Int
private set
var details: Json.Object
private set
constructor(code: Int) : super("error") {
this.code = code
details = Json.Object("message" to message)
}
constructor(code: Int, message: String?) : super(message) {
this.code = code
details = Json.Object("message" to message)
}
constructor(code: Int, cause: Exception) : super(cause) {
this.code = code
details = Json.Object("message" to "Erreur interne du serveur : " + cause.message)
}
constructor(code: Int, message: String, cause: Exception) : super(message, cause) {
this.code = code
details = Json.Object("message" to message + " : " + cause.message)
}
constructor(code: Int, details: Json.Object) : super(details.getString("message")) {
this.code = code
this.details = details
}
}

View File

@@ -1,4 +1,4 @@
package org.jeudego.pairgoth.web
package org.jeudego.pairgoth.server
import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler
@@ -23,7 +23,7 @@ import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class ApiServlet : HttpServlet() {
class ApiServlet: HttpServlet() {
public override fun doGet(request: HttpServletRequest, response: HttpServletResponse) {
doRequest(request, response)
@@ -62,7 +62,7 @@ class ApiServlet : HttpServlet() {
// validate request
if ("dev" == WebappManager.getProperty("webapp.env")) {
if ("dev" == WebappManager.getProperty("env")) {
response.addHeader("Access-Control-Allow-Origin", "*")
}
validateAccept(request);
@@ -90,7 +90,7 @@ class ApiServlet : HttpServlet() {
"team" -> TeamHandler
else -> ApiHandler.badRequest("unknown sub-entity: $subEntity")
}
"player" -> PlayerHandler
// "player" -> PlayerHandler
else -> ApiHandler.badRequest("unknown entity: $entity")
}
@@ -235,7 +235,7 @@ class ApiServlet : HttpServlet() {
cause: Throwable? = null
) {
try {
if (code == 500) {
if (code == 500 || response.isCommitted) {
logger.error(
"Request {} {} gave error {} {}",
request.method,
@@ -245,7 +245,14 @@ class ApiServlet : HttpServlet() {
cause
)
}
response.sendError(code, message)
response.status = code
if (response.isCommitted) return
val errorPayload = Json.Object(
"success" to false,
"error" to (message ?: "unknown error")
)
setContentType(response)
errorPayload.toString(response.writer)
} catch (ioe: IOException) {
logger.error("Could not send back error", ioe)
}

View File

@@ -1,4 +1,4 @@
package org.jeudego.pairgoth.web
package org.jeudego.pairgoth.server
import info.macias.sse.events.MessageEvent
import java.util.concurrent.atomic.AtomicLong

View File

@@ -1,4 +1,4 @@
package org.jeudego.pairgoth.web
package org.jeudego.pairgoth.server
import com.republicate.kson.Json
import org.jeudego.pairgoth.util.Colorizer.blue
@@ -15,9 +15,11 @@ fun Logger.logRequest(req: HttpServletRequest, logHeaders: Boolean = false) {
.append(req.localName)
val port = req.localPort
if (port != 80) builder.append(':').append(port)
/*
if (!req.contextPath.isEmpty()) {
builder.append(req.contextPath)
}
*/
builder.append(req.requestURI)
if (req.method == "GET") {
val qs = req.queryString

View File

@@ -1,4 +1,4 @@
package org.jeudego.pairgoth.web
package org.jeudego.pairgoth.server
import info.macias.sse.EventBroadcast
import info.macias.sse.events.MessageEvent

View File

@@ -1,4 +1,4 @@
package org.jeudego.pairgoth.web
package org.jeudego.pairgoth.server
import com.republicate.mailer.SmtpLoop
import org.apache.commons.lang3.tuple.Pair
@@ -51,8 +51,7 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
/* ServletContextListener interface */
override fun contextInitialized(sce: ServletContextEvent) {
context = sce.servletContext
logger.info("---------- Starting Pairgoth Server ----------")
context.setAttribute("manager", this)
logger.info("---------- Starting $WEBAPP_NAME ----------")
webappRoot = context.getRealPath("/")
try {
// load default properties
@@ -63,7 +62,8 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
properties[(key as String).removePrefix(PAIRGOTH_PROPERTIES_PREFIX)] = value
}
logger.info("Using profile {}", properties.getProperty("webapp.env"))
val env = properties.getProperty("env")
logger.info("Using profile $env", )
// set system user agent string to empty string
System.setProperty("http.agent", "")
@@ -84,11 +84,15 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
}
override fun contextDestroyed(sce: ServletContextEvent) {
logger.info("---------- Stopping Web Application ----------")
logger.info("---------- Stopping $WEBAPP_NAME ----------")
stopService("smtp");
val context = sce.servletContext
for (service in webServices.keys) stopService(service, true)
// ??? DriverManager.deregisterDriver(com.mysql.cj.jdbc.Driver ...);
logger.info("---------- Stopped $WEBAPP_NAME ----------")
}
/* ServletContextAttributeListener interface */
@@ -101,6 +105,7 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
override fun sessionDestroyed(se: HttpSessionEvent) {}
companion object {
const val WEBAPP_NAME = "Pairgoth API Server"
const val PAIRGOTH_PROPERTIES_PREFIX = "pairgoth."
lateinit var webappRoot: String
lateinit var context: ServletContext
@@ -111,10 +116,10 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
return properties.getProperty(prop)
}
fun getMandatoryProperty(prop: String): String {
return properties.getProperty(prop) ?: throw Error("missing property: ${prop}")
return getProperty(prop) ?: throw Error("missing property: ${prop}")
}
val webappURL by lazy { getProperty("webapp.url") }
val webappURL by lazy { getProperty("webapp.external.url") }
private val services = mutableMapOf<String, Pair<Runnable, Thread>>()

View File

@@ -10,7 +10,7 @@ import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toFullJson
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.model.toJson
import java.lang.Integer.max
import java.nio.file.Path
import java.text.DateFormat
import java.text.SimpleDateFormat
@@ -64,11 +64,15 @@ class FileStore(pathStr: String): StoreImplementation {
}
val json = Json.parse(path.resolve(file).readText())?.asObject() ?: throw Error("could not read tournament")
val tournament = Tournament.fromJson(json)
var maxPlayerId = 0
var maxGameId = 0
val players = json["players"] as Json.Array? ?: Json.Array()
tournament.players.putAll(
players.associate {
(it as Json.Object).let { player ->
Pair(player.getID("id") ?: throw Error("invalid tournament file"), Player.fromJson(player))
Pair(player.getID("id") ?: throw Error("invalid tournament file"), Player.fromJson(player)).also {
maxPlayerId = max(maxPlayerId, it.first)
}
}
}
)
@@ -77,29 +81,41 @@ class FileStore(pathStr: String): StoreImplementation {
tournament.teams.putAll(
teams.associate {
(it as Json.Object).let { team ->
Pair(team.getID("id") ?: throw Error("invalid tournament file"), tournament.teamFromJson(team))
Pair(team.getID("id") ?: throw Error("invalid tournament file"), tournament.teamFromJson(team)).also {
maxPlayerId = max(maxPlayerId, it.first)
}
}
}
)
}
val games = json["games"] as Json.Array? ?: Json.Array()
(1..games.size).forEach { round ->
var nextDefaultTable = 1;
val roundGames = games[round - 1] as Json.Array
tournament.games(round).putAll(
games.associate {
roundGames.associate {
(it as Json.Object).let { game ->
Pair(game.getID("id") ?: throw Error("invalid tournament file"), Game.fromJson(game))
val fixedGame =
if (game.containsKey("t")) game
else Json.MutableObject(game).set("t", nextDefaultTable++)
Pair(game.getID("id") ?: throw Error("invalid tournament file"), Game.fromJson(fixedGame)).also {
maxGameId = max(maxGameId, it.first)
}
}
}
)
}
_nextPlayerId.set(maxPlayerId + 1)
_nextGameId.set(maxGameId + 1)
return tournament
}
override fun replaceTournament(tournament: Tournament<*>) {
val filename = tournament.filename()
val file = path.resolve(filename).toFile()
if (!file.exists()) throw Error("File $filename does not exist")
file.renameTo(path.resolve(filename + "-${timestamp}").toFile())
if (file.exists()) {
file.renameTo(path.resolve(filename + "-${timestamp}").toFile())
}
addTournament(tournament)
}

View File

@@ -2,14 +2,14 @@ package org.jeudego.pairgoth.store
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.web.WebappManager
import org.jeudego.pairgoth.server.WebappManager
import java.util.concurrent.atomic.AtomicInteger
private fun createStoreImplementation(): StoreImplementation {
return when (val storeProperty = WebappManager.getProperty("store") ?: "memory") {
"memory" -> MemoryStore()
"file" -> {
val filePath = WebappManager.getMandatoryProperty("store.file.path") ?: "."
val filePath = WebappManager.getProperty("store.file.path") ?: "."
FileStore(filePath)
}
else -> throw Error("unknown store: $storeProperty")

View File

@@ -1,6 +1,19 @@
# webapp
webapp.env = dev
webapp.url = http://localhost:8080
# environment
env = dev
# webapp connector
webapp.protocol = http
webapp.interface = localhost
webapp.port = 8080
webapp.context = /
webapp.external.url = http://localhost:8080
# api connector
api.protocol = http
api.interface = localhost
api.port = 8085
api.context = /api
api.external.url = http://localhost:8085/api
# store
store = file

View File

@@ -8,7 +8,7 @@
<listener-class>com.republicate.slf4j.impl.ServletContextLoggerListener</listener-class>
</listener>
<listener>
<listener-class>org.jeudego.pairgoth.web.WebappManager</listener-class>
<listener-class>org.jeudego.pairgoth.server.WebappManager</listener-class>
</listener>
<!-- filters -->
@@ -29,11 +29,11 @@
<!-- servlets -->
<servlet>
<servlet-name>api</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.ApiServlet</servlet-class>
<servlet-class>org.jeudego.pairgoth.server.ApiServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>sse</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.SSEServlet</servlet-class>
<servlet-class>org.jeudego.pairgoth.server.SSEServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>

View File

@@ -180,7 +180,7 @@ class BasicTests: TestBase() {
"""[{"id":$aTournamentGameID,"w":$anotherPlayerID,"b":$aPlayerID,"h":0,"r":"?","dd":0}]"""
)
assertTrue(possibleResults.contains(games.toString()), "pairing differs")
games = TestAPI.get("/api/tour/$aTournamentID/res/1").asArray()
games = TestAPI.get("/api/tour/$aTournamentID/res/1").asObject().getArray("games")!!
assertTrue(possibleResults.contains(games.toString()), "results differs")
val empty = TestAPI.get("/api/tour/$aTournamentID/pair/1").asArray()
assertEquals("[]", empty.toString(), "no more pairables for round 1")

View File

@@ -2,9 +2,9 @@ package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.web.ApiServlet
import org.jeudego.pairgoth.web.SSEServlet
import org.jeudego.pairgoth.web.WebappManager
import org.jeudego.pairgoth.server.ApiServlet
import org.jeudego.pairgoth.server.SSEServlet
import org.jeudego.pairgoth.server.WebappManager
import org.mockito.kotlin.*
import java.io.*
import java.util.*

View File

@@ -6,5 +6,5 @@ CSSWATCH=$!
export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006"
#mvn --projects view-webapp -Dpairgoth.api.url=http://localhost:8085/api/ package jetty:run
mvn --projects view-webapp package jetty:run
mvn -DskipTests=true --projects view-webapp package jetty:run
kill $CSSWATCH

99
pom.xml
View File

@@ -19,7 +19,8 @@
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository> <repository>
</repository>
<repository>
<id>republicate.com</id>
<url>https://republicate.com/maven2</url>
<releases>
@@ -31,6 +32,39 @@
</repository>
</repositories>
<profiles>
<profile>
<id>manual-properties</id>
<activation>
<file>
<exists>pairgoth.properties</exists>
</file>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>1.2.0</version>
<executions>
<execution>
<phase>initialize</phase>
<goals>
<goal>read-project-properties</goal>
</goals>
<configuration>
<files>
<file>pairgoth.properties</file>
</files>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
@@ -62,6 +96,28 @@
<kotlin.code.style>official</kotlin.code.style>
<kotlin.compiler.jvmTarget>10</kotlin.compiler.jvmTarget>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
<!-- pairgoth default properties -->
<pairgoth.env>dev</pairgoth.env>
<pairgoth.webapp.protocol>http</pairgoth.webapp.protocol>
<pairgoth.webapp.host>localhost</pairgoth.webapp.host>
<pairgoth.webapp.port>8080</pairgoth.webapp.port>
<pairgoth.webapp.context>/</pairgoth.webapp.context>
<pairgoth.webapp.external.url>http://localhost:8080</pairgoth.webapp.external.url>
<pairgoth.api.protocol>http</pairgoth.api.protocol>
<pairgoth.api.host>localhost</pairgoth.api.host>
<pairgoth.api.port>8085</pairgoth.api.port>
<pairgoth.api.context>/api</pairgoth.api.context>
<pairgoth.api.external.url>http://localhost:8085/api</pairgoth.api.external.url>
<pairgoth.store>file</pairgoth.store>
<pairgoth.store.file.path>tournamentfiles</pairgoth.store.file.path>
<pairgoth.smtp.sender></pairgoth.smtp.sender>
<pairgoth.smtp.host></pairgoth.smtp.host>
<pairgoth.smtp.port>587</pairgoth.smtp.port>
<pairgoth.smtp.user></pairgoth.smtp.user>
<pairgoth.smtp.password></pairgoth.smtp.password>
<pairgoth.logger.level>info</pairgoth.logger.level>
<pairgoth.logger.format>[%level] %ip [%logger] %message</pairgoth.logger.format>
</properties>
<modules>
@@ -144,6 +200,47 @@
<artifactId>maven-war-plugin</artifactId>
<version>${maven.war.plugin.version}</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<id>regex-property</id>
<goals>
<goal>regex-property</goal>
</goals>
<configuration>
<name>webapp.port</name>
<value>${webapp.url}</value>
<regex>^.*:(\d+).*$</regex>
<replacement>$1</replacement>
<failIfNoMatch>true</failIfNoMatch>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>echo-property</id>
<goals>
<goal>run</goal>
</goals>
<configuration>
<tasks>
<echo>----------------- webapp.port=${webapp.port}</echo>
</tasks>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.mycila</groupId>
<artifactId>license-maven-plugin</artifactId>

View File

@@ -1,4 +1,4 @@
#!/bin/bash
export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005"
mvn --projects api-webapp package jetty:run
mvn -DskipTests=true --projects api-webapp package jetty:run

View File

@@ -3,4 +3,4 @@
# debug version
# mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar
mvn package && java -jar application/target/pairgoth-engine.jar
mvn -DskipTests=true package && java -jar application/target/pairgoth-engine.jar

View File

@@ -17,6 +17,7 @@
<url>TODO</url>
<properties>
<pac4j.version>5.7.1</pac4j.version>
<lucene.version>9.9.0</lucene.version>
</properties>
<build>
<defaultGoal>package</defaultGoal>
@@ -68,44 +69,32 @@
</executions>
<configuration>
<outputFolder>${project.build.directory}/generated-resources/css</outputFolder>
<version>1.69.5</version>
</configuration>
</plugin>
<!--
<plugin>
<groupId>com.gitlab.haynes</groupId>
<artifactId>libsass-maven-plugin</artifactId>
<version>0.2.29</version>
<executions>
<execution>
<phase>generate-resources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<configuration>
<inputPath>${basedir}/src/main/sass/</inputPath>
<inputSyntax>scss</inputSyntax>
<outputPath>${project.build.directory}/${project.build.finalName}/css</outputPath>
<generateSourceMap>true</generateSourceMap>
<sourceMapOutputPath>${project.build.directory}/${project.build.finalName}/css</sourceMapOutputPath>
</configuration>
</plugin>
-->
<plugin>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-maven-plugin</artifactId>
<version>${jetty.version}</version>
<configuration>
<scan>1</scan>
<scan>0</scan>
<httpConnector>
<port>8080</port>
<host>${pairgoth.webapp.host}</host>
<port>${pairgoth.webapp.port}</port>
</httpConnector>
<systemProperties>
<pairgoth.api.url>http://localhost:8085/api/</pairgoth.api.url>
<pairgoth.env>dev</pairgoth.env>
<pairgoth.env>${pairgoth.env}</pairgoth.env>
<pairgoth.api.external.url>${pairgoth.api.external.url}</pairgoth.api.external.url>
<pairgoth.webapp.external.url>${pairgoth.webapp.external.url}</pairgoth.webapp.external.url>
<pairgoth.store>${pairgoth.store}</pairgoth.store>
<pairgoth.store.file.path>${pairgoth.store}</pairgoth.store.file.path>
<pairgoth.logger.level>${pairgoth.logger.level}</pairgoth.logger.level>
<pairgoth.logger.format>${pairgoth.logger.format}</pairgoth.logger.format>
<pairgoth.logger.level.org.jeudego.pairgoth.web.ApiServlet.api>debug</pairgoth.logger.level.org.jeudego.pairgoth.web.ApiServlet.api>
<org.slf4j.simpleLogger.defaultLogLevel>debug</org.slf4j.simpleLogger.defaultLogLevel>
</systemProperties>
<webApp>
<contextPath>${pairgoth.webapp.context}/</contextPath>
<resourceBases>${project.basedir}/src/main/webapp,${project.build.directory}/generated-resources/</resourceBases>
</webApp>
</configuration>
@@ -125,6 +114,10 @@
</webResources>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<dependencyManagement>
@@ -168,6 +161,12 @@
<version>${servlet.api.version}</version>
<scope>provided</scope>
</dependency>
<!-- proxy -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-proxy</artifactId>
<version>${jetty.version}</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
@@ -249,6 +248,22 @@
<artifactId>velocity-engine-core</artifactId>
<version>2.4-SNAPSHOT</version>
</dependency>
<!-- indexing -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>${lucene.version}</version>
</dependency>
<!-- tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>

View File

@@ -21,7 +21,7 @@ abstract class OAuthHelper {
protected get() = WebappManager.getMandatoryProperty("oauth." + name + ".secret")
protected val redirectURI: String?
protected get() = try {
val uri: String = WebappManager.Companion.getProperty("webapp.url") + "/oauth.html"
val uri: String = WebappManager.Companion.getProperty("webapp.external.url") + "/oauth.html"
URLEncoder.encode(uri, "UTF-8")
} catch (uee: UnsupportedEncodingException) {
logger.error("could not encode redirect URI", uee)

View File

@@ -0,0 +1,14 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import java.net.URL
object AGARatingsHandler: RatingsHandler(RatingsManager.Ratings.AGA) {
override val defaultURL: URL by lazy {
throw Error("No URL for AGA...")
}
override val active = false
override fun parsePayload(payload: String): Json.Array {
return Json.Array()
}
}

View File

@@ -0,0 +1,30 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import java.net.URL
object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
override val defaultURL = URL("https://www.europeangodatabase.eu/EGD/EGD_2_0/downloads/allworld_lp.html")
override fun parsePayload(payload: String): Json.Array {
return payload.lines().filter {
it.matches(Regex("\\s+\\d+(?!.*\\(undefined\\)|Anonymous).*"))
}.mapNotNullTo(Json.MutableArray()) {
val match = linePattern.matchEntire(it)
if (match == null) {
logger.error("could not parse line: $it")
null
} else {
val pairs = groups.map {
Pair(it, match.groups[it]?.value)
}.toTypedArray()
Json.MutableObject(*pairs).also {
it["origin"] = "EGF"
}
}
}
}
// 19574643 Abad Jahin FR 38GJ 20k -- 15 2 T200202B
var linePattern =
Regex("\\s+(?<egf>\\d{8})\\s+(?<name>$atom+)\\s(?<firstname>$atom+)?,?\\s+(?<country>[A-Z]{2})\\s+(?<club>\\S{1,4})\\s+(?<rank>[1-9][0-9]?[kdp])\\s+(?<promotion>[1-9][0-9]?[kdp]|--)\\s+(?<rating>-?[0-9]+)\\s+(?<nt>[0-9]+)\\s+(?<last>\\S+)\\s*")
val groups = arrayOf("egf", "name", "firstname", "country", "club", "rank", "rating")
}

View File

@@ -0,0 +1,39 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URL
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
object FFGRatingsHandler: RatingsHandler(RatingsManager.Ratings.FFG) {
override val defaultURL = URL("https://ffg.jeudego.org/echelle/echtxt/ech_ffg_V3.txt")
override fun parsePayload(payload: String): Json.Array {
return payload.lines().mapNotNullTo(Json.MutableArray()) { line ->
val match = linePattern.matchEntire(line)
if (match == null) {
logger.error("could not parse line: $line")
null
} else {
val pairs = groups.map {
Pair(it, match.groups[it]?.value)
}.toTypedArray()
Json.MutableObject(*pairs).also {
it["origin"] = "FFG"
val rating = it["rating"]?.toString()?.toIntOrNull()
if (rating != null) {
it["rank"] = (rating/100).let { if (it < 0) "${-it}k" else "${it+1}d" }
}
}
}
}
}
override fun defaultCharset() = StandardCharsets.ISO_8859_1
var linePattern =
Regex("(?<name>$atom+(?:\\((?:$atom|[0-9])+\\))?)\\s(?<firstname>$atom+(?:\\((?:$atom|[0-9])+\\))?)\\s+(?<rating>-?[0-9]+)\\s(?<license>[-eCLX])\\s(?<ffg>(?:\\d|[A-Z]){7}|-------)\\s(?<club>xxxx|XXXX|\\d{2}[a-zA-Z0-9]{2})\\s(?<country>[A-Z]{2})")
val groups = arrayOf("name", "firstname", "rating", "license", "club", "country")
}

View File

@@ -0,0 +1,116 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import org.apache.lucene.analysis.LowerCaseFilter
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.document.Document
import org.apache.lucene.document.Field
import org.apache.lucene.document.StoredField
import org.apache.lucene.document.StringField
import org.apache.lucene.document.TextField
import org.apache.lucene.index.DirectoryReader
import org.apache.lucene.index.IndexWriter
import org.apache.lucene.index.IndexWriterConfig
import org.apache.lucene.index.Term
import org.apache.lucene.queryparser.complexPhrase.ComplexPhraseQueryParser
import org.apache.lucene.search.BooleanClause
import org.apache.lucene.search.BooleanQuery
import org.apache.lucene.search.FuzzyQuery
import org.apache.lucene.search.IndexSearcher
import org.apache.lucene.search.TermQuery
import org.apache.lucene.store.ByteBuffersDirectory
import org.apache.lucene.store.Directory
import org.apache.lucene.store.NoLockFactory
import org.slf4j.LoggerFactory
import java.util.*
class PlayerIndex {
companion object {
val ID = "id"
val ORIGIN = "origin"
val NAME = "name"
val FIRSTNAME = "firstname"
val TEXT = "text"
val COUNTRY = "country"
val MAX_HITS = 20
val logger = LoggerFactory.getLogger("index")
val queryParser = ComplexPhraseQueryParser(TEXT, StandardAnalyzer())
}
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
private val reader by lazy { DirectoryReader.open(directory) }
private val searcher by lazy { IndexSearcher(reader) }
// helper functions
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
fun Json.Object.nullableField(key: String) = getString(key) ?: ""
fun build(players: Json.Array) {
logger.info("indexing players")
var count = 0L
IndexWriter(directory, IndexWriterConfig(StandardAnalyzer()).apply {
setOpenMode(IndexWriterConfig.OpenMode.CREATE)
}).use { writer ->
players.forEachIndexed { i, p ->
val player = p as Json.Object
val origin = p.getString(ORIGIN) ?: throw Error("unknown origin")
val text = player.field(NAME).lowercase(Locale.ROOT)
val doc = Document()
doc.add(StoredField(ID, i));
doc.add(StringField(ORIGIN, player.field(ORIGIN).lowercase(Locale.ROOT), Field.Store.NO))
doc.add(StringField(COUNTRY, player.field(COUNTRY).lowercase(Locale.ROOT), Field.Store.NO))
doc.add(TextField(TEXT, "${player.field(NAME)} ${player.nullableField(FIRSTNAME)}", Field.Store.NO))
writer.addDocument(doc);
++count
}
}
logger.info("indexed $count players")
}
fun match(needle: String, origins: Int, country: String?): List<Int> {
val terms = needle.lowercase(Locale.ROOT)
.replace(Regex("([+&|!(){}\\[\\]^\\\\\"~*?:/]|(?<!\\b)-)"), "")
.split(Regex("[ -_']+"))
.filter { !it.isEmpty() }
.map { "$it~" }
.joinToString(" ")
.let { if (it.isEmpty()) it else "$it ${it.substring(0, it.length - 1) + "*^4"}" }
if (terms.isEmpty()) return emptyList()
logger.info("Search query: $terms")
val fuzzy = queryParser.parse(terms)
val activeMask = RatingsManager.activeMask()
val query = when (origins.countOneBits()) {
0 -> return emptyList()
1 -> {
val filter = TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf(origins)))
BooleanQuery.Builder()
.add(fuzzy, BooleanClause.Occur.SHOULD)
.add(filter, BooleanClause.Occur.FILTER)
.build()
}
2 -> {
if (activeMask.countOneBits() > 2) {
val filter =
TermQuery(Term(ORIGIN, RatingsManager.Ratings.codeOf((origins xor activeMask) and activeMask)))
BooleanQuery.Builder()
.add(fuzzy, BooleanClause.Occur.SHOULD)
.add(filter, BooleanClause.Occur.MUST_NOT)
.build()
} else fuzzy
}
3 -> fuzzy
else -> throw Error("wrong origins mask")
}.let {
if (country == null) it
else {
val countryFilter = TermQuery(Term(COUNTRY, country.lowercase(Locale.ROOT)))
BooleanQuery.Builder()
.add(it, BooleanClause.Occur.SHOULD)
.add(countryFilter, BooleanClause.Occur.FILTER)
.build()
}
}
val docs = searcher.search(query, MAX_HITS)
return docs.scoreDocs.map { searcher.doc(it.doc).getField(ID).numericValue().toInt() }.toList()
}
}

View File

@@ -0,0 +1,68 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.*
import java.util.concurrent.TimeUnit
abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
private val delay = TimeUnit.HOURS.toMillis(1L)
private val client = OkHttpClient()
abstract val defaultURL: URL
open val active = true
val cacheFile = RatingsManager.path.resolve("${origin.name}.json").toFile()
lateinit var players: Json.Array
private var updated = false
val url: URL by lazy {
WebappManager.getProperty("ratings.${origin.name.lowercase(Locale.ROOT)}")?.let { URL(it) } ?: defaultURL
}
fun updateIfNeeded(): Boolean {
return if (Date().time - cacheFile.lastModified() > delay) {
RatingsManager.logger.info("Updating $origin cache from $url")
val payload = fetchPayload()
players = parsePayload(payload).also {
val cachePayload = it.toString()
cacheFile.printWriter().use { out ->
out.println(cachePayload)
}
}
true
} else if (!this::players.isInitialized) {
players = Json.parse(cacheFile.readText())?.asArray() ?: Json.Array()
true
} else {
false
}
}
fun fetchPlayers(): Json.Array {
updated = updateIfNeeded()
return players
}
protected fun fetchPayload(): String {
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Error("Could not fetch $origin ratings: unexpected code $response")
val contentType = response.headers["Content-Type"]?.toMediaType()
return response.body!!.source().readString(contentType?.charset() ?: defaultCharset())
}
}
open fun defaultCharset() = StandardCharsets.UTF_8
fun updated() = updated
abstract fun parsePayload(payload: String): Json.Array
val logger = LoggerFactory.getLogger(origin.name)
val atom = "[-._`'a-zA-ZÀ-ÿ]"
}

View File

@@ -0,0 +1,87 @@
package org.jeudego.pairgoth.ratings
import com.republicate.kson.Json
import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory
import java.lang.Exception
import java.nio.file.Path
import java.util.*
import java.util.concurrent.locks.ReadWriteLock
import java.util.concurrent.locks.ReentrantReadWriteLock
object RatingsManager: Runnable {
enum class Ratings(val flag: Int) {
AGA(1),
EGF(2),
FFG(4);
companion object {
fun valueOf(mask: Int): Ratings {
if (mask.countOneBits() != 1) throw Error("wrong use")
return values().filter { it.flag == mask }.firstOrNull() ?: throw Error("wrong mask")
}
fun codeOf(mask: Int) = valueOf(mask).name.lowercase(Locale.ROOT)
}
}
val ratingsHandlers by lazy {
mapOf(
Pair(Ratings.AGA, AGARatingsHandler),
Pair(Ratings.EGF, EGFRatingsHandler),
Pair(Ratings.FFG, FFGRatingsHandler)
);
}
fun activeMask() = ratingsHandlers.entries.filter { it.value.active }.map { it.key.flag }.reduce { a,b -> a or b }
val timer = Timer()
lateinit var players: Json.MutableArray
val updateLock: ReadWriteLock = ReentrantReadWriteLock()
override fun run() {
logger.info("launching ratings manager")
timer.scheduleAtFixedRate(Task, 0L, 3600000L)
}
object Task: TimerTask() {
override fun run() {
try {
players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
ratings.fetchPlayers()
}
val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
u1 or u2
}
if (updated) {
try {
updateLock.writeLock().lock()
index.build(players)
} finally {
updateLock.writeLock().unlock()
}
}
} catch (e: Exception) {
logger.error("could not build or refresh index", e)
}
}
}
val logger = LoggerFactory.getLogger("ratings")
val path = Path.of(WebappManager.getProperty("ratings.path") ?: "ratings").also {
val file = it.toFile()
if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.ratings.path must be a directory")
}
fun search(needle: String, aga: Boolean, egf: Boolean, ffg: Boolean, country: String?): Json.Array {
try {
updateLock.readLock().lock()
var mask = 0
if (aga && ratingsHandlers[Ratings.AGA]!!.active) mask = mask or Ratings.AGA.flag
if (egf && ratingsHandlers[Ratings.EGF]!!.active) mask = mask or Ratings.EGF.flag
if (ffg && ratingsHandlers[Ratings.FFG]!!.active) mask = mask or Ratings.FFG.flag
val matches = index.match(needle, mask, country)
return matches.map { it -> players[it] }.toCollection(Json.MutableArray())
} finally {
updateLock.readLock().unlock()
}
}
val index = PlayerIndex()
}

View File

@@ -80,11 +80,15 @@ class Translator private constructor(private val iso: String) {
if (groupStart == -1) throw RuntimeException("unexpected case")
if (groupStart > start) output.print(text.substring(start, groupStart))
val capture = matcher.group(group)
var token: String = StringEscapeUtils.unescapeHtml4(capture)
// CB TODO - unescape and escape steps removed, because it breaks text blocks containing unescaped quotes.
// See how it impacts the remaining.
var token: String = capture // StringEscapeUtils.unescapeHtml4(capture)
if (StringUtils.containsOnly(token, "\r\n\t -;:.'\"/<>\u00A00123456789€[]!")) output.print(capture) else {
token = normalize(token)
token = translate(token)
output.print(StringEscapeUtils.escapeHtml4(token))
output.print(token) // (StringEscapeUtils.escapeHtml4(token))
}
val groupEnd = matcher.end(group)
if (groupEnd < end) output.print(text.substring(groupEnd, end))
@@ -132,7 +136,7 @@ class Translator private constructor(private val iso: String) {
get() = textAccessor[this] as String
set(value: String) { textAccessor[this] = value }
private val saveMissingTranslations = System.getProperty("pairgoth.env") == "dev"
private val saveMissingTranslations = System.getProperty("pairgoth.webapp.env") == "dev"
private val missingTranslations: MutableSet<String> = ConcurrentSkipListSet()
private fun reportMissingTranslation(enText: String) {
@@ -145,7 +149,7 @@ class Translator private constructor(private val iso: String) {
private val logger = LoggerFactory.getLogger("translation")
private val translatedTemplates: MutableMap<Pair<String, String>, Template> = ConcurrentHashMap<Pair<String, String>, Template>()
private val textExtractor = Pattern.compile(
"<[^>]+\\splaceholder=\"(?<placeholder>[^\"]*)\"[^>]*>|(?<=>)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<|$)|(?<=>|^)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text2>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<)|^(?:[ \\r\\n\\t /-]|&nbsp;|&dash;)*(?<text3>[^<>]+?)(?:[ \\r\\n\\t /-]|&nbsp;|&dash;)*(?=$)",
"<[^>]+\\s(?:placeholder|title)=\"(?<placeholder>[^\"]*)\"[^>]*>|(?<=>)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<|$)|(?<=>|^)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?<text2>[^<>]+?)(?:[ \\r\\n\\t\u00A0/-]|&nbsp;|&dash;)*(?=<)|^(?:[ \\r\\n\\t /-]|&nbsp;|&dash;)*(?<text3>[^<>]+?)(?:[ \\r\\n\\t /-]|&nbsp;|&dash;)*(?=$)",
Pattern.DOTALL
)
private val ignoredTags = setOf("head", "script", "style")
@@ -168,6 +172,7 @@ class Translator private constructor(private val iso: String) {
val providedLanguages = setOf("en", "fr")
const val defaultLanguage = "en"
const val defaultLocale = "en"
internal fun notifyExiting() {
translators.values.filter {

View File

@@ -6,27 +6,37 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST
import org.slf4j.LoggerFactory
class ApiTool {
companion object {
const val JSON = "application/json"
val apiRoot =
System.getProperty("pairgoth.api.url")?.let { "${it.removeSuffix("/")}/" }
?: System.getProperty("pairgoth.webapp.url")?.let { "${it.removeSuffix("/")}/api/" }
val apiRoot = System.getProperty("pairgoth.api.external.url")?.let { "${it.removeSuffix("/")}/" }
?: throw Error("no configured API url")
val logger = LoggerFactory.getLogger("api")
}
private val client = OkHttpClient()
private fun prepare(url: String) = Request.Builder().url("$apiRoot$url").header("Accept", JSON)
private fun Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType())
private fun Request.Builder.process(): Json {
client.newCall(build()).execute().use { response ->
if (response.isSuccessful) {
when (response.body?.contentType()?.subtype) {
null -> throw Error("null body or content type")
"json" -> return Json.parse(response.body!!.string()) ?: throw Error("could not parse json")
else -> throw Error("unhandled content type: ${response.body!!.contentType()}")
try {
return client.newCall(build()).execute().use { response ->
if (response.isSuccessful) {
when (response.body?.contentType()?.subtype) {
null -> throw Error("null body or content type")
"json" -> Json.parse(response.body!!.string()) ?: throw Error("could not parse json")
else -> throw Error("unhandled content type: ${response.body!!.contentType()}")
}
} else {
when (response.body?.contentType()?.subtype) {
"json" -> Json.parse(response.body!!.string()) ?: throw Error("could not parse error json")
else -> throw Error("${response.code} ${response.message}")
}
}
} else throw Error("api call failed: ${response.code} ${response.message}")
}
} catch (e: Throwable) {
logger.error("api call failed", e)
return Json.Object("error" to e.message)
}
}

View File

@@ -0,0 +1,239 @@
package org.jeudego.pairgoth.view
import org.apache.velocity.tools.config.ValidScope
import org.jeudego.pairgoth.web.WebappManager
import javax.servlet.http.HttpServletRequest
@ValidScope("request")
class CountriesTool {
public var country: Pair<String, String>? = null
public fun configure(params: Map<*, *>) {
val request = params["request"]!! as HttpServletRequest
country = request.getHeader("Accept-Language")?.let { header ->
langHeaderParser.find(header)
}?.let { match ->
match.groupValues.getOrNull(2)?.lowercase() ?: match.groupValues[1].lowercase()
}?.let { iso ->
countries[iso]?.let { name ->
Pair(iso, name)
}
}
}
public fun getCountries() = countries.entries
companion object {
private val langHeaderParser = Regex("(?:\\b(\\*|[a-z]{2})(?:(?:_|-)([a-z]{2}))?)(?:;q=([0-9.]+))?", RegexOption.IGNORE_CASE)
public val countries = mapOf(
"ad" to "Andorra",
"ae" to "United Arab Emirates",
"af" to "Afghanistan",
"ai" to "Anguilla",
"al" to "Albania",
"am" to "Armenia",
"ao" to "Angola",
"ar" to "Argentina",
"at" to "Austria",
"au" to "Australia",
"aw" to "Aruba",
"az" to "Azerbaijan",
"ba" to "Bosnia and Herzegovina",
"bb" to "Barbados",
"bd" to "Bangladesh",
"be" to "Belgium",
"bf" to "Burkina Faso",
"bg" to "Bulgaria",
"bh" to "Bahrain",
"bi" to "Burundi",
"bj" to "Benin",
"bl" to "Saint Barthélemy",
"bm" to "Bermuda",
"bo" to "Bolivia",
"br" to "Brazil",
"bs" to "Bahamas",
"bt" to "Bhutan",
"bw" to "Botswana",
"by" to "Belarus",
"bz" to "Belize",
"ca" to "Canada",
"cd" to "Dem. Rep. of the Congo",
"cf" to "Central African Republic",
"cg" to "Congo",
"ch" to "Switzerland",
"ci" to "Ivory Coast",
"cl" to "Chile",
"cm" to "Cameroon",
"cn" to "China",
"co" to "Colombia",
"cr" to "Costa Rica",
"cu" to "Cuba",
"cv" to "Cabo Verde",
"cw" to "Curaçao",
"cy" to "Cyprus",
"cz" to "Czechia",
"de" to "Germany",
"dj" to "Djibouti",
"dk" to "Denmark",
"dm" to "Dominica",
"do" to "Dominican Republic",
"dz" to "Algeria",
"ec" to "Ecuador",
"ee" to "Estonia",
"eg" to "Egypt",
"eh" to "Western Sahara*",
"er" to "Eritrea",
"es" to "Spain",
"et" to "Ethiopia",
"fi" to "Finland",
"fj" to "Fiji",
"fr" to "France",
"ga" to "Gabon",
"gb" to "United Kingdom",
"gd" to "Grenada",
"ge" to "Georgia",
"gg" to "Guernsey",
"gh" to "Ghana",
"gi" to "Gibraltar",
"gl" to "Greenland",
"gm" to "Gambia",
"gn" to "Guinea",
"gq" to "Equatorial Guinea",
"gr" to "Greece",
"gt" to "Guatemala",
"gu" to "Guam",
"gw" to "Guinea-Bissau",
"gy" to "Guyana",
"hk" to "Hong Kong",
"hn" to "Honduras",
"hr" to "Croatia",
"ht" to "Haiti",
"hu" to "Hungary",
"id" to "Indonesia",
"ie" to "Ireland",
"il" to "Israel",
"im" to "Isle of Man",
"in" to "India",
"iq" to "Iraq",
"ir" to "Iran",
"is" to "Iceland",
"it" to "Italy",
"je" to "Jersey",
"jm" to "Jamaica",
"jo" to "Jordan",
"jp" to "Japan",
"ke" to "Kenya",
"kg" to "Kyrgyzstan",
"kh" to "Cambodia",
"ki" to "Kiribati",
"km" to "Comoros",
"kp" to "North Korea",
"kr" to "South Korea",
"kw" to "Kuwait",
"kz" to "Kazakhstan",
"la" to "Lao People's Dem. Rep.",
"lb" to "Lebanon",
"li" to "Liechtenstein",
"lk" to "Sri Lanka",
"lr" to "Liberia",
"ls" to "Lesotho",
"lt" to "Lithuania",
"lu" to "Luxembourg",
"lv" to "Latvia",
"ly" to "Libya",
"ma" to "Morocco",
"mc" to "Monaco",
"md" to "Moldova",
"me" to "Montenegro",
"mg" to "Madagascar",
"mk" to "Macedonia",
"ml" to "Mali",
"mm" to "Myanmar",
"mn" to "Mongolia",
"mo" to "Macao",
"mq" to "Martinique",
"mr" to "Mauritania",
"ms" to "Montserrat",
"mt" to "Malta",
"mu" to "Mauritius",
"mv" to "Maldives",
"mw" to "Malawi",
"mx" to "Mexico",
"my" to "Malaysia",
"mz" to "Mozambique",
"na" to "Namibia",
"nc" to "New Caledonia",
"ne" to "Niger",
"ng" to "Nigeria",
"ni" to "Nicaragua",
"nl" to "The Netherlands",
"no" to "Norway",
"np" to "Nepal",
"nr" to "Nauru",
"nu" to "Niue",
"nz" to "New Zealand",
"om" to "Oman",
"pa" to "Panama",
"pe" to "Peru",
"pf" to "French Polynesia",
"pg" to "Papua New Guinea",
"ph" to "The Philippines",
"pk" to "Pakistan",
"pl" to "Poland",
"pn" to "Pitcairn",
"pr" to "Puerto Rico",
"pt" to "Portugal",
"pw" to "Palau",
"py" to "Paraguay",
"qa" to "Qatar",
"re" to "Réunion",
"ro" to "Romania",
"rs" to "Serbia",
"ru" to "Russia",
"rw" to "Rwanda",
"sa" to "Saudi Arabia",
"sc" to "Seychelles",
"sd" to "Sudan",
"se" to "Sweden",
"sg" to "Singapore",
"si" to "Slovenia",
"sk" to "Slovakia",
"sl" to "Sierra Leone",
"sm" to "San Marino",
"sn" to "Senegal",
"so" to "Somalia",
"sr" to "Suriname",
"ss" to "South Sudan",
"sv" to "El Salvador",
"sy" to "Syrian Arab Republic",
"sz" to "Swaziland",
"td" to "Chad",
"tg" to "Togo",
"th" to "Thailand",
"tj" to "Tajikistan",
"tm" to "Turkmenistan",
"tn" to "Tunisia",
"to" to "Tonga",
"tr" to "Turkey",
"tv" to "Tuvalu",
"tw" to "Taiwan",
"tz" to "Tanzania",
"ua" to "Ukraine",
"ug" to "Uganda",
"us" to "United States of America",
"uy" to "Uruguay",
"uz" to "Uzbekistan",
"ve" to "Venezuela",
"vn" to "Viet Nam",
"vu" to "Vanuatu",
"ws" to "Samoa",
"xk" to "Kosovo",
"ye" to "Yemen",
"yt" to "Mayotte",
"za" to "South Africa",
"zm" to "Zambia",
"zw" to "Zimbabwe"
)
}
}

View File

@@ -0,0 +1,11 @@
package org.jeudego.pairgoth.view
import com.republicate.kson.Json
/**
* Generic utilities
*/
class PairgothTool {
public fun toMap(array: Json.Array) = array.map { ser -> ser as Json.Object }.associateBy { it.getLong("id")!! }
}

View File

@@ -21,6 +21,14 @@ class TranslationTool {
}.let { Pair(iso, it) }
}
val defaultCountry = Translator.providedLanguages.associate { iso ->
when (iso) {
"en" -> "gb"
"zh" -> "cn"
else -> iso
}.let { Pair(iso, it) }
}
fun url(request: HttpServletRequest, lang: String): String {
val out = StringBuilder()
out.append(request.requestURL.replaceFirst(Regex("://"), "://$lang/"))
@@ -29,7 +37,12 @@ class TranslationTool {
return out.toString()
}
fun datepickerLocale(language: String, locale: String) =
if (datepickerLocales.contains(locale)) locale
else language
companion object {
val datepickerLocales = setOf("ar-DZ", "ar", "ar-TN", "az", "bg", "bm", "bn", "br", "bs", "ca", "cs", "cy", "da", "de", "el", "en-AU", "en-CA", "en-GB", "en-IE", "en-NZ", "en-ZA", "eo", "es", "et", "eu", "fa", "fi", "fo", "fr-CH", "fr", "gl", "he", "hi", "hr", "hu", "hy", "id", "is", "it-CH", "it", "ja", "ka", "kk", "km", "ko", "lt", "lv", "me", "mk", "mn", "mr", "ms", "nl-BE", "nl", "no", "oc", "pl", "pt-BR", "pt", "ro", "ru", "si", "sk", "sl", "sq", "sr", "sr-latn", "sv", "sw", "ta", "tg", "th", "tk", "tr", "uk", "uz-cyrl", "uz-latn", "vi", "zh-CN", "zh-TW")
val translator = ThreadLocal<Translator>()
}
}

View File

@@ -0,0 +1,25 @@
package org.jeudego.pairgoth.web
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.proxy.AsyncProxyServlet;
import javax.servlet.http.HttpServletRequest
class ApiServlet : AsyncProxyServlet() {
override fun addProxyHeaders(clientRequest: HttpServletRequest, proxyRequest: Request) {
// proxyRequest.header("X-EGC-User", some user id...)
}
override fun rewriteTarget(clientRequest: HttpServletRequest): String {
val uri = clientRequest.requestURI
if (!uri.startsWith("/api/")) throw Error("unhandled API uri: $uri")
val path = uri.substringAfter("/api")
val qr = clientRequest.queryString?.let { "?$it" } ?: ""
return "$apiRoot$path$qr"
}
companion object {
private val apiRoot = System.getProperty("pairgoth.api.external.url")?.let { "${it.removeSuffix("/")}" }
?: throw Error("no configured API url")
}
}

View File

@@ -32,7 +32,7 @@ class DispatchingFilter : Filter {
when {
uri.endsWith('/') -> response.sendRedirect("${uri}index")
uri.contains('.') ->
if (uri.endsWith(".html")) resp.sendError(404)
if (uri.endsWith(".html") || uri.contains(".inc.")) resp.sendError(404)
else defaultRequestDispatcher.forward(request, response)
else -> chain.doFilter(request, response)
}

View File

@@ -2,8 +2,10 @@ package org.jeudego.pairgoth.web
import org.jeudego.pairgoth.util.Translator
import org.jeudego.pairgoth.util.Translator.Companion.defaultLanguage
import org.jeudego.pairgoth.util.Translator.Companion.defaultLocale
import org.jeudego.pairgoth.util.Translator.Companion.providedLanguages
import org.jeudego.pairgoth.view.TranslationTool
import java.util.*
import javax.servlet.Filter
import javax.servlet.FilterChain
import javax.servlet.FilterConfig
@@ -22,48 +24,63 @@ class LanguageFilter : Filter {
override fun doFilter(req: ServletRequest, resp: ServletResponse, chain: FilterChain) {
val request = req as HttpServletRequest
val response = resp as HttpServletResponse
val uri = request.requestURI
if (uri.startsWith("/api/")) {
chain.doFilter(request, response)
return
}
val match = langPattern.matchEntire(uri)
val askedLanguage = match?.groupValues?.get(1)
val target = match?.groupValues?.get(2) ?: uri
val reqLang = request.getAttribute("lang") as String?
if (reqLang != null) {
// this is a forwarded request, language and locale should already have been set
TranslationTool.translator.set(Translator.getTranslator(reqLang))
chain.doFilter(request, response)
} else {
val uri = request.requestURI
val match = langPattern.matchEntire(uri)
val lang = match?.groupValues?.get(1)
val target = match?.groupValues?.get(2) ?: uri
if (lang != null && providedLanguages.contains(lang)) {
val (preferredLanguage, preferredLocale) = parseLanguageHeader(request)
if (askedLanguage != null && providedLanguages.contains(askedLanguage)) {
// the target URI contains a language we provide
request.setAttribute("lang", lang)
request.setAttribute("lang", askedLanguage)
request.setAttribute("loc",
if (askedLanguage == preferredLanguage) preferredLocale
else askedLanguage
)
request.setAttribute("target", target)
filterConfig!!.servletContext.getRequestDispatcher(target).forward(request, response)
} else {
// the request must be redirected
val preferredLanguage = getPreferredLanguage(request)
val destination = if (lang != null) target else uri
val destination = if (askedLanguage != null) target else uri
response.sendRedirect("/${preferredLanguage}${destination}")
}
}
}
private fun getPreferredLanguage(request: HttpServletRequest): String {
return (request.session.getAttribute("lang") as String?) ?:
( langHeaderParser.findAll(request.getHeader("Accept-Language") ?: "").filter {
/**
* Returns Pair(language, locale)
*/
private fun parseLanguageHeader(request: HttpServletRequest): Pair<String, String> {
langHeaderParser.findAll(request.getHeader("Accept-Language") ?: "").filter {
providedLanguages.contains(it.groupValues[1])
}.sortedByDescending {
it.groupValues[2].toDoubleOrNull() ?: 1.0
}.firstOrNull()?.let {
it.groupValues[1]
} ?: defaultLanguage ).also {
request.session.setAttribute("lang", it)
it.groupValues[3].toDoubleOrNull() ?: 1.0
}.firstOrNull()?.let { match ->
val lang = match.groupValues[1].let { if (it == "*") defaultLanguage else it }
val variant = match.groupValues.getOrNull(2)?.lowercase(Locale.ROOT)
// by convention, the variant is only kept if different from the language (fr-FR => fr)
val locale = variant?.let { if (lang == variant) lang else "$lang-${variant.uppercase(Locale.ROOT)}" } ?: lang
return Pair(lang, locale)
}
return Pair(defaultLanguage, defaultLanguage)
}
override fun destroy() {}
companion object {
private val langPattern = Regex("/([a-z]{2})(/.+)")
private val langHeaderParser = Regex("(?:\\b(\\*|[a-z]{2})(?:_\\w+)?)(?:;q=([0-9.]+))?")
private val langHeaderParser = Regex("(?:\\b(\\*|[a-z]{2})(?:(?:_|-)(\\w+))?)(?:;q=([0-9.]+))?")
}
}

View File

@@ -0,0 +1,159 @@
package org.jeudego.pairgoth.web
import com.republicate.kson.Json
import org.jeudego.pairgoth.ratings.RatingsManager
import org.jeudego.pairgoth.util.Colorizer
import org.jeudego.pairgoth.util.parse
import org.jeudego.pairgoth.util.toString
import org.slf4j.LoggerFactory
import java.io.IOException
import java.util.*
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class SearchServlet: HttpServlet() {
public override fun doPost(request: HttpServletRequest, response: HttpServletResponse) {
val uri = request.requestURI
logger.logRequest(request, !uri.contains(".") && uri.length > 1)
var payload: Json? = null
var reason = "OK"
try {
validateContentType(request)
val query = request.getAttribute(PAYLOAD_KEY) as Json.Object? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload")
val needle = query.getString("needle") ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no needle")
val country = query.getString("countryFilter")
val aga = query.getBoolean("aga") ?: false
val egf = query.getBoolean("egf") ?: false
val ffg = query.getBoolean("ffg") ?: false
payload = RatingsManager.search(needle, aga, egf, ffg, country)
setContentType(response)
payload.toString(response.writer)
} catch (ioe: IOException) {
logger.error(Colorizer.red("could not process call"), ioe)
reason = ioe.message ?: "unknown i/o exception"
error(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, reason, ioe)
} finally {
val builder = StringBuilder()
builder.append(response.status).append(' ')
.append(reason)
if (response.status == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) {
logger.trace(Colorizer.red(">> {}"), builder.toString())
} else {
logger.trace(Colorizer.green(">> {}"), builder.toString())
}
// CB TODO - should be bufferized and asynchronously written in synchronous chunks
// so that header lines from parallel requests are not mixed up in the logs ;
// synchronizing the whole request log is not desirable
for (header in response.headerNames) {
val value = response.getHeader(header)
logger.trace(Colorizer.green(">> {}: {}"), header, value)
}
if (payload != null) {
try {
logger.logPayload(">> ", payload, false)
} catch (ioe: IOException) {
}
}
}
}
@Throws(ApiException::class)
protected fun validateContentType(request: HttpServletRequest) {
// extract content type parts
val contentType = request.contentType
if (contentType == null) {
if (request.method == "GET") return
throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"no content type header"
)
}
val sep = contentType.indexOf(';')
val mimeType: String
var charset: String? = null
if (sep == -1) mimeType = contentType else {
mimeType = contentType.substring(0, sep).trim { it <= ' ' }
val params =
contentType.substring(sep + 1).split("=".toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray()
if (params.size == 2 && params[0].lowercase(Locale.getDefault())
.trim { it <= ' ' } == "charset"
) {
charset = params[1].lowercase(Locale.getDefault()).trim { it <= ' ' }
.replace("-".toRegex(), "")
}
}
// check charset
if (charset != null && EXPECTED_CHARSET != charset.lowercase(Locale.ROOT).replace("-", "")) throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"UTF-8 content expected"
)
// check content type
if (isJson(mimeType)) {
// put Json body as request attribute
try {
Json.parse(request.reader)?.let { payload: Json ->
request.setAttribute(PAYLOAD_KEY, payload)
if (logger.isInfoEnabled) {
logger.logPayload("<< ", payload, true)
}
}
} catch (ioe: IOException) {
throw ApiException(HttpServletResponse.SC_BAD_REQUEST, ioe)
}
}
else throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"JSON content expected"
)
}
protected fun error(
request: HttpServletRequest,
response: HttpServletResponse,
code: Int,
message: String?,
cause: Throwable? = null
) {
try {
if (code == 500 || response.isCommitted) {
logger.error(
"Request {} {} gave error {} {}",
request.method,
request.requestURI,
code,
message,
cause
)
}
response.status = code
if (response.isCommitted) return
val errorPayload = Json.Object(
"success" to false,
"error" to (message ?: "unknown error")
)
setContentType(response)
errorPayload.toString(response.writer)
} catch (ioe: IOException) {
logger.error("Could not send back error", ioe)
}
}
protected fun setContentType(response: HttpServletResponse) {
response.contentType = "application/json; charset=UTF-8"
}
companion object {
private var logger = LoggerFactory.getLogger("search")
private const val EXPECTED_CHARSET = "utf8"
const val PAYLOAD_KEY = "PAYLOAD"
fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json")
}
}

View File

@@ -5,6 +5,7 @@ import org.apache.velocity.context.Context
import org.apache.velocity.exception.ResourceNotFoundException
import org.apache.velocity.tools.view.ServletUtils
import org.apache.velocity.tools.view.VelocityViewServlet
import org.slf4j.LoggerFactory
import java.io.File
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
@@ -95,7 +96,14 @@ class ViewServlet : VelocityViewServlet() {
}
override fun doRequest(request: HttpServletRequest, response: HttpServletResponse ) {
// val uri = request.requestURI
logger.logRequest(request) //, !uri.contains(".") && uri.length > 1)
super.doRequest(request, response)
}
companion object {
private var logger = LoggerFactory.getLogger("view")
private const val STANDARD_LAYOUT = "/WEB-INF/layouts/standard.html"
}
}

View File

@@ -1,15 +1,13 @@
package org.jeudego.pairgoth.web
import com.republicate.mailer.SmtpLoop
import org.apache.commons.lang3.tuple.Pair
import org.jeudego.pairgoth.ratings.RatingsManager
import org.jeudego.pairgoth.util.Translator
import org.slf4j.LoggerFactory
import java.io.IOException
import java.lang.IllegalAccessError
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.*
import java.util.IllegalFormatCodePointException
import javax.net.ssl.*
import javax.servlet.*
import javax.servlet.annotation.WebListener
@@ -53,7 +51,9 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
override fun contextInitialized(sce: ServletContextEvent) {
context = sce.servletContext
logger.info("---------- Starting $WEBAPP_NAME ----------")
context.setAttribute("manager", this)
logger.info("info level is active")
logger.debug("debug level is active")
logger.trace("trace level is active")
webappRoot = context.getRealPath("/")
try {
// load default properties
@@ -64,7 +64,11 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
properties[(key as String).removePrefix(PAIRGOTH_PROPERTIES_PREFIX)] = value
}
logger.info("Using profile {}", properties.getProperty("webapp.env"))
val env = properties.getProperty("env")
logger.info("Using profile {}", )
// let the view be aware of the environment
context.setAttribute("env", env)
// set system user agent string to empty string
System.setProperty("http.agent", "")
@@ -73,6 +77,9 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
// fail to correctly implement SSL...
disableSSLCertificateChecks()
registerService("ratings", RatingsManager)
startService("ratings")
} catch (ioe: IOException) {
logger.error("webapp initialization error", ioe)
}
@@ -111,10 +118,10 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
return properties.getProperty(prop)
}
fun getMandatoryProperty(prop: String): String {
return properties.getProperty(prop) ?: throw Error("missing property: ${prop}")
return getProperty(prop) ?: throw Error("missing property: ${prop}")
}
val webappURL by lazy { getProperty("webapp.url") }
val webappURL by lazy { getProperty("webapp.external.url") }
private val services = mutableMapOf<String, Pair<Runnable, Thread>>()

View File

@@ -1,3 +1,4 @@
@import "/fonts/hornbill.css";
@import "/lib/fomantic-ui-2.9.2/semantic.min.css" layer(semantic);
@layer pairgoth {
@@ -24,6 +25,20 @@
}
}
.logo {
font-family: Hornbill, serif;
font-weight: bolder;
font-style: normal;
}
.centered {
text-align: center;
}
.nobreak {
white-space: nowrap;
}
/* header, center, footer */
#header {
@@ -42,6 +57,7 @@
}
#center {
position: relative;
flex: 1;
overflow: auto;
#inner {
@@ -63,6 +79,7 @@
}
.section {
text-align: center;
padding: 0.5em;
}
@@ -77,7 +94,7 @@
background-color: black;
opacity: 0;
transition: opacity 0.5s;
z-index: 1000;
z-index: 50;
pointer-events: none;
}
@@ -136,27 +153,31 @@
position: relative;
cursor: pointer;
transform: scale(1.2);
#lang-list {
position: absolute;
display: none;
top:100%;
right: 1em;
flex-flow: column nowrap;
padding: 0.5em;
gap: 0.5em;
background-color: #dddddd;
align-items: flex-start;
z-index: 50;
&.shown {
display: flex;
}
a {
display: inline-block;
white-space: nowrap;
text-align: center;
i {
vertical-align: middle;
}
}
#lang-list {
position: fixed;
display: none;
top:3em;
right: 1em;
flex-flow: column nowrap;
padding: 0.5em;
gap: 0.5em;
background-color: #dddddd;
align-items: flex-start;
z-index: 60;
&.shown {
display: flex;
}
.lang {
cursor: pointer;
}
a {
display: inline-block;
white-space: nowrap;
text-align: center;
i {
vertical-align: middle;
}
}
}
@@ -164,6 +185,7 @@
/* UI fixes */
.ui.form, .ui.segment, .ui.form .field > label { font-size: 1em; }
.ui.form .fields { }
span > input[type="radio"] { vertical-align: text-top; }
span > input[type="text"] { vertical-align: baseline; width: initial; }
span > input.date { vertical-align: baseline; width: 8em; }
@@ -175,10 +197,150 @@
.step:last-child { padding-right: 1em; }
.step .description { display: none; }
.ui.form input[type=text], .ui.form input[type="number"], .ui.form select {
padding: 0.4em 0.2em;
}
.ui.form input[type="number"], input.duration {
text-align: center;
}
.form-actions {
position: sticky;
bottom: 1em;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
}
input[type="number"] {
padding: 0.2em 0.1em 0.2em 1em;
vertical-align: baseline;
width: 3.5em;
}
.hidden {
display: none;
}
.roundbox {
border: solid 2px darkgray;
border-radius: 10px;
margin: 1em;
padding: 1em;
}
#backdrop {
display: none;
&.active {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
background-color: rgba(0,0,0,0.2);
cursor: wait;
}
}
#backdrop.active {
}
#feedback {
position: absolute;
top: 1em;
left: 50%;
font-weight: bold;
}
#success, #error {
position: relative;
left: -50%;
border-radius: 10px;
padding: 0.5em 1em;
z-index: 100;
}
#success {
background: lightgreen;
border: solid 2px green;
color: green;
}
#error {
background: lightcoral;
border: solid 2px red;
color: red;
}
body.initial {
.popup-body {
transition: initial;
}
}
.popup {
position: fixed;
top: 10vh;
left: 50%;
transform: translate(-50%, 0px);
max-width: max(90vw, 800px);
max-height: 80vh;
margin: auto;
z-index: 100;
pointer-events: none;
div.close {
position: absolute;
width: 30px;
height: 30px;
top: -10px;
right: -10px;
cursor: pointer;
.icon {
background-color: white;
box-shadow: 0px 2px 4px 0px rgb(0 0 0 / 34%);
}
}
.popup-body {
max-height: 80vh;
max-width: 80vw;
transform: rotate3d(1, 0, 0, 90deg);
transition: transform 1s cubic-bezier(0.500, 0.250, 0.300, 1.650);
background-color: white;
padding: 1em;
border-radius: 5px;
overflow-x: hidden;
overflow-y: auto;
.popup-content {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
text-align: justify;
max-height: inherit;
}
.popup-footer {
position: relative;
text-align: justify;
display: flex;
flex-flow: row wrap;
justify-content: space-around;
gap: 2em;
align-items: center;
}
}
&.shown {
pointer-events: initial;
transition: transform 1s cubic-bezier(0.500, 0.250, 0.300, 1.650);
display: block;
.popup-body {
transform: rotate3d(1, 0, 0, 0deg);
}
}
}
.clickable {
pointer-events: all;
cursor: pointer;
}
}

View File

@@ -0,0 +1,225 @@
@layer pairgoth {
/* general rules */
.steps .step:not(.active) {
cursor: pointer;
}
.tab-content {
display: none;
&.active {
display: block;
}
}
/* information section */
form {
input, select, .edit {
display: none;
}
&.edit {
input:not(.hidden), select:not(.hidden), .edit:not(.hidden) {
display: initial;
}
.info, #edit {
display: none;
}
}
}
div.field:not(.hidden) {
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
margin: 1px;
background-color: #eeeeee;
&.centered {
align-items: center;
}
}
.inline.fields {
background-color: #eeeeee;
margin-left: -0.5em;
margin-right: -0.5em;
padding-left: 0.5em;
padding-right: 0.5em;
.centered.field > label {
margin-right: 0;
}
}
/* registration section */
#player {
&.create {
.edition {
display: none;
}
#unregister {
display: none;
}
}
&.edit {
.creation {
display: none;
}
}
}
#player-form {
&:not(.add) {
#search-form, #search-result {
display: none;
}
}
}
#search-form {
position: relative;
.toggle {
cursor: pointer;
input {
display: none;
}
label {
display: block;
text-align: center;
cursor: pointer;
}
.checkbox {
width: 50px;
height: 26px;
border-radius: 18px;
background-color: #F7D6A3;
display: flex;
align-items: center;
padding-left: 5px;
padding-right: 5px;
cursor: pointer;
.circle {
background-color: #6B5E8A;
transform: translateX(0px);
border-radius: 50%;
width: 20px;
height: 20px;
transition: 300ms;
}
}
input:checked + .checkbox {
background-color: rgb(218, 114, 80);
.circle {
transform: translateX(20px);
}
}
}
}
#search-result {
position: absolute;
background-color: gray;
z-index: 2;
width:100%;
top: 100%;
padding: 1em;
overflow-y: auto;
&:empty {
display: none;
}
.result-line {
cursor: pointer;
&:hover, &.highlighted {
background-color: rgba(100,200,255,200);
}
}
}
#player.popup {
min-width: 65vw;
}
/* pairing section */
#pairing-content {
display: flex;
flex-flow: column;
justify-content: start;
align-items: center;
gap: 1em;
}
#pairing-lists {
margin-top: 1em;
display: flex;
flex-flow: row wrap;
justify-content: center;
gap: 1em;
align-items: start;
}
.multi-select {
position: relative;
display: flex;
flex-flow: column nowrap;
overflow-x: hidden;
overflow-y: auto;
min-height: 30vh;
max-height: 60vh;
min-width: 20vw;
max-width: 40vw;
border: solid 2px darkgray;
border-radius: 5px;
padding: 1em 0.5em;
&:before {
position: absolute;
content: attr(title);
top: -0.5em;
left: 50%;
transform: translate(-50%, 0px);
white-space: nowrap;
font-size: smaller;
font-weight: bold;
}
.listitem {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
gap: 1em;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(50, 50, 50, .2);
}
&.selected {
background-color: rgba(100,200,255,200);
cursor: grab;
}
}
}
#pairing-buttons {
display: flex;
flex-flow: column nowrap;
justify-content: start;
align-items: stretch;
gap: 1em;
}
#unpairables {
display: flex;
flex-flow: column nowrap;
min-height: 10vh;
max-height: 30vh;
min-width: 50vw;
}
.choose-round.button {
padding: 0.2em 0.8em;
}
/* results section */
#results-list {
text-align: center;
.player, .result {
cursor: pointer;
&:hover {
background: rgba(0,0,0,.05);
color: rgba(0,0,0,.95);
}
}
}
}

View File

@@ -1,31 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<html lang="${request.lang}">
<head>
<meta charset="UTF-8">
<title>Pairgoth</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Pairgoth Go Paring Engine">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/lib/fork-awesome-1.2.0/fork-awesome.min.css">
<link rel="stylesheet" href="/css/main.css">
<script type="text/javascript" src="/js/domhelper.js"></script>
<script type="text/javascript">
// #[[
let initFunctions = [];
function onLoad(fct) {
if (typeof(fct) == "function") initFunctions.push(fct);
}
document.addEventListener('DOMContentLoaded', () => {
initFunctions.forEach(fct => {
fct();
});
});
// ]]#
</script>
</head>
<body class="vert flex">
<body class="vert flex initial">
#* Debugging code to list all web context properties
<blockquote>
#foreach($attr in $application.getAttributeNames())
<div>$attr = $application.getAttribute($attr)</div>
#end
</blockquote>
*#
<div id="header" class="horz flex">
<div id="logo">
<img src="/img/logo.svg"/>
<img src="/img/logov2.svg"/>
</div>
<div id="title">
</div>
<div id="lang">
<i class="$translate.flags[$request.lang] flag"></i>
<div id="lang-list">
#foreach($lang in $translate.flags.entrySet())
#if($lang != $request.lang)
<a class="lang" data-lang="$lang.key" href="#"><i class="$lang.value flag"></i>&nbsp;$lang.key</a>
#end
#end
</div>
</div>
</div>
<div id="center">
@@ -37,15 +47,38 @@
<div id="version">pairgoth v0.1</div>
<div id="contact"><a href="mailto:pairgoth@jeudego.org">contact</a></div>
</div>
<div id="feedback">
<div id="success" class="hidden"></div>
<div id="error" class="hidden"></div>
</div>
<div id="backdrop"></div>
<div id="dimmer"></div>
<script type="text/javascript" src="/js/store2-2.14.2.min.js"></script>
<script type="text/javascript" src="/js/tablesort-5.4.0.min.js"></script>
<script type="text/javascript" src="/js/formproxy.js"></script>
<div id="lang-list">
#foreach($lang in $translate.flags.entrySet())
#if($lang != $request.lang)
<a class="lang" data-lang="$lang.key" href="#"><i class="$lang.value flag"></i>&nbsp;$lang.key</a>
#end
#end
</div>
<script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>
<link rel="stylesheet" href="/lib/tablesort-5.4.0/tablesort.css"/>
<script type="text/javascript" src="/lib/imaskjs-7.1.3/imask.min.js"></script>
<script type="text/javascript" src="/js/api.js"></script>
<script type="text/javascript" src="/js/main.js"></script>
<script type="text/javascript" src="/js/domhelper.js"></script>
<link rel="stylesheet" href="/lib/fork-awesome-1.2.0/fork-awesome.min.css"/>
<link rel="stylesheet" href="/css/main.css"/>
#if($css)
<link rel="stylesheet" href="$css"/>
#end
<script type="text/javascript">
const lang = '${request.lang}';
const locale = '${request.locale}';
// #[[
onLoad(() => {
$('body').removeClass('initial');
$('#lang').on('click', e => {
$('#lang-list').toggleClass('shown');
});
@@ -59,7 +92,26 @@
$('#lang-list').removeClass('shown');
}
});
$('.popup .popup-footer .close').on('click', e => {
let popup = e.target.closest('.popup');
if (popup) {
popup.classList.remove('shown');
$('body').removeClass('dimmed');
}
});
});
// syntaxic sugar for IMask
NodeList.prototype.imask = function(options) {
this.forEach(function (elem, i) {
elem.imask(options);
});
return this;
}
HTMLInputElement.prototype.imask = function(options) {
IMask(this, options);
}
// ]]#
</script>
</body>

View File

@@ -0,0 +1 @@
level = info

View File

@@ -1,7 +1,19 @@
# webapp
# environment
env = dev
webapp.url = https://localhost:8080
api.url = https://localhost:8085/api
# webapp connector
webapp.protocol = http
webapp.interface = localhost
webapp.port = 8080
webapp.context = /
webapp.external.url = http://localhost:8080
# api connector
api.protocol = http
api.interface = localhost
api.port = 8085
api.context = /api
api.external.url = http://localhost:8085/api
# store
store = file

View File

@@ -3,14 +3,15 @@
<toolbox scope="application">
<tool key="translate" class="org.jeudego.pairgoth.view.TranslationTool"/>
<tool key="strings" class="org.apache.commons.lang3.StringUtils"/>
<tool key="utils" class="org.jeudego.pairgoth.view.PairgothTool"/>
<!--
<tool key="number" format="#0.00"/>
<tool key="date" locale="fr_FR" format="yyyy-MM-dd"/>
<tool key="inflector" class="org.atteo.evo.inflector.English"/>
<tool key="strings" class="org.apache.commons.lang3.StringUtils"/>
-->
</toolbox>
Exception
<toolbox scope="session">
<!--
<tool key="oauth" class="org.jeudego.egc2024.tool.OAuthTool"/>
@@ -19,6 +20,7 @@
<toolbox scope="request">
<tool key="api" class="org.jeudego.pairgoth.view.ApiTool"/>
<tool key="countries" class="org.jeudego.pairgoth.view.CountriesTool"/>
</toolbox>
</tools>

View File

@@ -59,6 +59,18 @@
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<servlet>
<servlet-name>api</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.ApiServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<servlet>
<servlet-name>search</servlet-name>
<servlet-class>org.jeudego.pairgoth.web.SearchServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<!-- servlet mappings -->
<servlet-mapping>
@@ -69,10 +81,30 @@
<servlet-name>sse</servlet-name>
<url-pattern>/events/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>api</servlet-name>
<url-pattern>/api/tour/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>search</servlet-name>
<url-pattern>/api/search/*</url-pattern>
</servlet-mapping>
<!-- context params -->
<context-param>
<param-name>webapp-slf4j-logger.format</param-name>
<param-value>%logger [%level] [%ip] %message @%file:%line:%column</param-value>
</context-param>
<context-param>
<param-name>org.apache.velocity.tools.loadDefaults</param-name>
<param-value>true</param-value>
</context-param>
<context-param>
<param-name>org.apache.velocity.tools.cleanConfiguration</param-name>
<param-value>true</param-value>
</context-param>
<context-param>
<param-name>org.apache.velocity.tools.userCanOverwriteTools</param-name>
<param-value>false</param-value>
</context-param>
</web-app>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,42 @@
@font-face {
font-family: Hornbill;
font-style: normal;
font-weight: 400;
src: url(Hornbill-Regular.otf) format("opentype");
}
@font-face {
font-family: Hornbill;
font-style: italic;
font-weight: 400;
src: url(Hornbill-Italic.otf) format("opentype");
}
@font-face {
font-family: Hornbill;
font-style: normal;
font-weight: 700;
src: url(Hornbill-Bold.otf) format("opentype");
}
@font-face {
font-family: Hornbill;
font-style: italic;
font-weight: 700;
src: url(Hornbill-BoldItalic.otf) format("opentype");
}
@font-face {
font-family: Hornbill;
font-style: normal;
font-weight: 900;
src: url(Hornbill-Black.otf) format("opentype");
}
@font-face {
font-family: Hornbill;
font-style: italic;
font-weight: 900;
src: url(Hornbill-BlackItalic.otf) format("opentype");
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,43 @@
<div class="section">
<h1>Welcome to <span class="logo">pairgoth</span>, your Go Pairing Engine!</h1>
<h2>What is <span class="logo">pairgoth</span>?</h2>
<p>At its core, <span class="logo">pairgoth</span> is a versatile Go tournament pairing engine designed to make your tournament experience effortless. <span class="logo">pairgoth</span> is the successor of <a href="https://github.com/lucvannier/opengotha">opengotha</a>, the well known pairing system software developed by <a href="http://vannier.info/jeux/accueil.htm">Luc Vannier</a> and uses the same algorithm internally, as well as import and export features towards its format.</p>
<p><span class="logo">pairgoth</span> version 1.0-BETA supports the <b>Swiss</b></a> pairing system, ideal for championships with no handicap games, as well as the <b>MacMahon</b> pairing system, more suited for classical tournaments and cups.</p>
<p>Future versions will support more pairing systems and more features. <a href="mailto:pairgoth@jeudego.org">Your feedback is welcome!</a></p>
<h2>How to use <span class="logo">pairgoth</span>?</h2>
<p>We offer you the flexibility to use <span class="logo">pairgoth</span> in a way that best suits your needs. Here are your options:</p>
<ol>
<li>
<p><b>Stay in the browser</b>: If you prefer convenience, you can simply use the <span class="logo">pairgoth</span> instance graciously hosted by the French Go Federation.</p>
<blockquote>
<a class="nobreak" href="https://pairgoth.jeudego.org/menu.html">Launch <span class="logo">pairgoth</span></a>
</blockquote>
</li>
<li>
<p><b>Launch a standalone instance</b>: This mode allows you to run <span class="logo">pairgoth</span> on your local computer.</p>
<p>That's the best option if you feel more comfortable when running locally or whenever you want to be able to do the pairing without internet. You can choose to use either the standard interface (which is meant to look a lot like opengotha) and the web interface (by launching the engine and connecting to it using a browser).</p>
<blockquote>
<a class="nobreak" href="https://pairgoth.jeudego.org/download.html#standalone">Download <span class="logo">pairgoth</span> standalone</a>
</blockquote>
</li>
<li>
<p><b>Launch a pairing server</b>: This mode is the best suited for big Go events like congresses, it allows to register players, enter results and manage pairing from several workstations at once.</p>
<blockquote>
<a class="nobreak" href="https://pairgoth.jeudego.org/download.html#server">Download <span class="logo">pairgoth</span> client/server</a>
</blockquote>
</li>
<li>
<p><b>Compile from the sources</b>: the <span class="logo">pairgoth</span> project is fully open source, and under the very permissive <a href="https://www.apache.org/licenses/LICENSE-2.0">apache licence</a>, allowing you to tweak it in any possible way. Be sure to contribute back your enhancements!</p>
<blockquote>
<a class="nobreak" href="https://github.com/ffrgo/pairgoth">Browse <span class="logo">pairgoth</span> sources</a>
</blockquote>
</li>
</ol>
</div>
<script type="text/javascript">
const lang = '${request.lang}';
// #[[
onLoad(() => {
});
// ]]#
</script>

View File

@@ -1,72 +1,15 @@
<div class="section">
<button id="new" class="ui blue icon floating button">
<a href="tour" class="ui blue icon floating button">
<i class="fa fa-plus-square-o"></i>
New tournament
</button>
</a>
</div>
#foreach($tour in $api.get('tour').entrySet())
<div class="section">
<button data-tour-id="$tour.key" class="ui open basic secondary white icon floating button">
$tour
<a href="tour?id=${tour.key}" class="ui open basic secondary white icon floating button">
<i class="fa fa-folder-open-o"></i>
Open $tour.value
</button>
Open
</a>
</div>
#end
##
## New Tournament dialog
##
<div id="new-tournament" class="ui fullscreen modal">
<i class="close icon"></i>
<div class="horz flex header">
<span>New tournament</span>
<div class="ui ordered unstackable steps">
<div class="active step">
<div class="content">
<div class="title">Infos</div>
<div class="description">name, place and date</div>
</div>
</div>
<div class="step">
<div class="content">
<div class="title">Type</div>
<div class="description">teams or players, rounds</div>
</div>
</div>
<div class="step">
<div class="content">
<div class="title">Pairing</div>
<div class="description">pairing system</div>
</div>
</div>
</div>
</div>
<div class="scrolling content">
#translate('tournament-form.inc.html')
</div>
<div class="actions">
<button class="ui cancel black floating button">Cancel</button>
<button class="ui next green right labeled icon floating button">
<i class="checkmark icon"></i>
Next
</button>
</div></div>
<script type="text/javascript">
const lang = '${request.lang}';
// #[[
onLoad(() => {
$('#new').on('click', e => {
$('#new-tournament').modal(true);
});
new DateRangePicker($('#date-range')[0], {
language: lang
});
$('#new-tournament .tab.segment:first-child').addClass('active');
});
// ]]#
</script>
<!-- date range picker -->
<script type="text/javascript" src="/lib/datepicker-1.3.3/datepicker-full.min.js"></script>
<script type="text/javascript" src="/lib/datepicker-1.3.3/locales/${request.lang}.js"></script>
<link rel="stylesheet" href="/lib/datepicker-1.3.3/datepicker.min.css">

View File

@@ -23,6 +23,34 @@ let headers = function() {
return ret;
};
function clearFeedback() {
$('#error')[0].innerText = '';
$('#error, #success').addClass('hidden');
}
function success() {
$('#error')[0].innerText = '';
$('#error').addClass('hidden');
}
function showError(message) {
console.error(message);
$('#error')[0].innerText = message;
$('#error').removeClass('hidden');
}
function error(response) {
const contentType = response.headers.get("content-type");
let promise =
(contentType && contentType.indexOf("application/json") !== -1)
? response.json().then(json => json.error || "unknown error")
: Promise.resolve(response.statusText);
promise.then(message => {
message = message.replaceAll(/([a-z])([A-Z])/g,"$1 $2").toLowerCase();
showError(message);
});
}
let api = {
get: (path) => fetch(base + path, {
credentials: "same-origin",
@@ -49,22 +77,82 @@ let api = {
/* then, some helpers */
getJson: (path) => api.get(path)
.then(resp => {
if (resp.ok) return resp.json();
else throw resp.statusText;
}),
getJson: (path) => {
clearFeedback();
return api.get(path)
.then(resp => {
if (resp.ok) {
return resp.json();
}
else throw resp;
})
.catch(err => {
error(err);
return 'error'
});
},
postJson: (path, body) => api.post(path, body)
.then(resp => {
if (resp.ok) return resp.json();
else throw resp.statusText;
}),
postJson: (path, body) => {
clearFeedback();
spinner(true);
return api.post(path, body)
.then(resp => {
if (resp.ok) {
success();
return resp.json();
}
else {
throw resp;
}
})
.catch(err => {
error(err);
return 'error';
})
.finally(() => {
spinner(false);
});
},
putJson: (path, body) => {
clearFeedback();
spinner(true);
return api.put(path, body)
.then(resp => {
if (resp.ok) {
success();
return resp.json();
}
else throw resp;
})
.catch(err => {
error(err);
return 'error';
})
.finally(() => {
spinner(false);
});
},
deleteJson: (path, body) => {
clearFeedback();
spinner(true);
return api.delete(path, body)
.then(resp => {
if (resp.ok) {
success();
return resp.json();
}
else throw resp;
})
.catch(err => {
error(err);
return 'error';
})
.finally(() => {
spinner(false);
});
}
putJson: (path, body) => api.put(path, body)
.then(resp => {
if (resp.ok) return resp.json();
else throw resp.statusText;
})
};

View File

@@ -49,8 +49,8 @@ Element.prototype.toggleClass = function(className) {
NodeList.prototype.hasClass = function(className) {
return this.item(0).classList.contains(className);
}
Element.prototype.toggleClass = function(className) {
this.classList.contains(className);
Element.prototype.hasClass = function(className) {
return this.classList.contains(className);
}
Node.prototype.offset = function() {
let _x = 0;
@@ -66,39 +66,130 @@ Node.prototype.offset = function() {
NodeList.prototype.offset = function() {
this.item(0).offset();
}
Element.prototype.attr = function (key) {
return this.attributes[key].value;
Element.prototype.attr = function (key, value) {
if (typeof(value) === 'undefined') {
return this.attributes[key]?.value;
} else {
this.setAttribute(key, value);
return this;
}
}
NodeList.prototype.attr = function(key) {
this.item(0).attr(key);
NodeList.prototype.attr = function(key, value) {
if (typeof(value) === 'undefined') {
return this.item(0).attr(key);
} else {
this.forEach(elem => {
elem.attr(key, value);
});
return this;
}
}
Element.prototype.data = function (key) {
return this.attributes[`data-${key}`].value
Element.prototype.data = function (key, value) {
if (typeof(value) === 'undefined') {
return this.attributes[`data-${key}`]?.value
} else {
this.setAttribute(`data-${key}`, value);
return this;
}
}
NodeList.prototype.data = function(key) {
this.item(0).data(key);
NodeList.prototype.data = function(key, value) {
if (typeof(value) === 'undefined') {
this.item(0).data(key);
} else {
this.forEach(elem => {
elem.data(key, value);
})
return this;
}
}
NodeList.prototype.show = function(key) {
this.item(0).show(key);
NodeList.prototype.show = function() {
this.item(0).show();
return this;
}
Element.prototype.show = function (key) {
Element.prototype.show = function() {
this.style.display = 'block';
}
NodeList.prototype.hide = function(key) {
this.item(0).hide(key);
NodeList.prototype.hide = function() {
this.item(0).hide();
return this;
}
Element.prototype.hide = function (key) {
Element.prototype.hide = function() {
this.style.display = 'none';
}
let initFunctions = [];
function onLoad(fct) {
if (typeof(fct) == "function") initFunctions.push(fct);
NodeList.prototype.text = function(txt) {
this.item(0).text(txt);
}
document.on("DOMContentLoaded", () => {
initFunctions.forEach(fct => {
fct();
Element.prototype.text = function(txt) {
if (typeof(txt) === 'undefined') {
return this.textContent;
} else {
this.textContent = txt;
}
}
NodeList.prototype.item = function (i) {
return this[+i || 0];
};
NodeList.prototype.find = function(selector) {
let result = [];
this.forEach(function (elem, i) {
let partial = elem.find(selector);
result = result.concat([...partial]);
});
});
return Reflect.construct(Array, result, NodeList);
}
Element.prototype.find = function(selector) {
return this.querySelectorAll(':scope ' + selector);
}
NodeList.prototype.clear = function() {
this.forEach(function (elem, i) {
elem.clear();
});
return this;
}
Element.prototype.clear = function() {
this.innerHTML = '';
return this;
}
/*
TODO - conflicts with from.val(), rename one of the two
NodeList.prototype.val = function(value) {
this.item(0).val(value);
}
Element.prototype.val = function(value) {
// TODO - check that "this" has the "value" property
if (typeof(value) === 'undefined') {
return this.value;
} else {
this.value = value;
}
}
*/
NodeList.prototype.focus = function() {
let first = this.item(0);
if (first) first.focus();
}
Element.prototype.index = function(selector) {
let i = 0;
let child = this;
while ((child = child.previousSibling) != null) {
if (typeof(selector) === 'undefined' || child.nodeType === Node.ELEMENT_NODE && child.matches(selector)) {
++i;
}
}
return i;
}
NodeList.prototype.filter = function(selector) {
let result = [];
this.forEach(elem => {
if (elem.nodeType === Node.ELEMENT_NODE && elem.matches(selector)) {
result.push(elem);
}
});
return Reflect.construct(Array, result, NodeList);
}

View File

@@ -1,163 +0,0 @@
// hack to replace .34 into 0.34 (CB TODO - upstream patch to inputmask)
const fixNumber = (value) => isNaN(value) || !`${value}`.startsWith('.') ? value : `0.${value}`;
class FormProxy {
constructor(formSelector, config) {
this.formSelector = formSelector;
this.config = config;
this.config.properties = () => Object.keys(this.config).filter(k => typeof(this.config[k]) !== 'function');
this.promises = [];
this.dirty = false;
this.config.import = function(obj) {
for (let key in obj) {
if (key in this.config) {
this.proxy[key] = obj[key];
} else {
console.warn(`ignoring property ${key}`)
}
}
this.config.dirty(false);
}.bind(this);
this.config.export = function() {
let ret = {}
this.config.properties().forEach(prop => {
ret[prop] = this.proxy[prop];
});
return ret;
}.bind(this);
this.config.dirty = function(value) {
if (typeof(value) === 'undefined') return thisProxy.dirty;
else thisProxy.dirty = Boolean(value);
}.bind(this);
this.config.valid = function() {
return $(`${thisProxy.formSelector} [required]:invalid`).length === 0
}.bind(this);
this.config.reset = function() {
this.initialize();
}.bind(this);
// CB TODO - needs a function to wait for promises coming from dependencies
this.setState('loading');
$(() => this.configure.bind(this)());
let thisProxy = this;
return this.proxy = new Proxy(config, {
get(target, prop) {
if (typeof(target[prop]) === 'function') {
return target[prop];
}
else {
let elem = config[prop];
if (typeof(elem) === 'undefined') throw `invalid property: ${prop}`
return elem.getter();
}
},
set(target, prop, value) {
let def = config[prop];
if (typeof(def) === 'undefined') throw `invalid property: ${prop}`
let depends = [].concat(def.depends ? def.depends : []);
let proms = depends.flatMap(field => config[field].promise).filter(prom => prom);
let operation = () => {
def.setter(value);
if (typeof(def.change) === 'function') {
let rst = def.change(value, def.elem);
if (typeof(rst?.then) === 'function') {
def.promise = rst;
}
}
};
if (proms.length) Promise.all(proms).then(() => operation());
else operation();
config.dirty(true);
return true;
}
});
}
configure() {
this.form = $(this.formSelector);
if (!this.form.length) throw `Form not found: ${this.formSelector}`;
this.form.on('submit', e => { e.preventDefault(); return false; });
let controls = this.form.find('input[name],select[name],textarea[name]');
controls.on('input change keyup', e => {
this.setState('editing');
this.config.dirty(true);
});
controls.each((i,e) => {
let name = $(e).attr('name');
if (!(name in this.config)) this.config[name] = {};
});
this.config.properties().forEach(key => {
let def = this.config[key];
if (!def) def = this.config[key] = {};
else if (typeof(def) === 'function') return true; // continue foreach
if (!def.getter) {
let elem = def.elem;
if (!elem || !elem.length) elem = $(`${this.formSelector} [name="${key}"]`);
if (!elem || !elem.length) elem = $(`#${key}`);
if (!elem || !elem.length) throw `element not found: ${key}`;
def.elem = elem;
def.getter = elem.is('input,select,textarea')
? elem.attr('type') === 'radio'
? (() => elem.filter(':checked').val())
: (() => elem.data('default')
? elem.val()
? elem.is('.number')
? elem.val().replace(/ /g, '')
: elem.val()
: elem.data('default')
: elem.is('.number')
? elem.val() ? elem.val().replace(/ /g, '') : elem.val()
: elem.val())
: (() => elem.text());
def.setter = elem.is('input,select,textarea')
? elem.attr('type') === 'radio'
? (value => elem.filter(`[value="${value}"]`).prop('checked', true))
: elem.is('input.number') ? (value => elem.val(fixNumber(value))) : (value => elem.val(value))
: (value => elem.text(value));
if (typeof(def.change) === 'function') {
elem.on('change', () => def.change(def.getter(), elem));
}
}
let loading = def?.loading;
switch (typeof(loading)) {
case 'function':
let rst = loading(def.elem);
if (typeof(rst?.then) === 'function') {
this.promises.push(rst);
}
break;
}
});
setTimeout(() => {
Promise.all(this.promises).then(() => { this.promises = []; this.initialize(); });
}, 10);
}
initialize() {
this.config.properties().forEach(key => {
let def = this.config[key];
if (typeof(def.initial) === 'undefined') {
this.proxy[key] = '';
} else {
if (typeof(def.initial) === 'function') {
def.initial(def.elem)
} else if (def.initial != null) {
this.proxy[key] = def.initial;
}
}
});
this.config.dirty(false);
this.setState('initial');
}
setState(state) {
if (this.form && this.form.length) this.form[0].dispatchEvent(new Event(state));
}
}

View File

@@ -84,8 +84,8 @@ function exportCSV(filename, content) {
document.body.removeChild(link);
}
/* modals */
/* modals
NOT IN USE, see popup-related code.
NodeList.prototype.modal = function(show) {
this.item(0).modal(show);
return this;
@@ -101,21 +101,127 @@ Element.prototype.modal = function(show) {
}
return this;
}
*/
/* DOM helpers */
HTMLFormElement.prototype.val = function(name, value) {
let hasValue = typeof(value) !== 'undefined';
let ctl = this.find(`[name="${name}"]`)[0];
if (!ctl) {
console.error(`unknown input name: ${name}`)
}
let tag = ctl.tagName;
let type = tag === 'INPUT' ? ctl.attr('type') : undefined;
if (
(tag === 'INPUT' && ['text', 'number', 'hidden'].includes(ctl.attr('type'))) ||
tag === 'SELECT'
) {
if (hasValue) {
ctl.value = value;
return;
}
else return ctl.value;
} else if (tag === 'INPUT' && ctl.attr('type') === 'radio') {
if (hasValue) {
ctl = $(`input[name="${name}"][value="${value}"]`);
if (ctl) ctl.checked = true;
return;
} else {
ctl = $(`input[name="${name}"]:checked`);
if (ctl) return ctl[0].value;
else return null;
}
} else if (tag === 'INPUT' && ctl.attr('type') === 'checkbox') {
if (hasValue) {
ctl.checked = value !== 'false' && Boolean(value);
return;
}
else return ctl.checked && ctl.value ? ctl.value : ctl.checked;
}
console.error(`unhandled input tag or type for input ${name} (tag: ${tag}, type:${type}`);
return null;
};
function msg(id) {
let ctl = $(`#${id}`)[0];
return ctl.textContent;
}
function spinner(show) {
if (show) $('#backdrop').addClass('active');
else $('#backdrop').removeClass('active');
}
function modal(id) {
$('body').addClass('dimmed');
$(`#${id}.popup`).addClass('shown');
}
function close_modal() {
$('body').removeClass('dimmed');
$(`.popup`).removeClass('shown');
}
onLoad(() => {
/*
document.on('click', e => {
if (!e.target.closest('.modal')) $('.modal').hide();
})
$('button.close').on('click', e => {
let modal = e.target.closest('.popup');
if (modal) {
modal.removeClass('shown');
$('body').removeClass('dimmed');
}
});
/* commented for now - do we want this?
$('#dimmer').on('click', e => $('.popup').removeClass('shown');
*/
$('i.close.icon').on('click', e => {
let modal = e.target.closest('.modal');
if (modal) modal.modal(false);
// keyboard handling
document.on('keyup', e => {
switch (e.key) {
case 'Escape': {
if ($('#player').hasClass('shown')) {
if ($('#needle')[0].value) {
$('#needle')[0].value = '';
initSearch();
} else {
close_modal();
}
}
break;
}
case 'ArrowDown': {
if (searchResultShown()) {
let lines = $('.result-line');
if (typeof (searchHighlight) === 'undefined') searchHighlight = 0;
else ++searchHighlight;
searchHighlight = Math.min(searchHighlight, lines.length - 1);
lines.removeClass('highlighted');
lines[searchHighlight].addClass('highlighted');
}
break;
}
case 'ArrowUp': {
if (searchResultShown()) {
let lines = $('.result-line');
if (typeof (searchHighlight) === 'undefined') searchHighlight = 0;
else --searchHighlight;
searchHighlight = Math.max(searchHighlight, 0);
lines.removeClass('highlighted');
lines[searchHighlight].addClass('highlighted');
}
break;
}
case 'Enter': {
if (searchResultShown()) {
fillPlayer(searchResult[searchHighlight]);
} else {
$('#register')[0].click();
}
break;
}
}
});
$('.modal .actions .cancel').on('click', e => {
e.target.closest('.modal').modal(false);
});
$('#dimmer').on('click', e => $('.modal').modal(false));
});

View File

@@ -0,0 +1,151 @@
onLoad(() => {
$('#edit').on('click', e => {
e.preventDefault();
$('#tournament-infos').addClass('edit');
return false;
});
$('#cancel, #close').on('click', e => {
e.preventDefault();
if ($('#tournament-infos').hasClass('edit') && typeof(tour_id) !== 'undefined') {
$('#tournament-infos').removeClass('edit')
} else {
document.location.href = '/index';
}
return false;
});
$('#validate').on('click', e => {
let form = e.target.closest('form');
let valid = true;
// validate required fields
let required = ['name', 'shortName', 'startDate', 'endDate'];
if (!form.find('input[name="online"]')[0].checked) required.push('location')
for (let name of required) {
let ctl = form.find(`input[name=${name}]`)[0];
let val = ctl.value;
if (val) {
ctl.setCustomValidity('');
} else {
valid = false;
ctl.setCustomValidity(msg('required_field'));
}
}
if (!valid) return;
// validate short_name
let shortNameCtl = form.find('input[name="shortName"]')[0];
let shortName = shortNameCtl.value;
if (safeRegex.test(shortName)) {
shortNameCtl.setCustomValidity('');
} else {
valid = false;
shortNameCtl.setCustomValidity(msg('invalid_character'));
}
// if (!valid) return;
// ...
});
for(let name of ['startDate', 'endDate']) {
let control = $(`input[name="${name}"]`)[0];
if (control.value) {
control.value = formatDate(control.value);
}
}
new DateRangePicker($('#date-range')[0], {
autohide: true,
language: datepickerLocale || 'en'
});
$('input[name="online"]').on('change', e => {
$('input[name="location"]')[0].disabled = e.target.checked;
});
$('select[name="timeSystemType"]').on('change', e => {
switch (e.target.value) {
case 'CANADIAN':
$('#increment').addClass('hidden');
$('#maxTime').addClass('hidden');
$('#byoyomi').removeClass('hidden');
$('#periods').addClass('hidden');
$('#stones').removeClass('hidden');
break;
case 'FISCHER':
$('#increment').removeClass('hidden');
$('#maxTime').removeClass('hidden');
$('#byoyomi').addClass('hidden');
$('#periods').addClass('hidden');
$('#stones').addClass('hidden');
break;
case 'STANDARD':
$('#increment').addClass('hidden');
$('#maxTime').addClass('hidden');
$('#byoyomi').removeClass('hidden');
$('#periods').removeClass('hidden');
$('#stones').addClass('hidden');
break;
case 'SUDDEN_DEATH':
$('#increment').addClass('hidden');
$('#maxTime').addClass('hidden');
$('#byoyomi').addClass('hidden');
$('#periods').addClass('hidden');
$('#stones').addClass('hidden');
break;
}
});
$('input.duration').imask({
mask: '00:00:00',
lazy: false,
overwrite: true
});
$('#tournament-infos').on('submit', e => {
e.preventDefault();
let form = e.target;
let tour = {
name: form.val('name'),
shortName: form.val('shortName'),
startDate: parseDate(form.val('startDate')),
endDate: parseDate(form.val('endDate')),
type: form.val('type'),
rounds: form.val('rounds'),
country: form.val('country'),
online: form.val('online'),
location: form.val('online') ? "" : form.val('location'),
pairing: {
type: form.val('pairing'),
// mmFloor: form.val('mmFloor'),
mmBar: form.val('mmBar'),
main: {
firstSeed: form.val('firstSeed'),
secondSeed: form.val('secondSeed')
},
handicap: {
correction: -form.val('correction'),
treshold: form.val('treshold')
}
},
timeSystem: {
type: form.val('timeSystemType'),
mainTime: fromHMS(form.val('mainTime')),
increment: fromHMS(form.val('increment')),
maxTime: fromHMS(form.val('maxTime')),
byoyomi: fromHMS(form.val('byoyomi')),
periods: form.val('periods'),
stones: form.val('stones')
}
}
console.log(tour);
if (typeof(tour_id) !== 'undefined') {
api.putJson(`tour/${tour_id}`, tour)
.then(tour => {
window.location.reload();
});
} else {
api.postJson('tour', tour)
.then(tour => {
window.location.href += `?id=${tour.id}`;
});
}
});
});

View File

@@ -0,0 +1,51 @@
let focused = undefined;
function pair(parts) {
api.postJson(`tour/${tour_id}/pair/${activeRound}`, parts)
.then(rst => {
if (rst !== 'error') {
document.location.reload();
}
});
}
function unpair(games) {
api.deleteJson(`tour/${tour_id}/pair/${activeRound}`, games)
.then(rst => {
if (rst !== 'error') {
document.location.reload();
}
});
}
onLoad(()=>{
$('.listitem').on('click', e => {
if (e.shiftKey && typeof(focused) !== 'undefined') {
let from = focused.index('.listitem');
let to = e.target.closest('.listitem').index('.listitem');
if (from > to) {
let tmp = from;
from = to;
to = tmp;
}
let parent = e.target.closest('.multi-select');
let children = parent.childNodes.filter('.listitem');
for (let j = from; j <= to; ++j) { new Tablesort($('#players')[0]);
children.item(j).addClass('selected');
children.item(j).attr('draggable', true);
}
} else {
let target = e.target.closest('.listitem');
focused = target.toggleClass('selected').attr('draggable', target.hasClass('selected'));
}
});
$('#pair').on('click', e => {
let parts = $('#pairables')[0].childNodes.filter('.selected.listitem').map(item => parseInt(item.data("id")));
pair(parts);
});
$('#unpair').on('click', e => {
let games = $('#paired')[0].childNodes.filter('.selected.listitem').map(item => parseInt(item.data("id")));
unpair(games);
});
});

View File

@@ -0,0 +1,214 @@
const SEARCH_DELAY = 100;
let searchTimer = undefined;
let resultTemplate;
let searchResult;
let searchHighlight;
function initSearch() {
let needle = $('#needle')[0].value.trim();
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
search(needle);
}, SEARCH_DELAY);
}
function searchResultShown() {
return !(typeof(searchHighlight) === 'undefined' || !searchResult || !searchResult.length || typeof(searchResult[searchHighlight]) === 'undefined')
}
function search(needle) {
needle = needle.trim();
if (needle && needle.length > 2) {
let form = $('#player-form')[0];
let search = {
needle: needle,
aga: form.val('aga'),
egf: form.val('egf'),
ffg: form.val('ffg'),
}
let country = form.val('countryFilter');
if (country) search.countryFilter = country;
let searchFormState = {
countryFilter: country ? true : false,
aga: search.aga,
egf: search.egf,
ffg: search.ffg
};
store('searchFormState', searchFormState);
api.postJson('search', search)
.then(result => {
if (Array.isArray(result)) {
searchResult = result
let html = resultTemplate.render(result);
$('#search-result')[0].innerHTML = html;
} else console.log(result);
})
} else {
$('#search-result').clear();
searchTimer = undefined;
searchResult = undefined;
searchHighlight = undefined;
}
}
function parseRank(rank) {
let groups = /(\d+)([kd])/.exec(rank)
if (groups) {
let level = parseInt(groups[1]);
let letter = groups[2];
switch (letter) {
case 'k': return -level;
case 'd': return level - 1;
}
}
return '';
}
function fillPlayer(player) {
let form = $('#player-form')[0];
form.val('name', player.name);
form.val('firstname', player.firstname);
form.val('country', player.country.toLowerCase());
form.val('club', player.club);
form.val('rank', parseRank(player.rank));
form.val('rating', player.rating);
$('#needle')[0].value = '';
initSearch();
$('#register').focus();
}
onLoad(() => {
$('input.numeric').imask({
mask: Number,
scale: 0,
min: 0,
max: 4000
});
new Tablesort($('#players')[0]);
$('#add').on('click', e => {
let form = $('#player-form')[0];
form.addClass('add');
// $('#player-form input.participation').forEach(chk => chk.checked = true);
form.reset();
$('#player').removeClass('edit').addClass('create');
modal('player');
$('#needle').focus();
});
$('#cancel-register').on('click', e => {
e.preventDefault();
close_modal();
searchHighlight = undefined;
return false;
});
$('#register').on('click', e => {
let form = e.target.closest('form');
let valid = true;
let required = ['name', 'firstname', 'country', 'club', 'rank', 'rating'];
for (let name of required) {
let ctl = form.find(`[name=${name}]`)[0];
let val = ctl.value;
if (val) {
ctl.setCustomValidity('');
} else {
valid = false;
ctl.setCustomValidity(msg('required_field'));
}
}
if (!valid) return;
// $('#player-form')[0].requestSubmit() not working?!
$('#player-form')[0].dispatchEvent(new CustomEvent('submit', {cancelable: true}));
});
$('#player-form').on('submit', e => {
("submitting!!")
e.preventDefault();
let form = $('#player-form')[0];
let player = {
name: form.val('name'),
firstname: form.val('firstname'),
rating: form.val('rating'),
rank: form.val('rank'),
country: form.val('country'),
club: form.val('club'),
skip: form.find('input.participation').map((input,i) => [i+1, input.checked]).filter(arr => !arr[1]).map(arr => arr[0])
}
if (form.hasClass('add')) {
api.postJson(`tour/${tour_id}/part`, player)
.then(player => {
if (player !== 'error') {
window.location.reload();
}
});
} else {
let id = form.val('id');
player['id'] = id;
api.putJson(`tour/${tour_id}/part/${id}`, player)
.then(player => {
if (player !== 'error') {
window.location.reload();
}
});
}
});
$('#players > tbody > tr').on('click', e => {
let id = e.target.closest('tr').attr('data-id');
api.getJson(`tour/${tour_id}/part/${id}`)
.then(player => {
if (player !== 'error') {
let form = $('#player-form')[0];
form.val('id', player.id);
form.val('name', player.name);
form.val('firstname', player.firstname);
form.val('rating', player.rating);
form.val('rank', player.rank);
form.val('country', player.country);
form.val('club', player.club);
for (r = 1; r <= tour_rounds; ++r) {
form.val(`r${r}`, !(player.skip && player.skip.includes(r)));
}
form.removeClass('add');
$('#player').removeClass('create').addClass('edit');
modal('player');
}
});
});
resultTemplate = jsrender.templates($('#result')[0]);
$('#needle').on('input', e => {
initSearch();
});
$('#clear-search').on('click', e => {
$('#needle')[0].value = '';
$('#search-result').clear();
});
let searchFromState = store('searchFormState')
if (searchFromState) {
for (let id of ["countryFilter", "aga", "egf", "ffg"]) {
$(`#${id}`)[0].checked = searchFromState[id];
}
}
$('.toggle').on('click', e => {
let chk = e.target.closest('.toggle');
let checkbox = chk.find('input')[0];
checkbox.checked = !checkbox.checked;
initSearch();
});
document.on('click', e => {
let resultLine = e.target.closest('.result-line');
if (resultLine) {
let index = e.target.closest('.result-line').data('index');
fillPlayer(searchResult[index]);
}
});
$('#unregister').on('click', e => {
let form = $('#player-form')[0];
let id = form.val('id');
api.deleteJson(`tour/${tour_id}/part/${id}`)
.then(ret => {
if (ret !== 'error') {
window.location.reload();
}
});
});
});

View File

@@ -0,0 +1,3 @@
onLoad(()=>{
new Tablesort($('#results-table')[0]);
});

View File

@@ -0,0 +1 @@
date picker: https://mymth.github.io/vanillajs-datepicker/#/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
New locales for the datepicker can be downloaded from [the datepicker repository](https://github.com/mymth/vanillajs-datepicker/tree/master/dist/js/locales)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,317 @@
.datepicker {
width: -moz-min-content;
width: min-content;
}
.datepicker:not(.active) {
display: none;
}
.datepicker-dropdown {
position: absolute;
z-index: 20;
padding-top: 4px;
}
.datepicker-dropdown.datepicker-orient-top {
padding-top: 0;
padding-bottom: 4px;
}
.datepicker-picker {
display: flex;
flex-direction: column;
border-radius: 4px;
background-color: hsl(0, 0%, 100%);
}
.datepicker-dropdown .datepicker-picker {
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
}
.datepicker-main {
flex: auto;
padding: 2px;
}
.datepicker-footer {
box-shadow: inset 0 1px 1px rgba(10, 10, 10, 0.1);
background-color: hsl(0, 0%, 96%);
}
.datepicker-title {
box-shadow: inset 0 -1px 1px rgba(10, 10, 10, 0.1);
background-color: hsl(0, 0%, 96%);
padding: 0.375rem 0.75rem;
text-align: center;
font-weight: 700;
}
.datepicker-controls {
display: flex;
}
.datepicker-header .datepicker-controls {
padding: 2px 2px 0;
}
.datepicker-controls .button {
display: inline-flex;
position: relative;
align-items: center;
justify-content: center;
margin: 0;
border: 1px solid gainsboro;
border-radius: 4px;
box-shadow: none;
background-color: hsl(0, 0%, 100%);
cursor: pointer;
padding: calc(0.375em - 1px) 0.75em;
height: 2.25em;
vertical-align: top;
text-align: center;
line-height: 1.5;
white-space: nowrap;
color: hsl(0, 0%, 21%);
font-size: 1rem;
}
.datepicker-controls .button:focus,
.datepicker-controls .button:active {
outline: none;
}
.datepicker-controls .button:hover {
border-color: #b8b8b8;
color: hsl(0, 0%, 21%);
}
.datepicker-controls .button:focus {
border-color: hsl(217, 71%, 53%);
color: hsl(0, 0%, 21%);
}
.datepicker-controls .button:focus:not(:active) {
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25);
}
.datepicker-controls .button:active {
border-color: #474747;
color: hsl(0, 0%, 21%);
}
.datepicker-controls .button[disabled] {
cursor: not-allowed;
}
.datepicker-header .datepicker-controls .button {
border-color: transparent;
font-weight: bold;
}
.datepicker-header .datepicker-controls .button:hover {
background-color: #f9f9f9;
}
.datepicker-header .datepicker-controls .button:active {
background-color: #f2f2f2;
}
.datepicker-footer .datepicker-controls .button {
flex: auto;
margin: calc(0.375rem - 1px) 0.375rem;
border-radius: 2px;
font-size: 0.75rem;
}
.datepicker-controls .view-switch {
flex: auto;
}
.datepicker-controls .prev-button,
.datepicker-controls .next-button {
padding-right: 0.375rem;
padding-left: 0.375rem;
flex: 0 0 14.2857142857%;
}
.datepicker-controls .prev-button.disabled,
.datepicker-controls .next-button.disabled {
visibility: hidden;
}
.datepicker-view,
.datepicker-grid {
display: flex;
}
.datepicker-view {
align-items: stretch;
width: 15.75rem;
}
.datepicker-grid {
flex-wrap: wrap;
flex: auto;
}
.datepicker .days {
display: flex;
flex-direction: column;
flex: auto;
}
.datepicker .days-of-week {
display: flex;
}
.datepicker .week-numbers {
display: flex;
flex-direction: column;
flex: 0 0 9.6774193548%;
}
.datepicker .weeks {
display: flex;
flex-direction: column;
align-items: stretch;
flex: auto;
}
.datepicker span {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: default;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.datepicker .dow {
height: 1.5rem;
font-size: 0.875rem;
font-weight: 700;
}
.datepicker .week {
flex: auto;
color: #b8b8b8;
font-size: 0.75rem;
}
.datepicker-cell,
.datepicker .days .dow {
flex-basis: 14.2857142857%;
}
.datepicker-cell {
height: 2.25rem;
}
.datepicker-cell:not(.day) {
flex-basis: 25%;
height: 4.5rem;
}
.datepicker-cell:not(.disabled):hover {
background-color: #f9f9f9;
cursor: pointer;
}
.datepicker-cell.focused:not(.selected) {
background-color: #e9e9e9;
}
.datepicker-cell.selected,
.datepicker-cell.selected:hover {
background-color: hsl(217, 71%, 53%);
color: #fff;
font-weight: 600;
}
.datepicker-cell.disabled {
color: gainsboro;
}
.datepicker-cell.prev:not(.disabled),
.datepicker-cell.next:not(.disabled) {
color: hsl(0, 0%, 48%);
}
.datepicker-cell.prev.selected,
.datepicker-cell.next.selected {
color: #e6e6e6;
}
.datepicker-cell.highlighted:not(.selected):not(.range):not(.today) {
border-radius: 0;
background-color: hsl(0, 0%, 96%);
}
.datepicker-cell.highlighted:not(.selected):not(.range):not(.today):not(.disabled):hover {
background-color: #efefef;
}
.datepicker-cell.highlighted:not(.selected):not(.range):not(.today).focused {
background-color: #e9e9e9;
}
.datepicker-cell.today:not(.selected) {
background-color: hsl(171, 100%, 41%);
}
.datepicker-cell.today:not(.selected):not(.disabled) {
color: #fff;
}
.datepicker-cell.today.focused:not(.selected) {
background-color: #00ccad;
}
.datepicker-cell.range-end:not(.selected),
.datepicker-cell.range-start:not(.selected) {
background-color: #b8b8b8;
color: #fff;
}
.datepicker-cell.range-end.focused:not(.selected),
.datepicker-cell.range-start.focused:not(.selected) {
background-color: #b3b3b3;
}
.datepicker-cell.range-start:not(.range-end) {
border-radius: 4px 0 0 4px;
}
.datepicker-cell.range-end:not(.range-start) {
border-radius: 0 4px 4px 0;
}
.datepicker-cell.range {
border-radius: 0;
background-color: gainsboro;
}
.datepicker-cell.range:not(.disabled):not(.focused):not(.today):hover {
background-color: #d7d7d7;
}
.datepicker-cell.range.disabled {
color: #c6c6c6;
}
.datepicker-cell.range.focused {
background-color: #d1d1d1;
}
.datepicker-input.in-edit {
border-color: #276bda;
}
.datepicker-input.in-edit:focus,
.datepicker-input.in-edit:active {
box-shadow: 0 0 0.25em 0.25em rgba(39, 107, 218, 0.2);
}

View File

@@ -0,0 +1,19 @@
/**
* Arabic-Algeria translation for bootstrap-datepicker
* Rabah Saadi <infosrabah@gmail.com>
*/
(function () {
Datepicker.locales['ar-DZ'] = {
days: ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت", "الأحد"],
daysShort: ["أحد", "اثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"],
daysMin: ["ح", "ن", "ث", "ع", "خ", "ج", "س", "ح"],
months: ["جانفي","فيفري","مارس","أفريل","ماي","جوان","جويليه","أوت","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],
monthsShort: ["جانفي","فيفري","مارس","أفريل","ماي","جوان","جويليه","أوت","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],
today: "هذا اليوم",
rtl: true,
monthsTitle: "أشهر",
clear: "إزالة",
format: "yyyy/mm/dd",
weekStart: 0
};
}());

View File

@@ -0,0 +1,15 @@
/**
* Arabic-Tunisia translation for bootstrap-datepicker
* Souhaieb Besbes <besbes.souhaieb@gmail.com>
*/
(function () {
Datepicker.locales['ar-tn'] = {
days: ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت", "الأحد"],
daysShort: ["أحد", "اثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"],
daysMin: ["ح", "ن", "ث", "ع", "خ", "ج", "س", "ح"],
months: ["جانفي","فيفري","مارس","أفريل","ماي","جوان","جويليه","أوت","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],
monthsShort: ["جانفي","فيفري","مارس","أفريل","ماي","جوان","جويليه","أوت","سبتمبر","أكتوبر","نوفمبر","ديسمبر"],
today: "هذا اليوم",
rtl: true
};
}());

View File

@@ -0,0 +1,15 @@
/**
* Arabic translation for bootstrap-datepicker
* Mohammed Alshehri <alshehri866@gmail.com>
*/
(function () {
Datepicker.locales.ar = {
days: ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت", "الأحد"],
daysShort: ["أحد", "اثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"],
daysMin: ["ح", "ن", "ث", "ع", "خ", "ج", "س", "ح"],
months: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"],
monthsShort: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"],
today: "هذا اليوم",
rtl: true
};
}());

View File

@@ -0,0 +1,14 @@
// Azerbaijani
(function () {
Datepicker.locales.az = {
days: ["Bazar", "Bazar ertəsi", "Çərşənbə axşamı", "Çərşənbə", "Cümə axşamı", "Cümə", "Şənbə"],
daysShort: ["B.", "B.e", "Ç.a", "Ç.", "C.a", "C.", "Ş."],
daysMin: ["B.", "B.e", "Ç.a", "Ç.", "C.a", "C.", "Ş."],
months: ["Yanvar", "Fevral", "Mart", "Aprel", "May", "İyun", "İyul", "Avqust", "Sentyabr", "Oktyabr", "Noyabr", "Dekabr"],
monthsShort: ["Yan", "Fev", "Mar", "Apr", "May", "İyun", "İyul", "Avq", "Sen", "Okt", "Noy", "Dek"],
today: "Bu gün",
weekStart: 1,
clear: "Təmizlə",
monthsTitle: 'Aylar'
};
}());

View File

@@ -0,0 +1,14 @@
/**
* Bulgarian translation for bootstrap-datepicker
* Apostol Apostolov <apostol.s.apostolov@gmail.com>
*/
(function () {
Datepicker.locales.bg = {
days: ["Неделя", "Понеделник", "Вторник", "Сряда", "Четвъртък", "Петък", "Събота"],
daysShort: ["Нед", "Пон", "Вто", "Сря", "Чет", "Пет", "Съб"],
daysMin: ["Н", "П", "В", "С", "Ч", "П", "С"],
months: ["Януари", "Февруари", "Март", "Април", "Май", "Юни", "Юли", "Август", "Септември", "Октомври", "Ноември", "Декември"],
monthsShort: ["Ян", "Фев", "Мар", "Апр", "Май", "Юни", "Юли", "Авг", "Сеп", "Окт", "Ное", "Дек"],
today: "днес"
};
}());

View File

@@ -0,0 +1,18 @@
/**
* Bamanankan (bm) translation for bootstrap-datepicker
* Fatou Fall <fatou@medicmobile.org>
*/
(function () {
Datepicker.locales.bm = {
days: ["Kari","Ntɛnɛn","Tarata","Araba","Alamisa","Juma","Sibiri"],
daysShort: ["Kar","Ntɛ","Tar","Ara","Ala","Jum","Sib"],
daysMin: ["Ka","Nt","Ta","Ar","Al","Ju","Si"],
months: ["Zanwuyekalo","Fewuruyekalo","Marisikalo","Awirilikalo","Mɛkalo","Zuwɛnkalo","Zuluyekalo","Utikalo","Sɛtanburukalo","ɔkutɔburukalo","Nowanburukalo","Desanburukalo"],
monthsShort: ["Zan","Few","Mar","Awi","Mɛ","Zuw","Zul","Uti","Sɛt","ɔku","Now","Des"],
today: "Bi",
monthsTitle: "Kalo",
clear: "Ka jɔsi",
weekStart: 1,
format: "dd/mm/yyyy"
};
}());

View File

@@ -0,0 +1,19 @@
/**
* Bengali (Bangla) translation for bootstrap-datepicker
* Karim Khan <kkhancse91@gmail.com>
* Orif N. Jr. <orif.zade@gmail.com>
*/
(function () {
Datepicker.locales.bn = {
days: ["রবিবার","সোমবার","মঙ্গলবার","বুধবার","বৃহস্পতিবার","শুক্রবার","শনিবার"],
daysShort: ["রবিবার","সোমবার","মঙ্গলবার","বুধবার","বৃহস্পতিবার","শুক্রবার","শনিবার"],
daysMin: ["রবি","সোম","মঙ্গল","বুধ","বৃহস্পতি","শুক্র","শনি"],
months: ["জানুয়ারী","ফেব্রুয়ারি","মার্চ","এপ্রিল","মে","জুন","জুলাই","অগাস্ট","সেপ্টেম্বর","অক্টোবর","নভেম্বর","ডিসেম্বর"],
monthsShort: ["জানুয়ারী","ফেব্রুয়ারি","মার্চ","এপ্রিল","মে","জুন","জুলাই","অগাস্ট","সেপ্টেম্বর","অক্টোবর","নভেম্বর","ডিসেম্বর"],
today: "আজ",
monthsTitle: "মাস",
clear: "পরিষ্কার",
weekStart: 0,
format: "mm/dd/yyyy"
};
}());

View File

@@ -0,0 +1,18 @@
/**
* Breton translation for bootstrap-datepicker
* Gwenn Meynier <tornoz@laposte.net>
*/
(function () {
Datepicker.locales.br = {
days: ["Sul", "Lun", "Meurzh", "Merc'her", "Yaou", "Gwener", "Sadorn"],
daysShort: ["Sul", "Lun", "Meu.", "Mer.", "Yao.", "Gwe.", "Sad."],
daysMin: ["Su", "L", "Meu", "Mer", "Y", "G", "Sa"],
months: ["Genver", "C'hwevrer", "Meurzh", "Ebrel", "Mae", "Mezheven", "Gouere", "Eost", "Gwengolo", "Here", "Du", "Kerzu"],
monthsShort: ["Genv.", "C'hw.", "Meur.", "Ebre.", "Mae", "Mezh.", "Goue.", "Eost", "Gwen.", "Here", "Du", "Kerz."],
today: "Hiziv",
monthsTitle: "Miz",
clear: "Dilemel",
weekStart: 1,
format: "dd/mm/yyyy"
};
}());

View File

@@ -0,0 +1,15 @@
/**
* Bosnian translation for bootstrap-datepicker
*/
(function () {
Datepicker.locales.bs = {
days: ["Nedjelja","Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak", "Subota"],
daysShort: ["Ned", "Pon", "Uto", "Sri", "Čet", "Pet", "Sub"],
daysMin: ["N", "Po", "U", "Sr", "Č", "Pe", "Su"],
months: ["Januar", "Februar", "Mart", "April", "Maj", "Juni", "Juli", "August", "Septembar", "Oktobar", "Novembar", "Decembar"],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"],
today: "Danas",
weekStart: 1,
format: "dd.mm.yyyy"
};
}());

View File

@@ -0,0 +1,18 @@
/**
* Catalan translation for bootstrap-datepicker
* J. Garcia <jogaco.en@gmail.com>
*/
(function () {
Datepicker.locales.ca = {
days: ["diumenge", "dilluns", "dimarts", "dimecres", "dijous", "divendres", "dissabte"],
daysShort: ["dg.", "dl.", "dt.", "dc.", "dj.", "dv.", "ds."],
daysMin: ["dg", "dl", "dt", "dc", "dj", "dv", "ds"],
months: ["gener", "febrer", "març", "abril", "maig", "juny", "juliol", "agost", "setembre", "octubre", "novembre", "desembre"],
monthsShort: ["gen.", "febr.", "març", "abr.", "maig", "juny", "jul.", "ag.", "set.", "oct.", "nov.", "des."],
today: "Avui",
monthsTitle: "Mesos",
clear: "Esborra",
weekStart: 1,
format: "dd/mm/yyyy"
};
}());

View File

@@ -0,0 +1,19 @@
/**
* Czech translation for bootstrap-datepicker
* Matěj Koubík <matej@koubik.name>
* Fixes by Michal Remiš <michal.remis@gmail.com>
*/
(function () {
Datepicker.locales.cs = {
days: ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"],
daysShort: ["Ned", "Pon", "Úte", "Stř", "Čtv", "Pát", "Sob"],
daysMin: ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"],
months: ["Leden", "Únor", "Březen", "Duben", "Květen", "Červen", "Červenec", "Srpen", "Září", "Říjen", "Listopad", "Prosinec"],
monthsShort: ["Led", "Úno", "Bře", "Dub", "Kvě", "Čer", "Čnc", "Srp", "Zář", "Říj", "Lis", "Pro"],
today: "Dnes",
clear: "Vymazat",
monthsTitle: "Měsíc",
weekStart: 1,
format: "dd.mm.yyyy"
};
}());

View File

@@ -0,0 +1,14 @@
/**
* Welsh translation for bootstrap-datepicker
* S. Morris <s.morris@bangor.ac.uk>
*/
(function () {
Datepicker.locales.cy = {
days: ["Sul", "Llun", "Mawrth", "Mercher", "Iau", "Gwener", "Sadwrn"],
daysShort: ["Sul", "Llu", "Maw", "Mer", "Iau", "Gwe", "Sad"],
daysMin: ["Su", "Ll", "Ma", "Me", "Ia", "Gwe", "Sa"],
months: ["Ionawr", "Chewfror", "Mawrth", "Ebrill", "Mai", "Mehefin", "Gorfennaf", "Awst", "Medi", "Hydref", "Tachwedd", "Rhagfyr"],
monthsShort: ["Ion", "Chw", "Maw", "Ebr", "Mai", "Meh", "Gor", "Aws", "Med", "Hyd", "Tach", "Rha"],
today: "Heddiw"
};
}());

View File

@@ -0,0 +1,19 @@
/**
* Danish translation for bootstrap-datepicker
* Christian Pedersen <https: //github.com/chripede>
* Ivan Mylyanyk <https: //github.com/imylyanyk>
*/
(function () {
Datepicker.locales.da = {
days: ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
daysShort: ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"],
daysMin: ["Sø", "Ma", "Ti", "On", "To", "Fr", "Lø"],
months: ["Januar", "Februar", "Marts", "April", "Maj", "Juni", "Juli", "August", "September", "Oktober", "November", "December"],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"],
today: "I Dag",
weekStart: 1,
clear: "Nulstil",
format: "dd/mm/yyyy",
monthsTitle: "Måneder"
};
}());

View File

@@ -0,0 +1,18 @@
/**
* German translation for bootstrap-datepicker
* Sam Zurcher <sam@orelias.ch>
*/
(function () {
Datepicker.locales.de = {
days: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
daysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
daysMin: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
monthsShort: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
today: "Heute",
monthsTitle: "Monate",
clear: "Löschen",
weekStart: 1,
format: "dd.mm.yyyy"
};
}());

View File

@@ -0,0 +1,16 @@
/**
* Greek translation for bootstrap-datepicker
*/
(function () {
Datepicker.locales.el = {
days: ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"],
daysShort: ["Κυρ", "Δευ", "Τρι", "Τετ", "Πεμ", "Παρ", "Σαβ"],
daysMin: ["Κυ", "Δε", "Τρ", "Τε", "Πε", "Πα", "Σα"],
months: ["Ιανουάριος", "Φεβρουάριος", "Μάρτιος", "Απρίλιος", "Μάιος", "Ιούνιος", "Ιούλιος", "Αύγουστος", "Σεπτέμβριος", "Οκτώβριος", "Νοέμβριος", "Δεκέμβριος"],
monthsShort: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μάι", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
today: "Σήμερα",
clear: "Καθαρισμός",
weekStart: 1,
format: "d/m/yyyy"
};
}());

View File

@@ -0,0 +1,18 @@
/**
* Australian English translation for bootstrap-datepicker
* Steve Chapman <steven.p.chapman@gmail.com>
*/
(function () {
Datepicker.locales['en-AU'] = {
days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
daysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
daysMin: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
today: "Today",
monthsTitle: "Months",
clear: "Clear",
weekStart: 1,
format: "d/mm/yyyy"
};
}());

Some files were not shown because too many files have changed in this diff Show More