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) => {