unify game exports

pull/4339/head
Thibault Duplessis 2018-05-07 02:10:47 +02:00
parent f83fedfc2e
commit 3428119b3c
10 changed files with 116 additions and 93 deletions

View File

@ -10,36 +10,6 @@ object Export extends LilaController {
private def env = Env.game
def pgn(id: String) = Open { implicit ctx =>
lila.mon.export.pgn.game()
OptionFuResult(GameRepo game id) { game =>
gameToPgn(
game,
asImported = get("as") contains "imported",
asRaw = get("as").contains("raw")
) map { content =>
Ok(content).withHeaders(
CONTENT_TYPE -> pgnContentType,
CONTENT_DISPOSITION -> ("attachment; filename=" + (Env.api.pgnDump filename game))
)
} recover {
case err => NotFound(err.getMessage)
}
}
}
private def gameToPgn(from: GameModel, asImported: Boolean, asRaw: Boolean): Fu[String] = from match {
case game if game.playable => fufail("Can't export PGN of game in progress")
case game => (game.pgnImport.ifTrue(asImported) match {
case Some(i) => fuccess(i.pgn)
case None => for {
initialFen <- GameRepo initialFen game
pgn <- Env.api.pgnDump(game, initialFen, analysis = none, PgnDump.WithFlags(clocks = !asRaw))
analysis !asRaw ?? (Env.analyse.analyser get game)
} yield Env.analyse.annotator(pgn, analysis, game.opening, game.winnerColor, game.status, game.clock).toString
})
}
private val PngRateLimitGlobal = new lila.memo.RateLimit[String](
credits = 240,
duration = 1 minute,

View File

@ -27,7 +27,29 @@ object Game extends LilaController {
}
}
def export(username: String) = OpenOrScoped()(
def exportOne(id: String) = Open { implicit ctx =>
OptionFuResult(GameRepo game id) { game =>
if (game.playable) BadRequest("Can't export PGN of game in progress").fuccess
else {
val config = GameApiV2.OneConfig(
format = if (HTTPRequest acceptsJson ctx.req) GameApiV2.Format.JSON else GameApiV2.Format.PGN,
imported = getBool("imported"),
flags = requestPgnFlags(ctx.req)
)
lila.mon.export.pgn.game()
Env.api.gameApiV2.exportOne(game, config) flatMap { content =>
Env.api.gameApiV2.filename(game, config.format) map { filename =>
Ok(content).withHeaders(
CONTENT_TYPE -> gameContentType(config),
CONTENT_DISPOSITION -> s"attachment; filename=$filename"
)
}
}
}
}
}
def exportByUser(username: String) = OpenOrScoped()(
open = ctx => handleExport(username, ctx.me, ctx.req, oauth = false),
scoped = req => me => handleExport(username, me.some, req, oauth = true)
)
@ -39,7 +61,7 @@ object Game extends LilaController {
Api.GlobalLinearLimitPerIP(HTTPRequest lastRemoteAddress req) {
Api.GlobalLinearLimitPerUserOption(me) {
val format = if (HTTPRequest acceptsNdJson req) GameApiV2.Format.JSON else GameApiV2.Format.PGN
val config = GameApiV2.Config(
val config = GameApiV2.ByUserConfig(
user = user,
format = format,
since = getLong("since", req) map { ts => new DateTime(ts) },
@ -49,13 +71,7 @@ object Game extends LilaController {
perfType = ~get("perfType", req) split "," flatMap { lila.rating.PerfType(_) } toSet,
color = get("color", req) flatMap chess.Color.apply,
analysed = getBoolOpt("analysed", req),
flags = lila.game.PgnDump.WithFlags(
moves = getBoolOpt("moves", req) | true,
tags = getBoolOpt("tags", req) | true,
clocks = getBoolOpt("clocks", req) | false,
evals = getBoolOpt("evals", req) | false,
opening = getBoolOpt("opening", req) | false
),
flags = requestPgnFlags(req),
perSecond = MaxPerSecond(me match {
case Some(m) if m is user.id => 50
case Some(_) if oauth => 20 // bonus for oauth logged in only (not for XSRF)
@ -63,12 +79,9 @@ object Game extends LilaController {
})
)
val date = (DateTimeFormat forPattern "yyyy-MM-dd") print new DateTime
Ok.chunked(Env.api.gameApiV2.exportUserGames(config)).withHeaders(
CONTENT_TYPE -> (format match {
case GameApiV2.Format.PGN => pgnContentType
case GameApiV2.Format.JSON => ndJsonContentType
}),
CONTENT_DISPOSITION -> ("attachment; filename=" + s"lichess_${user.username}_$date.${format.toString.toLowerCase}")
Ok.chunked(Env.api.gameApiV2.exportByUser(config)).withHeaders(
CONTENT_TYPE -> gameContentType(config),
CONTENT_DISPOSITION -> s"attachment; filename=lichess_${user.username}_$date.${format.toString.toLowerCase}"
).fuccess
}
}
@ -76,6 +89,23 @@ object Game extends LilaController {
}
}
private def requestPgnFlags(req: RequestHeader) =
lila.game.PgnDump.WithFlags(
moves = getBoolOpt("moves", req) | true,
tags = getBoolOpt("tags", req) | true,
clocks = getBoolOpt("clocks", req) | false,
evals = getBoolOpt("evals", req) | false,
opening = getBoolOpt("opening", req) | false
)
private def gameContentType(config: GameApiV2.Config) = config.format match {
case GameApiV2.Format.PGN => pgnContentType
case GameApiV2.Format.JSON => config match {
case _: GameApiV2.ByUserConfig => ndJsonContentType
case _: GameApiV2.OneConfig => JSON
}
}
private[controllers] def preloadUsers(game: GameModel): Funit =
Env.user.lightUserApi preloadMany game.userIds
}

View File

@ -69,12 +69,12 @@ atom = atom.some) {
<div class="pgn_options">
<strong>PGN</strong>
<div>
<a data-icon="x" class="text" rel="nofollow" href="@routes.Export.pgn(game.id)">@trans.downloadAnnotated()</a>
<a data-icon="x" class="text" rel="nofollow" href="@routes.Game.exportOne(game.id)?evals=1&clocks=1&opening=1">@trans.downloadAnnotated()</a>
@if(analysis.isDefined) {
<a data-icon="x" class="text" rel="nofollow" href="@routes.Export.pgn(game.id)?as=raw">@trans.downloadRaw()</a>
<a data-icon="x" class="text" rel="nofollow" href="@routes.Game.exportOne(game.id)">@trans.downloadRaw()</a>
}
@if(game.isPgnImport) {
<a data-icon="x" class="text" rel="nofollow" href="@routes.Export.pgn(game.id)?as=imported">@trans.downloadImported()</a>
<a data-icon="x" class="text" rel="nofollow" href="@routes.Game.exportOne(game.id)?imported=1">@trans.downloadImported()</a>
}
<a data-icon="=" class="text embed_howto" target="_blank">@trans.embedInYourWebsite()</a>
</div>

View File

@ -54,14 +54,14 @@ chessground = false) {
<div class="panel fen_pgn">
<p><strong>FEN</strong><input readonly="true" spellcheck="false" class="copyable autoselect fen" /></p>
<p><strong>PGN</strong>
<a data-icon="x" rel="nofollow" href="@routes.Export.pgn(game.id)"> @trans.downloadAnnotated()</a>
<a data-icon="x" rel="nofollow" href="@routes.Game.exportOne(game.id)?evals=1&clocks=1&opening=1"> @trans.downloadAnnotated()</a>
@if(analysis.isDefined) {
/
<a data-icon="x" rel="nofollow" href="@routes.Export.pgn(game.id)?as=raw"> @trans.downloadRaw()</a>
<a data-icon="x" rel="nofollow" href="@routes.Game.exportOne(game.id)"> @trans.downloadRaw()</a>
}
@if(game.isPgnImport) {
/
<a data-icon="x" rel="nofollow" href="@routes.Export.pgn(game.id)?as=imported"> @trans.downloadImported()</a>
<a data-icon="x" rel="nofollow" href="@routes.Game.exportOne(game.id)?imported=1"> @trans.downloadImported()</a>
}
</p>
<div class="pgn">@pgn</div>

View File

@ -61,7 +61,7 @@
}
}
@if(ctx is u) {
<a class="button hint--top-left" href="@routes.Game.export(u.username)" data-hint="@trans.exportGames()"><span data-icon="x"></span></a>
<a class="button hint--top-left" href="@routes.Game.exportByUser(u.username)" data-hint="@trans.exportGames()"><span data-icon="x"></span></a>
}
</div>
</div>

View File

@ -14,7 +14,7 @@ POST /timeline/unsub/:channel controllers.Timeline.unsub(channel: Strin
GET /games/search controllers.Search.index(page: Int ?= 1)
# Game export
GET /games/export/:username controllers.Game.export(username: String)
GET /games/export/:username controllers.Game.exportByUser(username: String)
# TV
GET /tv controllers.Tv.index
@ -283,7 +283,8 @@ GET /team/:id/users controllers.Team.users(id: String)
# Analyse
POST /$gameId<\w{8}>/request-analysis controllers.Analyse.requestAnalysis(gameId: String)
GET /game/export/$gameId<\w{8}>.pgn controllers.Export.pgn(gameId: String)
GET /game/export/$gameId<\w{8}> controllers.Game.exportOne(gameId: String)
GET /game/export/$gameId<\w{8}>.pgn controllers.Game.exportOne(gameId: String)
GET /game/export/png/$gameId<\w{8}>.png controllers.Export.png(gameId: String)
# Fishnet

View File

@ -6,6 +6,7 @@ import play.api.libs.json._
import scala.concurrent.duration._
import chess.format.FEN
import chess.format.pgn.Tag
import lila.analyse.{ AnalysisRepo, JsonView => analysisJson, Analysis }
import lila.common.{ LightUser, MaxPerSecond }
import lila.game.JsonView._
@ -20,7 +21,31 @@ final class GameApiV2(
import GameApiV2._
def exportUserGames(config: Config): Enumerator[String] = {
def exportOne(game: Game, config: OneConfig): Fu[String] =
game.pgnImport ifTrue config.imported match {
case Some(imported) => fuccess(imported.pgn)
case None => enrich(config.flags)(game) flatMap {
case (game, initialFen, analysis) => config.format match {
case Format.JSON => toJson(game, initialFen, analysis, config.flags) map Json.stringify
case Format.PGN => pgnDump.toPgnString(game, initialFen, analysis, config.flags)
}
}
}
private val fileR = """[\s,]""".r
def filename(game: Game, format: Format): Fu[String] = gameLightUsers(game) map {
case List(wu, bu) => fileR.replaceAllIn(
"lichess_pgn_%s_%s_vs_%s.%s.%s".format(
Tag.UTCDate.format.print(game.createdAt),
pgnDump.dumper.player(game.whitePlayer, wu),
pgnDump.dumper.player(game.blackPlayer, bu),
game.id,
format.toString.toLowerCase
), "_"
)
}
def exportByUser(config: ByUserConfig): Enumerator[String] = {
import reactivemongo.play.iteratees.cursorProducer
import lila.db.dsl._
@ -48,27 +73,27 @@ final class GameApiV2(
case Format.JSON => jsonFormatter(config.flags)
}
games &>
Enumeratee.mapM { game =>
GameRepo initialFen game flatMap { initialFen =>
(config.flags.evals ?? AnalysisRepo.byGame(game)) map { analysis =>
(game, initialFen, analysis)
}
}
} &> formatter
games &> Enumeratee.mapM(enrich(config.flags)) &> formatter
}
private def enrich(flags: WithFlags)(game: Game) =
GameRepo initialFen game flatMap { initialFen =>
(flags.evals ?? AnalysisRepo.byGame(game)) map { analysis =>
(game, initialFen, analysis)
}
}
private def jsonFormatter(flags: WithFlags) =
Enumeratee.mapM[(Game, Option[FEN], Option[Analysis])].apply[String] {
case (game, initialFen, analysis) => toJson(game, analysis, initialFen, flags) map { json =>
case (game, initialFen, analysis) => toJson(game, initialFen, analysis, flags) map { json =>
s"${Json.stringify(json)}\n"
}
}
private def toJson(
g: Game,
analysisOption: Option[Analysis],
initialFen: Option[FEN],
analysisOption: Option[Analysis],
withFlags: WithFlags
): Fu[JsObject] = gameLightUsers(g) map { lightUsers =>
Json.obj(
@ -119,7 +144,17 @@ object GameApiV2 {
case object JSON extends Format
}
case class Config(
sealed trait Config {
val format: Format
}
case class OneConfig(
format: Format,
imported: Boolean,
flags: WithFlags
) extends Config
case class ByUserConfig(
user: lila.user.User,
format: Format,
since: Option[DateTime] = None,
@ -131,7 +166,7 @@ object GameApiV2 {
color: Option[chess.Color],
flags: WithFlags,
perSecond: MaxPerSecond
) {
) extends Config {
def postFilter(g: Game) =
rated.fold(true)(g.rated ==) && {
perfType.isEmpty || g.perfType.exists(perfType.contains)

View File

@ -11,7 +11,7 @@ import lila.game.PgnDump.WithFlags
import lila.game.{ Game, GameRepo, Query }
final class PgnDump(
dumper: lila.game.PgnDump,
val dumper: lila.game.PgnDump,
getSimulName: String => Fu[Option[String]],
getTournamentName: String => Option[String]
) {
@ -37,17 +37,17 @@ final class PgnDump(
}))
}
def filename(game: Game) = dumper filename game
def formatter(flags: WithFlags) =
Enumeratee.mapM[(Game, Option[FEN], Option[Analysis])].apply[String] {
case (game, initialFen, analysis) =>
apply(game, initialFen, analysis, flags).map { pgn =>
// merge analysis & eval comments
// 1. e4 { [%eval 0.17] } { [%clk 0:00:30] }
// 1. e4 { [%eval 0.17] [%clk 0:00:30] }
s"$pgn\n\n\n".replace("] } { [", "] [")
}
case (game, initialFen, analysis) => toPgnString(game, initialFen, analysis, flags)
}
def toPgnString(game: Game, initialFen: Option[FEN], analysis: Option[Analysis], flags: WithFlags) =
apply(game, initialFen, analysis, flags).map { pgn =>
// merge analysis & eval comments
// 1. e4 { [%eval 0.17] } { [%clk 0:00:30] }
// 1. e4 { [%eval 0.17] [%clk 0:00:30] }
s"$pgn\n\n\n".replace("] } { [", "] [")
}
// def exportGamesFromIds(ids: List[String]): Enumerator[String] =

View File

@ -74,6 +74,6 @@ object HTTPRequest {
def isHttp10(req: RequestHeader) = req.version == "HTTP/1.0"
def acceptsNdJson(req: RequestHeader) =
req.headers get HeaderNames.ACCEPT contains "application/x-ndjson"
def acceptsNdJson(req: RequestHeader) = req.headers get HeaderNames.ACCEPT contains "application/x-ndjson"
def acceptsJson(req: RequestHeader) = req.headers get HeaderNames.ACCEPT contains "application/json"
}

View File

@ -36,19 +36,6 @@ final class PgnDump(
}
}
private val fileR = """[\s,]""".r
def filename(game: Game): Fu[String] = gameLightUsers(game) map {
case (wu, bu) => fileR.replaceAllIn(
"lichess_pgn_%s_%s_vs_%s.%s.pgn".format(
Tag.UTCDate.format.print(game.createdAt),
player(game.whitePlayer, wu),
player(game.blackPlayer, bu),
game.id
), "_"
)
}
private def gameUrl(id: String) = s"$netBaseUrl/$id"
private def gameLightUsers(game: Game): Fu[(Option[LightUser], Option[LightUser])] =
@ -56,7 +43,7 @@ final class PgnDump(
private def rating(p: Player) = p.rating.fold("?")(_.toString)
private def player(p: Player, u: Option[LightUser]) =
def player(p: Player, u: Option[LightUser]) =
p.aiLevel.fold(u.fold(p.name | lila.user.User.anonymous)(_.name))("lichess AI level " + _)
private val customStartPosition: Set[chess.variant.Variant] =