diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml index 6020034c1352e..84a11b474adf6 100644 --- a/.github/workflows/assets.yml +++ b/.github/workflows/assets.yml @@ -47,10 +47,9 @@ jobs: - run: ./ui/build --no-install -p - run: cd ui && pnpm run test && cd - - run: mkdir assets && mv public assets/ && cp bin/download-lifat LICENSE COPYING.md README.md assets/ && git log -n 1 --pretty=oneline > assets/commit.txt - - run: cd assets && tar -cvpJf ../assets.tar.xz . && cd - - env: - XZ_OPT: '-0' - - uses: actions/upload-artifact@v3 + - run: cd assets && tar --zstd -cvpf ../assets.tar.zst . && cd - + - uses: actions/upload-artifact@v4 with: name: lila-assets - path: assets.tar.xz + path: assets.tar.zst + compression-level: 0 # already compressed diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 99cbbecf0f960..473ae8e3ea2ca 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -41,10 +41,11 @@ jobs: - run: TZ=UTC git log -1 --date=iso-strict-local --pretty='format:app.version.commit = "%H"%napp.version.date = "%ad"%napp.version.message = """%s"""%n' | tee conf/version.conf - run: ./lila -Depoll=true "test;stage" - run: cp LICENSE COPYING.md README.md target/universal/stage && git log -n 1 --pretty=oneline > target/universal/stage/commit.txt - - run: cd target/universal/stage && tar -cvpJf ../../../lila-3.0.tar.xz . && cd - + - run: cd target/universal/stage && tar --zstd -cvpf ../../../lila-3.0.tar.zst . && cd - env: - XZ_OPT: '-0' - - uses: actions/upload-artifact@v3 + ZSTD_LEVEL: 1 # most files are already zipped + - uses: actions/upload-artifact@v4 with: name: lila-server - path: lila-3.0.tar.xz + path: lila-3.0.tar.zst + compression-level: 0 # already compressed diff --git a/app/controllers/Dev.scala b/app/controllers/Dev.scala index aecb23c38d2eb..2c07c4e968514 100644 --- a/app/controllers/Dev.scala +++ b/app/controllers/Dev.scala @@ -33,7 +33,10 @@ final class Dev(env: Env) extends LilaController(env): env.tutor.nbAnalysisSetting, env.tutor.parallelismSetting, env.firefoxOriginTrial, - env.credentiallessUaRegex + env.credentiallessUaRegex, + env.relay.proxyDomainRegex, + env.relay.proxyHostPort, + env.relay.proxyCredentials ) def settings = Secure(_.Settings) { _ ?=> _ ?=> diff --git a/app/controllers/Ublog.scala b/app/controllers/Ublog.scala index 975e917f582f2..3ece241718f9d 100644 --- a/app/controllers/Ublog.scala +++ b/app/controllers/Ublog.scala @@ -1,6 +1,8 @@ package controllers import play.api.i18n.Lang +import play.api.data.Form +import play.api.data.Forms.* import views.* import lila.app.{ given, * } @@ -188,6 +190,23 @@ final class Ublog(env: Env) extends LilaController(env): ) } + def rankAdjust(postId: String) = SecureBody(_.ModerateBlog) { ctx ?=> me ?=> + Found(env.ublog.api.getPost(UblogPostId(postId))): post => + Form: + single: + "value" -> optional(number) + .bindFromRequest() + .fold( + _ => Redirect(urlOfPost(post)).flashFailure, + rankAdjustDays => + for + _ <- env.ublog.api.setRankAdjust(post.id, ~rankAdjustDays) + _ <- env.mod.logApi.ublogRankAdjust(post.created.by, post.id, ~rankAdjustDays) + _ <- env.ublog.rank.recomputePostRank(post) + yield Redirect(urlOfPost(post)).flashSuccess + ) + } + private val ImageRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress]( key = "ublog.image.ip" )( diff --git a/app/controllers/Video.scala b/app/controllers/Video.scala index b2c3b3d27045c..debf9ec83a5f3 100644 --- a/app/controllers/Video.scala +++ b/app/controllers/Video.scala @@ -37,7 +37,7 @@ final class Video(env: Env) extends LilaController(env): def show(id: String) = Open: WithUserControl: control => - api.video.find(id) flatMap { + api.video.find(id) flatMap: case None => NotFound.page(html.video.bits.notFound(control)) case Some(video) => api.video.similar(ctx.me, video, 9) zip @@ -46,7 +46,6 @@ final class Video(env: Env) extends LilaController(env): } flatMap { (similar, _) => Ok.page(html.video.show(video, similar, control)) } - } def author(author: String) = Open: WithUserControl: control => diff --git a/app/views/challenge/mine.scala b/app/views/challenge/mine.scala index af743da4ce413..46aef1e144977 100644 --- a/app/views/challenge/mine.scala +++ b/app/views/challenge/mine.scala @@ -49,7 +49,7 @@ object mine: else div(cls := "invite")( div( - h2(cls := "ninja-title", trans.toInviteSomeoneToPlayGiveThisUrl(), ": "), + h2(cls := "ninja-title", trans.toInviteSomeoneToPlayGiveThisUrl()), br, p(cls := "challenge-id-form")( input( @@ -83,6 +83,13 @@ object mine: ), error.map { p(cls := "error")(_) } ) + ), + div(cls := "qr-code-invite")( + h2(cls := "ninja-title", trans.orLetYourOpponentScanQrCode()), + img( + src := s"https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=$challengeLink", + alt := "QR Code" + ) ) ) }, diff --git a/app/views/puzzle/bits.scala b/app/views/puzzle/bits.scala index fad6babdd7673..0808110b8092b 100644 --- a/app/views/puzzle/bits.scala +++ b/app/views/puzzle/bits.scala @@ -61,6 +61,9 @@ object bits: ) ) + private val themeI18nKeys = + PuzzleTheme.visible.map(_.name) ::: PuzzleTheme.visible.map(_.description) + private val baseI18nKeys = List( trans.puzzle.bestMove, trans.puzzle.keepGoing, @@ -102,8 +105,7 @@ object bits: trans.puzzle.nbPointsBelowYourPuzzleRating, trans.puzzle.nbPointsAboveYourPuzzleRating ) ::: - PuzzleTheme.visible.map(_.name) ::: - PuzzleTheme.visible.map(_.description) ::: + themeI18nKeys ::: PuzzleDifficulty.all.map(_.name) private val streakI18nKeys = baseI18nKeys ::: List( @@ -113,4 +115,4 @@ object bits: trans.puzzle.streakSkipExplanation, trans.puzzle.continueTheStreak, trans.puzzle.newStreak - ) + ) ::: themeI18nKeys diff --git a/app/views/ublog/post.scala b/app/views/ublog/post.scala index 60b4fb6f49282..1abe057ea04c0 100644 --- a/app/views/ublog/post.scala +++ b/app/views/ublog/post.scala @@ -100,6 +100,7 @@ object post: dataIcon := licon.CautionTriangle ) ), + !ctx.is(user) && isGranted(_.ModerateBlog) option rankAdjust(blog, post), div(cls := "ublog-post__topics")( post.topics.map: topic => a(href := routes.Ublog.topic(topic.url, 1))(topic.value) @@ -123,6 +124,25 @@ object post: ) ) + private def rankAdjust(blog: UblogBlog, post: UblogPost)(using PageContext) = + env.ublog.rank.computeRank(blog, post) map: rank => + postForm(cls := "ublog-post__meta", action := routes.Ublog.rankAdjust(post.id))( + "Rank date:", + span(cls := "ublog-post__meta__date")(semanticDate(rank.value)), + s"adjust${post.rankAdjustDays.nonEmpty so "ed"} by", + span( + input( + tpe := "number", + name := "value", + min := -180, + max := 180, + placeholder := "Days", + value := post.rankAdjustDays.so(_.toString) + ), + form3.submit("Submit")(cls := "button-empty") + ) + ) + private def editButton(post: UblogPost)(using PageContext) = a( href := editUrlOfPost(post), cls := "button button-empty text", diff --git a/app/views/video/bits.scala b/app/views/video/bits.scala index d4ad1c85d56a0..87f9b330d4d7f 100644 --- a/app/views/video/bits.scala +++ b/app/views/video/bits.scala @@ -57,15 +57,14 @@ object bits: def notFound(control: lila.video.UserControl)(using PageContext) = layout(title = "Video not found", control = control)( - div(cls := "content_box_top")( - a(cls := "is4 text", dataIcon := licon.Back, href := routes.Video.index)("Video library") - ), - div(cls := "not_found")( - h1("Video Not Found!"), - br, - br, - a(cls := "big button text", dataIcon := licon.Back, href := routes.Video.index)( - "Return to the video library" + boxTop( + h1( + a( + cls := "is4 text", + dataIcon := licon.Back, + href := s"${routes.Video.index}" + ), + "Video Not Found!" ) ) ) diff --git a/app/views/video/layout.scala b/app/views/video/layout.scala index 234a88570cc29..d049f09b6fe30 100644 --- a/app/views/video/layout.scala +++ b/app/views/video/layout.scala @@ -18,7 +18,7 @@ object layout: moreJs = infiniteScrollTag, wrapClass = "full-screen-force", openGraph = openGraph - ) { + ): main(cls := "video page-menu force-ltr")( st.aside(cls := "page-menu__menu")( views.html.site.bits.subnav( @@ -44,4 +44,3 @@ object layout: ), div(cls := "page-menu__content box")(body) ) - } diff --git a/bin/deploy b/bin/deploy index 67be8205b93f8..90752df383cf4 100755 --- a/bin/deploy +++ b/bin/deploy @@ -230,8 +230,13 @@ def artifact_url(session, run, name): for artifact in session.get(run["artifacts_url"]).json()["artifacts"]: if artifact["name"] == name: if artifact["expired"]: - print("Artifact expired.") - return artifact["archive_download_url"] + raise DeployError("Artifact expired.") + + # Will redirect to URL containing a short-lived authorization + # token. + resolved = session.head(artifact["archive_download_url"], allow_redirects=False) + resolved.raise_for_status() + return resolved.headers["Location"] raise DeployError(f"Did not find artifact {name}.") @@ -248,7 +253,6 @@ def tmux(ssh, script, *, dry_run=False): def deploy_script(profile, session, run, url): - auth_header = f"Authorization: {session.headers['Authorization']}" ua_header = f"User-Agent: {session.headers['User-Agent']}" deploy_dir = profile["deploy_dir"] artifact_unzipped = f"{ARTIFACT_DIR}/{profile['artifact_name']}-{run['id']:d}" @@ -259,12 +263,12 @@ def deploy_script(profile, session, run, url): "echo \\# Downloading ...", f"mkdir -p {ARTIFACT_DIR}", f"mkdir -p {deploy_dir}/logs", - f"[ -f {artifact_zip} ] || wget --header={shlex.quote(auth_header)} --header={shlex.quote(ua_header)} --no-clobber -O {artifact_zip} {shlex.quote(url)}", + f"[ -f {artifact_zip} ] || wget --header={shlex.quote(ua_header)} --no-clobber -O {artifact_zip} {shlex.quote(url)}", "echo", "echo \\# Unpacking ...", f"unzip -q -o {artifact_zip} -d {artifact_unzipped}", f"mkdir -p {artifact_unzipped}/d", - f"tar -xf {artifact_unzipped}/*.tar.xz -C {artifact_unzipped}/d", + f"tar -xf {artifact_unzipped}/*.tar.zst -C {artifact_unzipped}/d", f"cat {artifact_unzipped}/d/commit.txt", "echo", "echo \\# Preparing lifat ...", diff --git a/conf/routes b/conf/routes index 2bba0febfa3a6..9afe74cd48796 100644 --- a/conf/routes +++ b/conf/routes @@ -79,6 +79,7 @@ POST /ublog/$id<\w{8}>/edit controllers.Ublog.update(id) POST /ublog/$id<\w{8}>/del controllers.Ublog.delete(id) POST /ublog/$id<\w{8}>/like controllers.Ublog.like(id, v: Boolean) POST /ublog/:blogId/tier controllers.Ublog.setTier(blogId) +POST /ublog/$id<\w{8}>/adjust controllers.Ublog.rankAdjust(id) POST /upload/image/ublog/$id<\w{8}> controllers.Ublog.image(id) # User diff --git a/modules/common/src/main/base/LilaModel.scala b/modules/common/src/main/base/LilaModel.scala index 16033b9359ede..73af9fd7db03c 100644 --- a/modules/common/src/main/base/LilaModel.scala +++ b/modules/common/src/main/base/LilaModel.scala @@ -6,6 +6,13 @@ import java.time.Instant trait LilaModel: + type Update[A] = A => A + def UpdateOf[A](f: A => A): Update[A] = f + // apply updates to a value, and keep track of the updates + // so they can all be replayed on another value + case class Updating[A](current: A, reRun: Update[A] = (a: A) => a): + def apply(up: Update[A]) = Updating(up(current), up compose reRun) + trait OpaqueInstant[A](using A =:= Instant) extends TotalWrapper[A, Instant] trait Percent[A]: diff --git a/modules/common/src/main/config.scala b/modules/common/src/main/config.scala index 87a6556c8d401..7b154779fb33f 100644 --- a/modules/common/src/main/config.scala +++ b/modules/common/src/main/config.scala @@ -49,6 +49,20 @@ object config: opaque type EndpointUrl = String object EndpointUrl extends OpaqueString[EndpointUrl] + case class Credentials(user: String, password: Secret): + def show = s"$user:${password.value}" + object Credentials: + def read(str: String): Option[Credentials] = str.split(":") match + case Array(user, password) => Credentials(user, Secret(password)).some + case _ => none + + case class HostPort(host: String, port: Int): + def show = s"$host:$port" + object HostPort: + def read(str: String): Option[HostPort] = str.split(":") match + case Array(host, port) => port.toIntOption.map(HostPort(host, _)) + case _ => none + case class NetConfig( domain: NetDomain, prodDomain: NetDomain, diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala index 269ac53abc23b..5e1ba7ce4df6c 100644 --- a/modules/common/src/main/mon.scala +++ b/modules/common/src/main/mon.scala @@ -259,7 +259,8 @@ object mon: def moves(official: Boolean, slug: String) = counter("relay.moves").withTags(relay(official, slug)) def fetchTime(official: Boolean, slug: String) = timer("relay.fetch.time").withTags(relay(official, slug)) def syncTime(official: Boolean, slug: String) = timer("relay.sync.time").withTags(relay(official, slug)) - def httpGet(host: String) = future("relay.http.get", tags("host" -> host)) + def httpGet(host: String, proxy: Option[String]) = + future("relay.http.get", tags("host" -> host, "proxy" -> proxy.getOrElse("none"))) object bot: def moves(username: String) = counter("bot.moves").withTag("name", username) def chats(username: String) = counter("bot.chats").withTag("name", username) diff --git a/modules/i18n/src/main/I18nKeys.scala b/modules/i18n/src/main/I18nKeys.scala index 7238b74808f2f..00510e9d8a0c9 100644 --- a/modules/i18n/src/main/I18nKeys.scala +++ b/modules/i18n/src/main/I18nKeys.scala @@ -8,6 +8,7 @@ object I18nKeys: val `toInviteSomeoneToPlayGiveThisUrl` = I18nKey("toInviteSomeoneToPlayGiveThisUrl") val `gameOver` = I18nKey("gameOver") val `waitingForOpponent` = I18nKey("waitingForOpponent") + val `orLetYourOpponentScanQrCode` = I18nKey("orLetYourOpponentScanQrCode") val `waiting` = I18nKey("waiting") val `yourTurn` = I18nKey("yourTurn") val `aiNameLevelAiLevel` = I18nKey("aiNameLevelAiLevel") diff --git a/modules/i18n/src/main/LangList.scala b/modules/i18n/src/main/LangList.scala index de87994a10bb1..9100276260dc5 100644 --- a/modules/i18n/src/main/LangList.scala +++ b/modules/i18n/src/main/LangList.scala @@ -86,6 +86,7 @@ object LangList: Lang("sa", "IN") -> "संस्कृत", Lang("sk", "SK") -> "Slovenčina", Lang("sl", "SI") -> "Slovenščina", + Lang("so", "SO") -> "Af Soomaali", Lang("sq", "AL") -> "Shqip", Lang("sr", "SP") -> "Српски језик", Lang("sv", "SE") -> "Svenska", diff --git a/modules/memo/src/main/SettingStore.scala b/modules/memo/src/main/SettingStore.scala index 7d995e223aacd..a455eda1a8082 100644 --- a/modules/memo/src/main/SettingStore.scala +++ b/modules/memo/src/main/SettingStore.scala @@ -6,7 +6,7 @@ import reactivemongo.api.bson.BSONHandler import lila.db.dsl.* import play.api.data.*, Forms.* -import lila.common.{ Ints, Iso, Strings, UserIds } +import lila.common.{ Ints, Iso, Strings, UserIds, config } final class SettingStore[A: BSONHandler: SettingStore.StringReader: SettingStore.Formable] private ( coll: Coll, @@ -60,16 +60,18 @@ object SettingStore: final class StringReader[A](val read: String => Option[A]) object StringReader: - given StringReader[Boolean] = StringReader[Boolean]({ + given StringReader[Boolean] = StringReader[Boolean]: case "on" | "yes" | "true" | "1" => true.some case "off" | "no" | "false" | "0" => false.some case _ => none - }) given StringReader[Int] = StringReader[Int](_.toIntOption) given StringReader[Float] = StringReader[Float](_.toFloatOption) given StringReader[String] = StringReader[String](some) def fromIso[A](using iso: Iso.StringIso[A]) = StringReader[A](v => iso.from(v).some) + private type CredOption = Option[lila.common.config.Credentials] + private type HostOption = Option[lila.common.config.HostPort] + object Strings: val stringsIso = Iso.strings(",") given BSONHandler[Strings] = lila.db.dsl.isoHandler(using stringsIso) @@ -86,6 +88,14 @@ object SettingStore: val regexIso = Iso.string[Regex](_.r, _.toString) given BSONHandler[Regex] = lila.db.dsl.isoHandler(using regexIso) given StringReader[Regex] = StringReader.fromIso(using regexIso) + object CredentialsOption: + val credentialsIso = Iso.string[CredOption](lila.common.config.Credentials.read, _.so(_.show)) + given BSONHandler[CredOption] = lila.db.dsl.isoHandler(using credentialsIso) + given StringReader[CredOption] = StringReader.fromIso(using credentialsIso) + object HostPortOption: + val hostPortIso = Iso.string[HostOption](lila.common.config.HostPort.read, _.so(_.show)) + given BSONHandler[HostOption] = lila.db.dsl.isoHandler(using hostPortIso) + given StringReader[HostOption] = StringReader.fromIso(using hostPortIso) final class Formable[A](val form: A => Form[?]) object Formable: @@ -97,5 +107,11 @@ object SettingStore: given Formable[String] = Formable[String](v => Form(single("v" -> text)) fill v) given Formable[Strings] = Formable[Strings](v => Form(single("v" -> text)) fill Strings.stringsIso.to(v)) given Formable[UserIds] = Formable[UserIds](v => Form(single("v" -> text)) fill UserIds.userIdsIso.to(v)) + given Formable[CredOption] = stringPair(using CredentialsOption.credentialsIso) + given Formable[HostOption] = stringPair(using HostPortOption.hostPortIso) + private def stringPair[A](using iso: Iso.StringIso[A]): Formable[A] = Formable[A]: v => + Form( + single("v" -> text.verifying(t => t.isEmpty || t.count(_ == ':') == 1)) + ) fill iso.to(v) private val dbField = "setting" diff --git a/modules/mod/src/main/Modlog.scala b/modules/mod/src/main/Modlog.scala index 8ebe2119fd0ea..8d1f292d75ba6 100644 --- a/modules/mod/src/main/Modlog.scala +++ b/modules/mod/src/main/Modlog.scala @@ -81,6 +81,7 @@ case class Modlog( case Modlog.setKidMode => "set kid mode" case Modlog.weakPassword => "log in with weak password" case Modlog.blankedPassword => "log in with blanked password" + case Modlog.ublogRankAdjust => "adjust ublog post rank" case a => a override def toString = s"$mod $showAction $user $details" @@ -162,6 +163,7 @@ object Modlog: val setKidMode = "setKidMode" val weakPassword = "weakPassword" val blankedPassword = "blankedPassword" + val ublogRankAdjust = "ublogRankAdjust" private val explainRegex = """^[\w-]{3,}+: (.++)$""".r def explain(e: Modlog) = (e.index has "team") so ~e.details match diff --git a/modules/mod/src/main/ModlogApi.scala b/modules/mod/src/main/ModlogApi.scala index 94f6f92be7403..345f5668a6379 100644 --- a/modules/mod/src/main/ModlogApi.scala +++ b/modules/mod/src/main/ModlogApi.scala @@ -225,6 +225,9 @@ final class ModlogApi(repo: ModlogRepo, userRepo: UserRepo, ircApi: IrcApi, pres def appealPost(user: UserId)(using me: Me) = add: Modlog(me, user.some, Modlog.appealPost, details = none) + def ublogRankAdjust(user: UserId, postId: UblogPostId, adjust: Int)(using me: Me) = add: + Modlog(me.some, Modlog.ublogRankAdjust, details = s"$postId by $user, $adjust".some) + def wasUnengined(sus: Suspect) = coll.exists: $doc( "user" -> sus.user.id, diff --git a/modules/oauth/src/main/Env.scala b/modules/oauth/src/main/Env.scala index 0370439ce6288..1d1260b391c62 100644 --- a/modules/oauth/src/main/Env.scala +++ b/modules/oauth/src/main/Env.scala @@ -4,10 +4,9 @@ import com.softwaremill.macwire.* import com.softwaremill.tagging.* import play.api.Configuration -import lila.common.config.CollName +import lila.common.config.{ Secret, CollName } import lila.common.Strings import lila.memo.SettingStore.Strings.given -import lila.common.config.Secret @Module final class Env( diff --git a/modules/relay/src/main/Env.scala b/modules/relay/src/main/Env.scala index 033e52b0bc1c9..b6974deaa6f3f 100644 --- a/modules/relay/src/main/Env.scala +++ b/modules/relay/src/main/Env.scala @@ -1,11 +1,14 @@ package lila.relay import akka.actor.* +import scala.util.matching.Regex import com.softwaremill.macwire.* import com.softwaremill.tagging.* import play.api.libs.ws.StandaloneWSClient import lila.common.config.* +import lila.memo.SettingStore +import lila.memo.SettingStore.Formable.given @Module final class Env( @@ -21,6 +24,7 @@ final class Env( pgnDump: lila.game.PgnDump, gameProxy: lila.round.GameProxyRepo, cacheApi: lila.memo.CacheApi, + settingStore: SettingStore.Builder, irc: lila.irc.IrcApi, baseUrl: BaseUrl )(using @@ -60,6 +64,29 @@ final class Env( private lazy val delay = wire[RelayDelay] + import SettingStore.CredentialsOption.given + val proxyCredentials = settingStore[Option[Credentials]]( + "relayProxyCredentials", + default = none, + text = + "Broadcast: proxy credentials to fetch from external sources. Leave empty to use no proxy. Format: username:password".some + ).taggedWith[ProxyCredentials] + + import SettingStore.HostPortOption.given + val proxyHostPort = settingStore[Option[HostPort]]( + "relayProxyHostPort", + default = none, + text = + "Broadcast: proxy host and port to fetch from external sources. Leave empty to use no proxy. Format: host:port".some + ).taggedWith[ProxyHostPort] + + import SettingStore.Regex.given + val proxyDomainRegex = settingStore[Regex]( + "relayProxyDomainRegex", + default = "-".r, + text = "Broadcast: source domains that use a proxy, as a regex".some + ).taggedWith[ProxyDomainRegex] + // start the sync scheduler wire[RelayFetch] @@ -71,9 +98,10 @@ final class Env( api.onStudyRemove(studyId) }, "relayToggle" -> { case lila.study.actorApi.RelayToggle(id, v, who) => - studyApi.isContributor(id, who.u) foreach { - _ so api.requestPlay(id into RelayRoundId, v) - } + studyApi + .isContributor(id, who.u) + .foreach: + _ so api.requestPlay(id into RelayRoundId, v) }, "kickStudy" -> { case lila.study.actorApi.Kick(studyId, userId, who) => roundRepo.tourIdByStudyId(studyId).flatMapz(api.kickBroadcast(userId, _, who)) @@ -90,3 +118,7 @@ private class RelayColls(mainDb: lila.db.Db, yoloDb: lila.db.AsyncDb @@ lila.db. val round = mainDb(CollName("relay")) val tour = mainDb(CollName("relay_tour")) val delay = yoloDb(CollName("relay_delay")) + +private trait ProxyCredentials +private trait ProxyHostPort +private trait ProxyDomainRegex diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 8117d8ad1e0d3..11d55831578e0 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -266,24 +266,26 @@ final class RelayApi( if v then r.withSync(_.play) else r.withSync(_.pause) .void - def update(from: RelayRound)(f: RelayRound => RelayRound): Fu[RelayRound] = + def reFetchAndUpdate(round: RelayRound)(f: Update[RelayRound]): Fu[RelayRound] = + byId(round.id).orFail(s"Relay round ${round.id} not found").flatMap(update(_)(f)) + + def update(from: RelayRound)(f: Update[RelayRound]): Fu[RelayRound] = val round = f(from).pipe: r => if r.sync.upstream != from.sync.upstream then r.withSync(_.clearLog) else r - studyApi.rename(round.studyId, round.name into StudyName) >> { - if round == from then fuccess(round) - else - for - _ <- roundRepo.coll.update.one($id(round.id), round).void - _ <- (round.sync.playing != from.sync.playing) so - sendToContributors(round.id, "relaySync", jsonView sync round) - _ <- (round.finished != from.finished) so denormalizeTourActive(round.tourId) - yield - round.sync.log.events.lastOption - .ifTrue(round.sync.log != from.sync.log) - .foreach: event => - sendToContributors(round.id, "relayLog", Json.toJsObject(event)) - round - } + if round == from then fuccess(round) + else + for + _ <- (from.name != round.name) so studyApi.rename(round.studyId, round.name into StudyName) + _ <- roundRepo.coll.update.one($id(round.id), round).void + _ <- (round.sync.playing != from.sync.playing) so + sendToContributors(round.id, "relaySync", jsonView sync round) + _ <- (round.finished != from.finished) so denormalizeTourActive(round.tourId) + yield + round.sync.log.events.lastOption + .ifTrue(round.sync.log != from.sync.log) + .foreach: event => + sendToContributors(round.id, "relayLog", Json.toJsObject(event)) + round def reset(old: RelayRound)(using me: Me): Funit = WithRelay(old.id) { relay => diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index ce573ab4a97f2..c24e2e244159e 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -6,6 +6,7 @@ import lila.common.Seconds import lila.db.dsl.{ *, given } import lila.study.MultiPgn import chess.format.pgn.PgnStr +import lila.common.config.Max final private class RelayDelay(colls: RelayColls)(using Executor): @@ -14,7 +15,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): def apply( url: UpstreamUrl, rt: RelayRound.WithTour, - doFetchUrl: (UpstreamUrl, Int) => Fu[RelayGames] + doFetchUrl: (UpstreamUrl, Max) => Fu[RelayGames] ): Fu[RelayGames] = dedupCache(url, rt.round, () => doFetchUrl(url, RelayFetch.maxChapters(rt.tour))) .flatMap: latest => @@ -28,7 +29,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): private val cache = CacheApi.scaffeineNoScheduler .initialCapacity(8) - .maximumSize(64) + .maximumSize(128) .build[UpstreamUrl, GamesSeenBy]() .underlying @@ -54,16 +55,20 @@ final private class RelayDelay(colls: RelayColls)(using Executor): def putIfNew(upstream: UpstreamUrl, games: RelayGames): Funit = val newPgn = RelayGame.iso.from(games).toPgnStr - getPgn(upstream, Seconds(0)).flatMap: + getLatestPgn(upstream).flatMap: case Some(latestPgn) if latestPgn == newPgn => funit case _ => - val doc = $doc("_id" -> idOf(upstream, nowInstant), "at" -> nowInstant, "pgn" -> newPgn) + val now = nowInstant + val doc = $doc("_id" -> idOf(upstream, now), "at" -> now, "pgn" -> newPgn) colls.delay: _.insert.one(doc).void def get(upstream: UpstreamUrl, delay: Seconds): Fu[Option[RelayGames]] = getPgn(upstream, delay).map2: pgn => - RelayGame.iso.to(MultiPgn.split(pgn, 999)) + RelayGame.iso.to(MultiPgn.split(pgn, Max(999))) + + private def getLatestPgn(upstream: UpstreamUrl): Fu[Option[PgnStr]] = + getPgn(upstream, Seconds(0)) private def getPgn(upstream: UpstreamUrl, delay: Seconds): Fu[Option[PgnStr]] = colls.delay: diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 4b8b594736851..ae9499491ebab 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -15,6 +15,7 @@ import lila.round.GameProxyRepo import lila.study.MultiPgn import lila.tree.Node.Comments import RelayRound.Sync.{ UpstreamIds, UpstreamUrl } +import RelayFormat.CanProxy import lila.common.config.Max final private class RelayFetch( @@ -28,93 +29,112 @@ final private class RelayFetch( gameProxy: GameProxyRepo )(using Executor, Scheduler): - LilaScheduler("RelayFetch.official", _.Every(500 millis), _.AtMost(15 seconds), _.Delay(25 seconds)): + import RelayFetch.* + + LilaScheduler("RelayFetch.official", _.Every(500 millis), _.AtMost(15 seconds), _.Delay(15 seconds)): syncRelays(official = true) - LilaScheduler("RelayFetch.user", _.Every(750 millis), _.AtMost(10 seconds), _.Delay(40 seconds)): + LilaScheduler("RelayFetch.user", _.Every(750 millis), _.AtMost(10 seconds), _.Delay(33 seconds)): syncRelays(official = false) private val maxRelaysToSync = Max(50) - private def syncRelays(official: Boolean) = + private def syncRelays(official: Boolean): Funit = val relays = if official then api.toSyncOfficial(maxRelaysToSync) else api.toSyncUser(maxRelaysToSync) relays .flatMap: relays => lila.mon.relay.ongoing(official).update(relays.size) - relays.traverse: rt => - if rt.round.sync.ongoing then - processRelay(rt) flatMap: newRelay => - api.update(rt.round)(_ => newRelay) - else if rt.round.hasStarted then - logger.info(s"Finish by lack of activity ${rt.round}") - api.update(rt.round)(_.finish) - else if rt.round.shouldGiveUp then - val msg = "Finish for lack of start" - logger.info(s"$msg ${rt.round}") - if rt.tour.official then irc.broadcastError(rt.round.id, rt.fullName, msg) - api.update(rt.round)(_.finish) - else fuccess(rt.round) - .void + relays + .map: rt => + if rt.round.sync.ongoing then + processRelay(rt) flatMap: updating => + api.reFetchAndUpdate(rt.round)(updating.reRun) + else if rt.round.hasStarted then + logger.info(s"Finish by lack of activity ${rt.round}") + api.update(rt.round)(_.finish) + else if rt.round.shouldGiveUp then + val msg = "Finish for lack of start" + logger.info(s"$msg ${rt.round}") + if rt.tour.official then irc.broadcastError(rt.round.id, rt.fullName, msg) + api.update(rt.round)(_.finish) + else funit + .parallel + .void // no writing the relay; only reading! - private def processRelay(rt: RelayRound.WithTour): Fu[RelayRound] = - if !rt.round.sync.playing then fuccess(rt.round.withSync(_.play)) + // this can take a long time if the source is slow + private def processRelay(rt: RelayRound.WithTour): Fu[Updating[RelayRound]] = + val updating = Updating(rt.round) + if !rt.round.sync.playing then fuccess(updating(_.withSync(_.play))) else fetchGames(rt) .map(games => rt.tour.players.fold(games)(_ update games)) .mon(_.relay.fetchTime(rt.tour.official, rt.round.slug)) .addEffect(gs => lila.mon.relay.games(rt.tour.official, rt.round.slug).update(gs.size)) .flatMap: games => - sync(rt, games) + sync + .updateStudyChapters(rt, games) .withTimeoutError(7 seconds, SyncResult.Timeout) .mon(_.relay.syncTime(rt.tour.official, rt.round.slug)) .map: res => - res -> rt.round - .withSync(_ addLog SyncLog.event(res.nbMoves, none)) - .copy(finished = games.forall(_.end.isDefined)) + res -> updating: + _.withSync(_ addLog SyncLog.event(res.nbMoves, none)) + .copy(finished = games.nonEmpty && games.forall(_.ending.isDefined)) .recover: case e: Exception => - e.match { + val result = e.match case SyncResult.Timeout => if rt.tour.official then logger.info(s"Sync timeout ${rt.round}") SyncResult.Timeout case _ => if rt.tour.official then logger.info(s"Sync error ${rt.round} ${e.getMessage take 80}") SyncResult.Error(e.getMessage) - } -> rt.round.withSync(_ addLog SyncLog.event(0, e.some)) - .map: (result, newRelay) => - afterSync(result, newRelay withTour rt.tour) + result -> updating: + _.withSync(_ addLog SyncLog.event(0, e.some)) + .map: (result, updatingRelay) => + afterSync(result, rt.tour, updatingRelay) - private def afterSync(result: SyncResult, rt: RelayRound.WithTour): RelayRound = + private def afterSync( + result: SyncResult, + tour: RelayTour, + updating: Updating[RelayRound] + ): Updating[RelayRound] = + val round = updating.current result match - case result: SyncResult.Ok if result.nbMoves == 0 => continueRelay(rt) - case result: SyncResult.Ok => - lila.mon.relay.moves(rt.tour.official, rt.round.slug).increment(result.nbMoves) - if !rt.round.hasStarted && !rt.tour.official then irc.broadcastStart(rt.round.id, rt.fullName) - continueRelay(rt.round.ensureStarted.resume withTour rt.tour) - case _ => continueRelay(rt) + case result: SyncResult.Ok if result.nbMoves > 0 => + lila.mon.relay.moves(tour.official, round.slug).increment(result.nbMoves) + if !round.hasStarted && !tour.official then + irc.broadcastStart(round.id, round.withTour(tour).fullName) + continueRelay(tour, updating(_.ensureStarted.resume)) + case _ => continueRelay(tour, updating) - private def continueRelay(rt: RelayRound.WithTour): RelayRound = - rt.round.sync.upstream.fold(rt.round): upstream => + private def continueRelay(tour: RelayTour, updating: Updating[RelayRound]): Updating[RelayRound] = + val round = updating.current + round.sync.upstream.fold(updating): upstream => val seconds: Seconds = - if rt.round.sync.log.alwaysFails then - rt.round.sync.log.events.lastOption + if round.sync.log.alwaysFails then + round.sync.log.events.lastOption .filterNot(_.isTimeout) .flatMap(_.error) - .ifTrue(rt.tour.official && rt.round.shouldHaveStarted) + .ifTrue(tour.official && round.shouldHaveStarted) .filterNot(_ contains "Cannot parse moves") .filterNot(_ contains "Found an empty PGN") - .foreach { irc.broadcastError(rt.round.id, rt.fullName, _) } + .foreach { irc.broadcastError(round.id, round.withTour(tour).fullName, _) } Seconds(60) - else rt.round.sync.period | Seconds(if upstream.local then 3 else 6) - rt.round.withSync: - _.copy( - nextAt = nowInstant plusSeconds { - seconds.atLeast { - if rt.round.sync.log.justTimedOut then 10 else 2 - }.value - } some - ) + else + round.sync.period | Seconds: + if upstream.local then 3 + else if upstream.asUrl.exists(_.isLcc) && !tour.official then 10 + else 5 + updating: + _.withSync: + _.copy( + nextAt = nowInstant plusSeconds { + seconds.atLeast { + if round.sync.log.justTimedOut then 10 else 2 + }.value + } some + ) private val gameIdsUpstreamPgnFlags = PgnDump.WithFlags( clocks = true, @@ -135,19 +155,20 @@ final private class RelayFetch( gameRepo.withInitialFens flatMap { games => if games.size == ids.size then val pgnFlags = gameIdsUpstreamPgnFlags.copy(delayMoves = !rt.tour.official) - games.traverse { (game, fen) => - pgnDump(game, fen, pgnFlags).dmap(_.render) - } dmap MultiPgn.apply + games + .traverse: (game, fen) => + pgnDump(game, fen, pgnFlags).dmap(_.render) + .dmap(MultiPgn.apply) else throw LilaInvalid: s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)) mkString ", "}" } flatMap: - RelayFetch.multiPgnToGames(_).toFuture + multiPgnToGames(_).toFuture case url: UpstreamUrl => - delayer(url, rt, doFetchUrl) + delayer(url, rt, fetchFromUpstream(using CanProxy(rt.tour.official))) - private def doFetchUrl(upstream: UpstreamUrl, max: Int): Fu[RelayGames] = - import RelayFetch.DgtJson.* + private def fetchFromUpstream(using canProxy: CanProxy)(upstream: UpstreamUrl, max: Max): Fu[RelayGames] = + import DgtJson.* formatApi get upstream.withRound flatMap { case RelayFormat.SingleFile(doc) => doc.format match @@ -155,13 +176,12 @@ final private class RelayFetch( case RelayFormat.DocFormat.Pgn => httpGetPgn(doc.url) map { MultiPgn.split(_, max) } // maybe a single JSON game? Why not case RelayFormat.DocFormat.Json => - httpGetJson[GameJson](doc.url) map { game => + httpGetJson[GameJson](doc.url) map: game => MultiPgn(List(game.toPgn())) - } case RelayFormat.ManyFiles(indexUrl, makeGameDoc) => - httpGetJson[RoundJson](indexUrl) flatMap { round => + httpGetJson[RoundJson](indexUrl) flatMap: round => round.pairings.zipWithIndex - .traverse: (pairing, i) => + .map: (pairing, i) => val number = i + 1 val gameDoc = makeGameDoc(number) gameDoc.format @@ -171,23 +191,25 @@ final private class RelayFetch( httpGetJson[GameJson](gameDoc.url).recover { case _: Exception => GameJson(moves = Nil, result = none) } map { _.toPgn(pairing.tags) } + .recover: _ => + PgnStr(s"${pairing.tags}\n\n${pairing.result}") .map(number -> _) + .parallel .map: results => MultiPgn(results.sortBy(_._1).map(_._2)) - } - } flatMap { RelayFetch.multiPgnToGames(_).toFuture } + } flatMap { multiPgnToGames(_).toFuture } - private def httpGetPgn(url: URL): Fu[PgnStr] = PgnStr from formatApi.httpGet(url) + private def httpGetPgn(url: URL)(using CanProxy): Fu[PgnStr] = PgnStr from formatApi.httpGet(url) - private def httpGetJson[A: Reads](url: URL): Fu[A] = for + private def httpGetJson[A: Reads](url: URL)(using CanProxy): Fu[A] = for str <- formatApi.httpGet(url) json <- Future(Json parse str) // Json.parse throws exceptions (!) data <- summon[Reads[A]].reads(json).fold(err => fufail(s"Invalid JSON from $url: $err"), fuccess) yield data -private[relay] object RelayFetch: +private object RelayFetch: - def maxChapters(tour: RelayTour) = + def maxChapters(tour: RelayTour) = Max: lila.study.Study.maxChapters * (if tour.official then 2 else 1) private[relay] object DgtJson: @@ -216,7 +238,7 @@ private[relay] object RelayFetch: given Reads[RoundJson] = Json.reads case class GameJson(moves: List[String], result: Option[String]): - def toPgn(extraTags: Tags = Tags.empty) = + def toPgn(extraTags: Tags = Tags.empty): PgnStr = val strMoves = moves .map(_ split ' ') .mapWithIndex: (move, index) => @@ -265,5 +287,5 @@ private[relay] object RelayFetch: comments = Comments.empty, children = res.root.children.updateMainline(_.copy(comments = Comments.empty)) ), - end = res.end + ending = res.end ) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index 6d65396d69777..498e05accc605 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -1,33 +1,48 @@ package lila.relay import io.mola.galimatias.URL +import com.softwaremill.tagging.* +import scala.util.matching.Regex import play.api.libs.json.* -import play.api.libs.ws.StandaloneWSClient +import play.api.libs.ws.{ StandaloneWSClient, StandaloneWSRequest, DefaultWSProxyServer } import play.api.libs.ws.DefaultBodyReadables.* import chess.format.pgn.PgnStr import lila.study.MultiPgn -import lila.memo.CacheApi import lila.memo.CacheApi.* +import lila.memo.{ CacheApi, SettingStore } +import lila.common.config.{ Max, Credentials, HostPort } -final private class RelayFormatApi(ws: StandaloneWSClient, cacheApi: CacheApi)(using Executor): +final private class RelayFormatApi( + ws: StandaloneWSClient, + cacheApi: CacheApi, + proxyCredentials: SettingStore[Option[Credentials]] @@ ProxyCredentials, + proxyHostPort: SettingStore[Option[HostPort]] @@ ProxyHostPort, + proxyDomainRegex: SettingStore[Regex] @@ ProxyDomainRegex +)(using Executor): import RelayFormat.* import RelayRound.Sync.UpstreamUrl - private val cache = cacheApi[UpstreamUrl.WithRound, RelayFormat](8, "relay.format"): - _.refreshAfterWrite(10 minutes).expireAfterAccess(20 minutes).buildAsyncFuture(guessFormat) + private val cache = cacheApi[(UpstreamUrl.WithRound, CanProxy), RelayFormat](32, "relay.format"): + _.refreshAfterWrite(10 minutes) + .expireAfterAccess(20 minutes) + .buildAsyncFuture: (url, proxy) => + guessFormat(url)(using proxy) - def get(upstream: UpstreamUrl.WithRound): Fu[RelayFormat] = cache get upstream + def get(upstream: UpstreamUrl.WithRound)(using proxy: CanProxy): Fu[RelayFormat] = + cache get (upstream -> proxy) - def refresh(upstream: UpstreamUrl.WithRound): Unit = cache invalidate upstream + def refresh(upstream: UpstreamUrl.WithRound): Unit = + CanProxy.from(List(false, true)) foreach: proxy => + cache invalidate (upstream -> proxy) - private def guessFormat(upstream: UpstreamUrl.WithRound): Fu[RelayFormat] = { + private def guessFormat(upstream: UpstreamUrl.WithRound)(using CanProxy): Fu[RelayFormat] = { val originalUrl = URL parse upstream.url // http://view.livechesscloud.com/ed5fb586-f549-4029-a470-d590f8e30c76 - def guessLcc(url: URL): Fu[Option[RelayFormat]] = + def guessLcc(url: URL)(using CanProxy): Fu[Option[RelayFormat]] = url.toString match case UpstreamUrl.LccRegex(id) => guessManyFiles: @@ -35,15 +50,14 @@ final private class RelayFormatApi(ws: StandaloneWSClient, cacheApi: CacheApi)(u s"http://1.pool.livechesscloud.com/get/$id/round-${upstream.round | 1}/index.json" case _ => fuccess(none) - def guessSingleFile(url: URL): Fu[Option[RelayFormat]] = + def guessSingleFile(url: URL)(using CanProxy): Fu[Option[RelayFormat]] = List( url.some, !url.pathSegments.contains(mostCommonSingleFileName) option addPart(url, mostCommonSingleFileName) - ).flatten.distinct.findM(looksLikePgn) dmap2 { (u: URL) => + ).flatten.distinct.findM(looksLikePgn) dmap2: (u: URL) => SingleFile(pgnDoc(u)) - } - def guessManyFiles(url: URL): Fu[Option[RelayFormat]] = + def guessManyFiles(url: URL)(using CanProxy): Fu[Option[RelayFormat]] = (List(url) ::: mostCommonIndexNames .filterNot(url.pathSegments.contains) .map(addPart(url, _))) @@ -52,9 +66,8 @@ final private class RelayFormatApi(ws: StandaloneWSClient, cacheApi: CacheApi)(u val jsonUrl = (n: Int) => jsonDoc(replaceLastPart(index, s"game-$n.json")) val pgnUrl = (n: Int) => pgnDoc(replaceLastPart(index, s"game-$n.pgn")) looksLikeJson(jsonUrl(1).url).map(_ option jsonUrl) orElse - looksLikePgn(pgnUrl(1).url).map(_ option pgnUrl) dmap2 { + looksLikePgn(pgnUrl(1).url).map(_ option pgnUrl) dmap2: ManyFiles(index, _) - } guessLcc(originalUrl) orElse guessSingleFile(originalUrl) orElse @@ -63,30 +76,51 @@ final private class RelayFormatApi(ws: StandaloneWSClient, cacheApi: CacheApi)(u logger.info(s"guessed format of $upstream: $format") } - private[relay] def httpGet(url: URL): Fu[String] = - ws.url(url.toString) - .withRequestTimeout(4.seconds) - .withFollowRedirects(false) + private[relay] def httpGet(url: URL)(using CanProxy): Fu[String] = + val (req, proxy) = addProxy(url): + ws.url(url.toString) + .withRequestTimeout(4.seconds) + .withFollowRedirects(false) + req .get() .flatMap: res => if res.status == 200 then fuccess(res.body) else fufail(s"[${res.status}] $url") - .monSuccess(_.relay.httpGet(url.host.toString)) - - private def looksLikePgn(body: String): Boolean = - MultiPgn.split(PgnStr(body), 1).value.headOption so: pgn => + .monSuccess(_.relay.httpGet(url.host.toString, proxy)) + + private def addProxy(url: URL)(ws: StandaloneWSRequest)(using + allowed: CanProxy + ): (StandaloneWSRequest, Option[String]) = + def server = for + hostPort <- proxyHostPort.get() + if allowed.yes + if proxyDomainRegex.get().unanchored.matches(url.host.toString) + creds <- proxyCredentials.get() + yield DefaultWSProxyServer( + host = hostPort.host, + port = hostPort.port, + principal = Some(creds.user), + password = Some(creds.password.value) + ) + server.foldLeft(ws)(_ withProxyServer _) -> server.map(_.host) + + private def looksLikePgn(body: String)(using CanProxy): Boolean = + MultiPgn.split(PgnStr(body), Max(1)).value.headOption so: pgn => lila.study.PgnImport(pgn, Nil).isRight - private def looksLikePgn(url: URL): Fu[Boolean] = httpGet(url) map looksLikePgn + private def looksLikePgn(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url) map looksLikePgn private def looksLikeJson(body: String): Boolean = try Json.parse(body) != JsNull catch case _: Exception => false - private def looksLikeJson(url: URL): Fu[Boolean] = httpGet(url) map looksLikeJson + private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url) map looksLikeJson sealed private trait RelayFormat private object RelayFormat: + opaque type CanProxy = Boolean + object CanProxy extends YesNo[CanProxy] + enum DocFormat: case Json, Pgn diff --git a/modules/relay/src/main/RelayGame.scala b/modules/relay/src/main/RelayGame.scala index 1064251972583..6573b13ff43f3 100644 --- a/modules/relay/src/main/RelayGame.scala +++ b/modules/relay/src/main/RelayGame.scala @@ -9,7 +9,7 @@ case class RelayGame( tags: Tags, variant: chess.variant.Variant, root: Root, - end: Option[PgnImport.End] + ending: Option[PgnImport.End] ): def staticTagsMatch(chapterTags: Tags): Boolean = @@ -27,7 +27,7 @@ case class RelayGame( def resetToSetup = copy( root = root.withoutChildren, - end = None, + ending = None, tags = tags.copy(value = tags.value.filter(_.name != Tag.Result)) ) diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 67d357cca5182..c6e31c4c7b232 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -25,7 +25,8 @@ final class RelayPush(sync: RelaySync, api: RelayApi, irc: lila.irc.IrcApi)(usin .fold( err => fuccess(Left(err)), games => - sync(rt, games) + sync + .updateStudyChapters(rt, games) .map: res => SyncLog.event(res.nbMoves, none) .recover: @@ -37,7 +38,7 @@ final class RelayPush(sync: RelaySync, api: RelayApi, irc: lila.irc.IrcApi)(usin .update(rt.round): r1 => val r2 = r1.withSync(_ addLog event) val r3 = if event.hasMoves then r2.ensureStarted.resume else r2 - r3.copy(finished = games.nonEmpty && games.forall(_.end.isDefined)) + r3.copy(finished = games.nonEmpty && games.forall(_.ending.isDefined)) .inject: event.error.fold(Right(event.moves))(err => Left(LilaInvalid(err))) ) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 1bd2d5c8c01c5..7ffdbc909ab21 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -115,10 +115,10 @@ object RelayRound: def local = asUrl.fold(true)(_.isLocal) case class UpstreamUrl(url: String) extends Upstream: def isLocal = url.contains("://127.0.0.1") || url.contains("://[::1]") || url.contains("://localhost") - def withRound = - url.split(" ", 2) match - case Array(u, round) => UpstreamUrl.WithRound(u, round.toIntOption) - case _ => UpstreamUrl.WithRound(url, none) + def withRound = url.split(" ", 2) match + case Array(u, round) => UpstreamUrl.WithRound(u, round.toIntOption) + case _ => UpstreamUrl.WithRound(url, none) + def isLcc: Boolean = UpstreamUrl.LccRegex.matches(url) object UpstreamUrl: case class WithRound(url: String, round: Option[Int]) val LccRegex = """.*view\.livechesscloud\.com/#?([0-9a-f\-]+)""".r diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index 58c58d05c14de..f8d470604d483 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -14,7 +14,7 @@ final private class RelaySync( leaderboard: RelayLeaderboardApi )(using Executor): - def apply(rt: RelayRound.WithTour, games: RelayGames): Fu[SyncResult.Ok] = for + def updateStudyChapters(rt: RelayRound.WithTour, games: RelayGames): Fu[SyncResult.Ok] = for study <- studyApi.byId(rt.round.studyId).orFail("Missing relay study!") chapters <- chapterRepo.orderedByStudy(study.id) sanitizedGames <- RelayInputSanity(chapters, games).fold(x => fufail(x.msg), fuccess) @@ -38,7 +38,7 @@ final private class RelaySync( chapterRepo .countByStudyId(study.id) .flatMap: - case nb if nb >= RelayFetch.maxChapters(rt.tour) => fuccess(none) + case nb if RelayFetch.maxChapters(rt.tour) <= nb => fuccess(none) case _ => createChapter(study, game).flatMap: chapter => chapters.find(_.isEmptyInitial).ifTrue(chapter.order == 2).so { initial => @@ -130,7 +130,7 @@ final private class RelaySync( val gameTags = game.tags.value.foldLeft(Tags(Nil)): (newTags, tag) => if !chapter.tags.value.has(tag) then newTags + tag else newTags - val newEndTag = game.end + val newEndTag = game.ending .ifFalse(gameTags(_.Result).isDefined) .filterNot(end => chapter.tags(_.Result).has(end.resultText)) .map(end => Tag(_.Result, end.resultText)) diff --git a/modules/round/src/main/Env.scala b/modules/round/src/main/Env.scala index 51182f94d72fb..c9205ce56606a 100644 --- a/modules/round/src/main/Env.scala +++ b/modules/round/src/main/Env.scala @@ -199,5 +199,5 @@ final class Env( if pov.game.abortableByUser then tellRound(pov.gameId, Abort(pov.playerId)) else if pov.game.resignable then tellRound(pov.gameId, Resign(pov.playerId)) -trait SelfReportEndGame -trait SelfReportMarkUser +private trait SelfReportEndGame +private trait SelfReportMarkUser diff --git a/modules/study/src/main/BSONHandlers.scala b/modules/study/src/main/BSONHandlers.scala index a6ad27987cec0..8f662b8f4ce09 100644 --- a/modules/study/src/main/BSONHandlers.scala +++ b/modules/study/src/main/BSONHandlers.scala @@ -339,7 +339,7 @@ object BSONHandlers: private[study] given (using handler: BSONHandler[Map[String, DbMember]]): BSONHandler[StudyMembers] = handler.as[StudyMembers]( members => - StudyMembers(members map { case (id, dbMember) => + StudyMembers(members map { (id, dbMember) => UserId(id) -> StudyMember(UserId(id), dbMember.role) }), _.members.view.map((id, m) => id.value -> DbMember(m.role)).toMap diff --git a/modules/study/src/main/MultiPgn.scala b/modules/study/src/main/MultiPgn.scala index b8c291356c705..5fd155fe727a7 100644 --- a/modules/study/src/main/MultiPgn.scala +++ b/modules/study/src/main/MultiPgn.scala @@ -1,6 +1,7 @@ package lila.study import chess.format.pgn.PgnStr +import lila.common.config.Max case class MultiPgn(value: List[PgnStr]) extends AnyVal: @@ -10,10 +11,10 @@ object MultiPgn: private[this] val splitPat = """\n\n(?=\[)""".r.pattern - def split(str: PgnStr, max: Int) = MultiPgn: + def split(str: PgnStr, max: Max) = MultiPgn: PgnStr from splitPat - .split(str.value.replaceIf('\r', ""), max + 1) + .split(str.value.replaceIf('\r', ""), max.value + 1) .view .filter(_.nonEmpty) - .take(max) + .take(max.value) .toList diff --git a/modules/study/src/main/StudyFlatTree.scala b/modules/study/src/main/StudyFlatTree.scala index 3d4d115357ff7..a6ede289af11d 100644 --- a/modules/study/src/main/StudyFlatTree.scala +++ b/modules/study/src/main/StudyFlatTree.scala @@ -19,7 +19,7 @@ private object StudyFlatTree: path.lastId.flatMap { readBranch(data, _) } map: _.copy(children = children | Branches.empty) - def toNodeWithChildren1(child: Option[NewTree]): Option[NewTree] = + def toNodeWithChild(child: Option[NewTree]): Option[NewTree] = readNewBranch(data, path).map(NewTree(_, child, Nil)) object reader: @@ -61,7 +61,7 @@ private object StudyFlatTree: xs.nonEmpty so xs.foldLeft(Map.empty[UciPath, NewTree]) { (roots, flat) => flat - .toNodeWithChildren1(roots.get(flat.path)) + .toNodeWithChild(roots.get(flat.path)) .fold(roots): node => roots .removed(flat.path) diff --git a/modules/study/src/main/StudyForm.scala b/modules/study/src/main/StudyForm.scala index 9a4203c97e6c5..920b49f90cf56 100644 --- a/modules/study/src/main/StudyForm.scala +++ b/modules/study/src/main/StudyForm.scala @@ -5,9 +5,10 @@ import chess.format.pgn.PgnStr import chess.variant.Variant import play.api.data.* import play.api.data.Forms.* +import play.api.data.format.Formatter import lila.common.Form.{ cleanNonEmptyText, formatter, into, defaulting, given } -import play.api.data.format.Formatter +import lila.common.config.Max object StudyForm: @@ -84,7 +85,7 @@ object StudyForm: ): def toChapterDatas: List[ChapterMaker.Data] = - val pgns = MultiPgn.split(pgn, max = 32).value + val pgns = MultiPgn.split(pgn, max = Max(32)).value pgns.mapWithIndex: (onePgn, index) => ChapterMaker.Data( // only the first chapter can be named diff --git a/modules/ublog/src/main/UblogApi.scala b/modules/ublog/src/main/UblogApi.scala index 2dc7a2ea77a0a..b6e2569d0354f 100644 --- a/modules/ublog/src/main/UblogApi.scala +++ b/modules/ublog/src/main/UblogApi.scala @@ -147,6 +147,11 @@ final class UblogApi( .one($id(blog), $set("modTier" -> tier, "tier" -> tier), upsert = true) .void + def setRankAdjust(id: UblogPostId, adjust: Int): Funit = + colls.post.update + .one($id(id), if adjust == 0 then $unset("rankAdjustDays") else $set("rankAdjustDays" -> adjust)) + .void + def postCursor(user: User): AkkaStreamCursor[UblogPost] = colls.post.find($doc("blog" -> s"user:${user.id}")).cursor[UblogPost](ReadPref.priTemp) diff --git a/modules/ublog/src/main/UblogForm.scala b/modules/ublog/src/main/UblogForm.scala index ae881e0cdc68b..2c18620bd7a76 100644 --- a/modules/ublog/src/main/UblogForm.scala +++ b/modules/ublog/src/main/UblogForm.scala @@ -82,7 +82,8 @@ object UblogForm: updated = none, lived = none, likes = UblogPost.Likes(1), - views = UblogPost.Views(0) + views = UblogPost.Views(0), + rankAdjustDays = none ) def update(user: User, prev: UblogPost) = diff --git a/modules/ublog/src/main/UblogPost.scala b/modules/ublog/src/main/UblogPost.scala index b383c3d5258a5..d18a95d6388b6 100644 --- a/modules/ublog/src/main/UblogPost.scala +++ b/modules/ublog/src/main/UblogPost.scala @@ -21,7 +21,8 @@ case class UblogPost( updated: Option[UblogPost.Recorded], lived: Option[UblogPost.Recorded], likes: UblogPost.Likes, - views: UblogPost.Views + views: UblogPost.Views, + rankAdjustDays: Option[Int] ) extends UblogPost.BasePost: def isBy[U: UserIdOf](u: U) = created.by is u diff --git a/modules/ublog/src/main/UblogRank.scala b/modules/ublog/src/main/UblogRank.scala index df707a366110f..b3db4cfabfc09 100644 --- a/modules/ublog/src/main/UblogRank.scala +++ b/modules/ublog/src/main/UblogRank.scala @@ -1,9 +1,11 @@ package lila.ublog +import java.time.Duration; import akka.stream.scaladsl.* import play.api.i18n.Lang import reactivemongo.akkastream.cursorProducer import reactivemongo.api.* +import reactivemongo.api.bson.* import lila.db.dsl.{ *, given } import lila.hub.actorApi.timeline.{ Propagate, UblogPostLike } @@ -39,12 +41,13 @@ final class UblogRank( UnwindField("blog"), Project( $doc( - "tier" -> "$blog.tier", - "likes" -> $doc("$size" -> "$likers"), // do not use denormalized field - "at" -> "$lived.at", - "language" -> true, - "title" -> true, - "imageId" -> "$image.id" + "tier" -> "$blog.tier", + "likes" -> $doc("$size" -> "$likers"), // do not use denormalized field + "at" -> "$lived.at", + "language" -> true, + "title" -> true, + "imageId" -> "$image.id", + "rankAdjustDays" -> true ) ) ) @@ -57,11 +60,12 @@ final class UblogRank( tier <- doc.getAsOpt[UblogBlog.Tier]("tier") language <- doc.getAsOpt[Language]("language") title <- doc string "title" + adjust = ~doc.int("rankAdjustDays") hasImage = doc.contains("imageId") - yield (id, likes, liveAt, tier, language, title, hasImage) + yield (id, likes, liveAt, tier, language, title, hasImage, adjust) .flatMap: - case None => fuccess(UblogPost.Likes(0)) - case Some((id, likes, liveAt, tier, language, title, hasImage)) => + case None => fuccess(UblogPost.Likes(0)) + case Some((id, likes, liveAt, tier, language, title, hasImage, adjust)) => // Multiple updates may race to set denormalized likes and rank, // but values should be approximately correct, match a real like // count (though perhaps not the latest one), and any uncontended @@ -70,37 +74,43 @@ final class UblogRank( $id(postId), $set( "likes" -> likes, - "rank" -> computeRank(likes, liveAt, language, tier, hasImage) + "rank" -> computeRank(likes, liveAt, language, tier, hasImage, adjust) ) ) andDo { if res.nModified > 0 && v && tier >= UblogBlog.Tier.LOW then timeline ! (Propagate(UblogPostLike(me, id.value, title)) toFollowersOf me) } inject likes - def recomputeRankOfAllPostsOfBlog(blogId: UblogBlog.Id): Funit = - colls.blog.byId[UblogBlog](blogId.full) flatMapz recomputeRankOfAllPostsOfBlog + def recomputePostRank(post: UblogPost): Funit = + recomputeRankOfAllPostsOfBlog(post.blog, post.id.some) - def recomputeRankOfAllPostsOfBlog(blog: UblogBlog): Funit = + def recomputeRankOfAllPostsOfBlog(blogId: UblogBlog.Id, only: Option[UblogPostId] = none): Funit = + colls.blog.byId[UblogBlog](blogId.full).flatMapz(recomputeRankOfAllPostsOfBlog(_, only)) + + def recomputeRankOfAllPostsOfBlog(blog: UblogBlog, only: Option[UblogPostId]): Funit = colls.post .find( - $doc("blog" -> blog.id), - $doc("likes" -> true, "lived" -> true, "language" -> true).some + $doc("blog" -> blog.id) ++ only.so($id), + $doc(List("likes", "lived", "language", "rankAdjustDays", "image").map(_ -> BSONBoolean(true))).some ) .cursor[Bdoc](ReadPref.sec) .list(500) .flatMap: _.traverse_ : doc => - ( - doc.string("_id"), - doc.getAsOpt[UblogPost.Likes]("likes"), - doc.getAsOpt[UblogPost.Recorded]("lived"), - doc.getAsOpt[Language]("language"), - doc.contains("image").some - ).tupled so { (id, likes, lived, language, hasImage) => - colls.post - .updateField($id(id), "rank", computeRank(likes, lived.at, language, blog.tier, hasImage)) - .void - } + ~(for + id <- doc.string("_id") + likes <- doc.getAsOpt[UblogPost.Likes]("likes") + lived <- doc.getAsOpt[UblogPost.Recorded]("lived") + language <- doc.getAsOpt[Language]("language") + hasImage = doc.contains("image") + adjust = ~doc.int("rankAdjustDays") + yield colls.post + .updateField( + $id(id), + "rank", + computeRank(likes, lived.at, language, blog.tier, hasImage, adjust) + ) + .void) def recomputeRankOfAllPosts: Funit = colls.blog @@ -108,20 +118,28 @@ final class UblogRank( .sort($sort desc "tier") .cursor[UblogBlog](ReadPref.sec) .documentSource() - .mapAsyncUnordered(4)(recomputeRankOfAllPostsOfBlog) + .mapAsyncUnordered(4)(recomputeRankOfAllPostsOfBlog(_, none)) .runWith(lila.common.LilaStream.sinkCount) .map(nb => println(s"Recomputed rank of $nb blogs")) def computeRank(blog: UblogBlog, post: UblogPost): Option[UblogPost.RankDate] = post.lived.map: lived => - computeRank(post.likes, lived.at, post.language, blog.tier, post.image.nonEmpty) + computeRank( + post.likes, + lived.at, + post.language, + blog.tier, + post.image.nonEmpty, + ~post.rankAdjustDays + ) private def computeRank( likes: UblogPost.Likes, liveAt: Instant, language: Language, tier: UblogBlog.Tier, - hasImage: Boolean + hasImage: Boolean, + days: Int ) = UblogPost.RankDate { import UblogBlog.Tier.* if tier < LOW || !hasImage then liveAt minusMonths 3 @@ -134,9 +152,9 @@ final class UblogRank( case BEST => 15 case _ => 0 - val likesBonus = math.sqrt(likes.value * 25) + likes.value / 100 - - val langBonus = if language == lila.i18n.defaultLanguage then 0 else -24 * 10 + val adjustBonus = 24 * days + val likesBonus = math.sqrt(likes.value * 25) + likes.value / 100 + val langBonus = if language == lila.i18n.defaultLanguage then 0 else -24 * 10 - (tierBase + likesBonus + langBonus).toInt + (tierBase + likesBonus + langBonus + adjustBonus).toInt } diff --git a/public/flair/img/symbols.move-blunder.webp b/public/flair/img/symbols.move-blunder.webp new file mode 100644 index 0000000000000..f1e6c05b08c05 Binary files /dev/null and b/public/flair/img/symbols.move-blunder.webp differ diff --git a/public/flair/img/symbols.move-brilliant.webp b/public/flair/img/symbols.move-brilliant.webp new file mode 100644 index 0000000000000..18be1b1e5fd0e Binary files /dev/null and b/public/flair/img/symbols.move-brilliant.webp differ diff --git a/public/flair/img/symbols.move-dubious.webp b/public/flair/img/symbols.move-dubious.webp new file mode 100644 index 0000000000000..0ed1ddfa3f866 Binary files /dev/null and b/public/flair/img/symbols.move-dubious.webp differ diff --git a/public/flair/img/symbols.move-good.webp b/public/flair/img/symbols.move-good.webp new file mode 100644 index 0000000000000..d4a32f876d1ba Binary files /dev/null and b/public/flair/img/symbols.move-good.webp differ diff --git a/public/flair/img/symbols.move-interesting.webp b/public/flair/img/symbols.move-interesting.webp new file mode 100644 index 0000000000000..10009a6de855d Binary files /dev/null and b/public/flair/img/symbols.move-interesting.webp differ diff --git a/public/flair/img/symbols.move-mistake.webp b/public/flair/img/symbols.move-mistake.webp new file mode 100644 index 0000000000000..578d4f83262f0 Binary files /dev/null and b/public/flair/img/symbols.move-mistake.webp differ diff --git a/public/flair/list.txt b/public/flair/list.txt index cbc7f323d33d4..0271660032dae 100644 --- a/public/flair/list.txt +++ b/public/flair/list.txt @@ -3182,6 +3182,12 @@ symbols.mending-heart symbols.mens-room symbols.minus symbols.mobile-phone-off +symbols.move-blunder +symbols.move-brilliant +symbols.move-dubious +symbols.move-good +symbols.move-interesting +symbols.move-mistake symbols.multiply symbols.name-badge symbols.new-button diff --git a/translation/dest/arena/be-BY.xml b/translation/dest/arena/be-BY.xml index 498eef0ac8677..95b959d2078a3 100644 --- a/translation/dest/arena/be-BY.xml +++ b/translation/dest/arena/be-BY.xml @@ -55,4 +55,6 @@ Дазволіць гульцам абмеркаванне ў чаце Серыі Арэны Пасля 2 перамог запар, наступныя перамогі прынясуць 4 ачкі, замест 2. + Берсерк не дазволены + Мае турніры diff --git a/translation/dest/broadcast/be-BY.xml b/translation/dest/broadcast/be-BY.xml index 185f060fd079a..7e41521fc0e6e 100644 --- a/translation/dest/broadcast/be-BY.xml +++ b/translation/dest/broadcast/be-BY.xml @@ -1,8 +1,12 @@ Трансляцыі + Мае трансляцыі Прамыя трансляцыі турніраў Новая прамая трансляцыя + Пра трансляцыіі + Пакуль няма тураў. + Як карыстацца трансляцыямі Lichess. Дадаць тур Бягучыя Надыходзячыя @@ -25,4 +29,9 @@ Спампаваць усе туры Скасаваць гэты тур Выдаліць гэты тур + Канчаткова выдаліць ​​тур і ўсе яго гульні. + Выдаліць усе гульні гэтага тура. Для іх паўторнага стварэння крыніца павінна быць актыўнай. + Рэдагаваць навучанне туру + Выдаліць гэты турнір + Канчаткова выдаліць увесь турнір, усе яго туры і ўсе гульні. diff --git a/translation/dest/broadcast/tr-TR.xml b/translation/dest/broadcast/tr-TR.xml index 62c7c07a98853..75fe893cb6669 100644 --- a/translation/dest/broadcast/tr-TR.xml +++ b/translation/dest/broadcast/tr-TR.xml @@ -8,6 +8,8 @@ Canlı Turnuva Yayınları Canlı Turnuva Ekle + Canlı Turnuvalar hakkında + Lichess Canlı Turnuvaları nasıl kullanılır. Bir tur ekle Devam eden turnuvalar Yaklaşan turnuvalar diff --git a/translation/dest/broadcast/vi-VN.xml b/translation/dest/broadcast/vi-VN.xml index 5e36af89b59b5..ca99a331a5658 100644 --- a/translation/dest/broadcast/vi-VN.xml +++ b/translation/dest/broadcast/vi-VN.xml @@ -1,13 +1,13 @@ - Phát sóng + Các phát sóng Các buổi phát sóng của tôi %s phát sóng Giải đấu phát trực tuyến Phát sóng trực tiếp mới - Giới thiệu phát sóng + Giới thiệu về phát sóng Chưa có vòng nào. Cách sử dụng Phát sóng Lichess. Vòng mới sẽ có các thành viên và cộng tác viên giống như vòng trước. @@ -39,8 +39,8 @@ Chỉnh sửa vòng nghiên cứu Xóa giải đấu này Xóa dứt khoát toàn bộ giải đấu, tất cả các vòng và tất cả ván cờ trong đó. - Bảng xếp hạng tự động - Tính toán và hiển thị bảng xếp hạng đơn giản dựa trên kết quả ván đấu + Bảng xếp hạng tự động + Tính toán và hiển thị bảng xếp hạng đơn giản dựa trên kết quả ván đấu Tùy chọn: biệt danh, hệ số Elo và danh hiệu Một dòng cho mỗi người chơi, được định dạng như sau: Tên khai sinh; Tên thay thế; Hệ số Elo thay thế tùy chọn; Danh hiệu thay thế tùy chọn diff --git a/translation/dest/class/vi-VN.xml b/translation/dest/class/vi-VN.xml index 9f47870f3ca59..f737c4f38c02d 100644 --- a/translation/dest/class/vi-VN.xml +++ b/translation/dest/class/vi-VN.xml @@ -12,7 +12,7 @@ Giáo viên: %s Lớp học mới Đóng lớp học - Đã xóa bởi %s + Đã bị xóa bởi %s Mở lại Loại bỏ học viên Đã loại bỏ @@ -48,7 +48,7 @@ Mật khẩu: %3$s Nếu họ đã có, hãy sử dụng biểu mẫu mời thay thế. Chỉ được tạo tài khoản cho học sinh có thật. Không được lợi dụng để tạo nhiều tài khoản cho bản thân. Bạn sẽ bị ban. Tên người dùng Lichess - Tạo một tài khoản mới + Gợi ý tạo một tên người dùng mới Chào mừng đến với lớp học của bạn: %s. Đây là đường link để tham gia lớp học. Bạn được mời đến vào lớp học \"%s\" với tư cách là một học viên. @@ -58,9 +58,9 @@ Mật khẩu: %3$s Chỉ hiển thị cho các giáo viên Hoạt động - Quản lý + Được quản lý Tài khoản học viên này đã được quản lý - Nâng cấp từ được quản lý lên tự chủ + Nâng cấp từ bị quản lý sang tự chủ Bản phát hành Giải phóng tài khoản để học viên có thể quản lý nó một cách tự chủ. Một tài khoản đã được tốt nghiệp không thể được quản lý lại. Học viên sẽ có thể chuyển đổi chế độ trẻ em và tự đặt lại mật khẩu. @@ -79,11 +79,11 @@ Mật khẩu: %3$s Học viên Tiến trình - Không có học viên nào trong lớp. - Không học viên nào được loại bỏ. - Qua nhiều ngày + Chưa có học viên nào trong lớp. + Không có học viên nào bị loại bỏ. + Qua số ngày Thời gian chơi - %1$s qua %2$s cuối + %1$s qua %2$s qua Tỉ lệ thắng Không xác định Tổng quan @@ -91,7 +91,7 @@ Mật khẩu: %3$s Tin tức mới về lớp học Sửa tin tức Thông báo tất cả học viên - Không có gì ở đây. + Chưa có gì ở đây. Tất cả các tin tức lớp học trong một lĩnh vực đơn lẻ. Thêm những tin tức mới nhất lên đầu trang. Đừng xóa tin tức trước đó. Phân cách tin tức bởi --- nó sẽ hiển thị một đường ngang. diff --git a/translation/dest/contact/be-BY.xml b/translation/dest/contact/be-BY.xml index 845b94d48db0e..6ae22945e9543 100644 --- a/translation/dest/contact/be-BY.xml +++ b/translation/dest/contact/be-BY.xml @@ -55,6 +55,9 @@ У пэўных абставінах, калі гуляючы супраць уліковых запісаў ботаў, рэйтангавая партыя можа не прыводзіць да змены рэйтынгу. Калі ўстаноўлена, што гулец злоўжывае бота дзеля павялячэння рэйтынгу. Старонка памылкі Калі вы траміце на старонку з памылкай, вы можаце паведаміць пра яе: + Я хачу трансляваць турнір + Дазнайцеся, як карыстацца трансляцыямі Lichess + Вы можаце звязацца з камандай трансляцый пра афіцыйныя эфіры. Абскарджанне бана ўліковага запісу ці абмежавання IP-адраса Выкарыстанне рухавіка або несумленная гульня Вы можаце накіраваць апеляцыю праз %s. diff --git a/translation/dest/contact/lb-LU.xml b/translation/dest/contact/lb-LU.xml index 0d481162f181b..f4c9369168088 100644 --- a/translation/dest/contact/lb-LU.xml +++ b/translation/dest/contact/lb-LU.xml @@ -23,8 +23,18 @@ Géi op dës Säit, fir d\'Grouss- a Klengschreiwung vun dengem Benotzernumm ze änneren Ech wëll mäin Verlaf oder meng Wäertung läschen Ech wëll een Spiller mellen + Fir ee Spiller ze mellen, benotz de Mellformulaire + Du kënns och op déi Säit, andeems de op den %s-Mell-Knäppchen op enger Profilsäit klicks. + Mell keng Spiller am Forum. + Schéck eis keng Mell-E-Mailen. + Schéck wgl. keng direkt Messagen un d\'Moderatoren. + Just d\'Melle vu Spiller iwwert de Mellformulaire hëlleft eppes. Ech wëll een Bug mellen + An der Lichess-Feedback-Sektioun vum Forum + Als e Lichess-Websäiteproblem op GitHub + Als e Lichess-Mobil-Applikatiouns-Problem op GitHub Am Lichess-Discordserver + Beschreif wgl., wéi de Feeler ausgesäit, wat s de amplaz erwaart hues a wat ee maache muss, fir de Feeler ze reproduzéieren. Illegalen Bauerenschlagzuch Dat gëtt „en passant“ genannt an ass eng vun de Reegele vum Schach. Probéiert dëst klengt, interaktiivt Spill fir méi iwwer „en passant“ ze léieren. @@ -43,6 +53,7 @@ Asproch géint een Bann oder eng IP Restriktioun Engine- oder Bedruchsmarkéierung Du kanns däin Asproch un %s schécken. + Falsch-Positiv-Resultater kënnen heiansdo virkommen a mir entschëllegen eis dofir. Aner Aschränkung Zesummenaarbecht, Legales, Kommerzielles Lichess monetiséieren diff --git a/translation/dest/contact/vi-VN.xml b/translation/dest/contact/vi-VN.xml index b5ea415556c38..2aa4c0592cb71 100644 --- a/translation/dest/contact/vi-VN.xml +++ b/translation/dest/contact/vi-VN.xml @@ -64,7 +64,7 @@ Những giá trị dương do đánh giá sai đôi khi vẫn xẩy ra và chúng tôi xin thứ lỗi vì điều đó. Nếu khiếu nại của bạn hợp lệ, chúng tôi sẽ dỡ bỏ lệnh cấm sớm nhất có thể. Tuy nhiên nếu bạn quả thực dùng máy tính trợ giúp, dù chỉ một lần, tài khoản của bạn sẽ bị mất. - Đừng phủ nhận đã lừa dối. Nếu bạn muốn được phép tạo một tài khoản mới, chỉ cần thừa nhận những gì bạn đã làm và cho thấy rằng bạn hiểu rằng đó là một sai lầm. + Đừng phủ nhận đã gian lận. Nếu bạn muốn được phép tạo một tài khoản mới, chỉ cần thừa nhận những gì bạn đã làm và chứng tỏ bạn hiểu rằng đó là một sai lầm. Hạn chế khác Cộng tác, hợp pháp, thương mại Kiếm tiền với Lichess diff --git a/translation/dest/dgt/tr-TR.xml b/translation/dest/dgt/tr-TR.xml index c51d58584ce53..82e0d9a67129a 100644 --- a/translation/dest/dgt/tr-TR.xml +++ b/translation/dest/dgt/tr-TR.xml @@ -12,6 +12,7 @@ Eğer bir hamle algılanmadıysa Sayfayı yeniden yükle DGT - Yapılandır + Lichess bağlantısı %2$s farklı bir bilgisayarda veya portta çalışmıyorsa \"%1$s\" sayfasını kullanın. Hamlelerin seslendirilişini yapılandırın, böylece tahtaya odaklanmaya devam edebilirsiniz. Bütün Hamleleri Seslendir diff --git a/translation/dest/faq/ko-KR.xml b/translation/dest/faq/ko-KR.xml index 354bcf55a968c..e9727759ad88d 100644 --- a/translation/dest/faq/ko-KR.xml +++ b/translation/dest/faq/ko-KR.xml @@ -143,4 +143,10 @@ LM 타이틀에 대해 문의하지 마세요. 브라우저의 URL 표시 줄에서 lichess.org 주소 옆에 있는 자물쇠 아이콘을 클릭합니다. 그런 다음 Lichess의 알림을 허용할지 차단할지 선택합니다. + 소리 자동 재생을 활성화하고 싶어요. + 대부분의 브라우저가 사용자를 보호하기 위해 새롭게 로드된 페이지가 소리를 재생하는 것을 방지할 수 있습니다. 모든 웹사이트가 들어가자마자 음성 광고를 쏟아붓는다고 상상해 보세요. + +브라우저에서 lichess.org가 소리를 재생하는 것을 방지했을 때 붉은 음소거 아이콘이 나타납니다. 보통 무언가를 클릭하면 이 제한은 없어집니다. 일부 모바일 브라우저에서는, 기물을 드래그하는 것은 클릭으로 간주되지 않습니다. 이 경우 소리 재생을 허용하려면 매 게임이 시작할 때마다 체스판을 탭해야 합니다. + +리체스는 이러한 경우가 발생했을 때 알려주기 위하여 붉은색 아이콘을 보여줍니다. 당신은 lichess.org가 소리를 재생하도록 명시적으로 허용할 수 있습니다. 다음은 최근 버전의 몇몇 인기 브라우저에서 소리 재생을 허용하는 방법입니다. diff --git a/translation/dest/lag/ko-KR.xml b/translation/dest/lag/ko-KR.xml index 2164156517b95..6f3738f5e86b7 100644 --- a/translation/dest/lag/ko-KR.xml +++ b/translation/dest/lag/ko-KR.xml @@ -9,7 +9,7 @@ 리체스 서버 지연 행마를 서버에서 처리하는 시간. 모두에게 동일하고, 서버의 작업량에만 관련이 있습니다. 사람이 많아질수록 커지지만, 리체스 개발자들은 이 수치가 낮도록 최선을 다합니다. 거의 10ms를 넘지 않습니다. 리체스와 귀하의 컴퓨터 사이의 네트워크 - 당신의 컴퓨터에서 리체서 서버로 행마를 전송하고, 다시 수신하는 시간. 당신과 리체스(프랑스) 사이의 거리, 당신의 인터넷 품질에 딷라 정해집니다. 리체스 개발자는 당신의 와이파이를 고칠 수 없고 빛이 더 빨리 가도록 할 수도 없습니다. + 당신의 컴퓨터에서 리체스 서버로 행마를 전송하고, 다시 수신하는 시간. 당신과 리체스(프랑스) 사이의 거리, 당신의 인터넷 품질에 따라 정해집니다. 리체스 개발자는 당신의 와이파이를 고칠 수 없고 빛이 더 빨리 가도록 할 수도 없습니다. 상단 바의 사용자명을 클릭해서 언제든 이 두 수치를 확인할 수 있습니다. 렉 보상 리체스는 렉에 대한 보상을 합니다. 지속되는 렉과 갑자기 일어나는 렉에 대해 최대한 공평하게 보상을 하고 있습니다. 그렇기 때문에 상대보다 네트워크에 렉이 심해도 크게 불리해지지 않습니다. diff --git a/translation/dest/oauthScope/be-BY.xml b/translation/dest/oauthScope/be-BY.xml index 610a3e1cc1a95..a2c843d12001f 100644 --- a/translation/dest/oauthScope/be-BY.xml +++ b/translation/dest/oauthScope/be-BY.xml @@ -8,10 +8,11 @@ Што токен можа рабіць ад вашага імя: Токен дасць доступ да вашага ўліковага запісу. НЕ дзяліцеся ім ні з кім! Не забудзьцеся скапіраваць свой новы асабісты токен доступу. Вы не зможаце ўбачыць яго зноў! - Паглядзець налады - Змяніць налады - Паглядзець адрас электроннай пошты - Паглядзець уваходныя выклікі + Паглядзець налады + Змяніць налады + Паглядзець адрас электроннай пошты + Паглядзець уваходныя выклікі + Стварыць, абнавіць і далучыцца да турніраў Прагледзець і выкарыстаць вашы знешнія рухавікі Стварыць і абнавіць знешнія рухавікі diff --git a/translation/dest/oauthScope/vi-VN.xml b/translation/dest/oauthScope/vi-VN.xml index ee4d2f1ee5b5e..46533b55fafd2 100644 --- a/translation/dest/oauthScope/vi-VN.xml +++ b/translation/dest/oauthScope/vi-VN.xml @@ -8,47 +8,47 @@ Những gì mà Token có thể làm thay bạn: Token sẽ cấp quyền truy cập vào tài khoản của bạn. KHÔNG chia sẻ nó với bất kỳ ai! Đảm bảo rằng bạn đã sao chép mã truy cập cá nhân mới ngay bây giờ. Bạn sẽ không thể nhìn thấy nó lần nữa! - Tùy chọn đọc - Tùy chọn viết - Đọc địa chỉ email - Đọc các lời thách đấu được gửi đến - Gửi, chấp nhận và từ chối thách đấu + Tùy chọn đọc + Tùy chọn viết + Đọc địa chỉ email + Đọc các lời thách đấu được gửi đến + Gửi, chấp nhận và từ chối thách đấu Tạo nhiều ván đấu cùng lúc với nhiều người chơi - Đọc các bài học riêng và các chương trình phát sóng - Tạo, cập nhật, xóa các bài học và các chương trình phát sóng - Tạo, cập nhật và tham gia các giải đấu - Tạo và tham gia đua câu đố - Đọc các hoạt động câu đố - Đọc thông tin riêng của đội - Tham gia và rời khỏi đội + Đọc các bài học riêng và các chương trình phát sóng + Tạo, cập nhật, xóa các bài học và các chương trình phát sóng + Tạo, cập nhật và tham gia các giải đấu + Tạo và tham gia đua câu đố + Đọc các hoạt động câu đố + Đọc thông tin riêng của đội + Tham gia và rời khỏi đội Quản lý các đội bạn đứng đầu: gửi tin nhắn riêng, xóa thành viên - Đọc người chơi đã theo dõi - Theo dõi và bỏ theo dõi những người chới khác - Gửi tin nhắn riêng đến những người chơi khác - Chơi với bàn cờ API - Chơi cờ với bot API + Đọc người chơi đã theo dõi + Theo dõi và bỏ theo dõi những người chới khác + Gửi tin nhắn riêng đến những người chơi khác + Chơi với bàn cờ API + Chơi cờ với bot API Xem và sử dụng động cơ máy tính bên ngoài Tạo và cập nhật các động cơ máy tính - Tạo các phiên trang web đã được xác thực (cấp toàn quyền truy cập!) - Sử dụng các công cụ quản trị (nằm trong quyền kiểm soát của bạn) + Tạo các phiên trang web đã được xác thực (cấp toàn quyền truy cập!) + Sử dụng các công cụ quản trị (nằm trong quyền kiểm soát của bạn) Khóa truy cập API cá nhân Bạn có thể thực hiện các yêu cầu OAuth mà không cần thông qua %s. - luồng mã ủy quyền + luồng mã ủy quyền Thay vào đó, %s mà bạn có thể sử dụng trực tiếp trong các yêu cầu API. - tạo khóa truy cập cá nhân + tạo khóa truy cập cá nhân Hãy bảo vệ những khóa này một cách cẩn thận! Chúng giống như mật khẩu. Ưu điểm của việc sử dụng mã thông báo thay vì đặt mật khẩu của bạn vào tập lệnh là mã thông báo có thể bị thu hồi và bạn có thể tạo nhiều mã thông báo. Đây là %1$s và %2$s. - ví dụ về ứng dụng khóa cá nhân - Tài liệu API + ví dụ về ứng dụng khóa cá nhân + Tài liệu API Khoá truy cập mới Khoá truy cập API Đã tạo %s - Lần cuối sử dụng %s + Lần cuối sử dụng %s Bạn đã từng chơi ván cờ rồi! Lưu ý chỉ dành cho nhà phát triển: Có thể điền trước biểu mẫu này bằng cách điều chỉnh các tham số truy vấn của URL. - Ví dụ như: %s - đánh dấu vào phạm vi %1$s và %2$s, đồng thời đặt mô tả khóa. - Mã phạm vi có thể được tìm thấy trong mã HTML của biểu mẫu. + Ví dụ như: %s + đánh dấu vào phạm vi %1$s và %2$s, đồng thời đặt mô tả khóa. + Mã phạm vi có thể được tìm thấy trong mã HTML của biểu mẫu. Việc cung cấp các URL điền sẵn này cho người dùng của bạn sẽ giúp họ có được phạm vi mã thông báo phù hợp. diff --git a/translation/dest/onboarding/be-BY.xml b/translation/dest/onboarding/be-BY.xml index 3ea04e700dfa8..452d88dc48ea3 100644 --- a/translation/dest/onboarding/be-BY.xml +++ b/translation/dest/onboarding/be-BY.xml @@ -1,2 +1,4 @@ - + + Гуляйце ў турнірах. + diff --git a/translation/dest/onboarding/mr-IN.xml b/translation/dest/onboarding/mr-IN.xml index 3ea04e700dfa8..8188569b5969c 100644 --- a/translation/dest/onboarding/mr-IN.xml +++ b/translation/dest/onboarding/mr-IN.xml @@ -1,2 +1,6 @@ - + + सुस्वागतम! + Lichess.org वर स्वागत! + बुद्धीबळाचे नियम शिका + diff --git a/translation/dest/onboarding/th-TH.xml b/translation/dest/onboarding/th-TH.xml index 3ea04e700dfa8..09e2dd12e37c0 100644 --- a/translation/dest/onboarding/th-TH.xml +++ b/translation/dest/onboarding/th-TH.xml @@ -1,2 +1,17 @@ - + + ยินดีต้อนรับ! + ยินดีต้อนรับสู่ lichess.org + นี้คือหน้าโปรไฟล์ของคุณ + เด็กจะใช้บัญชีนี้หรือไม่ คุณอาจต้องการเปิดใช้งาน %s + แล้วอย่างไรต่อ? นี้คือคำแนะนำเล็กๆน้อยๆ + เรียนรู้กฎของหมากรุก + เก่งขึ้นด้วยปริศนากลยุทธ์ของหมากรุก + เล่นกับปัญญาประดิษฐ์ + เล่นกับคู่แข่งทั่วโลก + ติดตามเพื่อนของคุณบน Lichess + แข่งขันในทัวร์นาเมนต์ + เรียนรู้จาก %1$s และ %2$s + ตั้งค่า Lichess ตามความชอบของคุณ + สำรวจเว็บไซต์ให้สนุกนะ :) + diff --git a/translation/dest/patron/vi-VN.xml b/translation/dest/patron/vi-VN.xml index 398973919a495..c45a8b93bfe21 100644 --- a/translation/dest/patron/vi-VN.xml +++ b/translation/dest/patron/vi-VN.xml @@ -2,7 +2,7 @@ Ủng hộ Ủng hộ bằng tài khoản %s - Bảo trợ Lichess + Người bảo trợ Lichess Tài khoản miễn phí Trở thành một Người bảo trợ Lichess %s đã trở thành một Người bảo trợ Lichess @@ -47,7 +47,7 @@ Hoặc bạn có thể %s. Xin lưu ý rằng chỉ có hình thức ủng hộ ở trên mới được cấp trạng thái Người bảo trợ. Có tính năng nào được dành riêng cho những Người bảo trợ không? Không, bởi vì Lichess hoàn toàn miễn phí, mãi mãi và dành cho tất cả mọi người. Đó là lời hứa của chúng tôi. -Tuy nhiên, Patron được quyền khoe khoang với những cánh Người bảo trợ mới thú vị hiển thị trên hồ sơ của bạn. +Tuy nhiên, Patron được quyền khoe khoang với những đôi cánh Người bảo trợ mới thú vị hiển thị trên hồ sơ của bạn. Xem so sánh các tính năng chi tiết Bảo trợ Lichess trong %s tháng @@ -58,8 +58,8 @@ Tuy nhiên, Patron được quyền khoe khoang với những cánh Người b Lần thanh toán tiếp theo Bạn sẽ trả %1$s vào ngày %2$s. Ủng hộ thêm ngay bây giờ - Tặng cánh Người bảo trợ - Tặng cánh Người bảo trợ cho kỳ thủ + Tặng đôi cánh Người bảo trợ + Tặng đôi cánh Người bảo trợ cho kỳ thủ Cập nhật Đổi số tiền hàng tháng (%s) Hủy sự ủng hộ của bạn diff --git a/translation/dest/puzzle/da-DK.xml b/translation/dest/puzzle/da-DK.xml index b6ef720c97384..6ab4e652a9123 100644 --- a/translation/dest/puzzle/da-DK.xml +++ b/translation/dest/puzzle/da-DK.xml @@ -1,6 +1,6 @@ - Taktikopgaver + Opgaver Opgavetemaer Anbefalet Faser diff --git a/translation/dest/site/be-BY.xml b/translation/dest/site/be-BY.xml index ff442b4f6b25c..71a92b734cc65 100644 --- a/translation/dest/site/be-BY.xml +++ b/translation/dest/site/be-BY.xml @@ -555,6 +555,7 @@ Імя Прозвішча Біяграфія + Краіна або рэгіён Дзякуй! Спасылкі на сацсеткі Адзін URL на радок. diff --git a/translation/dest/site/da-DK.xml b/translation/dest/site/da-DK.xml index a8f3bd47afef5..9e50258e2a4a5 100644 --- a/translation/dest/site/da-DK.xml +++ b/translation/dest/site/da-DK.xml @@ -528,7 +528,7 @@ Automatisk videre til næste spil efter træk Autoskift - Puslespil + Taktikopgaver Turneringsvindere Navn Beskrivelse diff --git a/translation/dest/site/gl-ES.xml b/translation/dest/site/gl-ES.xml index f5dc4ec5fdc32..68bdfcd29b2cb 100644 --- a/translation/dest/site/gl-ES.xml +++ b/translation/dest/site/gl-ES.xml @@ -773,7 +773,7 @@ Análise da partida %1$s crea %2$s %1$s únese a %2$s - a %1$s gústalle %2$s + A %1$s gústalle %2$s Emparellamento rápido Retos Anónimo diff --git a/translation/dest/site/it-IT.xml b/translation/dest/site/it-IT.xml index 873e2e024baee..711c138c0f2bc 100644 --- a/translation/dest/site/it-IT.xml +++ b/translation/dest/site/it-IT.xml @@ -405,7 +405,7 @@ Quando incolli una partita tramite PGN potrai rivederla, analizzarla con il computer, commentarla in chat, e condividerla tramite un indirizzo URL. Le varianti saranno cancellate. Per salvarle, importa il PGN in uno studio. - Questo PGN è accessibile dal pubblico. Per importare un gioco privatamente, utilizza uno studio. + Questo PGN è accessibile pubblicamente. Per importare una partita privatamente, utilizza uno studio. %s partita importata %s partite importate diff --git a/translation/dest/site/lb-LU.xml b/translation/dest/site/lb-LU.xml index ed30763d84730..eb60e9dc2e19c 100644 --- a/translation/dest/site/lb-LU.xml +++ b/translation/dest/site/lb-LU.xml @@ -484,6 +484,7 @@ Zich gespillt Wäiss Victoiren Schwaarz Victoiren + Remisquot Remis Nächsten %s Turnéier: Duerschnëttlechen Géigner @@ -505,6 +506,7 @@ Virnumm Numm Biographie + Land oder Regioun Merci! Sozial Medien Links Eng URL pro Zeil. @@ -967,6 +969,8 @@ Looss et eidel, fir Partie aus der normaler Ausgangspositioun ze starten.Faarf wiesselen Däin Benotzerkonto zou ze maachen wäert och däin Asproch zeréckzéihen Eis Tipps fir d\'Organiséieren vun Turnéier + Instruktiounen + Alles weisen Lichess ass eng Wohltätegkeetsorganisatioun an eng komplett kostenfrei/open source Software. All Betriebskäschten, Entwécklung an Inhalter ginn ausschließlech vun Benotzerspenden finanzéiert. diff --git a/translation/dest/site/ro-RO.xml b/translation/dest/site/ro-RO.xml index 146da576aab30..14156ebdf6236 100644 --- a/translation/dest/site/ro-RO.xml +++ b/translation/dest/site/ro-RO.xml @@ -432,6 +432,7 @@ Importați partida Copiați o partidă în format PGN pentru a putea apoi sa o rejucati, sa cereti o analiză a computerului, sa folositi functia de chat și sa obtineti un URL pentru distribuire. Variațiile vor fi șterse. Pentru a le păstra, importați PGN-ul printr-un studiu. + Acest PGN poate fi accesat public. Pentru a importa un joc în mod privat, folosește un studiu. %s partidă importată %s partide importate diff --git a/translation/dest/site/so-SO.xml b/translation/dest/site/so-SO.xml index 3b57473dc62fb..08cd77dc0505c 100644 --- a/translation/dest/site/so-SO.xml +++ b/translation/dest/site/so-SO.xml @@ -49,8 +49,8 @@ Madow wa iscasilay Cadaan wuu ka baxay ciyaarta Madow wuu ka baxay ciyaarta - Cadaan waxba ma dhaqaaqijin - Madow waxba ma dhaqaajin + Cadaanku muu dhaqaaqin + Madowgu muu dhaqaaqin Dalbo in computer falanqeeyo Falanqaynta computerka falanqaynta computerka wa diyaar @@ -62,7 +62,7 @@ Cabirid dhaqdhaqaaq... Qalad adeege Falanqayn hawo - Gudaha usii gal + Sii gudagal Tus khatarta aaladdaada Dooro flanqaynta aaladdaada @@ -75,7 +75,7 @@ Guukdarro nooc Nooc badis Qalab yari - Guurid askari + Dhaqaaq askari Qabasho Xidh Guuleysta @@ -89,10 +89,10 @@ Ciyaaraha ugu sareeya Ciyaaraha MD ee %1$s+ ciyaartoyda FIDE ee %2$s ilaa %3$s - Mayd %s badh-tallaabo gudeheed - Mayd %s badh-tallaabo gudohood + Mayd %s badh-dhaqaaq gudihii + Mayd %s badh-dhaqaaq gudohood - DTZ50\" la soo gaabiyey, kuna salaysan tirada badh-tallaabood ee ka hadhay qabashada ama tallaabada askari ee xigta + DTZ50\" la soo gaabiyey, kuna salaysan tirada badh-dhaqaaq ee ka hadhay qabashada ama dhaqaaqa askari ee xiga Ciyaari ma jirto Muggii ugu weynaa la gaadh! Malaha kaga soo dar ciyaaro kale meesha doorashada? @@ -100,9 +100,9 @@ Furitaan baadhaha Furitaan/dhamaad baadhaha Furitaan baadhaha %s - Ciyaar tallaabada ugu horeysa ee furitaan/dhamaad-baadhaha - Badis uu hor istaagay xeerka 50 tallaabo - Guuldarro uu hor istaagay xeerka 50 tallaabo + Ciyaar dhaqaaqa ugu horeeya ee furitaan/dhamaad-baadhaha + Xeerka 50 tallaabo ayaa diiday guul + Xeerka 50 tallaabo ayaa diiday guuldarro Guul ama 50 tallaabo khalad hore dartii Guuldarro ama 50 tallaabo khalad hore dartii Diyaar! @@ -148,7 +148,7 @@ %s ciyaartoy Heshiis baraaje - Konton tallaabo bilaa natiijo + Konton dhaqaaq bilaa natiijo Ciyaaraha hadda %s ciyaar @@ -182,9 +182,9 @@ Farac Faracyada Xadka wakhtiga - Isla imika + Imika Maalinle - Maalmood tallaabadiiba + Maalmood doorkiiba Hal maalin %s maalin @@ -241,9 +241,13 @@ Akoonka %s wuxuu diwaangashanyay bilaa iimayl. Darajo Darajo: %s + + Qiimayntu waxay is bedeshaa daqiiqo kasta + Qiimayntu waxay is bedeshaa %s daqiiqo kasta + - %s halxiraalaha - %s xujooyinka + %s xujooyinka + %s xujo Ciyaaraha dhamaaday @@ -269,7 +273,7 @@ Tuur ciyaartan La tuur ciyaartan Heerka - Xadidneyn + Bilaa xad Nooca Aan qiimaysnayn Qiimasan @@ -293,8 +297,13 @@ Farriin samee Hordhac Dir + + %s ciyaaraya + %s ciyaaraya + Horjeedahaagu wuxuu kaa dalbaday ka noqosho Ku noqo tartanka + Ku noqo ciyaarta Jes bilaash ah oo onlayn ah. Ku ciyaar jes madal nadiif ah. Uma baahna diwaangelin, ma leh xayeysiis, umana baahna adeeg kale. La ciyaar jes aaladdaada, asxaabtaada amma dadka hawada ku jira. %1$s wuxu ku biiray kooxda %2$s Goobta @@ -309,8 +318,14 @@ Dhibco Horjeedaha celceliska ah Gaar ah - Halxiraalaha + + %s ciyaar baa socota + %s ciyaarood baa socda + + Xujooyin Waxa soo geliyey %s + Qoraalada + Halkan ku qor qoraalo kuu gooni ah Magacan-akoonkan ama iimayl khaldan Cod Marna @@ -321,6 +336,10 @@ Daawo ciyaaro 50 ciyaarood Fischer wuxuu ka keenay 47 guulood, 2 baraaje iyo 1 guuldarro. Abuur + Jes maalinle ah + Xalka eeg + Kulan degdeg ah + Dooro kulan Qarsoodi Dhibcahaaga: %s Luqadda @@ -338,7 +357,10 @@ Waan ka xumahay :( Waayo? Dhibcaha abid + Jes maalinle ah: hal ama dhowr cisho dhaqaaqiiba + Tababaraha xeeladaha jesta Ciyaarta >< %1$s + Caawintan tus Is deji! Waxaad la ciyaaraysaa hadda %s. Tuur ciyaarta diff --git a/translation/dest/site/th-TH.xml b/translation/dest/site/th-TH.xml index 387754e4250ce..648e3af0a7ff0 100644 --- a/translation/dest/site/th-TH.xml +++ b/translation/dest/site/th-TH.xml @@ -372,6 +372,7 @@ นำเข้าเกม เมื่อวาง PGN ของเกมแล้ว คุณจะได้รับความสามารถเรียกดูการเล่นซ้ำ, การวิเคราะห์ด้วยคอมพิวเตอร์, แชทของเกม และ URL ที่สามารถแชร์ได้ การเดินรูปแบบต่างๆ จะถูกลบทิ้ง หากต้องการเก็บไว้ ให้นำเข้า PGN ผ่านการศึกษา + PGN นี้เป็นสาธารณะ หากอยากจะนำเข้าเกมแบบส่วนตัว โปรดใช้หน้ากรณีศึกษา %s เกมที่ถูกนำเข้า @@ -469,6 +470,7 @@ ชื่อจริง นามสกุล ตั้งค่ารูปตกแต่ง + รูปตกแต่ง มันมีการตั้งค่าที่ทำให้ไม่สามารถเห็นรูปตกแต่งของผู้ใช้ได้ทั้งเวบไซต์ ชีวประวัติ ประเทศหรือภูมิภาค @@ -703,6 +705,7 @@ กับเพื่อนๆ กับทุกคน โหมดเด็ก + โหมดเด็กถูกเปิดใช้งาน สิ่งนี้เป็นเรื่องเกี่ยวกับความปลอดภัย ในโหมดสำหรับเด็ก การสื่อสารของไซต์ทั้งหมดจะถูกปิด จงใช้งานสิ่งนี้สำหรับเด็กและนักเรียนของคุณ เพื่อปกป้องพวกเขาจากผู้ใช้อินเทอร์เน็ตอื่นๆ ในโหมดสำหรับเด็ก โลโก้ lichess จะมีไอคอน %s เพื่อให้คุณรู้ว่าเด็กๆของคุณปลอดภัย บัญชีของคุณได้รับการจัดการ ถามครูหมากรุกของคุณเกี่ยวกับการยกระดับโหมดเด็ก @@ -812,6 +815,8 @@ และบันทึก %s เส้นทางเดินล่วงหน้า + คุณได้รับข้อความส่วนตัวจาก Lichess + คลิกที่นี้เพื่ออ่าน ขออภัย :( เราจำเป็นต้องให้คุณรอคอยสักช่วงหนึ่ง ทำไม? @@ -833,6 +838,7 @@ ฉันยอมรับว่า ฉันจะปฏิบัติตามทุกนโยบายของ Lichess ค้นหา หรือเริ่มการสนทนาใหม่ แก้ไข + บุลเล็ต บลิตซ์ แรปพิด คลาสสิก diff --git a/translation/dest/site/tr-TR.xml b/translation/dest/site/tr-TR.xml index e1e31e9117f02..dd19bab3ffe45 100644 --- a/translation/dest/site/tr-TR.xml +++ b/translation/dest/site/tr-TR.xml @@ -404,6 +404,7 @@ Oyun yükle Göz atılabilir bir oyun tekrarı, bilgisayar analizi, oyun sohbeti ve paylaşılabilir bir URL edinmek için bir oyun PGN\'si yapıştırın. Varyasyonlar silinecek. Varyasyonları saklamak için bir çalışma aracılığıyla PGN\'yi içe aktarın. + Bu PGN herkes tarafından erişilebilir. Bir oyunu özel olarak yüklemek istiyorsanız bir çalışma kullanın. %s yüklenen oyun %s yüklenen oyun @@ -856,6 +857,8 @@ ve %s önceki varyantları kaydedin ve %s önceki varyantları kaydedin + Lichess size bir özel mesaj gönderdi. + Okumak için buraya tıklayın Üzgünüz :( Sizi bir süreliğine oyunlardan men etmek zorunda kaldık. Neden? @@ -877,6 +880,7 @@ Lichess kurallarını takip edeceğim. Tartışma ara veya yenisini başlat Düzenle + Kurşun Yıldırım Hızlı Klasik diff --git a/translation/dest/site/vi-VN.xml b/translation/dest/site/vi-VN.xml index 84daf20830b53..d56ab0e7b6ec8 100644 --- a/translation/dest/site/vi-VN.xml +++ b/translation/dest/site/vi-VN.xml @@ -488,7 +488,7 @@ trò chuyện trong ván đấu và có một URL có thể chia sẻ công khai Xóa nước cờ Ván trước được lên Lichess TV Các kỳ thủ đang trực tuyến - Các kỳ thủ tích cực + Những kỳ thủ tích cực Lưu ý, ván cờ có xếp hạng nhưng không tính thời gian! Thành công @@ -672,7 +672,7 @@ trò chuyện trong ván đấu và có một URL có thể chia sẻ công khai chơi nước đi đã chọn Giải đấu mới Giải đấu cờ vua với nhiều thiết lập thời gian và biến thể phong phú - Chơi các giải đấu cờ vua nhịp độ nhanh! Tham gia một giải đấu chính thức hoặc tự tạo giải đấu của bạn. Cờ Siêu Chớp, cờ Chớp, cờ Nhanh, cờ Chậm, Chess960, King of the Hill, Threecheck và nhiều lựa chọn khác cho niềm vui đánh cờ vô tận. + Chơi các giải đấu cờ vua nhịp độ nhanh! Tham gia một giải đấu chính thức hoặc tự tạo giải đấu của bạn. Cờ Đạn, cờ Chớp, cờ Nhanh, cờ Chậm, Chess960, King of the Hill, Threecheck và nhiều lựa chọn khác cho niềm vui đánh cờ vô tận. Không tìm thấy giải đấu Giải đấu này không tồn tại. Giải đấu có thể đã bị huỷ, nếu tất cả người chơi rời giải trước khi giải đấu bắt đầu. @@ -945,5 +945,5 @@ Bỏ trống để bắt đầu tất cả ván đấu bằng thế trận ban Hướng dẫn Cho tôi xem mọi thứ nào Lichess là một tổ chức phi lợi nhuận và là một phần mềm mã nguồn mở/hoàn toàn miễn phí. -Tất cả chi phí vận hành, phát triển và nội dung được tài trợ bởi sự đóng góp của người dùng. +Tất cả chi phí vận hành, phát triển và nội dung được tài trợ bởi những đóng góp của người dùng. diff --git a/translation/dest/streamer/be-BY.xml b/translation/dest/streamer/be-BY.xml index 128eb010ebaa0..8e11d4b329df3 100644 --- a/translation/dest/streamer/be-BY.xml +++ b/translation/dest/streamer/be-BY.xml @@ -4,8 +4,8 @@ Стрымер на Lichess У ЭФІРЫ! АФЛАЙН - Зараз стрыміць: %s - Апошні стрым %s + Зараз стрыміць: %s + Апошні стрым %s Стаць страмерам на Lichess У вас ёсць каналы на Twitch або YouTube? Пачынаем! @@ -29,9 +29,10 @@ Ваш стрым разглядаецца мадэратарамі. Калі ласка, запоўніце інфармацыю наконт стрыму і загрузіце выяву. Калі будзіце гатовы быць адлюстраваным як стрымер Lichess, %s - запрасіць прагляд мадэратара - Ваша імя карыстальніка/спасылка на Twich + запрасіць прагляд мадэратара + Ваша імя карыстальніка/спасылка на Twich Апцыянальна. Калі няма, пакіньце пустым + ID вашага YouTube канала Ваша стрымерскае імя на Lichess Сцісла: максімальна %s сімвал diff --git a/translation/dest/team/lb-LU.xml b/translation/dest/team/lb-LU.xml index f8f4f95a0b18c..49a74aba7b495 100644 --- a/translation/dest/team/lb-LU.xml +++ b/translation/dest/team/lb-LU.xml @@ -25,7 +25,10 @@ All d\'Memberen kontaktéieren Ekipp opléisen Léist d\'Ekipp fir ëmmer op. + Falsche Bäitrëttscode. Dës Ekipp gëtt et schonn. + Nächst Turnéieren + Vergaangen Turnéieren Ofgeleenten Ufroen Ekippensäit diff --git a/translation/dest/timeago/fa-IR.xml b/translation/dest/timeago/fa-IR.xml index 593c42930d888..deddf2eda237f 100644 --- a/translation/dest/timeago/fa-IR.xml +++ b/translation/dest/timeago/fa-IR.xml @@ -54,4 +54,5 @@ %s سال پیش %s سال پیش + کامل شده diff --git a/translation/dest/timeago/gl-ES.xml b/translation/dest/timeago/gl-ES.xml index 59754416ee23a..17ea2696aa460 100644 --- a/translation/dest/timeago/gl-ES.xml +++ b/translation/dest/timeago/gl-ES.xml @@ -54,6 +54,10 @@ Hai %s ano Hai %s anos + + %s minuto restante + %s minutos restantes + %s hora restante %s horas restantes diff --git a/translation/dest/timeago/it-IT.xml b/translation/dest/timeago/it-IT.xml index cf39688c8d4b4..e44adecb7faba 100644 --- a/translation/dest/timeago/it-IT.xml +++ b/translation/dest/timeago/it-IT.xml @@ -54,4 +54,5 @@ %s anno fa %s anni fa + completato diff --git a/translation/dest/timeago/th-TH.xml b/translation/dest/timeago/th-TH.xml index 643d183045691..9569a596161e5 100644 --- a/translation/dest/timeago/th-TH.xml +++ b/translation/dest/timeago/th-TH.xml @@ -41,4 +41,11 @@ %s ปีที่แล้ว + + เหลือ %s นาที + + + เหลือ %s ชั่วโมง + + เสร็จสมบูรณ์ diff --git a/translation/dest/ublog/vi-VN.xml b/translation/dest/ublog/vi-VN.xml index 6073d9406bbbc..cc7e6565154cd 100644 --- a/translation/dest/ublog/vi-VN.xml +++ b/translation/dest/ublog/vi-VN.xml @@ -40,5 +40,5 @@ Vui lòng chỉ đăng những nội dung an toàn và có tính tôn trọng. Không được phép sao chép nội dung của bất kì ai khác. Chỉ một hành động không phù hợp, tài khoản của bạn có thể bị đóng. Vài mẹo đơn giản để giúp viết ra một bài blog tuyệt vời - Thảo luận về bài blog này trong diễn đàn + Thảo luận về bài blog này trong diễn đàn diff --git a/translation/source/site.xml b/translation/source/site.xml index d704cf34eb787..5e249b9525de2 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -5,6 +5,7 @@ To invite someone to play, give this URL Game Over Waiting for opponent + Or let your opponent scan this QR code Waiting Your turn %1$s level %2$s diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts index 96716cccaeb92..93735fe8a0648 100644 --- a/ui/@types/lichess/index.d.ts +++ b/ui/@types/lichess/index.d.ts @@ -199,7 +199,7 @@ interface Pubsub { } interface LichessStorageHelper { - make(k: string): LichessStorage; + make(k: string, ttl?: number): LichessStorage; boolean(k: string): LichessBooleanStorage; get(k: string): string | null; set(k: string, v: string): void; diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 0e8725ba1adf2..8a647864fce0c 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -81,7 +81,7 @@ export default class RelayCtrl { }, 4500); this.redraw(); if (event.error) { - lichess.sound.play('error'); + if (this.data.sync.log.slice(-2).every(e => e.error)) lichess.sound.play('error'); console.warn(`relay synchronisation error: ${event.error}`); } }, diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index 8578b5a7963d4..44f8b3c085ada 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -7,17 +7,20 @@ } } + .qr-code-invite { + @extend %flex-between-nowrap; + gap: $block-gap; + } + .invite { - display: flex; - flex-flow: row wrap; + display: grid; + gap: $block-gap; + grid-template-columns: repeat(auto-fill, minmax(25em, 1fr)); > div { - @extend %box-radius; - @include padding-direction(2em, 2em, 1em, 2em); - + @extend %box-neat; + padding: $block-gap; background: $c-bg-zebra; - margin: 1em; - flex: 1 1 auto; } p.error { diff --git a/ui/common/src/userLink.ts b/ui/common/src/userLink.ts index aea30ca2988a5..0b77488ec7351 100644 --- a/ui/common/src/userLink.ts +++ b/ui/common/src/userLink.ts @@ -38,7 +38,7 @@ export const userLink = (u: AnyUser): VNode => class: { 'user-link': true, ulpt: u.name != 'ghost', online: !!u.online }, attrs: { href: `/@/${u.name}`, ...u.attrs }, }, - [userLine(u), ...fullName(u), userRating(u)], + [userLine(u), ...fullName(u), u.rating && ` ${userRating(u)} `], ); export const userFlair = (u: HasFlair): VNode | undefined => diff --git a/ui/site/css/_video.scss b/ui/site/css/_video.scss index c6d3a847ca0e6..7739ea66ff206 100644 --- a/ui/site/css/_video.scss +++ b/ui/site/css/_video.scss @@ -257,11 +257,6 @@ } #video { - .not_found { - margin-top: 200px; - text-align: center; - } - .not_much { margin-top: 100px; text-align: center; diff --git a/ui/site/css/ublog/_post.scss b/ui/site/css/ublog/_post.scss index 74b8478e62316..61e6cc0218fb1 100644 --- a/ui/site/css/ublog/_post.scss +++ b/ui/site/css/ublog/_post.scss @@ -32,6 +32,10 @@ &__date { unicode-bidi: embed; } + input[type='number'] { + width: 6em; + margin-right: 1em; + } } &__topics { @extend %flex-wrap; diff --git a/ui/site/src/component/socket.ts b/ui/site/src/component/socket.ts index 3c59df0c1e8e5..10303eb10d6f6 100644 --- a/ui/site/src/component/socket.ts +++ b/ui/site/src/component/socket.ts @@ -62,9 +62,10 @@ export default class StrongSocket { tryOtherUrl = false; autoReconnect = true; nbConnects = 0; - storage: LichessStorage = makeStorage.make('surl17'); + storage: LichessStorage = makeStorage.make('surl17', 30 * 60 * 1000); private _sign?: string; private resendWhenOpen: [string, any, any][] = []; + private baseUrls = document.body.dataset.socketDomains!.split(','); static defaultOptions: Options = { idle: false, @@ -121,13 +122,7 @@ export default class StrongSocket { try { const ws = (this.ws = new WebSocket(fullUrl)); ws.onerror = e => this.onError(e); - ws.onclose = () => { - this.pubsub.emit('socket.close'); - if (this.autoReconnect) { - this.debug('Will autoreconnect in ' + this.options.autoReconnectDelay); - this.scheduleConnect(this.options.autoReconnectDelay); - } - }; + ws.onclose = e => this.onClose(e, fullUrl); ws.onopen = () => { this.debug('connected to ' + fullUrl); this.onSuccess(); @@ -148,7 +143,7 @@ export default class StrongSocket { this.handle(m); }; } catch (e) { - this.onError(e); + this.onClose({ code: 4000, reason: String(e) } as CloseEvent, fullUrl); } this.scheduleConnect(this.options.pingMaxLag); }; @@ -197,7 +192,11 @@ export default class StrongSocket { this.connectSchedule = setTimeout(() => { document.body.classList.add('offline'); document.body.classList.remove('online'); - this.tryOtherUrl = true; + if (!this.tryOtherUrl) { + // if this was set earlier, we've already logged the error + this.tryOtherUrl = true; + lichess.log(`socket.ts: Timeout ${delay}ms, rotating to ${this.baseUrl()}`); + } this.connect(); }, delay); }; @@ -294,6 +293,17 @@ export default class StrongSocket { onError = (e: unknown) => { this.options.debug = true; this.debug(`error: ${e} ${JSON.stringify(e)}`); // e not always from lila + }; + + onClose = (e: CloseEvent, url: string) => { + this.pubsub.emit('socket.close'); + if (this.autoReconnect) { + this.debug('Will autoreconnect in ' + this.options.autoReconnectDelay); + this.scheduleConnect(this.options.autoReconnectDelay); + } + if (e.wasClean && e.code < 1002) return; + + lichess.log(`socket.ts: Unclean close ${e.code} ${url} ${e.reason}`); this.tryOtherUrl = true; clearTimeout(this.pingSchedule); }; @@ -319,12 +329,16 @@ export default class StrongSocket { }; baseUrl = () => { - const baseUrls = document.body.dataset.socketDomains!.split(','); let url = this.storage.get(); - if (!url || this.tryOtherUrl) { - url = baseUrls[Math.floor(Math.random() * baseUrls.length)]; + if (!url) { + url = this.baseUrls[Math.floor(Math.random() * this.baseUrls.length)]; + this.storage.set(url); + } else if (this.tryOtherUrl) { + const i = this.baseUrls.findIndex(u => u === url); + url = this.baseUrls[(i + 1) % this.baseUrls.length]; this.storage.set(url); } + this.tryOtherUrl = false; return url; }; diff --git a/ui/site/src/component/storage.ts b/ui/site/src/component/storage.ts index eaf055651a9a2..c22af8cbcd076 100644 --- a/ui/site/src/component/storage.ts +++ b/ui/site/src/component/storage.ts @@ -14,25 +14,41 @@ const builder = (storage: Storage): LichessStorageHelper => { }), ), remove: (k: string) => storage.removeItem(k), - make: (k: string) => ({ - get: () => api.get(k), - set: (v: any) => api.set(k, v), - fire: (v?: string) => api.fire(k, v), - remove: () => api.remove(k), - listen: (f: (e: LichessStorageEvent) => void) => - window.addEventListener('storage', e => { - if (e.key !== k || e.storageArea !== storage || e.newValue === null) return; - let parsed: LichessStorageEvent | null; - try { - parsed = JSON.parse(e.newValue); - } catch (_) { - return; - } - // check sri, because Safari fires events also in the original - // document when there are multiple tabs - if (parsed?.sri && parsed.sri !== sri) f(parsed); - }), - }), + make: (k: string, ttl?: number) => { + const bdKey = ttl && `${k}--bd`; + const remove = () => { + api.remove(k); + if (bdKey) api.remove(bdKey); + }; + return { + get: () => { + if (!bdKey) return api.get(k); + const birthday = Number(api.get(bdKey)); + if (!birthday) api.set(bdKey, String(Date.now())); + else if (Date.now() - birthday > ttl) remove(); + return api.get(k); + }, + set: (v: any) => { + api.set(k, v); + if (bdKey) api.set(bdKey, String(Date.now())); + }, + fire: (v?: string) => api.fire(k, v), + remove, + listen: (f: (e: LichessStorageEvent) => void) => + window.addEventListener('storage', e => { + if (e.key !== k || e.storageArea !== storage || e.newValue === null) return; + let parsed: LichessStorageEvent | null; + try { + parsed = JSON.parse(e.newValue); + } catch (_) { + return; + } + // check sri, because Safari fires events also in the original + // document when there are multiple tabs + if (parsed?.sri && parsed.sri !== sri) f(parsed); + }), + }; + }, boolean: (k: string) => ({ get: () => api.get(k) == '1', getOrDefault: (defaultValue: boolean) => {