diff --git a/app/controllers/Importer.scala b/app/controllers/Importer.scala index 092a4ae852..92bd879b53 100644 --- a/app/controllers/Importer.scala +++ b/app/controllers/Importer.scala @@ -54,16 +54,6 @@ object Importer extends LilaController { val fenParam = get("fen").??(f => s"?fen=$f") s"$url$fenParam" } - GameRepo game id flatMap { - case Some(game) if game.createdAt.isAfter(masterGameEncodingFixedAt) => fuccess(redirectAtFen(game)) - case _ => (GameRepo remove id) >> Env.explorer.fetchPgn(id) flatMap { - case None => fuccess(NotFound) - case Some(pgn) => env.importer( - lila.importer.ImportData(pgn, none), - user = "lichess".some, - forceId = id.some - ) map redirectAtFen - } - } + Env.explorer.importer(id) map2 redirectAtFen } } diff --git a/build.sbt b/build.sbt index 9d88e10d43..a981cb0822 100644 --- a/build.sbt +++ b/build.sbt @@ -272,7 +272,7 @@ lazy val challenge = module("challenge", Seq(common, db, hub, setup, game, relat ) lazy val study = module("study", Seq( - common, db, hub, socket, game, round, importer, notifyModule, relation, evalCache + common, db, hub, socket, game, round, importer, notifyModule, relation, evalCache, explorer )).settings( libraryDependencies ++= provided(play.api, reactivemongo.driver) ) @@ -370,7 +370,7 @@ lazy val report = module("report", Seq(common, db, user, game, security)).settin ) ) -lazy val explorer = module("explorer", Seq(common, db, game)).settings( +lazy val explorer = module("explorer", Seq(common, db, game, importer)).settings( libraryDependencies ++= provided( play.api, reactivemongo.driver, reactivemongo.iteratees diff --git a/modules/explorer/src/main/Env.scala b/modules/explorer/src/main/Env.scala index 23aac35fda..a1dfc1136e 100644 --- a/modules/explorer/src/main/Env.scala +++ b/modules/explorer/src/main/Env.scala @@ -6,6 +6,7 @@ import com.typesafe.config.Config final class Env( config: Config, gameColl: lila.db.dsl.Coll, + importer: lila.importer.Importer, system: ActorSystem ) { @@ -23,15 +24,6 @@ final class Env( } } - def fetchPgn(id: String): Fu[Option[String]] = { - import play.api.libs.ws.WS - import play.api.Play.current - WS.url(s"$InternalEndpoint/master/pgn/$id").get() map { - case res if res.status == 200 => res.body.some - case _ => None - } - } - if (IndexFlow) system.lilaBus.subscribe(system.actorOf(Props(new Actor { def receive = { case lila.game.actorApi.FinishGame(game, _, _) if !game.aborted => indexer(game) @@ -44,6 +36,7 @@ object Env { lazy val current = "explorer" boot new Env( config = lila.common.PlayApp loadConfig "explorer", gameColl = lila.game.Env.current.gameColl, + importer = lila.importer.Env.current.importer, system = lila.common.PlayApp.system ) } diff --git a/modules/explorer/src/main/ExplorerImport.scala b/modules/explorer/src/main/ExplorerImport.scala new file mode 100644 index 0000000000..250a4ba511 --- /dev/null +++ b/modules/explorer/src/main/ExplorerImport.scala @@ -0,0 +1,36 @@ +package lila.explorer + +import org.joda.time.DateTime + +import lila.game.{ Game, GameRepo } +import lila.importer.{ Importer, ImportData } + +final class ExplorerImport( + endpoint: String, + importer: Importer +) { + + private val masterGameEncodingFixedAt = new DateTime(2016, 3, 9, 0, 0) + + def apply(id: Game.ID): Fu[Option[Game]] = + GameRepo game id flatMap { + case Some(game) if game.createdAt.isAfter(masterGameEncodingFixedAt) => fuccess(game.some) + case _ => (GameRepo remove id) >> fetchPgn(id) flatMap { + case None => fuccess(none) + case Some(pgn) => importer( + ImportData(pgn, none), + user = "lichess".some, + forceId = id.some + ) map some + } + } + + private def fetchPgn(id: String): Fu[Option[String]] = { + import play.api.libs.ws.WS + import play.api.Play.current + WS.url(s"$endpoint/master/pgn/$id").get() map { + case res if res.status == 200 => res.body.some + case _ => None + } + } +} diff --git a/modules/study/src/main/ExplorerGame.scala b/modules/study/src/main/ExplorerGame.scala new file mode 100644 index 0000000000..85f86bea57 --- /dev/null +++ b/modules/study/src/main/ExplorerGame.scala @@ -0,0 +1,38 @@ +package lila.study + +import lila.game.{ Game, Namer, PgnImport } +import lila.user.User +import chess.format.pgn.{ Parser, Reader, ParsedPgn, Tag, TagType } + +private final class ExplorerGame( + importer: lila.explorer.ExplorerImport +) { + + def quote(userId: User.ID, study: Study, chapter: Chapter, path: Path, gameId: Game.ID): Fu[Comment] = + importer(gameId) flatMap { + _.fold(false) { game => + + val comment = Comment( + id = Comment.Id.make, + text = game.pgnImport.fold(lichessTitle)(importTitle(game)), + by = Comment.Author.User(author.id, author.titleName) + ) + } + } + + private def importTitle(g: Game)(pgnImport: PgnImport): String = + Parser.full(pgnImport.pgn) flatMap { + case ParsedPgn(_, tags, _) => + def tag(which: Tag.type => TagType): Option[String] = + tags find (_.name == which(Tag)) map (_.value) + + ImportData(pgnImport.pgn, none).preprocess(none).fold( + _ => lichessTitle(g), + processed => + val players = Namer.vsText(game, withRatings = true) + val result = chess.Color.showResult(game.winnerColor) + val text = s"$players, $result + } + + def insert(userId: User.ID, study: Study, chapter: Chapter, gameId: Game.ID) = ??? +} diff --git a/modules/study/src/main/SocketHandler.scala b/modules/study/src/main/SocketHandler.scala index 5cf94429c3..b15b605a51 100644 --- a/modules/study/src/main/SocketHandler.scala +++ b/modules/study/src/main/SocketHandler.scala @@ -228,6 +228,13 @@ private[study] final class SocketHandler( } api.toggleGlyph(userId, studyId, position.ref, glyph, uid) } + case ("explorerGame", o) => + reading[actorApi.ExplorerGame](o) { data => + member.userId foreach { byUserId => + api.explorerGame(byUserId, studyId, data, uid) + } + } + case ("like", o) => for { byUserId <- member.userId v <- (o \ "d" \ "liked").asOpt[Boolean] @@ -260,6 +267,7 @@ private[study] final class SocketHandler( private implicit val StudyDataReader = Json.reads[Study.Data] private implicit val setTagReader = Json.reads[actorApi.SetTag] private implicit val gamebookReader = Json.reads[Gamebook] + private implicit val explorerGame = Json.reads[actorApi.ExplorerGame] def join( studyId: Study.Id, diff --git a/modules/study/src/main/StudyCommenter.scala b/modules/study/src/main/StudyCommenter.scala new file mode 100644 index 0000000000..8ea40ed32b --- /dev/null +++ b/modules/study/src/main/StudyCommenter.scala @@ -0,0 +1,19 @@ +package lila.study + +private final class StudyCommenter() { + + def apply(chapter: Chapteer, position: Position, comment: Comment) = + chapter.setComment(comment, position.path) match { + case Some(newChapter) => + studyRepo.updateNow(study) + newChapter.root.nodeAt(position.path) ?? { node => + node.comments.findBy(comment.by) ?? { c => + chapterRepo.setComments(newChapter, position.path, node.comments.filterEmpty) >>- { + sendTo(study, Socket.SetComment(position, c, uid)) + indexStudy(study) + sendStudyEnters(study, userId) + } + } + } + } +} diff --git a/modules/study/src/main/actorApi.scala b/modules/study/src/main/actorApi.scala index 61cb4c0756..fd6fdba3b5 100644 --- a/modules/study/src/main/actorApi.scala +++ b/modules/study/src/main/actorApi.scala @@ -6,3 +6,4 @@ case class SaveStudy(study: Study) case class SetTag(chapterId: Chapter.Id, name: String, value: String) { def tag = chess.format.pgn.Tag(name, value take 140) } +case class ExplorerGame(chapterId: Chapter.Id, path: String, gameId: String, insert: Boolean) diff --git a/public/stylesheets/analyse.css b/public/stylesheets/analyse.css index 9974bba7d2..6f5ac53f80 100644 --- a/public/stylesheets/analyse.css +++ b/public/stylesheets/analyse.css @@ -608,9 +608,30 @@ body.dark .explorer_box .black { text-align: center; padding: 3px 5px; border-radius: 3px; - font-family: 'Roboto Mono', 'Roboto'; font-size: 0.9em; } +.explorer_box td.game_menu { + background: #759900; + cursor: default; + padding: 5px 0 0 0; +} +.explorer_box .game_menu .game_title { + text-align: center; + font-style: italic; + color: #fff; +} +.explorer_box .game_menu .menu { + display: flex; + justify-content: space-between; + text-transform: uppercase; +} +.explorer_box .game_menu .menu a { + color: #fff; + padding: 5px; +} +.explorer_box .game_menu .menu a:hover { + background: rgba(255,255,255,0.2); +} .explorer_box .tablebase { width: 100%; } diff --git a/ui/analyse/src/explorer/explorerCtrl.ts b/ui/analyse/src/explorer/explorerCtrl.ts index 4b3f41b999..38131a3888 100644 --- a/ui/analyse/src/explorer/explorerCtrl.ts +++ b/ui/analyse/src/explorer/explorerCtrl.ts @@ -17,13 +17,15 @@ function tablebaseRelevant(variant: string, fen: Fen) { } export default function(root: AnalyseCtrl, opts, allow: boolean): ExplorerCtrl { - const allowed = prop(allow); - const enabled = root.embed ? prop(false) : storedProp('explorer.enabled', false); + const allowed = prop(allow), + enabled = root.embed ? prop(false) : storedProp('explorer.enabled', false), + loading = prop(true), + failing = prop(false), + hovering = prop(null), + movesAway = prop(0), + gameMenu = prop(null); + if ((location.hash === '#explorer' || location.hash === '#opening') && !root.embed) enabled(true); - const loading = prop(true); - const failing = prop(false); - const hovering = prop(null); - const movesAway = prop(0); let cache = {}; function onConfigClose() { @@ -62,6 +64,7 @@ export default function(root: AnalyseCtrl, opts, allow: boolean): ExplorerCtrl { function setNode() { if (!enabled()) return; + gameMenu(null); const node = root.node; if (node.ply > 50 && !tablebaseRelevant(effectiveVariant, node.fen)) { cache[node.fen] = empty; @@ -87,6 +90,7 @@ export default function(root: AnalyseCtrl, opts, allow: boolean): ExplorerCtrl { movesAway, config, withGames, + gameMenu, current: () => cache[root.node.fen], toggle() { movesAway(0); @@ -97,6 +101,7 @@ export default function(root: AnalyseCtrl, opts, allow: boolean): ExplorerCtrl { disable() { if (enabled()) { enabled(false); + gameMenu(null); root.autoScroll(); } }, diff --git a/ui/analyse/src/explorer/explorerView.ts b/ui/analyse/src/explorer/explorerView.ts index bd40b3fb98..986011471d 100644 --- a/ui/analyse/src/explorer/explorerView.ts +++ b/ui/analyse/src/explorer/explorerView.ts @@ -79,6 +79,7 @@ function showResult(winner: Color): VNode { function showGameTable(ctrl: AnalyseCtrl, title: string, games): VNode | null { if (!ctrl.explorer.withGames || !games.length) return null; + const openedId = ctrl.explorer.gameMenu(); return h('table.games', [ h('thead', [ h('tr', [ @@ -86,29 +87,46 @@ function showGameTable(ctrl: AnalyseCtrl, title: string, games): VNode | null { ]) ]), h('tbody', { - insert: vnode => { - const el = vnode.elm as HTMLElement; - el.addEventListener('click', e => { - const $tr = $(e.target).parents('tr'); - if (!$tr.length) return; - const orientation = ctrl.chessground.state.orientation; - const fenParam = ctrl.node.ply > 0 ? ('?fen=' + ctrl.node.fen) : ''; - if (ctrl.explorer.config.data.db.selected() === 'lichess') - window.open('/' + $tr.data('id') + '/' + orientation + fenParam, '_blank'); - else window.open('/import/master/' + $tr.data('id') + '/' + orientation + fenParam, '_blank'); - }); - // el.oncontextmenu = (e: MouseEvent) => { - // const path = eventPath(e); - // if (path !== null) contextMenu(e, { - // path, - // root: ctrl - // }); - // ctrl.redraw(); - // return false; - // }; - } - }, games.map(function(game) { - return h('tr', { + hook: bind('click', e => { + const $tr = $(e.target).parents('tr'); + if (!$tr.length) return; + ctrl.explorer.gameMenu($tr.data('id')); + ctrl.redraw(); + }) + }, games.map(game => { + return openedId && openedId === game.id ? h('tr', [ + h('td.game_menu', { + attrs: { colspan: 4 }, + }, [ + h('div.game_title', `${game.white.name} - ${game.black.name}, ${showResult(game.winner).text}, ${game.year}`), + h('div.menu', [ + h('a.text', { + attrs: dataIcon('v'), + hook: bind('click', _ => { + const orientation = ctrl.chessground.state.orientation, + fenParam = ctrl.node.ply > 0 ? ('?fen=' + ctrl.node.fen) : ''; + if (ctrl.explorer.config.data.db.selected() === 'lichess') + window.open('/' + openedId + '/' + orientation + fenParam, '_blank'); + else window.open('/import/master/' + openedId + '/' + orientation + fenParam, '_blank'); + }) + }, 'View'), + ...(ctrl.study ? [ + h('a.text', { + attrs: dataIcon('c'), + hook: bind('click', _ => ctrl.study!.explorerGame(openedId, false), ctrl.redraw) + }, 'Quote'), + h('a.text', { + attrs: dataIcon('O'), + hook: bind('click', _ => ctrl.study!.explorerGame(openedId, true), ctrl.redraw) + }, 'Insert') + ] : []), + h('a.text', { + attrs: dataIcon('L'), + hook: bind('click', _ => ctrl.explorer.gameMenu(null), ctrl.redraw) + }, 'Close') + ]) + ]) + ]) : h('tr', { key: game.id, attrs: { 'data-id': game.id } }, [ diff --git a/ui/analyse/src/explorer/interfaces.ts b/ui/analyse/src/explorer/interfaces.ts index 5e818c4312..4b55850334 100644 --- a/ui/analyse/src/explorer/interfaces.ts +++ b/ui/analyse/src/explorer/interfaces.ts @@ -93,6 +93,7 @@ export interface ExplorerCtrl { movesAway: Prop; config: ExplorerConfigCtrl; withGames: boolean; + gameMenu: Prop; current(): ExplorerData | undefined; hovering: Prop; setNode(); diff --git a/ui/analyse/src/study/interfaces.ts b/ui/analyse/src/study/interfaces.ts index 5fbb457a90..6d5b52dd87 100644 --- a/ui/analyse/src/study/interfaces.ts +++ b/ui/analyse/src/study/interfaces.ts @@ -42,6 +42,7 @@ export interface StudyCtrl { mutateCgConfig(config: any): void; isUpdatedRecently(): boolean; setGamebookOverride(o: GamebookOverride): void; + explorerGame(gameId: string, insert: boolean): void; redraw(): void; } diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index fa92191b7a..a29abb5c3c 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -247,6 +247,12 @@ export default function(data: StudyData, ctrl: AnalyseCtrl, tagTypes: TagTypes, vm.updatedAt = Date.now(); } + function withPosition(obj: any) { + obj.ch = vm.chapterId; + obj.path = ctrl.path; + return obj; + } + return { data, form, @@ -284,11 +290,7 @@ export default function(data: StudyData, ctrl: AnalyseCtrl, tagTypes: TagTypes, if (practice) practice.onJump(); if (gamebookPlay) gamebookPlay.onJump(); }, - withPosition(obj) { - obj.ch = vm.chapterId; - obj.path = ctrl.path; - return obj; - }, + withPosition, setPath(path, node) { onSetPath(path); setTimeout(() => commentForm.onSetPath(path, node), 100); @@ -347,6 +349,9 @@ export default function(data: StudyData, ctrl: AnalyseCtrl, tagTypes: TagTypes, if (!o) xhrReload(); }, mutateCgConfig, + explorerGame(gameId: string, insert: boolean) { + makeChange('explorerGame', withPosition({ gameId, insert })); + }, redraw, socketHandlers: { path(d) {