diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index 6a9e37603f762..e3d7e00c1aacd 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -16,7 +16,7 @@ import lila.core.study.Order import lila.study.JsonView.JsData import lila.study.PgnDump.WithFlags import lila.study.Study.WithChapter -import lila.study.actorApi.{ BecomeStudyAdmin, Who } +import lila.study.{ BecomeStudyAdmin, Who } import lila.study.{ Chapter, Orders, Settings, Study as StudyModel, StudyForm } import lila.tree.Node.partitionTreeJsonWriter import com.fasterxml.jackson.core.JsonParseException diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index 2ec885fe0784e..3511026391278 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -34,7 +34,7 @@ final class AccountClosure( ) def close(u: User)(using me: Me): Funit = for - playbanned <- playbanApi.HasCurrentPlayban(u.id) + playbanned <- playbanApi.hasCurrentPlayban(u.id) selfClose = me.is(u) teacherClose = !selfClose && !Granter(_.CloseAccount) && Granter(_.Teacher) modClose = !selfClose && Granter(_.CloseAccount) diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala index e9cf5918618ca..855dd94f1ca85 100644 --- a/modules/common/src/main/mon.scala +++ b/modules/common/src/main/mon.scala @@ -287,10 +287,10 @@ object mon: timer("relay.fetch.time").withTags(relay(official, id, slug)) def syncTime(official: Boolean, id: RelayTourId, slug: String) = timer("relay.sync.time").withTags(relay(official, id, slug)) - def httpGet(code: Int, host: String, proxy: Option[String]) = timer("relay.http.get").withTags: - tags("code" -> code, "host" -> host, "proxy" -> proxy.getOrElse("none")) - val dedup = counter("relay.fetch.dedup").withoutTags() - def etag(hit: "hit" | "miss" | "first") = counter("relay.fetch.etag").withTag("hit", hit) + def httpGet(code: Int, host: String, etag: String, proxy: Option[String]) = + timer("relay.http.get").withTags: + tags("code" -> code.toLong, "host" -> host, "etag" -> etag, "proxy" -> proxy.getOrElse("none")) + val dedup = counter("relay.fetch.dedup").withoutTags() object bot: def moves(username: String) = counter("bot.moves").withTag("name", username) diff --git a/modules/i18n/src/main/LangList.scala b/modules/i18n/src/main/LangList.scala index f7718cfedb4d1..66ce29925038e 100644 --- a/modules/i18n/src/main/LangList.scala +++ b/modules/i18n/src/main/LangList.scala @@ -11,7 +11,7 @@ object LangList extends lila.core.i18n.LangList: Lang("af", "ZA") -> "Afrikaans", Lang("an", "ES") -> "Aragonés", Lang("ar", "SA") -> "العربية", - Lang("as", "IN") -> "অসমীয়া", + Lang("ast", "ES") -> "Asturianu", Lang("av", "DA") -> "авар мацӀ", Lang("az", "AZ") -> "Azərbaycanca", Lang("be", "BY") -> "Беларуская", @@ -37,7 +37,6 @@ object LangList extends lila.core.i18n.LangList: Lang("fi", "FI") -> "Suomen kieli", Lang("fo", "FO") -> "Føroyskt", Lang("fr", "FR") -> "Français", - Lang("frp", "IT") -> "Arpitan", Lang("fy", "NL") -> "Frysk", Lang("ga", "IE") -> "Gaeilge", Lang("gd", "GB") -> "Gàidhlig", @@ -51,19 +50,16 @@ object LangList extends lila.core.i18n.LangList: Lang("hy", "AM") -> "Հայերեն", Lang("ia", "IA") -> "Interlingua", Lang("id", "ID") -> "Bahasa Indonesia", - Lang("io", "EN") -> "Ido", Lang("is", "IS") -> "Íslenska", Lang("it", "IT") -> "Italiano", Lang("ja", "JP") -> "日本語", Lang("jbo", "EN") -> "Lojban", - Lang("jv", "ID") -> "Basa Jawa", Lang("ka", "GE") -> "ქართული", Lang("kab", "DZ") -> "Taqvaylit", Lang("kk", "KZ") -> "қазақша", Lang("kmr", "TR") -> "Kurdî (Kurmancî)", Lang("kn", "IN") -> "ಕನ್ನಡ", Lang("ko", "KR") -> "한국어", - Lang("ky", "KG") -> "кыргызча", Lang("la", "LA") -> "Lingua Latina", Lang("lb", "LU") -> "Lëtzebuergesch", Lang("lt", "LT") -> "Lietuvių kalba", @@ -85,7 +81,6 @@ object LangList extends lila.core.i18n.LangList: Lang("ro", "RO") -> "Română", Lang("ru", "RU") -> "русский язык", Lang("ry", "UA") -> "Русинська бисїда", - Lang("sa", "IN") -> "संस्कृत", Lang("sk", "SK") -> "Slovenčina", Lang("sl", "SI") -> "Slovenščina", Lang("so", "SO") -> "Af Soomaali", @@ -94,7 +89,6 @@ object LangList extends lila.core.i18n.LangList: Lang("sv", "SE") -> "Svenska", Lang("sw", "KE") -> "Kiswahili", Lang("ta", "IN") -> "தமிழ்", - Lang("tg", "TJ") -> "тоҷикӣ", Lang("th", "TH") -> "ไทย", Lang("tk", "TM") -> "Türkmençe", Lang("tl", "PH") -> "Tagalog", @@ -104,7 +98,6 @@ object LangList extends lila.core.i18n.LangList: Lang("ur", "PK") -> "اُردُو", Lang("uz", "UZ") -> "oʻzbekcha", Lang("vi", "VN") -> "Tiếng Việt", - Lang("yo", "NG") -> "Yorùbá", Lang("zh", "CN") -> "中文", Lang("zh", "TW") -> "繁體中文", Lang("zu", "ZA") -> "isiZulu" diff --git a/modules/playban/src/main/Env.scala b/modules/playban/src/main/Env.scala index e5afe21054980..6731822e2b352 100644 --- a/modules/playban/src/main/Env.scala +++ b/modules/playban/src/main/Env.scala @@ -20,4 +20,4 @@ final class Env( private val feedback = wire[PlaybanFeedback] val api = wire[PlaybanApi] - export api.{ bansOf, HasCurrentPlayban, rageSitOf } + export api.{ bansOf, hasCurrentPlayban, rageSitOf } diff --git a/modules/playban/src/main/PlaybanApi.scala b/modules/playban/src/main/PlaybanApi.scala index e982e8267e407..4b4763bb2be6b 100644 --- a/modules/playban/src/main/PlaybanApi.scala +++ b/modules/playban/src/main/PlaybanApi.scala @@ -188,7 +188,7 @@ final class PlaybanApi( .addEffect: ban => if ban.isEmpty then cleanUserIds.put(user.id) - val HasCurrentPlayban: lila.core.playban.HasCurrentPlayban = userId => currentBan(userId).map(_.isDefined) + val hasCurrentPlayban: lila.core.playban.HasCurrentPlayban = userId => currentBan(userId).map(_.isDefined) val bansOf: lila.core.playban.BansOf = userIds => coll diff --git a/modules/practice/src/main/Env.scala b/modules/practice/src/main/Env.scala index 894ac94cd8dfa..7feda25a58c4e 100644 --- a/modules/practice/src/main/Env.scala +++ b/modules/practice/src/main/Env.scala @@ -21,6 +21,6 @@ final class Env( def getStudies: lila.core.practice.GetStudies = api.structure.getStudies - lila.common.Bus.subscribeFun("study") { case lila.study.actorApi.SaveStudy(study) => + lila.common.Bus.subscribeFun("study") { case lila.study.SaveStudy(study) => api.structure.onSave(study) } diff --git a/modules/relay/src/main/Env.scala b/modules/relay/src/main/Env.scala index 38dbf6af506e1..4cc805748fd10 100644 --- a/modules/relay/src/main/Env.scala +++ b/modules/relay/src/main/Env.scala @@ -93,6 +93,11 @@ final class Env( private lazy val sync = wire[RelaySync] + private lazy val proxy = wire[RelayProxy] + private def selectProxy: ProxySelector = proxy.select + + private lazy val httpClient = wire[HttpClient] + private lazy val formatApi = wire[RelayFormatApi] private lazy val delay = wire[RelayDelay] @@ -139,19 +144,19 @@ final class Env( "study" -> { case lila.core.study.RemoveStudy(studyId) => api.onStudyRemove(studyId) }, - "relayToggle" -> { case lila.study.actorApi.RelayToggle(id, v, who) => + "relayToggle" -> { case lila.study.RelayToggle(id, v, who) => studyApi .isContributor(id, who.u) .foreach: _.so(api.requestPlay(id.into(RelayRoundId), v, "manual toggle")) }, - "kickStudy" -> { case lila.study.actorApi.Kick(studyId, userId, who) => + "kickStudy" -> { case lila.study.Kick(studyId, userId, who) => roundRepo.tourIdByStudyId(studyId).flatMapz(api.kickBroadcast(userId, _, who)) }, - "adminStudy" -> { case lila.study.actorApi.BecomeStudyAdmin(studyId, me) => + "adminStudy" -> { case lila.study.BecomeStudyAdmin(studyId, me) => api.becomeStudyAdmin(studyId, me) }, - "isOfficialRelay" -> { case lila.study.actorApi.IsOfficialRelay(studyId, promise) => + "isOfficialRelay" -> { case lila.study.IsOfficialRelay(studyId, promise) => promise.completeWith(api.isOfficial(studyId.into(RelayRoundId))) } ) diff --git a/modules/relay/src/main/HttpClient.scala b/modules/relay/src/main/HttpClient.scala new file mode 100644 index 0000000000000..2ac116407e2f0 --- /dev/null +++ b/modules/relay/src/main/HttpClient.scala @@ -0,0 +1,94 @@ +package lila.relay + +import java.nio.charset.{ Charset, StandardCharsets } +import io.mola.galimatias.URL +import play.api.libs.ws.* +import play.shaded.ahc.org.asynchttpclient.util.HttpUtils.extractContentTypeCharsetAttribute + +import lila.core.lilaism.LilaException + +/* Extra generic features for play WS client, + * without any knowledge of broadcast specifics. + * This could be moved for reuse later on. + * - Proxies + * - Etag cache + */ +private final class HttpClient( + ws: StandaloneWSClient, + cacheApi: lila.memo.CacheApi, + proxySelector: ProxySelector +)(using Executor): + + import HttpClient.* + + val etagCache = cacheApi.notLoadingSync[URL, (Body, Etag)](256, "relay.fetch.etagCache"): + _.expireAfterWrite(10 minutes).build() + + def get(url: URL)(using CanProxy): Fu[Body] = + etagCache + .getIfPresent(url) + .match + case None => + fetchBodyAndEtag(url, none) + case Some((prevBody, prevEtag)) => + fetchBodyAndEtag(url, prevEtag.some).map: (newBody, newEtag) => + val body = if newBody.isEmpty && newEtag.has(prevEtag) then prevBody.some else newBody + (body, newEtag) + .map: (body, etag) => + (body, etag).mapN((b, e) => etagCache.put(url, b -> e)) + ~body + + private def fetchBodyAndEtag(url: URL, etag: Option[Etag])(using + CanProxy + ): Fu[(Option[Body], Option[Etag])] = + val req = etag.foldLeft(toRequest(url))((req, etag) => req.addHttpHeaders("If-None-Match" -> etag)) + fetchResponse(req).map: res => + val newEtag = res.header("Etag") + if res.status == 304 + then none -> newEtag.orElse(etag) + else decodeResponseBody(res).some -> newEtag + + private def fetchResponse(req: StandaloneWSRequest): Fu[StandaloneWSResponse] = + Future + .fromTry(lila.common.url.parse(req.url)) + .flatMap: url => + req + .get() + .monValue: res => + _.relay.httpGet( + res.status, + url.host.toString, + etag = monitorEtagHit(req, res), + req.proxyServer.map(_.host) + ) + .flatMap: res => + if res.status == 200 || res.status == 304 then fuccess(res) + else fufail(Status(res.status, url)) + + private def decodeResponseBody(res: StandaloneWSResponse): Body = + val charset = Option(extractContentTypeCharsetAttribute(res.contentType)) + .orElse(res.contentType.startsWith("text/").option(StandardCharsets.ISO_8859_1)) + charset match + case None => lila.common.String.charset.guessAndDecode(res.bodyAsBytes) + case Some(known) => res.bodyAsBytes.decodeString(known) + + private def toRequest(url: URL)(using CanProxy): StandaloneWSRequest = + val req = ws + .url(url.toString) + .withRequestTimeout(5.seconds) + .withFollowRedirects(false) + proxySelector(url).foldLeft(req)(_ withProxyServer _) + + private def monitorEtagHit(req: StandaloneWSRequest, res: StandaloneWSResponse): String = + (req.header("If-None-Match"), res.header("Etag")) match + case (None, None) => "none" // endpoint doesn't support Etag + case (None, Some(_)) => "first" // local cache is cold + case (Some(_), _) if res.status == 304 => "hit" // cache hit + case (Some(_), Some(_)) => "miss" // new data from the endpoint + case (Some(_), None) => "fail" // we sent an etag but the endpoint doesn't support it? + +private object HttpClient: + type Etag = String + type Body = String + case class Status(code: Int, url: URL) extends LilaException: + override val message = s"$code: $url" diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index 9c380bd031260..ae43d28b12e7b 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -19,7 +19,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): ): Fu[RelayGames] = dedupCache(url, round, () => doFetchUrl(url)) .flatMap: latest => - round.sync.delay match + round.sync.delayMinusLag match case Some(delay) if delay > 0 => store.get(url, delay).map(_ | latest.map(_.resetToSetup)) case _ => fuccess(latest) @@ -48,7 +48,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): ) .games .addEffect: games => - if round.sync.hasDelay then store.putIfNew(url, games) + if round.sync.delayMinusLag.isDefined then store.putIfNew(url, games) private object store: diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index b7267ca84d5fc..43fa4b3454e5c 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -5,14 +5,12 @@ import chess.{ Outcome, Ply } import com.github.blemale.scaffeine.LoadingCache import io.mola.galimatias.URL import play.api.libs.json.* -import play.api.libs.ws.{ StandaloneWSRequest, StandaloneWSResponse } import scalalib.model.Seconds import lila.common.LilaScheduler import lila.core.lilaism.LilaInvalid import lila.game.{ GameRepo, PgnDump } import lila.memo.CacheApi -import lila.relay.RelayFormat.CanProxy import lila.relay.RelayRound.Sync import lila.study.{ MultiPgn, StudyPgnImport } @@ -20,6 +18,7 @@ final private class RelayFetch( sync: RelaySync, api: RelayApi, irc: lila.core.irc.IrcApi, + http: HttpClient, formatApi: RelayFormatApi, delayer: RelayDelay, fidePlayers: RelayFidePlayerApi, @@ -148,16 +147,9 @@ final private class RelayFetch( private def continueRelay(tour: RelayTour, updating: Updating[RelayRound]): Updating[RelayRound] = val round = updating.current round.sync.upstream.fold(updating): upstream => + reportBroadcastFailure(round.withTour(tour)) val seconds: Seconds = if round.sync.log.alwaysFails then - round.sync.log.events.lastOption - .filterNot(_.isTimeout) - .flatMap(_.error) - .ifTrue(tour.official && round.shouldHaveStarted) - .filterNot(_.contains("Cannot parse move")) - .filterNot(_.contains("Cannot parse pgn")) - .filterNot(_.contains("Found an empty PGN")) - .foreach { irc.broadcastError(round.id, round.withTour(tour).fullName, _) } Seconds(tour.tier.fold(60): case RelayTour.Tier.best => 10 case RelayTour.Tier.high => 20 @@ -174,6 +166,17 @@ final private class RelayFetch( }.some ) + private def reportBroadcastFailure(r: RelayRound.WithTour): Unit = + if r.round.sync.log.alwaysFails then + r.round.sync.log.events.lastOption + .filterNot(_.isTimeout) + .flatMap(_.error) + .ifTrue(r.tour.official && r.round.shouldHaveStarted) + .filterNot(_.contains("Cannot parse move")) + .filterNot(_.contains("Cannot parse pgn")) + .filterNot(_.contains("Found an empty PGN")) + .foreach { irc.broadcastError(r.round.id, r.fullName, _) } + private def dynamicPeriod(tour: RelayTour, round: RelayRound, upstream: Sync.Upstream) = Seconds: val base = if upstream.hasLcc then 5 @@ -271,7 +274,7 @@ final private class RelayFetch( // the point is to avoid messing up slices in multi-URL setups. // if a single URL fails, it should not moves the games of the following URLs. private val multiUrlFetchRecoverCache = - cacheApi.notLoadingSync[URL, RelayGames](256, "relay.fetch.recoverCache"): + cacheApi.notLoadingSync[URL, RelayGames](16, "relay.fetch.recoverCache"): _.expireAfterWrite(1 hour).build() private def fetchFromUpstreamWithRecovery(rt: RelayRound.WithTour)(url: URL)(using @@ -292,7 +295,7 @@ final private class RelayFetch( import DgtJson.* formatApi .get(url) - .flatMap { + .flatMap: case RelayFormat.Round(id) => studyChapterRepo .orderedByStudyLoadingAllInMemory(id.into(StudyId)) @@ -302,73 +305,45 @@ final private class RelayFetch( .map { MultiPgn.split(_, RelayFetch.maxGamesToRead(rt.tour.official)) } .flatMap(multiPgnToGames.future) case RelayFormat.LccWithGames(lcc) => - lccRoundJsonWithEtag(lcc.indexUrl).flatMap: round => - val lookForStart: Boolean = - rt.round.startsAtTime - .map(_.minusSeconds(rt.round.sync.delay.so(_.value) + 5 * 60)) - .forall(_.isBeforeNow) - round.pairings - .mapWithIndex: (pairing, i) => - val game = i + 1 - val tags = pairing.tags(lcc.round, game, round.date) - lccCache(lcc, game, tags, lookForStart): () => - lccGameJsonWithEtag(lcc.gameUrl(game)).recover: - case _: Exception => GameJson(moves = Nil, result = none) - .map { _.toPgn(tags) } - .recover: _ => - PgnStr(s"${tags}\n\n${pairing.result}") - .map(game -> _) - .parallel - .map: pgns => - MultiPgn(pgns.sortBy(_._1).map(_._2)) - .flatMap(multiPgnToGames.future) + httpGetRoundJson(lcc.indexUrl) + .flatMap: round => + val lookForStart: Boolean = + rt.round.startsAtTime + .map(_.minusSeconds(rt.round.sync.delay.so(_.value) + 5 * 60)) + .forall(_.isBeforeNow) + round.pairings + .mapWithIndex: (pairing, i) => + val game = i + 1 + val tags = pairing.tags(lcc.round, game, round.date) + lccCache(lcc, game, tags, lookForStart): () => + httpGetGameJson(lcc.gameUrl(game)).recover: + case _: Exception => GameJson(moves = Nil, result = none) + .map { _.toPgn(tags) } + .recover: _ => + PgnStr(s"${tags}\n\n${pairing.result}") + .map(game -> _) + .parallel + .map: pgns => + MultiPgn(pgns.sortBy(_._1).map(_._2)) + .flatMap(multiPgnToGames.future) case RelayFormat.LccWithoutGames(lcc) => - lccRoundJsonWithEtag(lcc.indexUrl) + httpGetRoundJson(lcc.indexUrl) .map: round => MultiPgn: round.pairings.mapWithIndex: (pairing, i) => PgnStr(s"${pairing.tags(lcc.round, i + 1, round.date)}\n\n${pairing.result}") .flatMap(multiPgnToGames.future) - } - - private def httpGetPgn(url: URL)(using CanProxy): Fu[PgnStr] = - PgnStr.from(formatApi.httpGetAndGuessCharset(url)) - private def readAsJson[A: Reads](url: URL)(body: String): Fu[A] = for + private def httpGetPgn(url: URL)(using CanProxy): Fu[PgnStr] = PgnStr.from(http.get(url)) + private def httpGetRoundJson(url: URL)(using CanProxy): Fu[DgtJson.RoundJson] = + http.get(url).flatMap(readAsJson[DgtJson.RoundJson](url)) + private def httpGetGameJson(url: URL)(using CanProxy): Fu[DgtJson.GameJson] = + http.get(url).flatMap(readAsJson[DgtJson.GameJson](url)) + private def readAsJson[A: Reads](url: URL)(body: HttpClient.Body): Fu[A] = for json <- Future(Json.parse(body)) // Json.parse throws exceptions (!) data <- summon[Reads[A]].reads(json).fold(err => fufail(s"Invalid JSON from $url: $err"), fuccess) yield data - // lcc supports https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match - private def fetchJsonWithEtag[A: Reads](initialCapacity: Int): URL => CanProxy ?=> Fu[A] = - import RelayFormat.Etag - val cache = cacheApi.notLoadingSync[URL, (Etag, A)](initialCapacity, "relay.fetch.jsonWithEtag"): - _.expireAfterWrite(10 minutes).build() - url => - CanProxy ?=> - cache - .getIfPresent(url) - .match - case None => - for - (body, newEtag) <- formatApi.httpGetWithEtag(url, none) - data <- readAsJson[A](url)(~body) - _ = lila.mon.relay.etag("first").increment() - yield (data, newEtag) - case Some((etag, prev)) => - for - (body, newEtag) <- formatApi.httpGetWithEtag(url, etag.some) - isHit = body.isEmpty && newEtag.forall(_ == etag) // on 304 response, Etag might be empty - data <- if isHit then fuccess(prev) else readAsJson[A](url)(~body) - _ = lila.mon.relay.etag(if isHit then "hit" else "miss").increment() - yield (data, newEtag.orElse(etag.some)) - .map: (data, newEtag) => - newEtag.foreach(e => cache.put(url, e -> data)) - data - - private val lccGameJsonWithEtag = fetchJsonWithEtag[DgtJson.GameJson](512) - private val lccRoundJsonWithEtag = fetchJsonWithEtag[DgtJson.RoundJson](32) - private object RelayFetch: val maxChaptersToShow: Max = Max(100) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index a2409cd1db9ff..b999ed0d1105c 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -1,38 +1,24 @@ package lila.relay import chess.format.pgn.PgnStr -import com.softwaremill.tagging.* import io.mola.galimatias.URL import play.api.libs.json.* -import play.api.libs.ws.{ - DefaultWSProxyServer, - StandaloneWSClient, - StandaloneWSRequest, - StandaloneWSResponse -} -import scala.util.matching.Regex -import lila.core.config.{ Credentials, HostPort } -import lila.core.lilaism.{ LilaException, LilaInvalid } +import lila.core.lilaism.LilaInvalid import lila.memo.CacheApi.* -import lila.memo.{ CacheApi, SettingStore } -import lila.study.MultiPgn final private class RelayFormatApi( roundRepo: RelayRoundRepo, - ws: StandaloneWSClient, - cacheApi: CacheApi, - proxyCredentials: SettingStore[Option[Credentials]] @@ ProxyCredentials, - proxyHostPort: SettingStore[Option[HostPort]] @@ ProxyHostPort, - proxyDomainRegex: SettingStore[Regex] @@ ProxyDomainRegex + cacheApi: lila.memo.CacheApi, + http: HttpClient )(using Executor): import RelayFormat.* + import HttpClient.* private val cache = cacheApi[(URL, CanProxy), RelayFormat](64, "relay.format"): - _.expireAfterWrite(5 minutes) - .buildAsyncFuture: (url, proxy) => - guessFormat(url)(using proxy) + _.expireAfterWrite(5 minutes).buildAsyncFuture: (url, proxy) => + guessFormat(url)(using proxy) def get(url: URL)(using proxy: CanProxy): Fu[RelayFormat] = cache.get(url -> proxy) @@ -68,74 +54,19 @@ final private class RelayFormatApi( .so: id => roundRepo.exists(id).map(_.option(RelayFormat.Round(id))) - def httpGet(url: URL)(using CanProxy): Fu[String] = - httpGetResponse(toRequest(url)).map(_.body) - - def httpGetAndGuessCharset(url: URL)(using CanProxy): Fu[String] = - httpGetResponse(toRequest(url)).map: res => - responseHeaderCharset(res) match - case None => lila.common.String.charset.guessAndDecode(res.bodyAsBytes) - case Some(known) => res.bodyAsBytes.decodeString(known) - - def httpGetWithEtag(url: URL, etag: Option[Etag])(using CanProxy): Fu[(Option[String], Option[Etag])] = - val req = etag.foldLeft(toRequest(url))((req, etag) => req.addHttpHeaders("If-None-Match" -> etag)) - httpGetResponse(req).map: res => - val newEtag = res.header("Etag") - if res.status == 304 then none -> newEtag.orElse(etag) - else (res.body: String).some -> newEtag - - private def httpGetResponse(req: StandaloneWSRequest)(using CanProxy): Fu[StandaloneWSResponse] = - Future - .fromTry(lila.common.url.parse(req.url)) - .flatMap: url => - req - .get() - .monValue: res => - _.relay.httpGet(res.status, url.host.toString, req.proxyServer.map(_.host)) - .flatMap: res => - if res.status == 200 || res.status == 304 then fuccess(res) - else fufail(Status(res.status, url)) - - private def responseHeaderCharset(res: StandaloneWSResponse): Option[java.nio.charset.Charset] = - import play.shaded.ahc.org.asynchttpclient.util.HttpUtils - Option(HttpUtils.extractContentTypeCharsetAttribute(res.contentType)).orElse: - res.contentType.startsWith("text/").option(java.nio.charset.StandardCharsets.ISO_8859_1) - - private def toRequest(url: URL)(using CanProxy): StandaloneWSRequest = - val req = ws - .url(url.toString) - .withRequestTimeout(5.seconds) - .withFollowRedirects(false) - proxyServerFor(url).foldLeft(req)(_ withProxyServer _) - - private def proxyServerFor(url: URL)(using allowed: CanProxy): Option[DefaultWSProxyServer] = - for - hostPort <- proxyHostPort.get() - if allowed.yes - proxyRegex = proxyDomainRegex.get() - if proxyRegex.toString.nonEmpty - if proxyRegex.unanchored.matches(url.host.toString) - creds = proxyCredentials.get() - yield DefaultWSProxyServer( - host = hostPort.host, - port = hostPort.port, - principal = creds.map(_.user), - password = creds.map(_.password.value) - ) - private def looksLikePgn(body: String)(using CanProxy): Boolean = - MultiPgn + lila.study.MultiPgn .split(PgnStr(body), Max(1)) .value .headOption .so(lila.game.importer.parseImport(_, none).isRight) - private def looksLikePgn(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url).map(looksLikePgn) + private def looksLikePgn(url: URL)(using CanProxy): Fu[Boolean] = http.get(url).map(looksLikePgn) private def looksLikeJson(body: String): Boolean = try Json.parse(body) != JsNull catch case _: Exception => false - private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url).map(looksLikeJson) + private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = http.get(url).map(looksLikeJson) private enum RelayFormat: case Round(id: RelayRoundId) @@ -144,13 +75,3 @@ private enum RelayFormat: // there will be game files with names like "game-1.json" or "game-1.pgn" // but not at the moment. The index is still useful. case LccWithoutGames(lcc: RelayRound.Sync.Lcc) - -private object RelayFormat: - - type Etag = String - - opaque type CanProxy = Boolean - object CanProxy extends YesNo[CanProxy] - - case class Status(code: Int, url: URL) extends LilaException: - override val message = s"$code: $url" diff --git a/modules/relay/src/main/RelayPlayerEnrich.scala b/modules/relay/src/main/RelayPlayerEnrich.scala index 9baefdfa247c4..f79fc3d72ae10 100644 --- a/modules/relay/src/main/RelayPlayerEnrich.scala +++ b/modules/relay/src/main/RelayPlayerEnrich.scala @@ -204,6 +204,6 @@ private final class RelayPlayerEnrich( chapterId = chapter.id, tags = enriched, newName = newName.filter(_ != chapter.name) - )(lila.study.actorApi.Who(chapter.ownerId, Sri(""))) + )(lila.study.Who(chapter.ownerId, Sri(""))) .runWith(Sink.ignore) yield () diff --git a/modules/relay/src/main/RelayProxy.scala b/modules/relay/src/main/RelayProxy.scala new file mode 100644 index 0000000000000..0e6dba5278417 --- /dev/null +++ b/modules/relay/src/main/RelayProxy.scala @@ -0,0 +1,36 @@ +package lila.relay + +import scala.util.matching.Regex +import io.mola.galimatias.URL +import play.api.libs.ws.* +import com.softwaremill.tagging.* + +import lila.memo.SettingStore +import lila.core.config.{ Credentials, HostPort } + +private opaque type CanProxy = Boolean +private object CanProxy extends YesNo[CanProxy] + +private type ProxySelector = URL => CanProxy ?=> Option[DefaultWSProxyServer] + +final private class RelayProxy( + proxyCredentials: SettingStore[Option[Credentials]] @@ ProxyCredentials, + proxyHostPort: SettingStore[Option[HostPort]] @@ ProxyHostPort, + proxyDomainRegex: SettingStore[Regex] @@ ProxyDomainRegex +): + + val select: ProxySelector = url => + allowed ?=> + for + hostPort <- proxyHostPort.get() + if allowed.yes + proxyRegex = proxyDomainRegex.get() + if proxyRegex.toString.nonEmpty + if proxyRegex.unanchored.matches(url.host.toString) + creds = proxyCredentials.get() + yield DefaultWSProxyServer( + host = hostPort.host, + port = hostPort.port, + principal = creds.map(_.user), + password = creds.map(_.password.value) + ) diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 70d63663e567e..248f883800954 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -40,7 +40,7 @@ final class RelayPush( parsed.map(_.map(g => Success(g.tags, g.root.mainline.size))) val andSyncTargets = response.exists(_.isRight) - rt.round.sync.nonEmptyDelay + rt.round.sync.delayMinusLag .ifTrue(games.exists(_.root.children.nonEmpty)) .match case None => push(rt, games, andSyncTargets).inject(response) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 6d6b7d62a4029..8b1a575559d4d 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -126,8 +126,9 @@ object RelayRound: def addLog(event: SyncLog.Event) = copy(log = log.add(event)) def clearLog = copy(log = SyncLog.empty) - def nonEmptyDelay = delay.filter(_.value > 0) - def hasDelay = nonEmptyDelay.isDefined + // subtract estimated source polling lag from transmission delay + private def pollingLag = Seconds(if isPush then 1 else 6) + def delayMinusLag = delay.map(_ - pollingLag).filter(_.value > 0) override def toString = upstream.toString diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index ad8f0a5643824..8b56af0aa98c8 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -6,6 +6,7 @@ import chess.format.pgn.{ Tag, Tags } import lila.core.socket.Sri import lila.study.* import lila.tree.Branch +import lila.study.AddNode final private class RelaySync( studyApi: StudyApi, @@ -91,20 +92,19 @@ final private class RelaySync( studyId = study.id, position = Position(chapter, path).ref, toMainline = true - )(by) >> chapterRepo.setRelayPath(chapter.id, path) + )(using by) >> chapterRepo.setRelayPath(chapter.id, path) _ <- newNode match case Some(newNode) => newNode.mainline .foldM(Position(chapter, path).ref): (position, n) => - studyApi - .addNode( - studyId = study.id, - position = position, - node = n, - opts = moveOpts, - relay = makeRelayFor(game, position.path + n.id).some - )(by) - .inject(position + n) + val node = AddNode( + studyId = study.id, + positionRef = position, + node = n, + opts = moveOpts, + relay = makeRelayFor(game, position.path + n.id).some + )(using by) + studyApi.addNode(node).inject(position + n) case None => // the chapter already has all the game moves, // but its relayPath might be out of sync. This can happen if the broadcast @@ -121,13 +121,14 @@ final private class RelaySync( game.root.children .nodeAt(gameMainlinePath) .so: lastMainlineNode => - studyApi.addNode( - studyId = study.id, - position = Position(chapter, gameMainlinePath.parent).ref, - node = lastMainlineNode, - opts = moveOpts, - relay = makeRelayFor(game, gameMainlinePath).some - )(by) + studyApi.addNode: + AddNode( + studyId = study.id, + positionRef = Position(chapter, gameMainlinePath.parent).ref, + node = lastMainlineNode, + opts = moveOpts, + relay = makeRelayFor(game, gameMainlinePath).some + )(using by) yield newNode.so(_.mainline.size) private def updateChapterTags( @@ -212,7 +213,7 @@ final private class RelaySync( ) private val sri = Sri("") - private def who(userId: UserId) = actorApi.Who(userId, sri) + private def who(userId: UserId) = Who(userId, sri) private def vs(tags: Tags) = s"${tags(_.White) | "?"} - ${tags(_.Black) | "?"}" diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index b0a03e27dffd9..52c9dba177298 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -38,7 +38,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): nonEmptyTier(_.high), nonEmptyTier(_.normal), h2(cls := "relay-index__section")(trc.pastBroadcasts()), - div(cls := "relay-cards relay-cards--past"): + div(cls := "relay-cards"): past.map: t => card.render(t, live = _ => false) , @@ -150,7 +150,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): div(cls := "page-menu__content box box-pad")( boxTop(h1(dataIcon := Icon.RadioTower, cls := "text")(trc.broadcastCalendar())), dateForm("top"), - div(cls := "relay-cards relay-cards--past"): + div(cls := "relay-cards"): tours.map(card.renderCalendar) , (tours.sizeIs > 8).option(dateForm("bottom")) diff --git a/modules/security/src/main/EmailAddressValidator.scala b/modules/security/src/main/EmailAddressValidator.scala index e1ceb1efc5504..b772adf791978 100644 --- a/modules/security/src/main/EmailAddressValidator.scala +++ b/modules/security/src/main/EmailAddressValidator.scala @@ -43,7 +43,8 @@ final class EmailAddressValidator( // only compute valid and non-whitelisted email domains private[security] def apply(e: EmailAddress): Fu[Result] = - e.domain.map(_.lower).fold(fuccess(Result.DomainMissing))(validateDomain) + if isInfiniteAlias(e) then fuccess(Result.Alias) + else e.domain.map(_.lower).fold(fuccess(Result.DomainMissing))(validateDomain) private[security] def validateDomain(domain: Domain.Lower): Fu[Result] = if DisposableEmailDomain.whitelisted(domain.into(Domain)) then fuccess(Result.Passlist) @@ -95,6 +96,14 @@ final class EmailAddressValidator( case (acc, _) => acc if variations.isEmpty then List(email) else variations + private def isInfiniteAlias(e: EmailAddress) = + duckAliases.is(e) + + private object duckAliases: + private val domain = Domain.Lower.from("duck.com") + private val regex = """^\w{3,}-\w{3,}-\w{3,}$""".r + def is(e: EmailAddress) = e.nameAndDomain.exists((n, d) => d.lower == domain && regex.matches(n)) + private def wasUsedTwiceRecently(email: EmailAddress): Fu[Boolean] = userRepo.countRecentByPrevEmail(email.normalize, nowInstant.minusWeeks(1)).dmap(_ >= 2) >>| userRepo.countRecentByPrevEmail(email.normalize, nowInstant.minusMonths(1)).dmap(_ >= 4) @@ -106,10 +115,8 @@ object EmailAddressValidator: case Alright extends Result(none) case DomainMissing extends Result("The email address domain is missing.".some) // no translation needed case Blocklist extends Result("Cannot use disposable email addresses (Blocklist).".some) + case Alias extends Result("Cannot use email address aliases.".some) case DnsMissing extends Result("This email domain doesn't seem to work (missing MX DNS)".some) case DnsTimeout extends Result("This email domain doesn't seem to work (timeout MX DNS)".some) - case DnsBlocklist - extends Result( - "Cannot use disposable email addresses (DNS blocklist).".some - ) - case Reputation extends Result("This email domain has a poor reputation and cannot be used.".some) + case DnsBlocklist extends Result("Cannot use disposable email addresses (DNS blocklist).".some) + case Reputation extends Result("This email domain has a poor reputation and cannot be used.".some) diff --git a/modules/security/src/main/VerifyMail.scala b/modules/security/src/main/VerifyMail.scala index 23edd96d7ab40..9dc04fc0954e4 100644 --- a/modules/security/src/main/VerifyMail.scala +++ b/modules/security/src/main/VerifyMail.scala @@ -71,7 +71,7 @@ final private class VerifyMail( if res.status == 429 then logger.info(s"Mailcheck rate limited $url") - rateLimitedUntil = nowInstant.plusMinutes(2) + rateLimitedUntil = nowInstant.plusMinutes(5) true else (for diff --git a/modules/study/src/main/Env.scala b/modules/study/src/main/Env.scala index c12c7b968433f..b0a9be849aac4 100644 --- a/modules/study/src/main/Env.scala +++ b/modules/study/src/main/Env.scala @@ -65,7 +65,7 @@ final class Env( private lazy val chapterMaker = wire[ChapterMaker] - private lazy val explorerGame = wire[ExplorerGame] + private lazy val explorerGame = wire[ExplorerGameApi] private lazy val studyMaker = wire[StudyMaker] diff --git a/modules/study/src/main/ExplorerGame.scala b/modules/study/src/main/ExplorerGame.scala index 8666062d10c6d..f4c069093deab 100644 --- a/modules/study/src/main/ExplorerGame.scala +++ b/modules/study/src/main/ExplorerGame.scala @@ -6,7 +6,7 @@ import chess.format.{ Fen, UciPath } import lila.tree.Node.Comment import lila.tree.{ Branch, Node, Root } -final private class ExplorerGame( +final private class ExplorerGameApi( explorer: lila.core.game.Explorer, namer: lila.core.game.Namer, lightUserApi: lila.core.user.LightUserApi, diff --git a/modules/study/src/main/JsonView.scala b/modules/study/src/main/JsonView.scala index 2faec52644254..73e31f3b88a97 100644 --- a/modules/study/src/main/JsonView.scala +++ b/modules/study/src/main/JsonView.scala @@ -218,5 +218,5 @@ object JsonView: private[study] given Writes[Chapter.ServerEval] = Json.writes - private[study] given OWrites[actorApi.Who] = OWrites: w => + private[study] given OWrites[Who] = OWrites: w => Json.obj("u" -> w.u, "s" -> w.sri) diff --git a/modules/study/src/main/StudyApi.scala b/modules/study/src/main/StudyApi.scala index 21197f0e144f5..b37182b242d59 100644 --- a/modules/study/src/main/StudyApi.scala +++ b/modules/study/src/main/StudyApi.scala @@ -13,8 +13,6 @@ import lila.core.timeline.{ Propagate, StudyLike } import lila.tree.Branch import lila.tree.Node.{ Comment, Gamebook, Shapes } -import actorApi.Who - final class StudyApi( studyRepo: StudyRepo, chapterRepo: ChapterRepo, @@ -22,7 +20,7 @@ final class StudyApi( studyMaker: StudyMaker, chapterMaker: ChapterMaker, inviter: StudyInvite, - explorerGameHandler: ExplorerGame, + explorerGameHandler: ExplorerGameApi, topicApi: StudyTopicApi, lightUserApi: lila.core.user.LightUserApi, chatApi: lila.core.chat.ChatApi, @@ -225,27 +223,21 @@ final class StudyApi( yield sendTo(study.id)(_.setPath(position, who)) case _ => funit - def addNode( - studyId: StudyId, - position: Position.Ref, - node: Branch, - opts: MoveOpts, - relay: Option[Chapter.Relay] = None - )(who: Who): Funit = - sequenceStudyWithChapter(studyId, position.chapterId): + def addNode(args: AddNode): Funit = + import args.{ *, given } + sequenceStudyWithChapter(studyId, positionRef.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): - doAddNode(study, Position(chapter, position.path), node, opts, relay)(who) + doAddNode(args, study, Position(chapter, positionRef.path)) .flatMapz { _() } private def doAddNode( + args: AddNode, study: Study, - position: Position, - rawNode: Branch, - opts: MoveOpts, - relay: Option[Chapter.Relay] - )(who: Who): Fu[Option[() => Funit]] = - val singleNode = rawNode.withoutChildren + position: Position + ): Fu[Option[() => Funit]] = + import args.{ *, given } + val singleNode = args.node.withoutChildren def failReload() = reloadSriBecauseOf(study, who.sri, position.chapter.id) if position.chapter.isOverweight then logger.info(s"Overweight chapter ${study.id}/${position.chapter.id}") @@ -272,7 +264,7 @@ final class StudyApi( isMainline = newPosition.path.isMainline(chapter.root) promoteToMainline = opts.promoteToMainline && !isMainline yield promoteToMainline.option: () => - promote(study.id, position.ref + node, toMainline = true)(who) + promote(study.id, position.ref + node, toMainline = true) } } @@ -326,7 +318,7 @@ final class StudyApi( yield onChapterChange(study.id, chapter.id, who) // rewrites the whole chapter because of `forceVariation`. Very inefficient. - def promote(studyId: StudyId, position: Position.Ref, toMainline: Boolean)(who: Who): Funit = + def promote(studyId: StudyId, position: Position.Ref, toMainline: Boolean)(using who: Who): Funit = sequenceStudyWithChapter(studyId, position.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): @@ -441,7 +433,7 @@ final class StudyApi( reloadSriBecauseOf(sc.study, who.sri, position.chapterId) fufail(s"Invalid setClock $position $clock") - def setTag(studyId: StudyId, setTag: actorApi.SetTag)(who: Who) = + def setTag(studyId: StudyId, setTag: SetTag)(who: Who) = sequenceStudyWithChapter(studyId, setTag.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): @@ -545,7 +537,7 @@ final class StudyApi( reloadSriBecauseOf(study, who.sri, chapter.id) fufail(s"Invalid setGamebook $studyId $position") - def explorerGame(studyId: StudyId, data: actorApi.ExplorerGame)(who: Who) = + def explorerGame(studyId: StudyId, data: ExplorerGame)(who: Who) = sequenceStudyWithChapter(studyId, data.position.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): diff --git a/modules/study/src/main/StudySocket.scala b/modules/study/src/main/StudySocket.scala index a53099cee2154..34c18c50cc60c 100644 --- a/modules/study/src/main/StudySocket.scala +++ b/modules/study/src/main/StudySocket.scala @@ -13,8 +13,6 @@ import lila.tree.Branch import lila.tree.Node.{ Comment, Gamebook, Shape, Shapes } import lila.tree.Node.minimalNodeJsonWriter -import actorApi.Who - final private class StudySocket( api: StudyApi, jsonView: JsonView, @@ -75,13 +73,13 @@ final private class StudySocket( AnaMove .parse(o) .foreach: move => - applyWho(moveOrDrop(studyId, move, MoveOpts.parse(o))) + applyWho(moveOrDrop(studyId, move, MoveOpts.parse(o))(using _)) case "anaDrop" => AnaDrop .parse(o) .foreach: drop => - applyWho(moveOrDrop(studyId, drop, MoveOpts.parse(o))) + applyWho(moveOrDrop(studyId, drop, MoveOpts.parse(o))(using _)) case "deleteNode" => reading[AtPosition](o): position => @@ -96,7 +94,7 @@ final private class StudySocket( (o \ "d" \ "toMainline") .asOpt[Boolean] .foreach: toMainline => - applyWho(api.promote(studyId, position.ref, toMainline)) + applyWho(api.promote(studyId, position.ref, toMainline)(using _)) case "forceVariation" => reading[AtPosition](o): position => @@ -114,7 +112,7 @@ final private class StudySocket( .foreach: username => applyWho: w => api.kick(studyId, username.id, w.myId) - Bus.publish(actorApi.Kick(studyId, username.id, w.myId), "kickStudy") + Bus.publish(Kick(studyId, username.id, w.myId), "kickStudy") case "leave" => who.foreach: w => @@ -177,7 +175,7 @@ final private class StudySocket( applyWho(api.editStudy(studyId, data)) case "setTag" => - reading[actorApi.SetTag](o): setTag => + reading[SetTag](o): setTag => applyWho(api.setTag(studyId, setTag)) case "setComment" => @@ -216,7 +214,7 @@ final private class StudySocket( applyWho(api.setTopics(studyId, topics)) case "explorerGame" => - reading[actorApi.ExplorerGame](o): data => + reading[ExplorerGame](o): data => applyWho(api.explorerGame(studyId, data)) case "requestAnalysis" => @@ -235,7 +233,7 @@ final private class StudySocket( case "relaySync" => applyWho: w => - Bus.publish(actorApi.RelayToggle(studyId, ~(o \ "d").asOpt[Boolean], w), "relayToggle") + Bus.publish(RelayToggle(studyId, ~(o \ "d").asOpt[Boolean], w), "relayToggle") case t => logger.warn(s"Unhandled study socket message: $t") @@ -246,18 +244,18 @@ final private class StudySocket( _ => _ => none, // the "talk" event is handled by the study API localTimeout = Some { (roomId, modId, suspectId) => api.isContributor(roomId, modId) >>& api.isMember(roomId, suspectId).not >>& - Bus.ask("isOfficialRelay") { actorApi.IsOfficialRelay(roomId, _) }.not + Bus.ask("isOfficialRelay") { IsOfficialRelay(roomId, _) }.not }, chatBusChan = _.study ) - private def moveOrDrop(studyId: StudyId, m: AnaAny, opts: MoveOpts)(who: Who) = + private def moveOrDrop(studyId: StudyId, m: AnaAny, opts: MoveOpts)(using Who) = m.branch.foreach: branch => if branch.ply < Node.MAX_PLIES then m.chapterId .ifTrue(opts.write) .foreach: chapterId => - api.addNode(studyId, Position.Ref(chapterId, m.path), branch, opts)(who) + api.addNode(AddNode(studyId, Position.Ref(chapterId, m.path), branch, opts)) private lazy val send = socketKit.send("study-out") @@ -458,9 +456,9 @@ object StudySocket: given Reads[ChapterMaker.EditData] = Json.reads given Reads[ChapterMaker.DescData] = Json.reads given studyDataReads: Reads[Study.Data] = Json.reads - given Reads[actorApi.SetTag] = Json.reads + given Reads[SetTag] = Json.reads given Reads[Gamebook] = Json.reads - given Reads[actorApi.ExplorerGame] = Json.reads + given Reads[ExplorerGame] = Json.reads object Out: def getIsPresent(reqId: Int, studyId: StudyId, userId: UserId) = diff --git a/modules/study/src/main/actorApi.scala b/modules/study/src/main/model.scala similarity index 78% rename from modules/study/src/main/actorApi.scala rename to modules/study/src/main/model.scala index 1378b69ac1131..abba07c496f34 100644 --- a/modules/study/src/main/actorApi.scala +++ b/modules/study/src/main/model.scala @@ -1,7 +1,7 @@ package lila.study -package actorApi import chess.format.UciPath +import lila.tree.Branch case class SaveStudy(study: Study) case class SetTag(chapterId: StudyChapterId, name: String, value: String): @@ -16,3 +16,11 @@ case class RelayToggle(studyId: StudyId, v: Boolean, who: Who) case class Kick(studyId: StudyId, userId: UserId, who: MyId) case class BecomeStudyAdmin(studyId: StudyId, me: Me) case class IsOfficialRelay(studyId: StudyId, promise: Promise[Boolean]) + +case class AddNode( + studyId: StudyId, + positionRef: Position.Ref, + node: Branch, + opts: MoveOpts, + relay: Option[Chapter.Relay] = None +)(using val who: Who) diff --git a/translation/dest/appeal/hr-HR.xml b/translation/dest/appeal/hr-HR.xml index 5aa5c5d675845..9709dd245426e 100644 --- a/translation/dest/appeal/hr-HR.xml +++ b/translation/dest/appeal/hr-HR.xml @@ -1,4 +1,20 @@ - Tvoj profil su zatvorili moderatori. + Vaš korisnički račun je označen zbog vanjske pomoći u partijama. + Definiramo ovo kao korištenje bilo koje vanjske pomoći u znanju i/ili računanju u svrhu postignuća nepoštene prednosti nad Vašim protivnikom. Pogledajte %s stranicu za više detalja. + Vašem korisničkom računu je zabranjen pristup arenama. + Vašem korisničkom računu je zabranjen pristup turnirima sa stvarnim nagradama. + Vaš korisnički račun je označen zbog manipuliranja rejtinga. + Namjernu manipulaciju rejtinga definiramo kao namjerno gubljenje partija ili igranje s protivnikom koji namjerno gubi partije. + Vaš korisnički račun je ušutkan. + Pročitajte naš %s. Kršeći komunikacijske smjernice može rezultirati ušutkavanjem Vašeg korisničkog računa. + Vaš korisnički račun je izuzet s ljestvice. + Definiramo ovo kao bilo koji nepošten način za doći na ljestvicu. + Vaš korisnički račun je zatvoren od strane moderatora. + Vaši blogovi su sakriveni od strane moderatora. + Pročitajte ponovno naše %s. + Imate vremensko ograničenje igranja. + komunikacijske smjernice + pravila bloga + Fair Play diff --git a/translation/dest/appeal/lt-LT.xml b/translation/dest/appeal/lt-LT.xml index 3ea04e700dfa8..9b1fd181f95e1 100644 --- a/translation/dest/appeal/lt-LT.xml +++ b/translation/dest/appeal/lt-LT.xml @@ -1,2 +1,11 @@ - + + Jūsų paskyra nėra pažymėta ar apribota. Su ja viskas gerai! + Jūsų paskyra pažymėta dėl išorinės pagalbos partijose. + Mes tai apibrėžiame kaip bet kokios išorinės pagalbos naudojimą savo žinioms ir/ar skaičiavimo įgūdžiams sustiprinti, siekiant įgyti nesąžiningą pranašumą prieš savo varžovą. Daugiau informacijos rasite %s puslapyje. + Jūsų paskyrai uždrausta prisijungti prie arenų. + Jūsų paskyrai uždrausta dalyvauti turnyruose su realiais prizais. + Jūsų paskyra pažymėta dėl manipuliavimo reitingu. + Tai apibrėžiame kaip tyčinį manipuliavimą reitingu pralaimint partijas arba žaidžiant prieš kitą paskyrą, kuri sąmoningai pralaimi. + Jūsų paskyra nutildyta. + diff --git a/translation/dest/arena/bn-BD.xml b/translation/dest/arena/bn-BD.xml index 93771bdca113b..fa5338268eceb 100644 --- a/translation/dest/arena/bn-BD.xml +++ b/translation/dest/arena/bn-BD.xml @@ -54,6 +54,8 @@ এরিনায় ধারাবাহিক জয় ২টি জয়ের পর প্রতি ধারাবাহিক জয়ের জন্য ২ পয়েন্টের বদলে ৪ পয়েন্ট দেয়া হবে। আপনার দল বাছাই করুন + আপনাকে একটি দলে যোগ দিতে হবে! + তৈরী হয়েছে মোট শুধুমাত্র খেতাবধারী দাবাড়ুগণ diff --git a/translation/dest/broadcast/fa-IR.xml b/translation/dest/broadcast/fa-IR.xml index debe3ec660d86..efca650098498 100644 --- a/translation/dest/broadcast/fa-IR.xml +++ b/translation/dest/broadcast/fa-IR.xml @@ -38,7 +38,7 @@ ویرایش مطالعه دور حذف این مسابقات کل مسابقات، شامل همه دورها و بازی‌هایش را به طور کامل حذف کن. - نمایش امتیاز بازیکنان بر پایه نتیجه بازی‌ها + نمایش امتیاز بازیکنان بر پایه نتیجه بازی‌ها اختیاری: عوض کردن نام، درجه‌بندی و عنوان بازیکنان کشورگان‌های فیده ده درجه‌بندی برتر @@ -57,7 +57,7 @@ بارگذاری تصویر مسابقات تاکنون هیچی. وقتی بازی‌ها بارگذاری شدند، میزها پدیدار خواهند شد. میزها را می‌توان از یک منبع یا از راه %s بارگذاری کرد - پخش زنده به زودی آغاز خواهد شد. + پخش زنده به زودی خواهد آغازید. پخش زنده هنوز نیاغازیده است. وبگاه رسمی رده‌بندی diff --git a/translation/dest/broadcast/hr-HR.xml b/translation/dest/broadcast/hr-HR.xml index 9765ee852e055..8b188c079fe0a 100644 --- a/translation/dest/broadcast/hr-HR.xml +++ b/translation/dest/broadcast/hr-HR.xml @@ -49,4 +49,35 @@ Federacija Starost ove godine Nerangiran + Nedavni turniri + Ekipe + Ploče + Pregled + Pretplatite se kako bi bili obaviješteni o početku runde. Možete uključiti obavijesti za prijenose u postavkama korisničkog računa. + Učitajte sliku turnira + Još nema ploča. Pojavit će se kad se partije učitaju. + Ploče mogu biti učitane iz izvora ili preko %s + Počinje nakon %s + Prijenos počinje uskoro. + Prijenos još nije počeo. + Službena stranica + Tablica + Službena tablica + Više mogučnosti na %s + portal za vlasnike web stranica + Javan izvorni PGN za ovu rundu u stvarnom vremenu. Također nudimo %s za bržu i efikasniju sinkronizaciju. + Ugradite ovaj prijenos na svoju web stranicu + Ugradi %s u svoju web stranicu + Razlika rejtinga + Partije u ovom turniru + Rezultat + Sve ekipe + Format turnira + Mjesto turnira + Najbolji igrači + Vremenska zona + FIDE rejting kategorija + Neobavezni detalji + Prijašnji prijenosi + Pogledaj sve prijenose prema mjesecu diff --git a/translation/dest/dgt/ast-ES.xml b/translation/dest/dgt/ast-ES.xml index 3ea04e700dfa8..710fbc5a42ffd 100644 --- a/translation/dest/dgt/ast-ES.xml +++ b/translation/dest/dgt/ast-ES.xml @@ -1,2 +1,6 @@ - + + Tableru DGT + Lichess & DGT + Requerimientos del tableru DGT + diff --git a/translation/dest/features/lt-LT.xml b/translation/dest/features/lt-LT.xml index 3ea04e700dfa8..451c5bcc2922d 100644 --- a/translation/dest/features/lt-LT.xml +++ b/translation/dest/features/lt-LT.xml @@ -1,2 +1,4 @@ - + + Nulis reklamos, jokio sekimo + diff --git a/translation/dest/oauthScope/hr-HR.xml b/translation/dest/oauthScope/hr-HR.xml index 5aeef17794ac2..5d75b2e8b6685 100644 --- a/translation/dest/oauthScope/hr-HR.xml +++ b/translation/dest/oauthScope/hr-HR.xml @@ -33,7 +33,7 @@ Koristite moderatorske alate (unutar granica vašeg dopuštenja) Osobni API pristupni bonovi Možeš zatražiti OAuth zahtijev bez da prolaziš kroz %s. - proces autorizacije koda + proces autorizacije koda Umjesto toga, %s kojeg možeš izravno koristiti u API zahtijevima. stvori osobni pristupni bon Pažljivo čuvaj bonove. Oni su poput lozinke. Prednost korištenja bonova nasuprot postavljanja lozinke u skriptu je ta što se bonovi mogu opozvati te ih možeš generirati gomilu. @@ -48,6 +48,7 @@ Napomena za razvojne programere: Moguće je unaprijed ispuniti ovaj obrazac podešavanjem parametara upita URL-a. Na primjer: %s + označi %1$s i %2$s kateogrije i postavi opis bon. Kodovi opsega mogu se pronaći u HTML kodu obrasca. Davanje ovih unaprijed ispunjenih URL-ova vašim korisnicima pomoći će im da dobiju prave opsege bona. diff --git a/translation/dest/onboarding/hr-HR.xml b/translation/dest/onboarding/hr-HR.xml index 3ea04e700dfa8..e12c3ff166c43 100644 --- a/translation/dest/onboarding/hr-HR.xml +++ b/translation/dest/onboarding/hr-HR.xml @@ -1,2 +1,17 @@ - + + Dobrodošli! + Dobrodošli na Lichess.org! + Ovo je Vaša profilna stranica. + Hoće li dijete koristiti ovaj korisnički račun? Možda bi htjeli uključiti %s. + Što sad? Evo par prijedloga: + Naučite pravila šaha + Poboljšajte se rješavajući zadatke sa šahovskim taktikama. + Igrajte s Umjetnom Inteligencijom. + Igrajte protiv ljudi diljem svijeta. + Pratite svoje prijatelje na Lichessu. + Igrajte u turnirima. + Učite iz %1$s i %2$s. + Podesite Lichess prema svojim željama. + Istražite stranicu i zabavite se :) + diff --git a/translation/dest/preferences/ast-ES.xml b/translation/dest/preferences/ast-ES.xml index 3ea04e700dfa8..dfdd6fa6e1168 100644 --- a/translation/dest/preferences/ast-ES.xml +++ b/translation/dest/preferences/ast-ES.xml @@ -1,2 +1,72 @@ - + + Preferencies + Amosar + Privacidá + Notificaciones + Animación de les pieces + Diferencia material + Destacar casielles del tableru (últimu movimientu y xaque) + Destín de les pieces (movimientos válidos y antemanaos) + Coordenaes del tableru (A-H, 1-8) + Llista de movimientos mientres la partida + Notación de los movimientos + Símbolu de la pieza + Inicial (n\'inglés) de la pieza (K, Q, R, B, N) + Mou Zen + Amosar les puntuaciones de los xugadores + Amosar el to figurín + Esto despinta\'l ELO en Lichess, pa ayudar a concentrate solo nel xuegu. Los xuegos por puntos van siguir afectando los tos ELO, esta opción ye solo pa lo que tu ves na plataforma. + Amosar el control de tamañu del tableru + Namás en posición inicial + Solu mientres la partida + Escepto nel xuegu + Reló d\'axedez + Décimes de segundu + Cuando queden menos de 10 segundos + Barres de progresu horizontales verdes + Alerta cuando quede pocu tiempu + Dar más tiempu + Comportamientu del xuegu + ¿Cómo quies mover les pieces? + Faciendo clic na pieza y depués na casiella de destín + Arrastrando la pieza hasta\'l casiella de destín + O cualquiera + Movimientos antemanaos (programar xugaes mientres xuega l\'oponente) + Desfacer xugada (con consentimientu del oponente) + Namás en partíes amistoses + Promover a la Reina automáticamente + Mantén pulsiada la tecla <ctrl> al promocionar pa desactivar temporalmente la promoción automática + Con antemanaes + Reclamar tables por triple repetición + Cuando\'l tiempu restante sía de < 30 segundos + Confirmación de movimientu + Puede desactivase mientres una partida col menú del tableru + Partíes por correspondencia + Correspondencia y ensin llende de tiempu + Confirmar abandonu y ufiertes de tables + Métodu d\'enroque + Moviendo\'l rei dos casielles + Moviendo\'l rei hasta la torre + Entrada de movimientos col tecláu + Realiza movimientos cola to voz + Xuntar fleches a movimientos válidos + Dicir \"Good game, well played\" (Bona partida, bien xugada) al perder o empatar + Les tos preferencies guardáronse. + Usa la rueda de desplazamientu sobro\'l tableru pa volver amosar los movimientos + Notificación diaria per corréu listando les tos partíes per correspondencia + El presentador ta en vivu + Nuevu mensaxe na bandexa d\'entrada + Comentariu del foru que te menta + Invitación a un estudiu + Actualizaciones de partida per correspondencia + Desafíos + El tornéu entama d\'arréu + Pocu tiempu restante na partida per correspondencia + Notificación de campana dientro de Lichess + Notificación de dispositivu cuando nun teas en Lichess + Restolador web + Dispositivu + Campana de notificación + A ciegas + diff --git a/translation/dest/puzzle/ast-ES.xml b/translation/dest/puzzle/ast-ES.xml index 2b3938b9a9a97..4c49bb93ea433 100644 --- a/translation/dest/puzzle/ast-ES.xml +++ b/translation/dest/puzzle/ast-ES.xml @@ -13,4 +13,7 @@ Movimientos especiales ¿Te gustó esti exerciciu? ¡Vota pa pasar al siguiente! + Exerciciu %s + Exerciciu del día + Exerciciu diariu diff --git a/translation/dest/site/NG.xml b/translation/dest/site/NG.xml new file mode 100644 index 0000000000000..da0ea35129b7c --- /dev/null +++ b/translation/dest/site/NG.xml @@ -0,0 +1,4 @@ + + + This is a TEST of Crowdin\'s language mapping (locale field) and the string will be removed within a few days. + diff --git a/translation/dest/site/ast-ES.xml b/translation/dest/site/ast-ES.xml index 74d0a44c59611..8b52df4e818b5 100644 --- a/translation/dest/site/ast-ES.xml +++ b/translation/dest/site/ast-ES.xml @@ -1,6 +1,6 @@ - Xuega con un amigu + Xuega con un collacio Xuega contra l\'ordenador Para convidar a daquién a xugar, da-y esti enllaz Xuegu termináu @@ -508,7 +508,7 @@ Nome real Pon la to bandera Bandera - Hai una opción pa ocultar la configuración d\'entornu en tou\'l sitiu. + Hai una opción pa amatagar la configuración d\'entornu en tou\'l sitiu. Biografía País o rexón ¡Gracies! @@ -634,4 +634,133 @@ Tien que tener %s caráuteres a lo menos Tien que tener %s caráuteres a lo máximu Tien que ser mayor o igual a %s + Tien que ser mayor o igual a %s + Si la puntuación ye ± %s + Si ta rexistráu + Solo conversaciones esistentes + Solo collacios + Menú + Enroque + Blanques O-O + Prietes O-O + + %s Aporte nel foru + %s Aportes nos foros + + Tiempu total xugao: %s + Ver partíes + Tiempu trescurríu en TV: %s + Ver + Llibrería de vídeos + Streamers + App pa móvil + Desarrolladores + Tocante a + Tocante a %s + %1$s ye un servidor d\'axedrez llibre y gratuitu (%2$s) de códigu abiertu y ensin publicidá. + realmente + Contribuyir + Términos de Serviciu + Códigu Fonte + Partíes simultánees + Agospiu + Color d\'agospiu: %s + Les tus simultánees pendientes + Simultanees nueves + Crear nueva simultánea + Rexistrate pa crear o entrar a una simultánea + Simultánea nun alcontrada + Esta simultánea nun esiste. + Tornar a la páxina de simultánees + Nes simultánees, un xugador enfréntase a dellos al mesmu tiempu. + De 50 oponentes, Fischer ganó 47 partíes, empató 2 y perdió 1. + El conceptu de simultánees en Lichess tomase de la vida real, onde l\'anfitrión va de mesa en mesa faciendo xugadas. + Cuando la simultánea empieza, cada xugador empieza una partida col agospiu, que xuega con blanques. La simultánea termina cuando s\'acaben toles partíes. + Les simultánees son partíes amistoses. Les opciones de pidimientu de revancha, desfacer xugada y añedir tiempu tán evacuaes. + Crear + Cuando creas una simultánea, xuegues contra dellos xugadores al empar. + Si escueyes distintes variantes, cada xugador escueye cuál xugar. + Configuración de reló Fischer. Cuantos más oponentes, más tiempu precísase. + Pues añedir tiempu adicional al to reló para ayudar na simultánea. + Tiempu extra del agospiu + Añedir tiempu inicial al to reló per cada xugador que s\'una a la simultánea. + Tiempu extra del agospiu per cada xugador + Torneos de Lichess + Entrugues frecuentes de los torneos + Tiempu primero qu\'empiece el tornéu + Pérdida permediu en centipeones + Precisión + Atayos del tecláu + movimientu escontra tras y escontra alante + dir al entamu/fin + Alterna la variante escoyida + amosar/amatagar comentarios + entrar/salir de la variante + Solicitar análisis del ordenador, apriende de los tos fallos + Siguiente (Apriende de los tos fallos) + Siguiente error grave + Siguiente error + Siguiente imprecisión + Rama anterior + Rama siguiente + Activar/Desactivar fleches de variantes + Alterna ente la variante siguiente y l\'anterior + Activa o desactiva l\'anotación de figurines + Alternar anotaciones de posición + Les fleches de variantes te dexen navegar ensin usar la llista de movimientos. + facer movimientu escoyíu + Nuevu tornéu + Torneos d\'axedrez con distintes variantes y controles de tiempu + ¡Xuega torneos d\'axedrez rápidu! Xúnite a un tornéu oficial programáu o crea\'l tuyu propiu. Bala, Rellámpagu, Clásicu, Axedrez960, Rei de la Colina, Tres xaques y más opciones disponibles pa una diversión ensin fin n\'axedrez. + Nun s\'atopó\'l tornéu + Esti tornéu nun esiste. + Pue que háyase canceláu, si toos los xugadores fuéronse primero qu\'empezara el tornéu. + Volver a la páxina principal de torneos + El to rating + Acumuláu + Confirma\'l to email + Tabla cruciada + Tamién pues usar la rueda del ratón pa movete pola partida. + Con naide + Con collacios + Con tol mundu + Trata de la seguridá. Na manera infantil, toles comunicaciones del sitiu tán desactivaes. Habilítelo pa los sos fíos y escolinos, pa protexelos d\'otros usuarios d\'Internet. + Seguridá + Sala d\'espera + Anónimu + La to puntuación: %s + Llinguaxe + Fondu + Claro + Escuru + Transparente + Tema del dispositivu + URL d\'imaxe de fondu: + Tableru + Tamañu + Opocidá + Brillu + Tonu + Restablecer colores predeterminaos + Rating: %s + La partida ye tables. + Amatagar meyor movimientu + Ganen les blanques + Ganen les prietes + Siguiente + Solución + Acepto qu\'en nengún momentu voi recibir ayuda mientres les mios partíes (d\'un ordenador d\'axedrez, un llibru, una base de datos o otra persona). + Editar + Bala + Rellámpagu + Rápido + Clásica + Pega un FEN válidu para empecipiar cada partida dende una posición determinada. +Namás funciona para partíes estándar, non con variantes. +Pues usar el %s para xenerar una posición FEN, y depués pegala equí. +Dexar vacíu de mano les partíes dende la posición inicial normal. + Instrucciones + Lichess ye una organización benéfica y un software de códigu abiertu totalmente llibre. +Tolos gastos de funcionamientu, desenvolvimientu y conteníos finánciense puramente con donaciones de los usuarios. + Estadístiques diff --git a/translation/dest/site/fa-IR.xml b/translation/dest/site/fa-IR.xml index 86f61bfdfba2b..f7786ae4754ae 100644 --- a/translation/dest/site/fa-IR.xml +++ b/translation/dest/site/fa-IR.xml @@ -936,7 +936,7 @@ مدیریت جریان‌سازی لغو مسابقه توضیحات مسابقه - نکته خاصی را می‌خواهید به شرکت‌کنندگان گویید؟ بکوشید کوتاه باشد. پیوندهای فرونشان موجودند: + نکته خاصی را می‌خواهید به شرکت‌کنندگان گویید؟ بکوشید کوتاه باشد. پیوندهای فرونشان موجودند: [name](https://url) بازی‌ها رسمی هستند و روی درجه‌بندی بازیکنان تاثیر می‌گذارند @@ -982,7 +982,7 @@ شما یک بازی در حال انجام با %s دارید. انصراف از بازی تسلیم - شما نمی توانید تا زمانی که این بازی تمام نشده بازی جدیدی آغاز کنید. + تا وقتی که این بازی تمام نشده، نمی‌توانید بازی جدیدی را بیاغازید. از وقتی که تا وقتی که بازی‌های رسمی برگزاریده در Lichess diff --git a/translation/dest/site/ha-NG.xml b/translation/dest/site/ha-NG.xml new file mode 100644 index 0000000000000..3ea04e700dfa8 --- /dev/null +++ b/translation/dest/site/ha-NG.xml @@ -0,0 +1,2 @@ + + diff --git a/translation/dest/site/hr-HR.xml b/translation/dest/site/hr-HR.xml index 70f85b093576b..3c9c78103c3fb 100644 --- a/translation/dest/site/hr-HR.xml +++ b/translation/dest/site/hr-HR.xml @@ -375,6 +375,11 @@ %s studije %s studija + + %s simultanka + %s simultanke + %s simultanki + Pogledaj turnir Povratak na turnir U švicarskom turniru neriješen rezultat nije moguć prije 30. poteza. diff --git a/translation/dest/site/km-KH.xml b/translation/dest/site/km-KH.xml index 3ea04e700dfa8..638bef491450e 100644 --- a/translation/dest/site/km-KH.xml +++ b/translation/dest/site/km-KH.xml @@ -1,2 +1,5 @@ - + + លេងជាមួយមិត្ត + លេងជាមួយកុំព្យូទ័រ + diff --git a/translation/dest/site/kmr-TR.xml b/translation/dest/site/kmr-TR.xml index 94ab7ab265e46..29fb35a7046fd 100644 --- a/translation/dest/site/kmr-TR.xml +++ b/translation/dest/site/kmr-TR.xml @@ -70,6 +70,8 @@ Varyasyonê bipêşîne Bike rêza esasî Ji vir pê de jê bibe + Varyasyonan teng bike + Varyasyonan fireh bike Zorê bide bo vê varyasyonê PGNa varyasyonê kopî bike Hemle @@ -101,8 +103,12 @@ Kaşifê destpêkê Geroka despêk/dawîyê %s kaşifê destpêkê + Hemleya pêşî ya kaşîfê vekirin/dawîya lîstikê bilîze Ji ber qaîdeya 50 hemleyê rê li ber serkeftinê hate girtin Rê li ber mexlubiyetê hate girtin ji ber qaîdeya 50 hemleyê + Qezenckirin an 50 hemleyên ji ber xetayên berê + Qeybkirin an 50 hemleyên ji ber xetayên berê + Qezenckirin/qeybkirin tenê gava ku xetên di binkeya tabloyê de pêşnîyarkirî piştî zeftkirin an hemleya pîyonê yê dawî û pê ve were şopandin tê garantîkirin, ji ber giloverkirina nirxên DTZyê yên di binkeyên tabloyê ya Syzygy de ya muhtemel. Temam e! PGNyê bîne navê Jê bibe @@ -112,6 +118,7 @@ Bi CPL Biçalakîne Nîşandana hemleya çêtirîn + Tîrikên varyasyonê nîşan bide Nîşandera nirxandinê Varyantên pirhejmar CPU @@ -143,11 +150,17 @@ %s lîzer %s lîzer + Beranberîya bi qebûla herdu alîyan + Pêncî hemleyên bêyî pêşketinek Lîstikên vê gavê %s lîstik %s lîstik + + %1$s puanên ji ser %2$s lîstikan + %1$s puanên ji ser %2$s lîstikan + %s cihnîşan %s cihnîşan @@ -165,6 +178,7 @@ Şandiyên forumê yên dawîn Lîzer Hevalan + lîstikvanên din Nîqaşan Îro Doh @@ -184,6 +198,10 @@ %s demjimêr %s demjimêr + + %s deqe + %s deqe + Dem Pile Statîstîkên pileyan @@ -364,6 +382,7 @@ Ya reş bi hemleyekê şahmat dike Cardin biceribîne Cardin tê girêdan + Offlayn %s heval serhêl e %s heval serhêl in @@ -428,6 +447,7 @@ Hemleyên lîstî Serkeftinên spî Serkeftinên reş + Rêjeya wekhevmayînê Wekhevî Pêşbirka li dû %s: Dijbera averaj diff --git a/translation/dest/site/ml-IN.xml b/translation/dest/site/ml-IN.xml index 037312962970e..17187d41f2b34 100644 --- a/translation/dest/site/ml-IN.xml +++ b/translation/dest/site/ml-IN.xml @@ -103,6 +103,7 @@ എക്സ്പ്ലോററും ടേബിൾ ബേസും തുറക്കാം ഓപ്പണിങ്ങ്/എൻഡ്ഗെയിം എക്‌സ്‌പ്ലോറർ %s ഓപ്പണിങ് എക്സ്പ്ലോറർ + ആദ്യഘട്ടം/അവസാനഘട്ടം-ആരായകൻ നീക്കം കളിക്കുക 50 നീക്കങ്ങളുടെ നിയമം പ്രകാരം ജയം തടയപ്പെട്ടു 50 നീക്കങ്ങളുടെ നിയമം പ്രകാരം തോൽവി തടയപ്പെട്ടു വിജയം അല്ലെങ്കിൽ മുൻപറ്റിയ പിഴകൊണ്ടു് 50 നീക്കങ്ങളിൽ സമനില @@ -167,9 +168,9 @@ രജിസ്റ്റര്‍ കമ്പ്യൂട്ടറുകള്‍ക്കും കമ്പ്യൂട്ടര്‍ സഹായമുള്ള കളിക്കാര്‍ക്കും കളിക്കാന്‍ അനുവാദമില്ല. ദയവായി കളിക്കിടെ ചെസ്സ്‌ എഞ്ചിനുകളുടെയോ ഡാറ്റാബേസുകളുടെയോ മറ്റു കളിക്കാരുടെയോ സഹായം തേടരുത്. നിരവധി അക്കൗണ്ടുകളുണ്ടാക്കുന്നത് ശക്തമായി നിരുത്സാഹപ്പെടുത്തുകയും അങ്ങനെ ചെയ്താല്‍ വിലക്കേര്‍പ്പെടുത്തും എന്നും പ്രത്യേകം ശ്രദ്ധിക്കുക. കളികള്‍ - ഫോറം + ചർച്ചാവേദി %1$s കുറിച്ചു %2$s എന്ന വിഷയത്തെ പറ്റി - ഏറ്റവും പുതിയ ഫോറം പോസ്റ്റുകള്‍ + ഏറ്റവും പുതിയ കൂട്ടായ്മ പോസ്റ്റുകള്‍ കളിക്കാര് കൂട്ടുകാർ മറ്റേ കളിക്കാർ @@ -214,6 +215,7 @@ പാസ്‌വേഡ് മറന്നുപോയോ? ഈ രഹസ്യവാക്ക് ഊഹിക്കുകാൻ വളരെ എളുപ്പമാണ്. ദയവായി താങ്ങളുടെ ഉപയോക്തൃനാമം രഹസ്യവാക്കയായി ഉപയോഗിക്കരുത്. + താങ്ങൾ ഇതേ രഹസ്യവാക്കു് മറ്റൊരു വെബ്സ്ഥാനത്തിൽ ഉപയോഗിച്ചിട്ടുണ്ടു് പക്ഷേ ആ വെബ്സ്ഥാനത്തിന്റെ ഒത്തുതീർപ്പു് നടന്നിരിക്കുന്നു. താങ്ങളുടെ Lichess അക്കൗണ്ടിന്റെ സുരക്ഷക്കു വേണ്ടി താങ്ങൾക്കു് ഒരു പുതിയ രഹസ്യവാക്കു് ഇടണ്ടി വരും. സഹകരിക്കുന്നതിനു് നന്ദി. താങ്ങൾ Lichess വിടുകയാണ് ഒരിക്കലും താങ്ങളുടെ Lichess രഹസ്യവാക്കു് മറ്റെ വെബ്സ്ഥാനത്തിൽ എഴുതരുതു്! %s വരെ ചെല്ലുക @@ -380,6 +382,7 @@ സൗജന്യ തൽസമയം ചെസ്സ്‌ സെര്‍വര്‍. ഇപ്പോള്‍ മികച്ച രീതിയില്‍ ചെസ്സ്‌ കളിക്കാം. രജിസ്റ്റര്‍ ചെയ്യണ്ട, പരസ്യങ്ങള്‍ ഇല്ല, പ്ലഗിനുകള്‍ വേണ്ട. കമ്പ്യൂട്ടറുമായോ സുഹൃത്തുക്കളുമായോ പുതിയ എതിരാളികളുമായോ ചെസ്സ്‌ കളിക്കാം. %1$s സംഘത്തിൽ %2$s അംഗമായി %1$s നിർമിച്ച %2$s സംഘം + സംപ്രേക്ഷണം ആരംഭിച്ചിരിക്കുന്നു %s സംപ്രേക്ഷണം ആരംഭിച്ചിരിക്കുന്നു ശരാശരി നിലവാരം സ്ഥലം @@ -398,6 +401,8 @@ പഠനം മത്സരം അപ്പ്‌ലോഡ് ചെയ്യുക ഒരു ഗെയിം PGN പേസ്റ്റ് ചെയ്യുമ്പോൾ ബ്രൗസ്‌ ചെയ്യാൻ പറ്റുന്ന റീപ്ലേ, കമ്പ്യൂട്ടർ വിശകലനം, ഗെയിം ചാറ്റ്, ഷെയർ ചെയ്യാൻ പറ്റുന്ന ഒരു URL എന്നിവ ലഭിക്കുന്നു. + വ്യതിയാനങ്ങൾ മായ്ക്കപ്പെടും. അവയെ വയ്ക്കാനായി PGN ഒരു പഠനം വഴി ഇറക്കുമതിക്കുക. + ഈ PGN എല്ലാവൎക്കും കാണാം. ഈ കളി സ്വകാര്യമായി ഇറക്കുമതിക്കാൻ ഒരു പഠനം ഉപയോഗിക്കുക. ഇറക്കിയ കളികൾ %s ഇറക്കിയ കളികൾ %s @@ -504,6 +509,7 @@ സമൂഹമാധ്യമ ലിങ്കുകൾ ഒരു വരിയിൽ ഒരു വിലാസം. ഇൻഫയൽ നോട്ടെഷൻ + കരുതിവയ്ക്കാനും പങ്കിടാനും ഒരു പഠനം ഉണ്ടാക്കാൻ പരിഗണയിൽ എടുക്കുക. നീക്കങ്ങൾ കളയുക Lichess TV യിൽ നേരെത്തെയുള്ളത് തൽസമയ കളിക്കാർ @@ -875,6 +881,8 @@ ഞാൻ Lichess പോളിസികൾ അനുകൂലിക്കുന്നതായി സമ്മതിക്കുന്നു. കണ്ടുപിടിക്കുക അല്ലെങ്കിൽ പുതിയൊരു സംഭാഷണം ആരംഭിക്കുക മാറ്റുക + വെടിയുണ്ടകളി + മിന്നൽകളി റാപിഡ് ക്ലാസിക്കല്‍ തീവ്രവേഗത്തിലുള്ള കളികള്‍: 30 സെക്കന്‍റില്‍ താഴെ @@ -897,8 +905,8 @@ %1$s ഇൽ ചേരുക, ഫോറത്തിൽ പോസ്റ്റ്‌ ചെയുന്നതിനു %1$s ടീം ഇപ്പോൾ ഈ ഫോറത്തിൽ നിങ്ങൾക്കു പോസ്റ്റ്‌ ചെയ്യാൻ കഴിയുകയില്ല കുറച്ചു ഗ്യാമുകൾക്കു ശേഷം ശ്രമിക്കുക! - സബ്സ്ക്രൈബ് - അൺസബ്സ്ക്രൈബ് + പേരെഴുതുക + പേർമാറ്റുക %1$s ഇൽ നിങ്ങളെ പരാമർശിച്ചു. %2$s നിങ്ങളെ %1$s പരാമർശിച്ചു. %1$s ഇലേക്കു നിങ്ങളെ ക്ഷണിക്കുന്നു. diff --git a/translation/dest/site/ns-ZA.xml b/translation/dest/site/ns-ZA.xml index 3ea04e700dfa8..96cfaf99adf72 100644 --- a/translation/dest/site/ns-ZA.xml +++ b/translation/dest/site/ns-ZA.xml @@ -1,2 +1,4 @@ - + + This is a TEST of Crowdin\'s language mapping (language field - should come through as nso instead of ns) and the string will be removed within a few days. + diff --git a/translation/dest/site/nso-ZA.xml b/translation/dest/site/nso-ZA.xml new file mode 100644 index 0000000000000..3ea04e700dfa8 --- /dev/null +++ b/translation/dest/site/nso-ZA.xml @@ -0,0 +1,2 @@ + + diff --git a/translation/dest/streamer/bg-BG.xml b/translation/dest/streamer/bg-BG.xml index 4dfdc51015ba2..2a5aeb1872852 100644 --- a/translation/dest/streamer/bg-BG.xml +++ b/translation/dest/streamer/bg-BG.xml @@ -28,8 +28,10 @@ Вашият стрийм е одобрен. Вашият стрийм се преглежда от модератори. Моля, попълнете информацията за своя стрийм и качете снимка. + Изпратете за преглед Страницата за стрийминг на Lichess е насочена към вашата аудитория с езика, предоставен от вашата платформа за стрийминг. Задайте правилния език по подразбиране за вашите шахматни стриймове в приложението или услугата, която използвате за излъчване. Вашето потребителско име в Twitch или URL адрес + Изисква се Twitch или YouTube ID на канала Ви в YouTube Име на вашата стрийм страница в Lichess Видимо на публичната страницата със стриймъри diff --git a/translation/dest/study/bg-BG.xml b/translation/dest/study/bg-BG.xml index fa134eb0d6572..e82197c89deae 100644 --- a/translation/dest/study/bg-BG.xml +++ b/translation/dest/study/bg-BG.xml @@ -166,4 +166,5 @@ Играйте отново Какво бихте играли в тази позиция? Поздравления! Вие завършихте този урок. + %s на страница diff --git a/translation/dest/study/zh-CN.xml b/translation/dest/study/zh-CN.xml index f791d41a7a416..e5816151c136e 100644 --- a/translation/dest/study/zh-CN.xml +++ b/translation/dest/study/zh-CN.xml @@ -163,4 +163,5 @@ 重玩 你会在这个位置上怎么走? 恭喜!你完成了这个课程! + %s 每页 diff --git a/translation/dest/timeago/hr-HR.xml b/translation/dest/timeago/hr-HR.xml index 18de0577bdb74..534880692699d 100644 --- a/translation/dest/timeago/hr-HR.xml +++ b/translation/dest/timeago/hr-HR.xml @@ -67,5 +67,15 @@ prije %s godine prije %s godina + + Preostala %s minuta + Preostale %s minute + Preostalo %s minuta + + + Preostao %s sat + Preostala %s sata + Preostalo %s sati + završeno diff --git a/ui/analyse/css/_tools.scss b/ui/analyse/css/_tools.scss index 93ff2ee2ee296..add14c15923c1 100644 --- a/ui/analyse/css/_tools.scss +++ b/ui/analyse/css/_tools.scss @@ -50,6 +50,8 @@ flex-direction: column; justify-content: space-between; + @include prevent-select; + // 0 size forces vertical scrollbar overflow-y: auto; overflow-x: hidden; diff --git a/ui/analyse/css/study/relay/_back-to-live.scss b/ui/analyse/css/study/relay/_back-to-live.scss new file mode 100644 index 0000000000000..7e3ff9c9b09a7 --- /dev/null +++ b/ui/analyse/css/study/relay/_back-to-live.scss @@ -0,0 +1,14 @@ +.relay-back-to-live { + @extend %flex-center; + justify-content: center; + padding: 0.5em 0; + background: $c-accent; + color: $c-over; + cursor: pointer; + + flex: 0 0 auto; + + @include mq-is-col1 { + display: none; + } +} diff --git a/ui/analyse/css/study/relay/_layout.scss b/ui/analyse/css/study/relay/_layout.scss index f6c9fdc79f63b..f5266613a702b 100644 --- a/ui/analyse/css/study/relay/_layout.scss +++ b/ui/analyse/css/study/relay/_layout.scss @@ -129,4 +129,9 @@ main.analyse.is-relay:not(.has-relay-tour) { flex: 1 0 200px; } } + &.relay-live-away { + .main-board { + filter: saturate(0.5) brightness(0.9); + } + } } diff --git a/ui/analyse/css/study/relay/_show.scss b/ui/analyse/css/study/relay/_show.scss index 340a8892c4aa0..f47865813f126 100644 --- a/ui/analyse/css/study/relay/_show.scss +++ b/ui/analyse/css/study/relay/_show.scss @@ -3,3 +3,4 @@ @import 'rounds'; @import 'teams'; @import 'video-player'; +@import 'back-to-live'; diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index 7c93e687c2a0c..bd791711b24b5 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -99,7 +99,7 @@ export default class AnalyseCtrl { keyboardHelp: boolean = location.hash === '#keyboard'; threatMode: Prop = prop(false); treeView: TreeView; - treeVersion = 1; // increment to recreate tree + treeVersion = 1; // increment to recreate vnode tree cgVersion = { js: 1, // increment to recreate chessground dom: 1, diff --git a/ui/analyse/src/study/multiBoard.ts b/ui/analyse/src/study/multiBoard.ts index b388e9108658b..85ce3b0d0e038 100644 --- a/ui/analyse/src/study/multiBoard.ts +++ b/ui/analyse/src/study/multiBoard.ts @@ -291,7 +291,7 @@ export const renderClock = (chapter: ChapterPreview, color: Color) => { const computeTimeLeft = (preview: ChapterPreview, color: Color): number | undefined => { const clock = preview.players?.[color]?.clock; if (notNull(clock)) { - if (defined(preview.lastMoveAt) && fenColor(preview.fen) === color) { + if (defined(preview.lastMoveAt) && defined(preview.lastMove) && fenColor(preview.fen) === color) { const spent = (Date.now() - preview.lastMoveAt) / 1000; return Math.max(0, clock / 100 - spent); } else { diff --git a/ui/analyse/src/study/playerBars.ts b/ui/analyse/src/study/playerBars.ts index 5c6ac428dc4e9..51b9d8823b2b4 100644 --- a/ui/analyse/src/study/playerBars.ts +++ b/ui/analyse/src/study/playerBars.ts @@ -7,6 +7,8 @@ import type { StudyPlayers, Federation, TagArray } from './interfaces'; import { findTag, isFinished, looksLikeLichessGame, resultOf } from './studyChapters'; import { userTitle } from 'common/userLink'; import RelayPlayers, { fidePageLinkAttrs } from './relay/relayPlayers'; +import { StudyCtrl } from './studyDeps'; +import { intersection } from 'tree/path'; export default function (ctrl: AnalyseCtrl): VNode[] | undefined { const study = ctrl.study; @@ -15,8 +17,8 @@ export default function (ctrl: AnalyseCtrl): VNode[] | undefined { const players = study.currentChapter().players, tags = study.data.chapter.tags, - clocks = renderClocks(ctrl), - ticking = !isFinished(study.data.chapter) && ctrl.turnColor(), + clocks = renderClocks(ctrl, selectClockPath(ctrl, study)), + tickingColor = study.isClockTicking(ctrl.path) && ctrl.turnColor(), materialDiffs = renderMaterialDiffs(ctrl); return (['white', 'black'] as Color[]).map(color => @@ -27,13 +29,27 @@ export default function (ctrl: AnalyseCtrl): VNode[] | undefined { materialDiffs, players, color, - ticking === color, + tickingColor === color, study.data.showRatings || !looksLikeLichessGame(tags), relayPlayers, ), ); } +// The tree node whose clocks are displayed. +// Finished game: last mainline node of the current variation. +// Ongoing game: the last mainline node, no matter what +function selectClockPath(ctrl: AnalyseCtrl, study: StudyCtrl): Tree.Path { + const gamePath = ctrl.gamePath || study.data.chapter.relayPath; + return isFinished(study.data.chapter) + ? ctrl.node.clock + ? ctrl.path + : gamePath + ? intersection(ctrl.path, gamePath) + : ctrl.path + : gamePath || ctrl.path; +} + function renderPlayer( ctrl: AnalyseCtrl, tags: TagArray[], diff --git a/ui/analyse/src/study/relay/relayView.ts b/ui/analyse/src/study/relay/relayView.ts index 546968987e505..716d620fe34c6 100644 --- a/ui/analyse/src/study/relay/relayView.ts +++ b/ui/analyse/src/study/relay/relayView.ts @@ -1,6 +1,6 @@ import { view as cevalView } from 'ceval'; import { onClickAway } from 'common'; -import { looseH as h, onInsert, type VNode } from 'common/snabbdom'; +import { bind, dataIcon, looseH as h, onInsert, type VNode } from 'common/snabbdom'; import * as licon from 'common/licon'; import type AnalyseCtrl from '../../ctrl'; import { view as keyboardView } from '../../keyboard'; @@ -34,6 +34,25 @@ export function relayView( ]); } +export const backToLiveView = (ctrl: AnalyseCtrl) => + ctrl.study?.isRelayAwayFromLive() + ? h( + 'button.fbt.relay-back-to-live.text', + { + attrs: dataIcon(licon.RadioTower), + hook: bind( + 'click', + () => { + const p = ctrl.study?.data.chapter.relayPath; + if (p) ctrl.userJump(p); + }, + ctrl.redraw, + ), + }, + 'Back to live move', + ) + : undefined; + export function renderStreamerMenu(relay: RelayCtrl): VNode { const makeUrl = (id: string) => { const url = new URL(location.href); diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index 51933f92576fa..bef1a6aef4f3c 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -44,7 +44,7 @@ import { MultiBoardCtrl } from './multiBoard'; import type { StudySocketSendParams } from '../socket'; import { storedMap } from 'common/storage'; import { opposite } from 'chessops/util'; -import StudyChaptersCtrl from './studyChapters'; +import StudyChaptersCtrl, { isFinished } from './studyChapters'; import { SearchCtrl } from './studySearch'; import type { GamebookOverride } from './gamebook/interfaces'; import type { EvalHitMulti, EvalHitMultiArray } from '../interfaces'; @@ -526,6 +526,15 @@ export default class StudyCtrl { }; onFlip = () => this.chapterFlipMapProp(this.data.chapter.id, this.ctrl.flipped); + isClockTicking = (path: Tree.Path) => + path !== '' && this.data.chapter.relayPath === path && !isFinished(this.data.chapter); + + isRelayAwayFromLive = (): boolean => + !!this.relay && + !isFinished(this.data.chapter) && + defined(this.data.chapter.relayPath) && + this.ctrl.path !== this.data.chapter.relayPath; + setPath = (path: Tree.Path, node: Tree.Node) => { this.onSetPath(path); this.commentForm.onSetPath(this.vm.chapterId, path, node); diff --git a/ui/analyse/src/view/clocks.ts b/ui/analyse/src/view/clocks.ts index bd0cf6581cb6c..8bee45fb5dada 100644 --- a/ui/analyse/src/view/clocks.ts +++ b/ui/analyse/src/view/clocks.ts @@ -1,25 +1,22 @@ import { h, type VNode } from 'snabbdom'; import type AnalyseCtrl from '../ctrl'; -import { isFinished } from '../study/studyChapters'; import { notNull } from 'common'; -export default function renderClocks(ctrl: AnalyseCtrl): [VNode, VNode] | undefined { - const node = ctrl.node, - clock = node.clock; - - const whitePov = ctrl.bottomIsWhite(), - parentClock = ctrl.tree.getParentClock(node, ctrl.path), +export default function renderClocks(ctrl: AnalyseCtrl, path: Tree.Path): [VNode, VNode] | undefined { + const node = ctrl.tree.nodeAtPath(path), + whitePov = ctrl.bottomIsWhite(), + parentClock = ctrl.tree.getParentClock(node, path), isWhiteTurn = node.ply % 2 === 0, - centis: Array = isWhiteTurn ? [parentClock, clock] : [clock, parentClock]; + centis: Array = isWhiteTurn ? [parentClock, node.clock] : [node.clock, parentClock]; if (!centis.some(notNull)) return; const study = ctrl.study; const lastMoveAt = study - ? study.data.chapter.relayPath !== ctrl.path || ctrl.path === '' || isFinished(study.data.chapter) - ? undefined - : study.relay?.lastMoveAt(study.vm.chapterId) + ? study.isClockTicking(path) + ? study.relay?.lastMoveAt(study.vm.chapterId) + : undefined : ctrl.autoplay.lastMoveAt; if (lastMoveAt) { diff --git a/ui/analyse/src/view/components.ts b/ui/analyse/src/view/components.ts index 625ec4bff1f2b..8ffb899fdd436 100644 --- a/ui/analyse/src/view/components.ts +++ b/ui/analyse/src/view/components.ts @@ -42,6 +42,7 @@ import type * as studyDeps from '../study/studyDeps'; import { renderPgnError } from '../pgnImport'; import { storage } from 'common/storage'; import { makeChat } from 'chat'; +import { backToLiveView } from '../study/relay/relayView'; export interface ViewContext { ctrl: AnalyseCtrl; @@ -91,6 +92,7 @@ export function renderMain( { ctrl, playerBars, gaugeOn, gamebookPlayView, needsInnerCoords, hasRelayTour }: ViewContext, kids: VNodeKids, ): VNode { + const isRelay = defined(ctrl.study?.relay); return h( 'main.analyse.variant-' + ctrl.data.game.variant.key, { @@ -118,9 +120,10 @@ export function renderMain( 'has-players': !!playerBars, 'gamebook-play': !!gamebookPlayView, 'has-relay-tour': hasRelayTour, - 'is-relay': ctrl.study?.relay !== undefined, + 'is-relay': isRelay, 'analyse-hunter': ctrl.opts.hunter, 'analyse--wiki': !!ctrl.wiki && !ctrl.study, + 'relay-live-away': !!ctrl.study?.isRelayAwayFromLive(), }, }, kids, @@ -137,6 +140,7 @@ export function renderTools({ ctrl, deps, concealOf, allowVideo }: ViewContext, !ctrl.retro?.isSolving() && !ctrl.practice && cevalView.renderPvs(ctrl), renderMoveList(ctrl, deps, concealOf), deps?.gbEdit.running(ctrl) ? deps?.gbEdit.render(ctrl) : undefined, + backToLiveView(ctrl), forkView(ctrl, concealOf), retroView(ctrl) || practiceView(ctrl) || explorerView(ctrl), ]), @@ -440,7 +444,7 @@ function renderPlayerStrips(ctrl: AnalyseCtrl): [VNode, VNode] | undefined { const renderPlayerStrip = (cls: string, materialDiff: VNode, clock?: VNode): VNode => h('div.analyse__player_strip.' + cls, [materialDiff, clock]); - const clocks = renderClocks(ctrl), + const clocks = renderClocks(ctrl, ctrl.path), whitePov = ctrl.bottomIsWhite(), materialDiffs = renderMaterialDiffs(ctrl); diff --git a/ui/chess/src/glyphs.ts b/ui/chess/src/glyphs.ts index ea2dc03da40da..f17cd17020571 100644 --- a/ui/chess/src/glyphs.ts +++ b/ui/chess/src/glyphs.ts @@ -91,7 +91,7 @@ const glyphToSvg: Dictionary = { // Equal position '=': composeGlyph( - '#f5918f', + '#82c2ef', '', ), diff --git a/ui/lobby/src/setupCtrl.ts b/ui/lobby/src/setupCtrl.ts index dfaa671f2075d..5557d4792820a 100644 --- a/ui/lobby/src/setupCtrl.ts +++ b/ui/lobby/src/setupCtrl.ts @@ -197,10 +197,7 @@ export default class SetupController { this.loadPropsFromStore(forceOptions); }; - closeModal = () => { - this.gameType = null; - this.root.redraw(); - }; + closeModal?: () => void; // managed by view/setup/modal.ts validateFen = debounce(() => { const fen = this.fen(); @@ -292,7 +289,7 @@ export default class SetupController { const poolMember = this.hookToPoolMember(color); if (poolMember) { this.root.enterPool(poolMember); - this.closeModal(); + this.closeModal?.(); return; } @@ -330,13 +327,13 @@ export default class SetupController { if (response.status === 403) { // 403 FORBIDDEN closes this modal because challenges to the recipient // will not be accepted. see friend() in controllers/Setup.scala - this.closeModal(); + this.closeModal?.(); } } else if (redirected) { location.href = url; } else { this.loading = false; - this.closeModal(); + this.closeModal?.(); } }; } diff --git a/ui/lobby/src/view/setup/modal.ts b/ui/lobby/src/view/setup/modal.ts index b4344a0d7c184..d86ae5ff76661 100644 --- a/ui/lobby/src/view/setup/modal.ts +++ b/ui/lobby/src/view/setup/modal.ts @@ -18,9 +18,17 @@ export default function setupModal(ctrl: LobbyController): MaybeVNode { return snabDialog({ class: 'game-setup', css: [{ hashed: 'lobby.setup' }], - onClose: setupCtrl.closeModal, + onClose: () => { + setupCtrl.closeModal = undefined; + setupCtrl.gameType = null; + setupCtrl.root.redraw(); + }, modal: true, vnodes: [...views[setupCtrl.gameType](ctrl), ratingView(ctrl)], + onInsert: dlg => { + setupCtrl.closeModal = dlg.close; + dlg.show(); + }, }); } diff --git a/ui/round/src/ctrl.ts b/ui/round/src/ctrl.ts index da498a0f2fe92..9e52423fa6152 100644 --- a/ui/round/src/ctrl.ts +++ b/ui/round/src/ctrl.ts @@ -584,6 +584,13 @@ export default class RoundController implements MoveRootCtrl { wakeLock.release(); if (this.data.game.status.name === 'started') site.sound.saySan(this.stepAt(this.ply).san, false); else site.sound.say(viewStatus(this), false, false, true); + if ( + !d.player.spectator && + o.status.name === 'outoftime' && + this.chessground.state.turnColor === d.opponent.color + ) { + notify(viewStatus(this)); + } }; challengeRematch = async (): Promise => { diff --git a/ui/site/src/serviceWorker.ts b/ui/site/src/serviceWorker.ts index 872a816c28044..e1e2c08635f97 100644 --- a/ui/site/src/serviceWorker.ts +++ b/ui/site/src/serviceWorker.ts @@ -2,10 +2,6 @@ import { url as assetUrl, jsModule } from './asset'; import { log } from 'common/permalog'; import { storage } from 'common/storage'; -function makeUrlSafe(base64: string): string { - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - export default async function () { if (!('serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window)) return; const workerUrl = new URL( @@ -25,10 +21,7 @@ export default async function () { if (!vapid || Notification.permission !== 'granted') return store.remove(); else if (sub && !resub) return; - newSub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: makeUrlSafe(vapid), - }); + newSub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid }); if (!newSub) throw new Error(JSON.stringify(await reg.pushManager.permissionState())); diff --git a/ui/tree/src/path.ts b/ui/tree/src/path.ts index 8ada21b70119e..2b26407c9a945 100644 --- a/ui/tree/src/path.ts +++ b/ui/tree/src/path.ts @@ -20,3 +20,9 @@ export function fromNodeList(nodes: Tree.Node[]): Tree.Path { export const isChildOf = (child: Tree.Path, parent: Tree.Path): boolean => !!child && child.slice(0, -2) === parent; + +export const intersection = (p1: Tree.Path, p2: Tree.Path): Tree.Path => { + const head1 = head(p1), + head2 = head(p2); + return head1 !== '' && head1 === head2 ? head1 + intersection(tail(p1), tail(p2)) : ''; +}; diff --git a/ui/tree/src/tree.ts b/ui/tree/src/tree.ts index e29acbbc3b7c7..7ae0023307012 100644 --- a/ui/tree/src/tree.ts +++ b/ui/tree/src/tree.ts @@ -74,8 +74,6 @@ export function build(root: Tree.Node): TreeWrapper { const pathIsMainline = (path: Tree.Path): boolean => pathIsMainlineFrom(root, path); - const pathExists = (path: Tree.Path): boolean => !!nodeAtPathOrNull(path); - function pathIsMainlineFrom(node: Tree.Node, path: Tree.Path): boolean { if (path === '') return true; const pathId = treePath.head(path), @@ -84,6 +82,8 @@ export function build(root: Tree.Node): TreeWrapper { return pathIsMainlineFrom(child, treePath.tail(path)); } + const pathExists = (path: Tree.Path): boolean => !!nodeAtPathOrNull(path); + const pathIsForcedVariation = (path: Tree.Path): boolean => !!getNodeList(path).find(n => n.forceVariation); function lastMainlineNodeFrom(node: Tree.Node, path: Tree.Path): Tree.Node { @@ -233,9 +233,7 @@ export function build(root: Tree.Node): TreeWrapper { }, pathIsMainline, pathIsForcedVariation, - lastMainlineNode(path: Tree.Path): Tree.Node { - return lastMainlineNodeFrom(root, path); - }, + lastMainlineNode: (path: Tree.Path): Tree.Node => lastMainlineNodeFrom(root, path), pathExists, deleteNodeAt, promoteAt,