diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml index dc8ba93e4a554..3b1862aaf348a 100644 --- a/.github/workflows/assets.yml +++ b/.github/workflows/assets.yml @@ -36,6 +36,8 @@ jobs: with: node-version: 22 cache: 'pnpm' + - name: Corepack + run: pnpm install -g corepack@latest - name: Install pnpm dependencies run: pnpm install - name: Checkout ab (optional) diff --git a/.gitignore b/.gitignore index aa83f02eacd4e..30099d92059a5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ dependency-graph.png dump.rdb RUNNING_PID +instance.lock .DS_Store # Eclipse auto-generated files diff --git a/app/Env.scala b/app/Env.scala index ee3b4f84cbf17..9d3f8c9bc6f6b 100644 --- a/app/Env.scala +++ b/app/Env.scala @@ -24,8 +24,9 @@ final class Env( given mode: Mode = environment.mode given translator: lila.core.i18n.Translator = lila.i18n.Translator given scheduler: Scheduler = system.scheduler - given RateLimit = net.rateLimit + given lila.core.config.RateLimit = net.rateLimit given NetDomain = net.domain + given getFile: (String => java.io.File) = environment.getFile // wire all the lila modules in the right order val i18n: lila.i18n.Env.type = lila.i18n.Env @@ -60,6 +61,7 @@ final class Env( val tournament: lila.tournament.Env = wire[lila.tournament.Env] val swiss: lila.swiss.Env = wire[lila.swiss.Env] val mod: lila.mod.Env = wire[lila.mod.Env] + val ask: lila.ask.Env = wire[lila.ask.Env] val team: lila.team.Env = wire[lila.team.Env] val teamSearch: lila.teamSearch.Env = wire[lila.teamSearch.Env] val forum: lila.forum.Env = wire[lila.forum.Env] @@ -98,6 +100,7 @@ final class Env( val bot: lila.bot.Env = wire[lila.bot.Env] val storm: lila.storm.Env = wire[lila.storm.Env] val racer: lila.racer.Env = wire[lila.racer.Env] + val local: lila.local.Env = wire[lila.local.Env] val opening: lila.opening.Env = wire[lila.opening.Env] val tutor: lila.tutor.Env = wire[lila.tutor.Env] val recap: lila.recap.Env = wire[lila.recap.Env] diff --git a/app/LilaComponents.scala b/app/LilaComponents.scala index 2fbfcc2ad72ed..005f16e0f6d84 100644 --- a/app/LilaComponents.scala +++ b/app/LilaComponents.scala @@ -103,6 +103,7 @@ final class LilaComponents( lazy val analyse: Analyse = wire[Analyse] lazy val api: Api = wire[Api] lazy val appealC: appeal.Appeal = wire[appeal.Appeal] + lazy val ask: Ask = wire[Ask] lazy val auth: Auth = wire[Auth] lazy val feed: Feed = wire[Feed] lazy val playApi: PlayApi = wire[PlayApi] @@ -127,6 +128,7 @@ final class LilaComponents( lazy val irwin: Irwin = wire[Irwin] lazy val learn: Learn = wire[Learn] lazy val lobby: Lobby = wire[Lobby] + lazy val localPlay: Local = wire[Local] lazy val main: Main = wire[Main] lazy val msg: Msg = wire[Msg] lazy val mod: Mod = wire[Mod] diff --git a/app/controllers/Ask.scala b/app/controllers/Ask.scala new file mode 100644 index 0000000000000..b79f82781391d --- /dev/null +++ b/app/controllers/Ask.scala @@ -0,0 +1,128 @@ +package controllers + +import play.api.data.Form +import play.api.data.Forms.single +import views.* + +import lila.app.{ given, * } +import lila.core.id.AskId +import lila.core.ask.Ask + +final class Ask(env: Env) extends LilaController(env): + + def view(aid: AskId, view: Option[String], tally: Boolean) = Open: _ ?=> + env.ask.repo.getAsync(aid).flatMap { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view), tally)) + case _ => fuccess(NotFound(s"Ask $aid not found")) + } + + def picks(aid: AskId, picks: Option[String], view: Option[String], anon: Boolean) = OpenBody: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + val setPicks = () => + env.ask.repo.setPicks(aid, id, intVec(picks)).map { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + feedbackForm + .bindFromRequest() + .fold( + _ => setPicks(), + text => + setPicks() >> env.ask.repo.setForm(aid, id, text.some).flatMap { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + ) + case _ => authenticationFailed + + def form(aid: AskId, view: Option[String], anon: Boolean) = OpenBody: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + env.ask.repo.setForm(aid, id, feedbackForm.bindFromRequest().value).map { + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + } + case _ => authenticationFailed + + def unset(aid: AskId, view: Option[String], anon: Boolean) = Open: _ ?=> + effectiveId(aid, anon).flatMap: + case Some(id) => + env.ask.repo + .unset(aid, id) + .map: + case Some(ask) => Ok.snip(views.askUi.renderOne(ask, intVec(view))) + case _ => NotFound(s"Ask $aid not found") + + case _ => authenticationFailed + + def admin(aid: AskId) = Auth: _ ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => Ok.snip(views.askAdminUi.renderOne(ask)) + case _ => NotFound(s"Ask $aid not found") + + def byUser(username: UserStr) = Auth: _ ?=> + me ?=> + Ok.async: + for + user <- env.user.lightUser(username.id) + asks <- env.ask.repo.byUser(username.id) + if (me.is(user)) || isGranted(_.ModerateForum) + yield views.askAdminUi.show(asks, user.get) + + def json(aid: AskId) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then JsonOk(ask.toJson) + else JsonBadRequest(jsonError(s"Not authorized to view ask $aid")) + case _ => JsonBadRequest(jsonError(s"Ask $aid not found")) + + def delete(aid: AskId) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .map: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then + env.ask.repo.delete(aid) + Ok + else Unauthorized + case _ => NotFound(s"Ask id ${aid} not found") + + def conclude(aid: AskId) = authorized(aid, env.ask.repo.conclude) + + def reset(aid: AskId) = authorized(aid, env.ask.repo.reset) + + private def effectiveId(aid: AskId, anon: Boolean)(using ctx: Context) = + ctx.myId match + case Some(u) => fuccess((if anon then Ask.anonHash(u.toString, aid) else u.toString).some) + case _ => + env.ask.repo + .isOpen(aid) + .map: + case true => Ask.anonHash(ctx.ip.toString, aid).some + case false => none[String] + + private def authorized(aid: AskId, action: AskId => Fu[Option[lila.core.ask.Ask]]) = Auth: _ ?=> + me ?=> + env.ask.repo + .getAsync(aid) + .flatMap: + case Some(ask) => + if (me.is(ask.creator)) || isGranted(_.ModerateForum) then + action(ask._id).map: + case Some(newAsk) => Ok.snip(views.askUi.renderOne(newAsk)) + case _ => NotFound(s"Ask id ${aid} not found") + else fuccess(Unauthorized) + case _ => fuccess(NotFound(s"Ask id $aid not found")) + + private def intVec(param: Option[String]) = + param.map(_.split('-').filter(_.nonEmpty).map(_.toInt).toVector) + + private val feedbackForm = + Form[String](single("text" -> lila.common.Form.cleanNonEmptyText(maxLength = 80))) diff --git a/app/controllers/Feed.scala b/app/controllers/Feed.scala index 89891233edab9..6627870d1d4fd 100644 --- a/app/controllers/Feed.scala +++ b/app/controllers/Feed.scala @@ -12,7 +12,8 @@ final class Feed(env: Env) extends LilaController(env): Reasonable(page): for updates <- env.feed.paginator.recent(isGrantedOpt(_.Feed), page) - renderedPage <- renderPage(views.feed.index(updates)) + hasAsks <- env.ask.repo.preload(updates.currentPageResults.map(_.content.value)*) + renderedPage <- renderPage(views.feed.index(updates, hasAsks)) yield Ok(renderedPage) def createForm = Secure(_.Feed) { _ ?=> _ ?=> @@ -29,12 +30,12 @@ final class Feed(env: Env) extends LilaController(env): } def edit(id: String) = Secure(_.Feed) { _ ?=> _ ?=> - Found(api.get(id)): up => + Found(api.edit(id)): up => Ok.async(views.feed.edit(api.form(up.some), up)) } def update(id: String) = SecureBody(_.Feed) { _ ?=> _ ?=> - Found(api.get(id)): from => + Found(api.edit(id)): from => bindForm(api.form(from.some))( err => BadRequest.async(views.feed.edit(err, from)), data => api.set(data.toUpdate(from.id.some)).inject(Redirect(routes.Feed.edit(from.id)).flashSuccess) diff --git a/app/controllers/ForumTopic.scala b/app/controllers/ForumTopic.scala index 4412b32d2660e..42433f2a6165a 100644 --- a/app/controllers/ForumTopic.scala +++ b/app/controllers/ForumTopic.scala @@ -54,10 +54,14 @@ final class ForumTopic(env: Env) extends LilaController(env) with ForumControlle .soUse: _ ?=> forms.postWithCaptcha(inOwnTeam).some _ <- env.user.lightUserApi.preloadMany(posts.currentPageResults.flatMap(_.post.userId)) + (_, hasAsks) <- env.user.lightUserApi + .preloadMany(posts.currentPageResults.flatMap(_.post.userId)) + .zip(env.ask.repo.preload(posts.currentPageResults.map(_.post.text)*)) res <- if canRead then Ok.page( - views.forum.topic.show(categ, topic, posts, form, unsub, canModCateg, None, replyBlocked) + views.forum.topic + .show(categ, topic, posts, form, unsub, canModCateg, None, replyBlocked, hasAsks) ).map(_.withCanonical(routes.ForumTopic.show(categ.id, topic.slug, page))) else notFound yield res diff --git a/app/controllers/Local.scala b/app/controllers/Local.scala new file mode 100644 index 0000000000000..545e78172415e --- /dev/null +++ b/app/controllers/Local.scala @@ -0,0 +1,138 @@ +package controllers + +import play.api.libs.json.* +import play.api.i18n.Lang +import play.api.mvc.* +import play.api.data.* +import play.api.data.Forms.* +import views.* + +import lila.app.{ given, * } +import lila.common.Json.given +import lila.user.User +import lila.rating.{ Perf, PerfType } +import lila.security.Permission +import lila.local.{ GameSetup, AssetType } + +final class Local(env: Env) extends LilaController(env): + def index( + white: Option[String], + black: Option[String], + fen: Option[String], + time: Option[String], + go: Option[String] + ) = Open: + val initial = time.map(_.toFloat) + val increment = time.flatMap(_.split('+').drop(1).headOption.map(_.toFloat)) + val setup = + if white.isDefined || black.isDefined || fen.isDefined || time.isDefined then + GameSetup(white, black, fen, initial, increment, optTrue(go)).some + else none + for + bots <- env.local.repo.getLatestBots() + page <- renderPage(indexPage(setup, bots, none)) + yield Ok(page).enforceCrossSiteIsolation.withHeaders("Service-Worker-Allowed" -> "/") + + def bots = Open: + env.local.repo + .getLatestBots() + .map: bots => + JsonOk(Json.obj("bots" -> bots)) + + def assetKeys = Open: // for service worker + JsonOk(env.local.api.assetKeys) + + def devIndex = Auth: _ ?=> + for + bots <- env.local.repo.getLatestBots() + assets <- getDevAssets + page <- renderPage(indexPage(none, bots, assets.some)) + yield Ok(page).enforceCrossSiteIsolation.withHeaders("Service-Worker-Allowed" -> "/") + + def devAssets = Auth: ctx ?=> + getDevAssets.map(JsonOk) + + def devBotHistory(botId: Option[String]) = Auth: _ ?=> + env.local.repo + .getVersions(botId.map(UserId.apply)) + .map: history => + JsonOk(Json.obj("bots" -> history)) + + def devPostBot = SecureBody(parse.json)(_.BotEditor) { ctx ?=> me ?=> + ctx.body.body + .validate[JsObject] + .fold( + err => BadRequest(Json.obj("error" -> err.toString)), + bot => + env.local.repo + .putBot(bot, me.userId) + .map: updatedBot => + JsonOk(updatedBot) + ) + } + + def devNameAsset(key: String, name: String) = Secure(_.BotEditor): _ ?=> + env.local.repo + .nameAsset(none, key, name, none) + .flatMap(_ => getDevAssets.map(JsonOk)) + + def devDeleteAsset(key: String) = Secure(_.BotEditor): _ ?=> + env.local.repo + .deleteAsset(key) + .flatMap(_ => getDevAssets.map(JsonOk)) + + def devPostAsset(notAString: String, key: String) = SecureBody(parse.multipartFormData)(_.BotEditor) { + ctx ?=> + val tpe: AssetType = notAString.asInstanceOf[AssetType] + val author: Option[String] = ctx.body.body.dataParts.get("author").flatMap(_.headOption) + val name = ctx.body.body.dataParts.get("name").flatMap(_.headOption).getOrElse(key) + ctx.body.body + .file("file") + .map: file => + env.local.api + .storeAsset(tpe, key, file) + .flatMap: + case Left(error) => InternalServerError(Json.obj("error" -> error.toString)).as(JSON) + case Right(assets) => + env.local.repo + .nameAsset(tpe.some, key, name, author) + .flatMap(_ => (JsonOk(Json.obj("key" -> key, "name" -> name)))) + .getOrElse(fuccess(BadRequest(Json.obj("error" -> "missing file")).as(JSON))) + } + + private def indexPage(setup: Option[GameSetup], bots: JsArray, devAssets: Option[JsObject] = none)(using + ctx: Context + ) = + given setupFormat: Format[GameSetup] = Json.format[GameSetup] + views.local.index( + Json + .obj("pref" -> pref, "bots" -> bots) + .add("setup", setup) + .add("assets", devAssets) + .add("userId", ctx.me.map(_.userId)) + .add("username", ctx.me.map(_.username)) + .add("canPost", isGrantedOpt(_.BotEditor)), + if devAssets.isDefined then "local.dev" else "local" + ) + + private def getDevAssets = + env.local.repo.getAssets.map: m => + JsObject: + env.local.api.assetKeys + .as[JsObject] + .fields + .collect: + case (category, JsArray(keys)) => + category -> JsArray: + keys.collect: + case JsString(key) if m.contains(key) => + Json.obj("key" -> key, "name" -> m(key)) + + private def pref(using ctx: Context) = + lila.pref.JsonView + .write(ctx.pref, false) + .add("animationDuration", ctx.pref.animationMillis.some) + .add("enablePremove", ctx.pref.premove.some) + + private def optTrue(s: Option[String]) = + s.exists(v => v == "" || v == "1" || v == "true") diff --git a/app/controllers/Main.scala b/app/controllers/Main.scala index bfdff3890b9bd..315c484116e64 100644 --- a/app/controllers/Main.scala +++ b/app/controllers/Main.scala @@ -98,7 +98,7 @@ final class Main( path match case "keyboard-move" => Ok.snip(lila.web.ui.help.keyboardMove) case "voice/move" => Ok.snip(lila.web.ui.help.voiceMove) - case "master" => Redirect("/verify-title") + case "master" => Redirect(routes.TitleVerify.index.url) case _ => notFound def movedPermanently(to: String) = Anon: diff --git a/app/controllers/Push.scala b/app/controllers/Push.scala index 357aa366c93d5..7794f13e73215 100644 --- a/app/controllers/Push.scala +++ b/app/controllers/Push.scala @@ -15,7 +15,7 @@ final class Push(env: Env) extends LilaController(env): def webSubscribe = AuthBody(parse.json) { ctx ?=> me ?=> val currentSessionId = ~env.security.api.reqSessionId(ctx.req) - ctx.body.body + ctx.body.body.pp .validate[WebSubscription] .fold( err => BadRequest(err.toString), diff --git a/app/controllers/RelayTour.scala b/app/controllers/RelayTour.scala index cf8eb67989496..bbf842cb22e4c 100644 --- a/app/controllers/RelayTour.scala +++ b/app/controllers/RelayTour.scala @@ -189,7 +189,7 @@ final class RelayTour(env: Env, apiC: => Api, roundC: => RelayRound) extends Lil _.map(_.withTour(tour)).fold(emptyBroadcastPage(tour))(roundC.embedShow) private def emptyBroadcastPage(tour: TourModel)(using Context) = for - owner <- env.user.lightUser(tour.ownerId) + owner <- env.user.lightUser(tour.ownerIds.head) markup = tour.markup.map(env.relay.markup(tour)) page <- Ok.page(views.relay.tour.showEmpty(tour, owner, markup)) yield page diff --git a/app/controllers/Round.scala b/app/controllers/Round.scala index 2dc90940d3f12..ea9a6c2e40955 100644 --- a/app/controllers/Round.scala +++ b/app/controllers/Round.scala @@ -71,7 +71,7 @@ final class Round( jsChat <- chat.flatMap(_.game).map(_.chat).soFu(lila.chat.JsonView.asyncLines) yield Ok(data.add("chat", jsChat)).noCache ) - yield res + yield res.enforceCrossSiteIsolation def player(fullId: GameFullId) = Open: env.round.proxyRepo diff --git a/app/controllers/Ublog.scala b/app/controllers/Ublog.scala index 9055e280444e2..3f1197bcf48e0 100644 --- a/app/controllers/Ublog.scala +++ b/app/controllers/Ublog.scala @@ -55,10 +55,20 @@ final class Ublog(env: Env) extends LilaController(env): prefFollowable <- ctx.isAuth.so(env.pref.api.followable(user.id)) blocked <- ctx.userId.so(env.relation.api.fetchBlocks(user.id, _)) followable = prefFollowable && !blocked - markup <- env.ublog.markup(post) + (markup, hasAsks) <- env.ublog.markup(post).zip(env.ask.repo.preload(post.markdown.value)) viewedPost = env.ublog.viewCounter(post, ctx.ip) page <- renderPage: - views.ublog.post.page(user, blog, viewedPost, markup, others, liked, followable, followed) + views.ublog.post.page( + user, + blog, + viewedPost, + markup, + others, + liked, + followable, + followed, + hasAsks + ) yield Ok(page) def discuss(id: UblogPostId) = Open: @@ -128,7 +138,11 @@ final class Ublog(env: Env) extends LilaController(env): def edit(id: UblogPostId) = AuthBody { ctx ?=> me ?=> NotForKids: FoundPage(env.ublog.api.findEditableByMe(id)): post => - views.ublog.form.edit(post, env.ublog.form.edit(post)) + env.ask.api + .unfreezeAndLoad(post.markdown.value) + .flatMap: frozen => + views.ublog.form.edit(post, env.ublog.form.edit(post.copy(markdown = Markdown(frozen)))) + // views.ublog.form.edit(post, env.ublog.form.edit(post)) } def update(id: UblogPostId) = AuthBody { ctx ?=> me ?=> diff --git a/app/mashup/Preload.scala b/app/mashup/Preload.scala index 3639252bb1016..04e99267fc4dd 100644 --- a/app/mashup/Preload.scala +++ b/app/mashup/Preload.scala @@ -30,6 +30,7 @@ final class Preload( simulIsFeaturable: SimulIsFeaturable, getLastUpdates: lila.feed.Feed.GetLastUpdates, lastPostsCache: AsyncLoadingCache[Unit, List[UblogPost.PreviewPost]], + askRepo: lila.ask.AskRepo, msgApi: lila.msg.MsgApi, relayListing: lila.relay.RelayListing, notifyApi: lila.notify.NotifyApi @@ -52,16 +53,19 @@ final class Preload( ( ( ( - (((((((data, povs), tours), events), simuls), feat), entries), puzzle), - streams + ( + (((((((data, povs), tours), events), simuls), feat), entries), puzzle), + streams + ), + playban ), - playban + blindGames ), - blindGames + ublogPosts ), - ublogPosts + lichessMsg ), - lichessMsg + hasAsks ) <- lobbyApi.apply .mon(_.lobby.segment("lobbyApi")) .zip(tours.mon(_.lobby.segment("tours"))) @@ -86,6 +90,7 @@ final class Preload( .filterNot(liveStreamApi.isStreaming) .so(msgApi.hasUnreadLichessMessage) ) + .zip(askRepo.preload(getLastUpdates().map(_.content.value)*)) (currentGame, _) <- (ctx.me .soUse(currentGameMyTurn(povs, lightUserApi.sync))) .mon(_.lobby.segment("currentGame")) @@ -111,7 +116,8 @@ final class Preload( getLastUpdates(), ublogPosts, withPerfs, - hasUnreadLichessMessage = lichessMsg + hasUnreadLichessMessage = lichessMsg, + hasAsks ) def currentGameMyTurn(using me: Me): Fu[Option[CurrentGame]] = @@ -155,7 +161,8 @@ object Preload: lastUpdates: List[lila.feed.Feed.Update], ublogPosts: List[UblogPost.PreviewPost], me: Option[UserWithPerfs], - hasUnreadLichessMessage: Boolean + hasUnreadLichessMessage: Boolean, + hasAsks: Boolean ) case class CurrentGame(pov: Pov, opponent: String) diff --git a/app/views/lobby/home.scala b/app/views/lobby/home.scala index 4acc82e718262..531835f0cbcf1 100644 --- a/app/views/lobby/home.scala +++ b/app/views/lobby/home.scala @@ -29,6 +29,8 @@ object home: ) ) .css("lobby") + .css(homepage.hasAsks.option("bits.ask")) + .js(homepage.hasAsks.option(esmInit("bits.ask"))) .graph( OpenGraph( image = staticAssetUrl("logo/lichess-tile-wide.png").some, diff --git a/app/views/ublog.scala b/app/views/ublog.scala index c78560a2f9771..c336e4d26cfe7 100644 --- a/app/views/ublog.scala +++ b/app/views/ublog.scala @@ -11,7 +11,8 @@ lazy val ui = lila.ublog.ui.UblogUi(helpers, views.atomUi)(picfitUrl) lazy val post = lila.ublog.ui.UblogPostUi(helpers, ui)( ublogRank = env.ublog.rank, - connectLinks = views.bits.connectLinks + connectLinks = views.bits.connectLinks, + askRender = views.askUi.render ) lazy val form = lila.ublog.ui.UblogFormUi(helpers, ui)( diff --git a/app/views/ui.scala b/app/views/ui.scala index 12204f8de321a..77da3d89e695b 100644 --- a/app/views/ui.scala +++ b/app/views/ui.scala @@ -32,12 +32,15 @@ object oAuth: val plan = lila.plan.ui.PlanUi(helpers)(netConfig.email) val planPages = lila.plan.ui.PlanPages(helpers)(lila.fishnet.FishnetLimiter.maxPerDay) +val askUi = lila.ask.ui.AskUi(helpers)(env.ask.api) +val askAdminUi = lila.ask.ui.AskAdminUi(helpers)(askUi.renderGraph) + val feed = - lila.feed.ui.FeedUi(helpers, atomUi)(title => _ ?=> site.ui.SitePage(title, "news", ""))(using + lila.feed.ui.FeedUi(helpers, atomUi)(title => _ ?=> site.ui.SitePage(title, "news", ""), askUi.render)(using env.executor ) -val cms = lila.cms.ui.CmsUi(helpers)(mod.ui.menu("cms")) +val cms = lila.cms.ui.CmsUi(helpers)(mod.ui.menu("cms"), askUi.render) val event = lila.event.ui.EventUi(helpers)(mod.ui.menu("event"))(using env.executor) @@ -59,12 +62,9 @@ val practice = lila.practice.ui.PracticeUi(helpers)( object forum: import lila.forum.ui.* val bits = ForumBits(helpers) - val post = PostUi(helpers, bits) + val post = PostUi(helpers, bits)(askUi.render, env.ask.api.unfreeze) val categ = CategUi(helpers, bits) - val topic = TopicUi(helpers, bits, post)( - captcha.apply, - lila.msg.MsgPreset.forumDeletion.presets - ) + val topic = TopicUi(helpers, bits, post)(captcha.apply, lila.msg.MsgPreset.forumDeletion.presets) val timeline = lila.timeline.ui.TimelineUi(helpers)(streamer.bits.redirectLink(_)) @@ -87,6 +87,8 @@ val challenge = lila.challenge.ui.ChallengeUi(helpers) val dev = lila.web.ui.DevUi(helpers)(mod.ui.menu) +val local = lila.local.ui.LocalUi(helpers) + def mobile(p: lila.cms.CmsPage.Render)(using Context) = lila.web.ui.mobile(helpers)(cms.render(p)) diff --git a/bin/deploy b/bin/deploy index c4def9b037782..8dbc913566adf 100755 --- a/bin/deploy +++ b/bin/deploy @@ -116,7 +116,6 @@ PROFILES = { "manta-assets": asset_profile("root@manta.lichess.ovh", deploy_dir="/home/lichess"), } - class DeployError(Exception): pass @@ -313,7 +312,7 @@ def deploy_script(profile, session, run, url): ] else: commands += [ - f'echo "{artifact_unzipped}/d/{symlink} -> {deploy_dir}/{symlink}";ln -f --no-target-directory -s {artifact_unzipped}/d/{symlink} {deploy_dir}/{symlink}' + f'echo "{artifact_unzipped}/d/{symlink} -> {deploy_dir}/{symlink}"; ln -f --no-target-directory -s {artifact_unzipped}/d/{symlink} {deploy_dir}/{symlink}' for symlink in profile["symlinks"] ] + [f"chmod -f +x {deploy_dir}/bin/lila || true"] diff --git a/bin/mongodb/fuckshoe.js b/bin/mongodb/fuckshoe.js new file mode 100644 index 0000000000000..c27b40c3b204e --- /dev/null +++ b/bin/mongodb/fuckshoe.js @@ -0,0 +1,20 @@ +const fs = require('node:fs'); +const ps = require('node:process'); +const fuckshoeText = fs.readFileSync(fuckshoe, 'utf-8'); +if (!fuckshoe || !fuckshoeText) { + console.log(fuckshoe, 'fuckshoe file not found'); + ps.exit(1); +} +db.boards.drop(); +db.pieces.drop(); +const lines = fuckshoeText.split('\n'); +for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line.startsWith('/poll')) continue; + const parts = line.split(' '); + const item = parts[1]; + const type = parts[2]; + const id = lines[i + 1].substring(4, 12); + if (type === 'board') db.boards.insertOne({ _id: item, type: type, id: id }); + else db.pieces.insertOne({ _id: item, type: type, id: id }); +} diff --git a/bin/mongodb/relay-tour-ownerIds.js b/bin/mongodb/relay-tour-ownerIds.js new file mode 100644 index 0000000000000..5fc28ebc9feae --- /dev/null +++ b/bin/mongodb/relay-tour-ownerIds.js @@ -0,0 +1,3 @@ +db.relay_tour.find().forEach(function (tour) { + db.relay_tour.updateOne({ _id: tour._id }, { $set: { ownerIds: [tour.ownerId] } }); +}); diff --git a/build.sbt b/build.sbt index 2b36efe95f5a7..d1625bdc21884 100644 --- a/build.sbt +++ b/build.sbt @@ -76,8 +76,8 @@ lazy val modules = Seq( // and then the smaller ones pool, lobby, relation, tv, coordinate, feed, history, recap, shutup, appeal, irc, explorer, learn, event, coach, - practice, evalCache, irwin, bot, racer, cms, i18n, - socket, bookmark, studySearch, gameSearch, forumSearch, teamSearch, + practice, evalCache, irwin, bot, racer, cms, i18n, local, + socket, bookmark, studySearch, gameSearch, forumSearch, teamSearch, ask ) lazy val moduleRefs = modules map projectToRef @@ -149,6 +149,11 @@ lazy val racer = module("racer", Seq() ) +lazy val local = module("local", + Seq(db, memo, ui, pref), + Seq() +) + lazy val video = module("video", Seq(memo, ui), macwire.bundle @@ -170,12 +175,12 @@ lazy val coordinate = module("coordinate", ) lazy val feed = module("feed", - Seq(memo, ui), + Seq(memo, ui, ask), Seq() ) lazy val ublog = module("ublog", - Seq(memo, ui), + Seq(memo, ui, ask), Seq(bloomFilter) ) @@ -425,8 +430,13 @@ lazy val msg = module("msg", Seq() ) +lazy val ask = module("ask", + Seq(memo, ui, security), + reactivemongo.bundle +) + lazy val forum = module("forum", - Seq(memo, ui), + Seq(memo, ui, ask), Seq() ) diff --git a/conf/base.conf b/conf/base.conf index a015667d5db6c..2c41591ba4162 100644 --- a/conf/base.conf +++ b/conf/base.conf @@ -379,6 +379,7 @@ insight { learn { collection.progress = learn_progress } +local.asset_path = "public/lifat/bots" kaladin.enabled = false zulip { domain = "" diff --git a/conf/routes b/conf/routes index ab577cfff43db..2bf6486dda62d 100644 --- a/conf/routes +++ b/conf/routes @@ -346,6 +346,18 @@ GET /streamer/:username controllers.Streamer.show(username: UserS GET /streamer/:username/redirect controllers.Streamer.redirect(username: UserStr) POST /streamer/:username/check controllers.Streamer.checkOnline(username: UserStr) +# Private Play + +GET /local controllers.Local.index(white: Option[String], black: Option[String], fen: Option[String], time: Option[String], go: Option[String]) +GET /local/bots controllers.Local.bots +GET /local/assets controllers.Local.assetKeys +GET /local/dev controllers.Local.devIndex +GET /local/dev/history controllers.Local.devBotHistory(id: Option[String]) +POST /local/dev/bot controllers.Local.devPostBot +GET /local/dev/assets controllers.Local.devAssets +POST /local/dev/asset/$tpe/$key<\w{12}(\.\w{2,4})?> controllers.Local.devPostAsset(tpe: String, key: String) +POST /local/dev/asset/mv/$key<\w{12}(\.\w{2,4})?>/:name controllers.Local.devNameAsset(key: String, name: String) + # Round GET /$gameId<\w{8}> controllers.Round.watcher(gameId: GameId, color: Color = Color.white) GET /$gameId<\w{8}>/$color controllers.Round.watcher(gameId: GameId, color: Color) @@ -603,6 +615,18 @@ GET /api/stream/irwin controllers.Irwin.eventStream # Kaladin GET /kaladin controllers.Irwin.kaladin +# Ask +GET /ask/:id controllers.Ask.view(id: AskId, view: Option[String], tally: Boolean ?= false) +POST /ask/picks/:id controllers.Ask.picks(id: AskId, picks: Option[String], view: Option[String], anon: Boolean ?= false) +POST /ask/form/:id controllers.Ask.form(id: AskId, view: Option[String], anon: Boolean ?= false) +POST /ask/conclude/:id controllers.Ask.conclude(id: AskId) +POST /ask/unset/:id controllers.Ask.unset(id: AskId, view: Option[String], anon: Boolean ?= false) +POST /ask/reset/:id controllers.Ask.reset(id: AskId) +POST /ask/delete/:id controllers.Ask.delete(id: AskId) +GET /ask/admin/:id controllers.Ask.admin(id: AskId) +GET /ask/byUser/:username controllers.Ask.byUser(username: UserStr) +GET /ask/json/:id controllers.Ask.json(id: AskId) + # Forum GET /forum controllers.ForumCateg.index GET /forum/search controllers.ForumPost.search(text ?= "", page: Int ?= 1) diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index 642bfe04bf5b6..2a68fef090580 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -7,6 +7,7 @@ import play.api.Mode import lila.chat.{ GetLinkCheck, IsChatFresh } import lila.common.Bus import lila.core.misc.lpv.* +import lila.core.config.CollName @Module final class Env( @@ -59,6 +60,7 @@ final class Env( realPlayerApi: lila.web.RealPlayerApi, bookmarkExists: lila.core.bookmark.BookmarkExists, manifest: lila.web.AssetManifest, + db: lila.db.Db, tokenApi: lila.oauth.AccessTokenApi )(using val mode: Mode, scheduler: Scheduler)(using Executor, @@ -80,6 +82,8 @@ final class Env( lazy val gameApiV2 = wire[GameApiV2] + lazy val pollColls = PollColls(db) + lazy val roundApi = wire[RoundApi] lazy val lobbyApi = wire[LobbyApi] @@ -120,3 +124,7 @@ final class Env( // ensure the Lichess user is online socketEnv.remoteSocket.onlineUserIds.getAndUpdate(_ + UserId.lichess) userEnv.repo.setSeenAt(UserId.lichess) + +private class PollColls(db: lila.db.Db): + val boards = db(CollName("boards")) + val pieces = db(CollName("pieces")) diff --git a/modules/api/src/main/RoundApi.scala b/modules/api/src/main/RoundApi.scala index c576d4614c6e9..52e1c3facad90 100644 --- a/modules/api/src/main/RoundApi.scala +++ b/modules/api/src/main/RoundApi.scala @@ -19,6 +19,8 @@ import lila.simul.Simul import lila.swiss.GameView as SwissView import lila.tournament.GameView as TourView import lila.tree.{ ExportOptions, Tree } +import lila.db.JSON +import lila.db.dsl.{ *, given } final private[api] class RoundApi( jsonView: JsonView, @@ -35,7 +37,8 @@ final private[api] class RoundApi( userApi: lila.user.UserApi, prefApi: lila.pref.PrefApi, getLightUser: lila.core.LightUser.GetterSync, - userLag: lila.socket.UserLagCache + userLag: lila.socket.UserLagCache, + colls: lila.api.PollColls )(using Executor, lila.core.i18n.Translator): def player( @@ -166,6 +169,20 @@ final private[api] class RoundApi( owner = owner ) .flatMap(externalEngineApi.withExternalEngines) + .flatMap: json => + colls.boards + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + json + ("boards" -> JsArray(docs.map(JSON.jval))) + .flatMap: json => + colls.pieces + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + json + ("pieces" -> JsArray(docs.map(JSON.jval))) private def withTree( pov: Pov, diff --git a/modules/ask/src/main/AskApi.scala b/modules/ask/src/main/AskApi.scala new file mode 100644 index 0000000000000..6df5589539ba1 --- /dev/null +++ b/modules/ask/src/main/AskApi.scala @@ -0,0 +1,191 @@ +package lila.ask + +import lila.db.dsl.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* +import lila.core.ask.Ask.{ frozenIdMagic, frozenIdRe } + +/* the freeze process transforms form text prior to database storage and creates/updates collection + * objects with data from ask markup. freeze methods return replacement text with magic id tags in place + * of any Ask markup found. unfreeze methods allow editing by doing the inverse, replacing magic + * tags in a previously frozen text with their markup. ids in magic tags correspond to db.ask._id + */ + +final class AskApi(val repo: lila.ask.AskRepo)(using Executor) extends lila.core.ask.AskApi: + + import AskApi.* + import Ask.* + + def freeze(text: String, creator: UserId): Frozen = + val askIntervals = getMarkupIntervals(text) + val asks = askIntervals.map((start, end) => textToAsk(text.substring(start, end), creator)) + + val it = asks.iterator + val sb = java.lang.StringBuilder(text.length) + + intervalClosure(askIntervals, text.length).map: seg => + if it.hasNext && askIntervals.contains(seg) then sb.append(s"$frozenIdMagic{${it.next()._id}}") + else sb.append(text, seg._1, seg._2) + + Frozen(sb.toString, asks) + + // commit flushes the asks to repo and optionally sets a timeline entry link (for poll conclusion) + def commit( + frozen: Frozen, + url: Option[String] = none[String] + ): Fu[Iterable[Ask]] = // TODO need return value? + frozen.asks.map(ask => repo.upsert(ask.copy(url = url))).parallel + + def freezeAndCommit(text: String, creator: UserId, url: Option[String] = none[String]): Fu[String] = + val askIntervals = getMarkupIntervals(text) + askIntervals + .map((start, end) => repo.upsert(textToAsk(text.substring(start, end), creator, url))) + .parallel + .map: asks => + val it = asks.iterator + val sb = java.lang.StringBuilder(text.length) + + intervalClosure(askIntervals, text.length).map: seg => + if it.hasNext && askIntervals.contains(seg) then sb.append(s"$frozenIdMagic{${it.next()._id}}") + else sb.append(text, seg._1, seg._2) + sb.toString + + // unfreeze methods replace magic ids with their ask markup to allow user edits + def unfreezeAndLoad(text: String): Fu[String] = + extractIds(text) + .map(repo.getAsync) + .parallel + .map: asks => + val it = asks.iterator + frozenIdRe.replaceAllIn(text, _ => it.next().fold(askNotFoundFrag)(askToText)) + + // dont call this without preloading first + def unfreeze(text: String): String = + val it = extractIds(text).map(repo.get).iterator + frozenIdRe.replaceAllIn(text, _ => it.next().fold(askNotFoundFrag)(askToText)) + + def isOpen(aid: AskId): Fu[Boolean] = repo.isOpen(aid) + + def bake(text: String, askFrags: Iterable[String]): String = AskApi.bake(text, askFrags) + +object AskApi: + val askNotFoundFrag = "<deleted>
" + + def hasAskId(text: String): Boolean = text.contains(frozenIdMagic) + + // the bake method interleaves rendered ask fragments within the html fragment, which is usually an + // inner html or

. any embedded asks should be directly in that root element. we make a best effort + // to close and reopen tags around asks, but attributes cannot be safely repeated so stick to plain + //

, ,

, etc if it's not a text node + def bake(html: String, askFrags: Iterable[String]): String = + val tag = if html.slice(0, 1) == "<" then html.slice(1, html.indexWhere(Set(' ', '>').contains)) else "" + val sb = java.lang.StringBuilder(html.length + askFrags.foldLeft(0)((x, y) => x + y.length)) + val magicIntervals = frozenIdRe.findAllMatchIn(html).map(m => (m.start, m.end)).toList + val it = askFrags.iterator + + intervalClosure(magicIntervals, html.length).map: seg => + val text = html.substring(seg._1, seg._2) + if it.hasNext && magicIntervals.contains(seg) then + if tag.nonEmpty then sb.append(s"") + sb.append(it.next) + else if !(text.isBlank() || text.startsWith(s"")) then + sb.append(if seg._1 > 0 && tag.nonEmpty then s"<$tag>$text" else text) + sb.toString + + def tag(html: String) = html.slice(1, html.indexOf(">")) + + def extractIds(t: String): List[AskId] = + frozenOffsets(t).map(off => lila.core.id.AskId(t.substring(off._1 + 5, off._2 - 1))) + + // render ask as markup text + private def askToText(ask: Ask): String = + val sb = scala.collection.mutable.StringBuilder(1024) + sb ++= s"/poll ${ask.question}\n" + // tags.mkString(" ") not used, make explicit tag conflict results for traceable/tally/anon on re-edits + sb ++= s"/id{${ask._id}}" + if ask.isForm then sb ++= " form" + if ask.isOpen then sb ++= " open" + if ask.isTraceable then sb ++= " traceable" + else + if ask.isTally then sb ++= " tally" + if ask.isAnon then sb ++= " anon" + if ask.isVertical then sb ++= " vertical" + if ask.isStretch then sb ++= " stretch" + if ask.isRandom then sb ++= " random" + if ask.isRanked then sb ++= " ranked" + if ask.isMulti then sb ++= " multiple" + if ask.isSubmit && !ask.isRanked && !ask.isForm then sb ++= " submit" + if ask.isConcluded then sb ++= " concluded" + sb ++= "\n" + sb ++= ask.choices.map(c => s"$c\n").mkString + sb ++= ~ask.footer.map(f => s"? $f\n") + sb.toString + + private def textToAsk(segment: String, creator: UserId, url: Option[String] = none[String]): Ask = + val tagString = extractTagString(segment) + Ask.make( + _id = extractIdFromTagString(tagString), + question = extractQuestion(segment), + choices = extractChoices(segment), + tags = extractTagList(tagString.map(_.toLowerCase)), + creator = creator, + footer = extractFooter(segment), + url = url + ) + + type Interval = (Int, Int) // [start, end) cleaner than regex match objects for our purpose + type Intervals = List[Interval] + + // return list of (start, end) indices of any ask markups in text. + private def getMarkupIntervals(t: String): Intervals = + if !t.contains("/poll") then List.empty[Interval] + else askRe.findAllMatchIn(t).map(m => (m.start, m.end)).toList + + // return intervals and their complement in [0, upper) + private def intervalClosure(intervals: Intervals, upper: Int): Intervals = + val points = (0 :: intervals.flatten(i => List(i._1, i._2)) ::: upper :: Nil).distinct.sorted + points.zip(points.tail) + + // // https://www.unicode.org/faq/private_use.html + // private val frozenIdMagic = "\ufdd6\ufdd4\ufdd2\ufdd0" + // private val frozenIdRe = s"$frozenIdMagic\\{(\\S{8})}".r + + // assemble a list of magic ids within a frozen text that look like: ﷖﷔﷒﷐{8 char id} + // this is called quite often so it's optimized and ugly + private def frozenOffsets(t: String): Intervals = + var i = t.indexOf(frozenIdMagic) + if i == -1 then List.empty + else + val ids = scala.collection.mutable.ListBuffer[Interval]() + while i != -1 && i <= t.length - 14 do // 14 is total magic length + ids.addOne(i, i + 14) // (5, 13) delimit id within magic + i = t.indexOf(frozenIdMagic, i + 14) + ids.toList + + private def extractQuestion(t: String): String = + questionInAskRe.findFirstMatchIn(t).fold("")(_.group(1)).trim + + private def extractTagString(t: String): Option[String] = + tagsInAskRe.findFirstMatchIn(t).map(_.group(1)).filter(_.nonEmpty) + + private def extractIdFromTagString(o: Option[String]): Option[String] = + o.flatMap(idInTagsRe.findFirstMatchIn(_).map(_.group(1))) + + private def extractTagList(o: Option[String]): Ask.Tags = + o.fold(Set.empty[String])( + tagListRe.findAllMatchIn(_).collect(_.group(1)).toSet + ).filterNot(_.startsWith("id{")) + + private def extractChoices(t: String): Ask.Choices = + (choiceInAskRe.findAllMatchIn(t).map(_.group(1).trim).distinct).toVector + + private def extractFooter(t: String): Option[String] = + footerInAskRe.findFirstMatchIn(t).map(_.group(1).trim).filter(_.nonEmpty) + + private val askRe = raw"(?m)^/poll\h+\S.*\R^(?:/.*(?:\R|$$))?(?:(?!/).*\S.*(?:\R|$$))*(?:\?.*)?".r + private val questionInAskRe = raw"^/poll\h+(\S.*)".r + private val tagsInAskRe = raw"(?m)^/poll(?:.*)\R^/(.*)$$".r + private val idInTagsRe = raw"\bid\{(\S{8})}".r + private val tagListRe = raw"\h*(\S+)".r + private val choiceInAskRe = raw"(?m)^(?![\?/])(.*\S.*)".r + private val footerInAskRe = raw"(?m)^\?(.*)".r diff --git a/modules/ask/src/main/AskRepo.scala b/modules/ask/src/main/AskRepo.scala new file mode 100644 index 0000000000000..b830b07a17afd --- /dev/null +++ b/modules/ask/src/main/AskRepo.scala @@ -0,0 +1,174 @@ +package lila.ask + +import scala.concurrent.duration.* +import scala.collection.concurrent.TrieMap +import reactivemongo.api.bson.* + +import lila.db.dsl.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* +import lila.core.timeline.{ AskConcluded, Propagate } + +final class AskRepo( + askDb: lila.db.AsyncColl, + // timeline: lila.timeline.Timeline, + cacheApi: lila.memo.CacheApi +)(using + Executor +) extends lila.core.ask.AskRepo: + import lila.core.ask.Ask.* + import AskApi.* + + given BSONDocumentHandler[Ask] = Macros.handler[Ask] + + private val cache = cacheApi.sync[AskId, Option[Ask]]( + name = "ask", + initialCapacity = 1000, + compute = getDb, + default = _ => none[Ask], + strategy = lila.memo.Syncache.Strategy.WaitAfterUptime(20.millis), + expireAfter = lila.memo.Syncache.ExpireAfter.Access(1.hour) + ) + + def get(aid: AskId): Option[Ask] = cache.sync(aid) + + def getAsync(aid: AskId): Fu[Option[Ask]] = cache.async(aid) + + def preload(text: String*): Fu[Boolean] = + val ids = text.flatMap(AskApi.extractIds) + ids.map(getAsync).parallel.inject(ids.nonEmpty) + + // vid (voter id) are sometimes anonymous hashes. + def setPicks(aid: AskId, vid: String, picks: Option[Vector[Int]]): Fu[Option[Ask]] = + update(aid, vid, picks, modifyPicksCached, writePicks) + + def setForm(aid: AskId, vid: String, form: Option[String]): Fu[Option[Ask]] = + update(aid, vid, form, modifyFormCached, writeForm) + + def unset(aid: AskId, vid: String): Fu[Option[Ask]] = + update(aid, vid, none[Unit], unsetCached, writeUnset) + + def delete(aid: AskId): Funit = askDb: coll => + cache.invalidate(aid) + coll.delete.one($id(aid)).void + + def conclude(aid: AskId): Fu[Option[Ask]] = askDb: coll => + coll + .findAndUpdateSimplified[Ask]($id(aid), $addToSet("tags" -> "concluded"), fetchNewObject = true) + .collect: + case Some(ask) => + cache.set(aid, ask.some) // TODO fix timeline + /*if ask.url.nonEmpty && !ask.isAnon then + timeline ! Propagate(AskConcluded(ask.creator, ask.question, ~ask.url)) + .toUsers(ask.participants.map(UserId(_)).toList) + .exceptUser(ask.creator)*/ + ask.some + + def reset(aid: AskId): Fu[Option[Ask]] = askDb: coll => + coll + .findAndUpdateSimplified[Ask]( + $id(aid), + $doc($unset("picks", "form"), $pull("tags" -> "concluded")), + fetchNewObject = true + ) + .collect: + case Some(ask) => + cache.set(aid, ask.some) + ask.some + + def byUser(uid: UserId): Fu[List[Ask]] = askDb: coll => + coll + .find($doc("creator" -> uid)) + .sort($sort.desc("createdAt")) + .cursor[Ask]() + .list(Int.MaxValue) + .map: asks => + asks.map(a => cache.set(a._id, a.some)) + asks + + def deleteAll(text: String): Funit = askDb: coll => + val ids = AskApi.extractIds(text) + ids.map(cache.invalidate) + if ids.nonEmpty then coll.delete.one($inIds(ids)).void + else funit + + // none values (deleted asks) in these lists are still important for sequencing in renders + def asksIn(text: String): Fu[List[Option[Ask]]] = askDb: coll => + val ids = AskApi.extractIds(text) + ids.map(getAsync).parallel.inject(ids.map(get)) + + def isOpen(aid: AskId): Fu[Boolean] = askDb: coll => + getAsync(aid).map(_.exists(_.isOpen)) + + // call this after freezeAsync on form submission for edits + def setUrl(text: String, url: Option[String]): Funit = askDb: coll => + if !hasAskId(text) then funit + else + val selector = $inIds(AskApi.extractIds(text)) + coll.update.one(selector, $set("url" -> url), multi = true) >> + coll.list(selector).map(_.foreach(ask => cache.set(ask._id, ask.copy(url = url).some))) + + private val emptyPicks = Map.empty[String, Vector[Int]] + private val emptyForm = Map.empty[String, String] + + private def getDb(aid: AskId) = askDb: coll => + coll.byId[Ask](aid) + + private def update[A]( + aid: AskId, + vid: String, + value: Option[A], + cached: (Ask, String, Option[A]) => Ask, + writeField: (AskId, String, Option[A], Boolean) => Fu[Option[Ask]] + ) = + cache.sync(aid) match + case Some(ask) => + val cachedAsk = cached(ask, vid, value) + cache.set(aid, cachedAsk.some) + writeField(aid, vid, value, false).inject(cachedAsk.some) + case _ => + writeField(aid, vid, value, true).collect: + case Some(ask) => + cache.set(aid, ask.some) + ask.some + + // hey i know, let's write 17 functions so we can reuse 2 lines of code + private def modifyPicksCached(ask: Ask, vid: String, newPicks: Option[Vector[Int]]) = + ask.copy(picks = newPicks.fold(ask.picks.fold(emptyPicks)(_ - vid).some): p => + ((ask.picks.getOrElse(emptyPicks) + (vid -> p)).some)) + + private def modifyFormCached(ask: Ask, vid: String, newForm: Option[String]) = + ask.copy(form = newForm.fold(ask.form.fold(emptyForm)(_ - vid).some): f => + ((ask.form.getOrElse(emptyForm) + (vid -> f)).some)) + + private def unsetCached(ask: Ask, vid: String, unused: Option[Unit]) = + ask.copy(picks = ask.picks.fold(emptyPicks)(_ - vid).some, form = ask.form.fold(emptyForm)(_ - vid).some) + + private def writePicks(aid: AskId, vid: String, picks: Option[Vector[Int]], fetchNew: Boolean) = + updateAsk(aid, picks.fold($unset(s"picks.$vid"))(r => $set(s"picks.$vid" -> r)), fetchNew) + + private def writeForm(aid: AskId, vid: String, form: Option[String], fetchNew: Boolean) = + updateAsk(aid, form.fold($unset(s"form.$vid"))(f => $set(s"form.$vid" -> f)), fetchNew) + + private def writeUnset(aid: AskId, vid: String, unused: Option[Unit], fetchNew: Boolean) = + updateAsk(aid, $unset(s"picks.$vid", s"form.$vid"), fetchNew) + + private def updateAsk(aid: AskId, update: BSONDocument, fetchNew: Boolean) = askDb: coll => + coll.update + .one($and($id(aid), $doc("tags" -> $ne("concluded"))), update) + .flatMap: + case _ => if fetchNew then getAsync(aid) else fuccess(none[Ask]) + + // only preserve votes if important fields haven't been altered + private[ask] def upsert(ask: Ask): Fu[Ask] = askDb: coll => + coll + .byId[Ask](ask._id) + .flatMap: + case Some(dbAsk) => + val mergedAsk = ask.merge(dbAsk) + cache.set(ask._id, mergedAsk.some) + if dbAsk eq mergedAsk then fuccess(mergedAsk) + else coll.update.one($id(ask._id), mergedAsk).inject(mergedAsk) + case _ => + cache.set(ask._id, ask.some) + coll.insert.one(ask).inject(ask) diff --git a/modules/ask/src/main/Env.scala b/modules/ask/src/main/Env.scala new file mode 100644 index 0000000000000..5d26228fa7c99 --- /dev/null +++ b/modules/ask/src/main/Env.scala @@ -0,0 +1,15 @@ +package lila.ask + +import com.softwaremill.macwire.* +import com.softwaremill.tagging.@@ +import lila.core.config.* + +@Module +final class Env( + db: lila.db.AsyncDb @@ lila.db.YoloDb, + // timeline: lila.hub.actors.Timeline, + cacheApi: lila.memo.CacheApi +)(using Executor, Scheduler): + private lazy val askColl = db(CollName("ask")) + lazy val repo = wire[AskRepo] + lazy val api = wire[AskApi] diff --git a/modules/ask/src/main/package.scala b/modules/ask/src/main/package.scala new file mode 100644 index 0000000000000..e91056809f5a5 --- /dev/null +++ b/modules/ask/src/main/package.scala @@ -0,0 +1,6 @@ +package lila.ask + +export lila.core.lilaism.Lilaism.{ *, given } +export lila.common.extensions.* + +private[ask] val logger = lila.log("ask") diff --git a/modules/ask/src/main/ui/AskAdminUi.scala b/modules/ask/src/main/ui/AskAdminUi.scala new file mode 100644 index 0000000000000..938ee96c4bd2b --- /dev/null +++ b/modules/ask/src/main/ui/AskAdminUi.scala @@ -0,0 +1,67 @@ +package lila.ask +package ui + +import lila.ui.{ *, given } +import ScalatagsTemplate.{ *, given } +import lila.core.ask.Ask + +final class AskAdminUi(helpers: Helpers)(askRender: (Ask) => Context ?=> Frag): + import helpers.{ *, given } + + def show(asks: List[Ask], user: lila.core.LightUser)(using Me, Context) = + val askmap = asks.sortBy(_.createdAt).groupBy(_.url) + Page(s"${user.titleName} polls") + .css("bits.ask") + .js(esmInit("bits.ask")): + main(cls := "page-small box box-pad")( + h1(s"${user.titleName} polls"), + askmap.keys.map(url => showAsks(url, askmap.get(url).get)).toSeq + ) + + def showAsks(urlopt: Option[String], asks: List[Ask])(using Me, Context) = + div( + hr, + h2( + urlopt match + case Some(url) => div(a(href := url)(url)) + case None => "no url" + ), + br, + asks.map(renderOne) + ) + + def renderOne(ask: Ask)(using Context)(using me: Me) = + div(cls := "ask-admin")( + a(name := ask._id), + div(cls := "header")( + ask.question, + div(cls := "url-actions")( + button(formaction := routes.Ask.delete(ask._id))("Delete"), + button(formaction := routes.Ask.reset(ask._id))("Reset"), + (!ask.isConcluded).option(button(formaction := routes.Ask.conclude(ask._id))("Conclude")), + a(href := routes.Ask.json(ask._id))("JSON") + ) + ), + div(cls := "inset")( + Granter.opt(_.ModerateForum).option(property("id:", ask._id.value)), + (!me.is(ask.creator)).option(property("creator:", ask.creator.value)), + property("created at:", showInstant(ask.createdAt)), + ask.tags.nonEmpty.option(property("tags:", ask.tags.mkString(", "))), + ask.picks.map(p => (p.size > 0).option(property("responses:", p.size.toString))), + p, + askRender(ask) + ), + frag: + ask.form.map: fbmap => + frag( + property("form respondents:", fbmap.size.toString), + div(cls := "inset-box")( + fbmap.toSeq.map: + case (uid, fb) if uid.startsWith("anon-") => p(s"anon: $fb") + case (uid, fb) => p(s"$uid: $fb") + ) + ) + ) + + def property(name: String, value: String) = + div(cls := "prop")(div(cls := "name")(name), div(cls := "value")(value)) diff --git a/modules/ask/src/main/ui/AskUi.scala b/modules/ask/src/main/ui/AskUi.scala new file mode 100644 index 0000000000000..2c9d271df5795 --- /dev/null +++ b/modules/ask/src/main/ui/AskUi.scala @@ -0,0 +1,294 @@ +package lila.ask +package ui + +import scala.collection.mutable.StringBuilder +import scala.util.Random.shuffle + +import scalatags.Text.TypedTag +import lila.ui.{ *, given } +import ScalatagsTemplate.{ *, given } +import lila.core.id.AskId +import lila.core.ask.* + +final class AskUi(helpers: Helpers)(askApi: AskApi): + import helpers.{ *, given } + + def render(fragment: Frag)(using Context): Frag = + val ids = extractIds(fragment, Nil) + if ids.isEmpty then fragment + else + RawFrag: + askApi.bake( + fragment.render, + ids.map: id => + askApi.repo.get(id) match + case Some(ask) => + div(cls := s"ask-container${ask.isStretch.so(" stretch")}", renderOne(ask)).render + case _ => + p("").render + ) + + def renderOne(ask: Ask, prevView: Option[Vector[Int]] = None, tallyView: Boolean = false)(using + Context + ): Frag = + RenderAsk(ask, prevView, tallyView).render + + def renderGraph(ask: Ask)(using Context): Frag = + if ask.isRanked then RenderAsk(ask, None, true).rankGraphBody + else RenderAsk(ask, None, true).pollGraphBody + + def unfreeze(text: String): String = askApi.unfreeze(text) + + // AskApi.bake only has to support embedding in single fragments for all use cases + // but keep this recursion around for later + private def extractIds(fragment: Modifier, ids: List[AskId]): List[AskId] = fragment match + case StringFrag(s) => ids ++ AskApi.extractIds(s) + case RawFrag(f) => ids ++ AskApi.extractIds(f) + case t: TypedTag[?] => t.modifiers.flatten.foldLeft(ids)((acc, mod) => extractIds(mod, acc)) + case _ => ids + +private case class RenderAsk( + ask: Ask, + prevView: Option[Vector[Int]], + tallyView: Boolean +)(using ctx: Context): + val voterId = ctx.me.fold(ask.toAnon(ctx.ip))(me => ask.toAnon(me.userId)) + + val view = prevView.getOrElse: + if ask.isRandom then shuffle(ask.choices.indices.toList) + else ask.choices.indices.toList + + def render = + fieldset( + cls := s"ask${ask.isAnon.so(" anon")}", + id := ask._id, + ask.hasPickFor(voterId).option(value := "") + )( + header, + ask.isConcluded.option(label(s"${ask.form.so(_ size).max(ask.picks.so(_ size))} responses")), + ask.choices.nonEmpty.option( + if ask.isRanked then + if ask.isConcluded || tallyView then rankGraphBody + else rankBody + else if ask.isConcluded || tallyView then pollGraphBody + else pollBody + ), + footer + ) + + def header = + val viewParam = view.mkString("-") + legend( + span(cls := "ask__header")( + label( + ask.question, + (!tallyView).option( + if ask.isConcluded then span("(Results)") + else if ask.isRanked then span("(Drag to sort)") + else if ask.isMulti then span("(Choose all that apply)") + else span("(Choose one)") + ) + ), + maybeDiv( + "url-actions", + ask.isTally.option( + button( + cls := (if tallyView then "view" else "tally"), + formmethod := "GET", + formaction := routes.Ask.view(ask._id, viewParam.some, !tallyView) + ) + ), + (ctx.me.exists(_.userId == ask.creator) || Granter.opt(_.ModerateForum)).option( + button( + cls := "admin", + formmethod := "GET", + formaction := routes.Ask.admin(ask._id), + title := "Administrate this poll" + ) + ), + ((ask.hasPickFor(voterId) || ask.hasFormFor(voterId)) && !ask.isConcluded).option( + button( + cls := "unset", + formaction := routes.Ask.unset(ask._id, viewParam.some, ask.isAnon), + title := "Unset your submission" + ) + ) + ), + maybeDiv( + "properties", + ask.isTraceable.option( + button( + cls := "property trace", + title := "Participants can see who voted for what" + ) + ), + ask.isAnon.option( + button( + cls := "property anon", + title := "Your identity is anonymized and secure" + ) + ), + ask.isOpen.option(button(cls := "property open", title := "Anyone can participate")) + ) + ) + ) + + def footer = + div(cls := "ask__footer")( + ask.footer.map(label(_)), + (ask.isSubmit && !ask.isConcluded && voterId.nonEmpty).option( + frag( + ask.isForm.option( + input( + cls := "form-text", + tpe := "text", + maxlength := 80, + placeholder := "80 characters max", + value := ~ask.formFor(voterId) + ) + ), + div(cls := "form-submit")(input(cls := "button", tpe := "button", value := "Submit")) + ) + ), + (ask.isConcluded && ask.form.exists(_.size > 0)).option(frag: + ask.form.map: fmap => + div(cls := "form-results")( + ask.footer.map(label(_)), + fmap.toSeq.flatMap: + case (user, text) => Seq(div(ask.isTraceable.so(s"$user:")), div(text)) + )) + ) + + def pollBody = choiceContainer: + val picks = ask.picksFor(voterId) + val sb = StringBuilder("choice ") + if ask.isCheckbox then sb ++= "cbx " else sb ++= "btn " + if ask.isMulti then sb ++= "multiple " else sb ++= "exclusive " + if ask.isStretch then sb ++= "stretch " + view + .map(ask.choices) + .zipWithIndex + .map: + case (choiceText, choice) => + val selected = picks.exists(_ contains choice) + if ask.isCheckbox then + label( + cls := sb.toString + (if selected then "selected" else "enabled"), + title := tooltip(choice), + value := choice + )(input(tpe := "checkbox", selected.option(checked)), choiceText) + else + button( + cls := sb.toString + (if selected then "selected" else "enabled"), + title := tooltip(choice), + value := choice + )(choiceText) + + def rankBody = choiceContainer: + validRanking.zipWithIndex.map: + case (choice, index) => + val sb = StringBuilder("choice btn rank") + if ask.isStretch then sb ++= " stretch" + if ask.hasPickFor(voterId) then sb ++= " submitted" + div(cls := sb.toString, value := choice, draggable := true)( + div(s"${index + 1}"), + label(ask.choices(choice)), + i + ) + + def pollGraphBody = + div(cls := "ask__graph")(frag: + val totals = ask.totals + val max = totals.max + totals.zipWithIndex.flatMap: + case (total, choice) => + val pct = if max == 0 then 0 else total * 100 / max + val hint = tooltip(choice) + Seq( + div(title := hint)(ask.choices(choice)), + div(cls := "votes-text", title := hint)(pluralize("vote", total)), + div(cls := "set-width", title := hint, css("width") := s"$pct%")(nbsp) + )) + + def rankGraphBody = + div(cls := "ask__rank-graph")(frag: + val tooltipVec = rankedTooltips + ask.averageRank.zipWithIndex + .sortWith((i, j) => i._1 < j._1) + .flatMap: + case (avgIndex, choice) => + val lastIndex = ask.choices.size - 1 + val pct = (lastIndex - avgIndex) / lastIndex * 100 + val hint = tooltipVec(choice) + Seq( + div(title := hint)(ask.choices(choice)), + div(cls := "set-width", title := hint, style := s"width: $pct%")(nbsp) + )) + + def maybeDiv(clz: String, tags: Option[Frag]*) = + if tags.toList.flatten.nonEmpty then div(cls := clz, tags) else emptyFrag + + def choiceContainer = + val sb = StringBuilder("ask__choices") + if ask.isVertical then sb ++= " vertical" + if ask.isStretch then sb ++= " stretch" + div(cls := sb.toString) + + def tooltip(choice: Int) = + val sb = StringBuilder(256) + val choiceText = ask.choices(choice) + val hasPick = ask.hasPickFor(voterId) + + val count = ask.count(choiceText) + val isAuthor = ctx.me.exists(_.userId == ask.creator) + val isMod = Granter.opt(_.ModerateForum) + + if !ask.isRanked then + if ask.isConcluded || tallyView then + sb ++= pluralize("vote", count) + if ask.isTraceable || isMod then sb ++= s"\n\n${whoPicked(choice)}" + else + if isAuthor || ask.isTally then sb ++= pluralize("vote", count) + if ask.isTraceable && ask.isTally || isMod then sb ++= s"\n\n${whoPicked(choice)}" + + if sb.isEmpty then choiceText else sb.toString + + def rankedTooltips = + val respondents = ask.picks.so(picks => picks.size) + val rankM = ask.rankMatrix + val notables = List( + 0 -> "ranked this first", + 1 -> "chose this in their top two", + 2 -> "chose this in their top three", + 3 -> "chose this in their top four", + 4 -> "chose this in their top five" + ) + ask.choices.zipWithIndex.map: + case (choiceText, choice) => + val sb = StringBuilder(s"$choiceText:\n\n") + notables + .filter(_._1 < rankM.length - 1) + .map: + case (i, text) => + sb ++= s" ${rankM(choice)(i)} $text\n" + sb.toString + + def pluralize(item: String, n: Int) = + s"${if n == 0 then "No" else n} ${item}${if n != 1 then "s" else ""}" + + def whoPicked(choice: Int, max: Int = 100) = + val who = ask.whoPicked(choice) + if ask.isAnon then s"${who.size} votes" + else who.take(max).mkString("", ", ", (who.length > max).so(", and others...")) + + def validRanking = + val initialOrder = + if ask.isRandom then shuffle((0 until ask.choices.size).toVector) + else (0 until ask.choices.size).toVector + ask + .picksFor(voterId) + .fold(initialOrder): r => + if r == Vector.empty || r.distinct.sorted != initialOrder.sorted then + // voterId.so(id => env.ask.repo.setPicks(ask._id, id, Vector.empty[Int].some)) + initialOrder + else r diff --git a/modules/cms/src/main/CmsUi.scala b/modules/cms/src/main/CmsUi.scala index 5b7acd962f9b6..becbe2920090f 100644 --- a/modules/cms/src/main/CmsUi.scala +++ b/modules/cms/src/main/CmsUi.scala @@ -9,7 +9,7 @@ import lila.ui.* import ScalatagsTemplate.{ *, given } -final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag): +final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag, askRender: (Frag) => Context ?=> Frag): import helpers.{ *, given } def render(page: CmsPage.Render)(using Context): Frag = @@ -23,7 +23,7 @@ final class CmsUi(helpers: Helpers)(menu: Context ?=> Frag): "This draft is not published" ) ), - rawHtml(page.html) + askRender(rawHtml(page.html)) ) def render(p: CmsPage.RenderOpt)(using Context): Frag = diff --git a/modules/core/src/main/ask.scala b/modules/core/src/main/ask.scala new file mode 100644 index 0000000000000..40e955fe402f0 --- /dev/null +++ b/modules/core/src/main/ask.scala @@ -0,0 +1,216 @@ +package lila.core +package ask + +import alleycats.Zero + +import scalalib.extensions.{ *, given } +import lila.core.id.AskId +import lila.core.userId.* + +trait AskApi: + def freeze(text: String, creator: UserId): Frozen + def commit(frozen: Frozen, url: Option[String] = none[String]): Fu[Iterable[Ask]] + def freezeAndCommit(text: String, creator: UserId, url: Option[String] = none[String]): Fu[String] + def unfreezeAndLoad(text: String): Fu[String] + def unfreeze(text: String): String + def isOpen(aid: AskId): Fu[Boolean] + def bake(text: String, askFrags: Iterable[String]): String + val repo: AskRepo + +trait AskRepo: + def get(aid: AskId): Option[Ask] + def getAsync(aid: AskId): Fu[Option[Ask]] + def preload(text: String*): Fu[Boolean] + def setPicks(aid: AskId, vid: String, picks: Option[Vector[Int]]): Fu[Option[Ask]] + def setForm(aid: AskId, vid: String, form: Option[String]): Fu[Option[Ask]] + def unset(aid: AskId, vid: String): Fu[Option[Ask]] + def delete(aid: AskId): Funit + def conclude(aid: AskId): Fu[Option[Ask]] + def reset(aid: AskId): Fu[Option[Ask]] + def deleteAll(text: String): Funit + def asksIn(text: String): Fu[List[Option[Ask]]] + def isOpen(aid: AskId): Fu[Boolean] + def setUrl(text: String, url: Option[String]): Funit + +case class Frozen(text: String, asks: Iterable[Ask]) + +case class Ask( + _id: AskId, + question: String, + choices: Ask.Choices, + tags: Ask.Tags, + creator: UserId, + createdAt: java.time.Instant, + footer: Option[String], // optional text prompt for forms + picks: Option[Ask.Picks], + form: Option[Ask.Form], + url: Option[String] +): + + // changes to any of the fields checked in compatible will invalidate votes and form + def compatible(a: Ask): Boolean = + question == a.question && + choices == a.choices && + footer == a.footer && + creator == a.creator && + isOpen == a.isOpen && + isTraceable == a.isTraceable && + isAnon == a.isAnon && + isRanked == a.isRanked && + isMulti == a.isMulti + + def merge(dbAsk: Ask): Ask = + if this.compatible(dbAsk) then // keep votes & form + if tags.equals(dbAsk.tags) then dbAsk + else dbAsk.copy(tags = tags) + else copy(url = dbAsk.url) // discard votes & form + + def participants: Seq[String] = picks match + case Some(p) => p.keys.filter(!_.startsWith("anon-")).toSeq + case None => Nil + + lazy val isOpen = tags contains "open" // allow votes from anyone (no acct reqired) + lazy val isTraceable = tags.exists(_.startsWith("trace")) // everyone can see who voted for what + lazy val isAnon = !isTraceable && tags.exists(_.startsWith("anon")) // hide voters from creator/mods + lazy val isTally = isTraceable || tags.contains("tally") // partial results viewable before conclusion + lazy val isConcluded = tags contains "concluded" // closed poll + lazy val isRandom = tags.exists(_.startsWith("random")) // randomize order of choices + lazy val isMulti = !isRanked && tags.exists(_.startsWith("multi")) // multiple choices allowed + lazy val isRanked = tags.exists(_.startsWith("rank")) // drag to sort + lazy val isForm = tags.exists(_.startsWith("form")) // has a form/submit form + lazy val isStretch = tags.exists(_.startsWith("stretch")) // stretch to fill width + lazy val isVertical = tags.exists(_.startsWith("vert")) // one choice per row + lazy val isCheckbox = !isRanked && isVertical // use checkboxes + lazy val isSubmit = isForm || isRanked || tags.contains("submit") // has a submit button + + def toAnon(user: UserId): Option[String] = + Some(if isAnon then Ask.anonHash(user.value, _id) else user.value) + + def toAnon(ip: lila.core.net.IpAddress): Option[String] = + isOpen.option(Ask.anonHash(ip.toString, _id)) + + // eid = effective id, either a user id or an anonymous hash + def hasPickFor(o: Option[String]): Boolean = + o.fold(false)(eid => picks.exists(_.contains(eid))) + + def picksFor(o: Option[String]): Option[Vector[Int]] = + o.flatMap(eid => picks.flatMap(_.get(eid))) + + def firstPickFor(o: Option[String]): Option[Int] = + picksFor(o).flatMap(_.headOption) + + def hasFormFor(o: Option[String]): Boolean = + o.fold(false)(eid => form.exists(_.contains(eid))) + + def formFor(o: Option[String]): Option[String] = + o.flatMap(eid => form.flatMap(_.get(eid))) + + def count(choice: Int): Int = picks.fold(0)(_.values.count(_ contains choice)) + def count(choice: String): Int = count(choices.indexOf(choice)) + + def whoPicked(choice: String): List[String] = whoPicked(choices.indexOf(choice)) + def whoPicked(choice: Int): List[String] = picks + .getOrElse(Nil) + .collect: + case (uid, ls) if ls contains choice => uid + .toList + + def whoPickedAt(choice: Int, rank: Int): List[String] = picks + .getOrElse(Nil) + .collect: + case (uid, ls) if ls.indexOf(choice) == rank => uid + .toList + + @inline private def constrain(index: Int) = index.atMost(choices.size - 1).atLeast(0) + + def totals: Vector[Int] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val results = Array.ofDim[Int](choices.size) + pmap.values.foreach(_.foreach { it => results(constrain(it)) += 1 }) + results.toVector + case _ => + Vector.fill(choices.size)(0) + + // index of returned vector maps to choices list, values from [0f, choices.size-1f] where 0 is "best" rank + def averageRank: Vector[Float] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val results = Array.ofDim[Int](choices.size) + pmap.values.foreach: ranking => + for it <- choices.indices do results(constrain(ranking(it))) += it + results.map(_ / pmap.size.toFloat).toVector + case _ => + Vector.fill(choices.size)(0f) + + // an [n]x[n-1] matrix M describing response rankings. each element M[i][j] is the number of + // respondents who preferred the choice i at rank j or below (effectively in the top j+1 picks) + // the rightmost column is omitted as it is always equal to the number of respondents + def rankMatrix: Array[Array[Int]] = picks match + case Some(pmap) if choices.nonEmpty && pmap.nonEmpty => + val n = choices.size - 1 + val mat = Array.ofDim[Int](choices.size, n) + pmap.values.foreach: ranking => + for i <- choices.indices do + val iRank = ranking.indexOf(i) + for j <- iRank until n do mat(i)(j) += (if iRank <= j then 1 else 0) + mat + case _ => + Array.ofDim[Int](0, 0) + + def toJson: play.api.libs.json.JsObject = + play.api.libs.json.Json.obj( + "id" -> _id.value, + "question" -> question, + "choices" -> choices, + "tags" -> tags, + "creator" -> creator.value, + "created" -> createdAt.toString, + "footer" -> footer, + "picks" -> picks, + "form" -> form, + "url" -> url + ) + +object Ask: + + // type ID = AskId + type Tags = Set[String] + type Choices = Vector[String] + type Picks = Map[String, Vector[Int]] // ranked list of indices into Choices vector + type Form = Map[String, String] + + // https://www.unicode.org/faq/private_use.html + val frozenIdMagic = "\ufdd6\ufdd4\ufdd2\ufdd0" + val frozenIdRe = s"$frozenIdMagic\\{(\\S{8})}".r + + def make( + _id: Option[String], + question: String, + choices: Choices, + tags: Tags, + creator: UserId, + footer: Option[String], + url: Option[String] + ) = Ask( + _id = AskId(_id.getOrElse(scalalib.ThreadLocalRandom.nextString(8))), + question = question, + choices = choices, + tags = tags, + createdAt = java.time.Instant.now(), + creator = creator, + footer = footer, + picks = None, + form = None, + url = None + ) + + def strip(text: String, n: Int = -1): String = + frozenIdRe.replaceAllIn(text, "").take(if n == -1 then text.length else n) + + def anonHash(text: String, aid: AskId): String = + "anon-" + base64 + .encodeToString( + java.security.MessageDigest.getInstance("SHA-1").digest(s"$text-$aid".getBytes("UTF-8")) + ) + .substring(0, 11) + + private lazy val base64 = java.util.Base64.getEncoder().withoutPadding(); diff --git a/modules/core/src/main/id.scala b/modules/core/src/main/id.scala index 8aa08eac7daa4..16e20d0eb192a 100644 --- a/modules/core/src/main/id.scala +++ b/modules/core/src/main/id.scala @@ -97,6 +97,9 @@ object id: opaque type ChallengeId = String object ChallengeId extends OpaqueString[ChallengeId] + opaque type AskId = String + object AskId extends OpaqueString[AskId] + opaque type ClasId = String object ClasId extends OpaqueString[ClasId] diff --git a/modules/core/src/main/perm.scala b/modules/core/src/main/perm.scala index 2ef4ff8a7c425..bef9f55e8e97f 100644 --- a/modules/core/src/main/perm.scala +++ b/modules/core/src/main/perm.scala @@ -103,6 +103,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str case ApiHog extends Permission("API_HOG", "API hog") case ApiChallengeAdmin extends Permission("API_CHALLENGE_ADMIN", "API Challenge admin") case LichessTeam extends Permission("LICHESS_TEAM", List(Beta), "Lichess team") + case BotEditor extends Permission("BOT_EDITOR", "Bot editor") case TimeoutMod extends Permission( "TIMEOUT_MOD", @@ -188,6 +189,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str extends Permission( "ADMIN", List( + BotEditor, LichessTeam, UserSearch, PrizeBan, @@ -243,7 +245,7 @@ object Permission: val all: Set[Permission] = values.toSet val nonModPermissions: Set[Permission] = - Set(Beta, Coach, Teacher, Developer, Verified, ContentTeam, BroadcastTeam, ApiHog) + Set(Beta, Coach, Teacher, Developer, Verified, ContentTeam, BroadcastTeam, ApiHog, BotEditor) val modPermissions: Set[Permission] = all.diff(nonModPermissions) diff --git a/modules/core/src/main/timeline.scala b/modules/core/src/main/timeline.scala index d1e13425a310e..b873b31b66e8d 100644 --- a/modules/core/src/main/timeline.scala +++ b/modules/core/src/main/timeline.scala @@ -42,6 +42,9 @@ case class UblogPostLike(userId: UserId, id: UblogPostId, title: String) extends def userIds = List(userId) case class StreamStart(id: UserId, name: String) extends Atom("streamStart", false): def userIds = List(id) +case class AskConcluded(userId: UserId, question: String, askUrl: String) + extends Atom(s"askConcluded:${question}", false): + def userIds = List(userId) enum Propagation: case Users(users: List[UserId]) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index 3773d57ab9250..2ee7428a89bbe 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -1387,6 +1387,7 @@ object I18nKey: val `thisAccountIsClosed`: I18nKey = "settings:thisAccountIsClosed" object site: + val `askConcluded`: I18nKey = "askConcluded" val `playWithAFriend`: I18nKey = "playWithAFriend" val `playWithTheMachine`: I18nKey = "playWithTheMachine" val `toInviteSomeoneToPlayGiveThisUrl`: I18nKey = "toInviteSomeoneToPlayGiveThisUrl" diff --git a/modules/feed/src/main/Env.scala b/modules/feed/src/main/Env.scala index e966276230bf0..e9bd7f85a68b3 100644 --- a/modules/feed/src/main/Env.scala +++ b/modules/feed/src/main/Env.scala @@ -6,9 +6,12 @@ import lila.core.config.CollName import lila.core.lilaism.Lilaism.* @Module -final class Env(cacheApi: lila.memo.CacheApi, db: lila.db.Db, flairApi: lila.core.user.FlairApi)(using - Executor -): +final class Env( + cacheApi: lila.memo.CacheApi, + db: lila.db.Db, + flairApi: lila.core.user.FlairApi, + askApi: lila.core.ask.AskApi +)(using Executor): private val feedColl = db(CollName("daily_feed")) val api = wire[FeedApi] diff --git a/modules/feed/src/main/Feed.scala b/modules/feed/src/main/Feed.scala index 7ef94f28c1082..16fc95d0d80af 100644 --- a/modules/feed/src/main/Feed.scala +++ b/modules/feed/src/main/Feed.scala @@ -5,6 +5,8 @@ import reactivemongo.api.bson.* import reactivemongo.api.bson.Macros.Annotations.Key import java.time.format.{ DateTimeFormatter, FormatStyle } +import lila.core.ask.{ Ask, AskApi } +import lila.db.dsl.{ *, given } import lila.core.lilaism.Lilaism.* export lila.common.extensions.unapply @@ -41,7 +43,9 @@ object Feed: import scalalib.ThreadLocalRandom def makeId = ThreadLocalRandom.nextString(6) -final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi)(using Executor): +final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi, askApi: AskApi)(using + Executor +): import Feed.* @@ -68,10 +72,25 @@ final class FeedApi(coll: Coll, cacheApi: CacheApi, flairApi: FlairApi)(using Ex def recentPublished = cache.store.get({}).map(_.filter(_.published)) - def get(id: ID): Fu[Option[Update]] = coll.byId[Update](id) - - def set(update: Update): Funit = - for _ <- coll.update.one($id(update.id), update, upsert = true) yield cache.clear() + def get(id: ID): Fu[Option[Update]] = coll + .byId[Update](id) + .flatMap: + case Some(up) => askApi.repo.preload(up.content.value).inject(up.some) + case _ => fuccess(none[Update]) + + def edit(id: ID): Fu[Option[Update]] = get(id).flatMap: + case Some(up) => + askApi + .unfreezeAndLoad(up.content.value) + .map: text => + up.copy(content = Markdown(text.pp)).some + case _ => fuccess(none[Update]) + + def set(update: Update)(using me: Me): Funit = + for + text <- askApi.freezeAndCommit(update.content.value, me, s"/feed#${update.id}".some) + _ <- coll.update.one($id(update.id), update.copy(content = Markdown(text)), upsert = true) + yield cache.clear() def delete(id: ID): Funit = for _ <- coll.delete.one($id(id)) yield cache.clear() diff --git a/modules/feed/src/main/FeedUi.scala b/modules/feed/src/main/FeedUi.scala index 10031b642e32f..86ca9da0ba76b 100644 --- a/modules/feed/src/main/FeedUi.scala +++ b/modules/feed/src/main/FeedUi.scala @@ -9,25 +9,28 @@ import lila.ui.{ *, given } import ScalatagsTemplate.{ *, given } final class FeedUi(helpers: Helpers, atomUi: AtomUi)( - sitePage: String => Context ?=> Page + sitePage: String => Context ?=> Page, + askRender: (Frag) => Context ?=> Frag )(using Executor): import helpers.{ *, given } - private def renderCache[A](ttl: FiniteDuration)(toFrag: A => Frag): A => Frag = - val cache = lila.memo.CacheApi.scaffeineNoScheduler - .expireAfterWrite(ttl) - .build[A, String]() - from => raw(cache.get(from, from => toFrag(from).render)) + // private def renderCache[A](ttl: FiniteDuration)(toFrag: A => Frag): A => Frag = + // val cache = lila.memo.CacheApi.scaffeineNoScheduler + // .expireAfterWrite(1 minute) + // .build[A, String]() + // from => raw(cache.get(from, from => toFrag(from).render)) - private def page(title: String, edit: Boolean = false)(using Context): Page = + private def page(title: String, hasAsks: Boolean, edit: Boolean = false)(using Context): Page = sitePage(title) .css("bits.dailyFeed") + .css(hasAsks.option("bits.ask")) .js(infiniteScrollEsmInit) .js(edit.option(Esm("bits.flatpickr"))) .js(edit.option(esmInitBit("dailyFeed"))) + .js(hasAsks.option(esmInit("bits.ask"))) - def index(ups: Paginator[Feed.Update])(using Context) = - page("Updates"): + def index(ups: Paginator[Feed.Update], hasAsks: Boolean)(using Context) = + page("Updates", hasAsks): div(cls := "daily-feed box box-pad")( boxTop( h1("Lichess updates"), @@ -48,28 +51,28 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( updates(ups, editor = Granter.opt(_.Feed)) ) - val lobbyUpdates = renderCache[List[Feed.Update]](1.minute): ups => + def lobbyUpdates(ups: List[Feed.Update])(using Context) = div(cls := "daily-feed__updates")( ups.map: update => div(cls := "daily-feed__update")( marker(update.flair), div( - a(cls := "daily-feed__update__day", href := s"/feed#${update.id}"): + a(cls := "daily-feed__update__day", href := s"${routes.Feed.index(1)}#${update.id}"): momentFromNow(update.at) , - rawHtml(update.rendered) + askRender(rawHtml(update.rendered)) ) ), div(cls := "daily-feed__update")( marker(), div: - a(cls := "daily-feed__update__day", href := "/feed"): + a(cls := "daily-feed__update__day", href := routes.Feed.index(1)): "All updates »" ) ) def create(form: Form[?])(using Context) = - page("Lichess updates: New", true): + page("Lichess updates: New", true, true): main(cls := "daily-feed page-small box box-pad")( boxTop( h1( @@ -83,7 +86,7 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( ) def edit(form: Form[?], update: Feed.Update)(using Context) = - page(s"Lichess update ${update.id}", true): + page(s"Lichess update ${update.id}", true, true): main(cls := "daily-feed page-small")( div(cls := "box box-pad")( boxTop( @@ -110,13 +113,16 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( title = "Lichess updates feed", updated = ups.headOption.map(_.at) ): up => + val url = s"$netBaseUrl${routes.Feed.index(1)}#${up.id}" frag( - tag("id")(up.id), + tag("id")(url), + tag("author")(tag("name")("Lichess")), tag("published")(atomUi.atomDate(up.at)), + tag("updated")(atomUi.atomDate(up.at)), link( rel := "alternate", tpe := "text/html", - href := s"$netBaseUrl${routes.Feed.index(1)}#${up.id}" + href := url ), tag("title")(up.title), tag("content")(tpe := "html")(convertToAbsoluteHrefs(up.rendered)) @@ -147,7 +153,9 @@ final class FeedUi(helpers: Helpers, atomUi: AtomUi)( ) ) ), - div(cls := "daily-feed__update__markup")(rawHtml(update.rendered)) + div(cls := "daily-feed__update__markup")( + askRender(rawHtml(update.rendered)) + ) ) ), pagerNext(ups, np => routes.Feed.index(np).url) diff --git a/modules/fide/src/main/Federation.scala b/modules/fide/src/main/Federation.scala index ddc3261e759bc..c28936dc0ba92 100644 --- a/modules/fide/src/main/Federation.scala +++ b/modules/fide/src/main/Federation.scala @@ -45,207 +45,210 @@ object Federation: nameToSlug(name) -> id val names: Map[Id, Name] = Map( - Id("FID") -> "FIDE", - Id("USA") -> "United States of America", - Id("IND") -> "India", - Id("CHN") -> "China", - Id("RUS") -> "Russia", - Id("AZE") -> "Azerbaijan", - Id("FRA") -> "France", - Id("UKR") -> "Ukraine", - Id("ARM") -> "Armenia", - Id("GER") -> "Germany", - Id("ESP") -> "Spain", - Id("NED") -> "Netherlands", - Id("HUN") -> "Hungary", - Id("POL") -> "Poland", - Id("ENG") -> "England", - Id("ROU") -> "Romania", - Id("NOR") -> "Norway", - Id("UZB") -> "Uzbekistan", - Id("ISR") -> "Israel", - Id("CZE") -> "Czech Republic", - Id("SRB") -> "Serbia", - Id("CRO") -> "Croatia", - Id("GRE") -> "Greece", - Id("IRI") -> "Iran", - Id("TUR") -> "Turkiye", - Id("SLO") -> "Slovenia", + Id("AFG") -> "Afghanistan", + Id("AHO") -> "Netherlands Antilles", + Id("ALB") -> "Albania", + Id("ALG") -> "Algeria", + Id("AND") -> "Andorra", + Id("ANG") -> "Angola", + Id("ANT") -> "Antigua and Barbuda", Id("ARG") -> "Argentina", - Id("SWE") -> "Sweden", - Id("GEO") -> "Georgia", - Id("ITA") -> "Italy", - Id("CUB") -> "Cuba", - Id("AUT") -> "Austria", - Id("PER") -> "Peru", - Id("BUL") -> "Bulgaria", - Id("BRA") -> "Brazil", - Id("DEN") -> "Denmark", - Id("SUI") -> "Switzerland", - Id("CAN") -> "Canada", - Id("SVK") -> "Slovakia", - Id("LTU") -> "Lithuania", - Id("VIE") -> "Vietnam", + Id("ARM") -> "Armenia", + Id("ARU") -> "Aruba", Id("AUS") -> "Australia", + Id("AUT") -> "Austria", + Id("AZE") -> "Azerbaijan", + Id("BAH") -> "Bahamas", + Id("BAN") -> "Bangladesh", + Id("BAR") -> "Barbados", + Id("BDI") -> "Burundi", Id("BEL") -> "Belgium", - Id("MNE") -> "Montenegro", - Id("MDA") -> "Moldova", - Id("KAZ") -> "Kazakhstan", - Id("ISL") -> "Iceland", - Id("COL") -> "Colombia", + Id("BER") -> "Bermuda", + Id("BHU") -> "Bhutan", Id("BIH") -> "Bosnia & Herzegovina", - Id("EGY") -> "Egypt", - Id("FIN") -> "Finland", - Id("MGL") -> "Mongolia", - Id("PHI") -> "Philippines", + Id("BIZ") -> "Belize", Id("BLR") -> "Belarus", - Id("LAT") -> "Latvia", - Id("POR") -> "Portugal", + Id("BOL") -> "Bolivia", + Id("BOT") -> "Botswana", + Id("BRA") -> "Brazil", + Id("BRN") -> "Bahrain", + Id("BRU") -> "Brunei Darussalam", + Id("BUL") -> "Bulgaria", + Id("BUR") -> "Burkina Faso", + Id("CAF") -> "Central African Republic", + Id("CAM") -> "Cambodia", + Id("CAN") -> "Canada", + Id("CAY") -> "Cayman Islands", + Id("CGO") -> "Congo", + Id("CHA") -> "Chad", Id("CHI") -> "Chile", - Id("MEX") -> "Mexico", - Id("MKD") -> "North Macedonia", - Id("INA") -> "Indonesia", - Id("PAR") -> "Paraguay", - Id("EST") -> "Estonia", - Id("SGP") -> "Singapore", - Id("SCO") -> "Scotland", - Id("VEN") -> "Venezuela", - Id("IRL") -> "Ireland", - Id("URU") -> "Uruguay", - Id("TKM") -> "Turkmenistan", - Id("MAR") -> "Morocco", - Id("MAS") -> "Malaysia", - Id("BAN") -> "Bangladesh", - Id("ALG") -> "Algeria", - Id("RSA") -> "South Africa", - Id("AND") -> "Andorra", - Id("ALB") -> "Albania", - Id("KGZ") -> "Kyrgyzstan", - Id("KOS") -> "Kosovo *", - Id("FAI") -> "Faroe Islands", - Id("ZAM") -> "Zambia", - Id("MYA") -> "Myanmar", - Id("NZL") -> "New Zealand", - Id("ECU") -> "Ecuador", + Id("CHN") -> "China", + Id("CIV") -> "Cote d’Ivoire", + Id("CMR") -> "Cameroon", + Id("COD") -> "Democratic Republic of the Congo", + Id("COL") -> "Colombia", + Id("COM") -> "Comoros Islands", + Id("CPV") -> "Cape Verde", Id("CRC") -> "Costa Rica", - Id("NGR") -> "Nigeria", - Id("JPN") -> "Japan", - Id("SYR") -> "Syria", + Id("CRO") -> "Croatia", + Id("CUB") -> "Cuba", + Id("CYP") -> "Cyprus", + Id("CZE") -> "Czech Republic", + Id("DEN") -> "Denmark", + Id("DJI") -> "Djibouti", + Id("DMA") -> "Dominica", Id("DOM") -> "Dominican Republic", - Id("LUX") -> "Luxembourg", - Id("WLS") -> "Wales", - Id("BOL") -> "Bolivia", - Id("TUN") -> "Tunisia", - Id("UAE") -> "United Arab Emirates", - Id("MNC") -> "Monaco", - Id("TJK") -> "Tajikistan", - Id("PAN") -> "Panama", - Id("LBN") -> "Lebanon", - Id("NCA") -> "Nicaragua", + Id("ECU") -> "Ecuador", + Id("EGY") -> "Egypt", + Id("ENG") -> "England", + Id("ERI") -> "Eritrea", Id("ESA") -> "El Salvador", - Id("ANG") -> "Angola", - Id("TTO") -> "Trinidad & Tobago", - Id("SRI") -> "Sri Lanka", + Id("ESP") -> "Spain", + Id("EST") -> "Estonia", + Id("ETH") -> "Ethiopia", + Id("FAI") -> "Faroe Islands", + Id("FID") -> "FIDE", + Id("FIJ") -> "Fiji", + Id("FIN") -> "Finland", + Id("FRA") -> "France", + Id("GAB") -> "Gabon", + Id("GAM") -> "Gambia", + Id("GCI") -> "Guernsey", + Id("GEO") -> "Georgia", + Id("GEQ") -> "Equatorial Guinea", + Id("GER") -> "Germany", + Id("GHA") -> "Ghana", + Id("GRE") -> "Greece", + Id("GRL") -> "Greenland", + Id("GRN") -> "Grenada", + Id("GUA") -> "Guatemala", + Id("GUM") -> "Guam", + Id("GUY") -> "Guyana", + Id("HAI") -> "Haiti", + Id("HKG") -> "Hong Kong, China", + Id("HON") -> "Honduras", + Id("HUN") -> "Hungary", + Id("INA") -> "Indonesia", + Id("IND") -> "India", + Id("IOM") -> "Isle of Man", + Id("IRI") -> "Iran", + Id("IRL") -> "Ireland", Id("IRQ") -> "Iraq", + Id("ISL") -> "Iceland", + Id("ISR") -> "Israel", + Id("ISV") -> "US Virgin Islands", + Id("ITA") -> "Italy", + Id("IVB") -> "British Virgin Islands", + Id("JAM") -> "Jamaica", + Id("JCI") -> "Jersey", Id("JOR") -> "Jordan", - Id("UGA") -> "Uganda", - Id("MAD") -> "Madagascar", - Id("ZIM") -> "Zimbabwe", - Id("MLT") -> "Malta", - Id("SUD") -> "Sudan", + Id("JPN") -> "Japan", + Id("KAZ") -> "Kazakhstan", + Id("KEN") -> "Kenya", + Id("KGZ") -> "Kyrgyzstan", Id("KOR") -> "South Korea", - Id("PUR") -> "Puerto Rico", - Id("HON") -> "Honduras", - Id("GUA") -> "Guatemala", - Id("PAK") -> "Pakistan", - Id("JAM") -> "Jamaica", - Id("THA") -> "Thailand", - Id("YEM") -> "Yemen", + Id("KOS") -> "Kosovo *", + Id("KSA") -> "Saudi Arabia", + Id("KUW") -> "Kuwait", + Id("LAO") -> "Laos", + Id("LAT") -> "Latvia", Id("LBA") -> "Libya", - Id("CYP") -> "Cyprus", - Id("NEP") -> "Nepal", - Id("HKG") -> "Hong Kong, China", - Id("SSD") -> "South Sudan", - Id("BOT") -> "Botswana", - Id("PLE") -> "Palestine", - Id("KEN") -> "Kenya", - Id("AHO") -> "Netherlands Antilles", - Id("MAW") -> "Malawi", + Id("LBN") -> "Lebanon", + Id("LBR") -> "Liberia", + Id("LCA") -> "Saint Lucia", + Id("LES") -> "Lesotho", Id("LIE") -> "Liechtenstein", - Id("TPE") -> "Chinese Taipei", - Id("AFG") -> "Afghanistan", + Id("LTU") -> "Lithuania", + Id("LUX") -> "Luxembourg", + Id("MAC") -> "Macau", + Id("MAD") -> "Madagascar", + Id("MAR") -> "Morocco", + Id("MAS") -> "Malaysia", + Id("MAW") -> "Malawi", + Id("MDA") -> "Moldova", + Id("MDV") -> "Maldives", + Id("MEX") -> "Mexico", + Id("MGL") -> "Mongolia", + Id("MKD") -> "North Macedonia", + Id("MLI") -> "Mali", + Id("MLT") -> "Malta", + Id("MNC") -> "Monaco", + Id("MNE") -> "Montenegro", Id("MOZ") -> "Mozambique", - Id("KSA") -> "Saudi Arabia", - Id("BAR") -> "Barbados", - Id("NAM") -> "Namibia", - Id("HAI") -> "Haiti", - Id("ARU") -> "Aruba", - Id("CIV") -> "Cote d’Ivoire", - Id("CPV") -> "Cape Verde", - Id("SUR") -> "Suriname", - Id("LBR") -> "Liberia", - Id("IOM") -> "Isle of Man", - Id("MTN") -> "Mauritania", - Id("BRN") -> "Bahrain", - Id("GHA") -> "Ghana", - Id("OMA") -> "Oman", - Id("BRU") -> "Brunei Darussalam", - Id("GCI") -> "Guernsey", - Id("GUM") -> "Guam", - Id("KUW") -> "Kuwait", - Id("JCI") -> "Jersey", Id("MRI") -> "Mauritius", - Id("SEN") -> "Senegal", - Id("BAH") -> "Bahamas", - Id("MDV") -> "Maldives", + Id("MTN") -> "Mauritania", + Id("MYA") -> "Myanmar", + Id("NAM") -> "Namibia", + Id("NCA") -> "Nicaragua", + Id("NCL") -> "New Caledonia", + Id("NED") -> "Netherlands", + Id("NEP") -> "Nepal", + Id("NGR") -> "Nigeria", + Id("NIG") -> "Niger", + Id("NOR") -> "Norway", Id("NRU") -> "Nauru", - Id("TOG") -> "Togo", - Id("FIJ") -> "Fiji", + Id("NZL") -> "New Zealand", + Id("OMA") -> "Oman", + Id("PAK") -> "Pakistan", + Id("PAN") -> "Panama", + Id("PAR") -> "Paraguay", + Id("PER") -> "Peru", + Id("PHI") -> "Philippines", + Id("PLE") -> "Palestine", Id("PLW") -> "Palau", - Id("GUY") -> "Guyana", - Id("LES") -> "Lesotho", - Id("CAY") -> "Cayman Islands", - Id("SOM") -> "Somalia", - Id("SWZ") -> "Eswatini", - Id("TAN") -> "Tanzania", - Id("LCA") -> "Saint Lucia", - Id("ISV") -> "US Virgin Islands", - Id("SLE") -> "Sierra Leone", - Id("BER") -> "Bermuda", - Id("SMR") -> "San Marino", - Id("BDI") -> "Burundi", - Id("QAT") -> "Qatar", - Id("ETH") -> "Ethiopia", - Id("DJI") -> "Djibouti", - Id("SEY") -> "Seychelles", Id("PNG") -> "Papua New Guinea", - Id("DMA") -> "Dominica", - Id("STP") -> "Sao Tome and Principe", - Id("MAC") -> "Macau", - Id("CAM") -> "Cambodia", - Id("VIN") -> "Saint Vincent and the Grenadines", - Id("BUR") -> "Burkina Faso", - Id("COM") -> "Comoros Islands", - Id("GAB") -> "Gabon", + Id("POL") -> "Poland", + Id("POR") -> "Portugal", + Id("PUR") -> "Puerto Rico", + Id("QAT") -> "Qatar", + Id("ROU") -> "Romania", + Id("RSA") -> "South Africa", + Id("RUS") -> "Russia", Id("RWA") -> "Rwanda", - Id("CMR") -> "Cameroon", - Id("MLI") -> "Mali", - Id("ANT") -> "Antigua and Barbuda", - Id("CHA") -> "Chad", - Id("GAM") -> "Gambia", - Id("COD") -> "Democratic Republic of the Congo", + Id("SCO") -> "Scotland", + Id("SEN") -> "Senegal", + Id("SEY") -> "Seychelles", + Id("SGP") -> "Singapore", Id("SKN") -> "Saint Kitts and Nevis", - Id("BHU") -> "Bhutan", - Id("NIG") -> "Niger", - Id("GRN") -> "Grenada", - Id("BIZ") -> "Belize", - Id("CAF") -> "Central African Republic", - Id("ERI") -> "Eritrea", - Id("GEQ") -> "Equatorial Guinea", - Id("IVB") -> "British Virgin Islands", - Id("LAO") -> "Laos", + Id("SLE") -> "Sierra Leone", + Id("SLO") -> "Slovenia", + Id("SMR") -> "San Marino", Id("SOL") -> "Solomon Islands", + Id("SOM") -> "Somalia", + Id("SRB") -> "Serbia", + Id("SRI") -> "Sri Lanka", + Id("SSD") -> "South Sudan", + Id("STP") -> "Sao Tome and Principe", + Id("SUD") -> "Sudan", + Id("SUI") -> "Switzerland", + Id("SUR") -> "Suriname", + Id("SVK") -> "Slovakia", + Id("SWE") -> "Sweden", + Id("SWZ") -> "Eswatini", + Id("SYR") -> "Syria", + Id("TAN") -> "Tanzania", Id("TGA") -> "Tonga", + Id("THA") -> "Thailand", + Id("TJK") -> "Tajikistan", + Id("TKM") -> "Turkmenistan", Id("TLS") -> "Timor-Leste", - Id("VAN") -> "Vanuatu" + Id("TOG") -> "Togo", + Id("TPE") -> "Chinese Taipei", + Id("TTO") -> "Trinidad & Tobago", + Id("TUN") -> "Tunisia", + Id("TUR") -> "Turkiye", + Id("UAE") -> "United Arab Emirates", + Id("UGA") -> "Uganda", + Id("UKR") -> "Ukraine", + Id("URU") -> "Uruguay", + Id("USA") -> "United States of America", + Id("UZB") -> "Uzbekistan", + Id("VAN") -> "Vanuatu", + Id("VEN") -> "Venezuela", + Id("VIE") -> "Vietnam", + Id("VIN") -> "Saint Vincent and the Grenadines", + Id("WLS") -> "Wales", + Id("YEM") -> "Yemen", + Id("ZAM") -> "Zambia", + Id("ZIM") -> "Zimbabwe" ) diff --git a/modules/fide/src/main/FidePlayerSync.scala b/modules/fide/src/main/FidePlayerSync.scala index 9f6e02e7e4ca2..640e54ccbe1fb 100644 --- a/modules/fide/src/main/FidePlayerSync.scala +++ b/modules/fide/src/main/FidePlayerSync.scala @@ -135,6 +135,7 @@ final private class FidePlayerSync(repo: FideRepo, ws: StandaloneWSClient)(using def number(start: Int, end: Int) = string(start, end).flatMap(_.toIntOption) def rating(start: Int) = Elo.from(number(start, start + 4).filter(_ >= 1400)) def kFactor(start: Int) = KFactor.from(number(start, start + 2).filter(_ > 0)) + val nowYear = nowDateTime.getYear for id <- number(0, 15) name1 <- string(15, 76) @@ -142,7 +143,7 @@ final private class FidePlayerSync(repo: FideRepo, ws: StandaloneWSClient)(using if name.sizeIs > 2 title = string(84, 89).flatMap(PlayerTitle.get) wTitle = string(89, 105).flatMap(PlayerTitle.get) - year = number(152, 156).filter(_ > 1000) + year = number(152, 156).filter(_ > 1000).filter(_ < nowYear) flags = string(158, 160) token = FidePlayer.tokenize(name) if token.sizeIs > 2 diff --git a/modules/fide/src/main/ui/FideUi.scala b/modules/fide/src/main/ui/FideUi.scala index 98f463f41d0d8..c9bc2cbe2b7fa 100644 --- a/modules/fide/src/main/ui/FideUi.scala +++ b/modules/fide/src/main/ui/FideUi.scala @@ -94,7 +94,7 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): def flag(id: lila.core.fide.Federation.Id, title: Option[String]) = img( cls := "flag", st.title := title.getOrElse(id.value), - src := assetUrl(s"images/fide-fed/${id}.svg") + src := assetUrl(s"images/fide-fed-webp/${id}.webp") ) private def card(name: Frag, value: Frag) = diff --git a/modules/forum/src/main/Env.scala b/modules/forum/src/main/Env.scala index 5316bb0adae50..eab27960402ba 100644 --- a/modules/forum/src/main/Env.scala +++ b/modules/forum/src/main/Env.scala @@ -30,7 +30,8 @@ final class Env( userApi: lila.core.user.UserApi, teamApi: lila.core.team.TeamApi, cacheApi: lila.memo.CacheApi, - ws: StandaloneWSClient + ws: StandaloneWSClient, + askApi: lila.core.ask.AskApi )(using Executor, Scheduler, akka.stream.Materializer): private val config = appConfig.get[ForumConfig]("forum")(AutoConfig.loader) diff --git a/modules/forum/src/main/ForumExpand.scala b/modules/forum/src/main/ForumExpand.scala index 44709f6121977..bfc67555aaef6 100644 --- a/modules/forum/src/main/ForumExpand.scala +++ b/modules/forum/src/main/ForumExpand.scala @@ -5,22 +5,19 @@ import scalatags.Text.all.{ Frag, raw } import lila.common.RawHtml import lila.core.config.NetDomain -final class ForumTextExpand(using Executor, Scheduler): +final class ForumTextExpand(askApi: lila.core.ask.AskApi)(using Executor, Scheduler): - private def one(text: String)(using NetDomain): Fu[Frag] = + private def one(post: ForumPost)(using NetDomain): Fu[ForumPost.WithFrag] = lila.common.Bus - .ask("lpv")(lila.core.misc.lpv.LpvLinkRenderFromText(text, _)) + .ask("lpv")(lila.core.misc.lpv.LpvLinkRenderFromText(post.text, _)) .map: linkRender => raw: RawHtml.nl2br { - RawHtml.addLinks(text, expandImg = true, linkRender = linkRender.some).value + RawHtml.addLinks(post.text, expandImg = true, linkRender = linkRender.some).value }.value + .zip(askApi.repo.preload(post.text)) + .map: (body, _) => + ForumPost.WithFrag(post, body) def manyPosts(posts: Seq[ForumPost])(using NetDomain): Fu[Seq[ForumPost.WithFrag]] = - posts.view - .map(_.text) - .toList - .sequentially(one) - .map: - _.zip(posts).map: (body, post) => - ForumPost.WithFrag(post, body) + posts.traverse(one) diff --git a/modules/forum/src/main/ForumForm.scala b/modules/forum/src/main/ForumForm.scala index d287f0f0b7949..0981571167c25 100644 --- a/modules/forum/src/main/ForumForm.scala +++ b/modules/forum/src/main/ForumForm.scala @@ -46,13 +46,12 @@ final private[forum] class ForumForm( single("categ" -> nonEmptyText.into[ForumCategId]) private def userTextMapping(inOwnTeam: Boolean, previousText: Option[String] = None)(using me: Me) = - cleanText(minLength = 3, 20_000) + cleanText(minLength = 3, 10_000_000) // bot move dumps .verifying( "You have reached the daily maximum for links in forum posts.", t => inOwnTeam || promotion.test(me, t, previousText) ) - - val diagnostic = Form(single("text" -> nonEmptyText(maxLength = 100_000))) + val diagnostic = Form(single("text" -> nonEmptyText(maxLength = 10_000_000))) // bot move dumps object ForumForm: diff --git a/modules/forum/src/main/ForumPostApi.scala b/modules/forum/src/main/ForumPostApi.scala index 633ae88af3636..6a784a81d6521 100644 --- a/modules/forum/src/main/ForumPostApi.scala +++ b/modules/forum/src/main/ForumPostApi.scala @@ -17,7 +17,8 @@ final class ForumPostApi( spam: lila.core.security.SpamApi, promotion: lila.core.security.PromotionApi, shutupApi: lila.core.shutup.ShutupApi, - detectLanguage: DetectLanguage + detectLanguage: DetectLanguage, + askApi: lila.core.ask.AskApi )(using Executor)(using scheduler: Scheduler) extends lila.core.forum.ForumPostApi: @@ -32,10 +33,11 @@ final class ForumPostApi( val publicMod = MasterGranter(_.PublicMod) val modIcon = ~data.modIcon && (publicMod || MasterGranter(_.SeeReport)) val anonMod = modIcon && !publicMod + val frozen = askApi.freeze(spam.replace(data.text), me) val post = ForumPost.make( topicId = topic.id, userId = (!anonMod).option(me), - text = spam.replace(data.text), + text = frozen.text, number = topic.nbPosts + 1, lang = lang.map(_.language), troll = me.marks.troll, @@ -49,6 +51,7 @@ final class ForumPostApi( _ <- postRepo.coll.insert.one(post) _ <- topicRepo.coll.update.one($id(topic.id), topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) + _ <- askApi.commit(frozen, s"/forum/redirect/post/${post.id}".some) yield promotion.save(me, post.text) if post.isTeam @@ -83,13 +86,16 @@ final class ForumPostApi( case (_, post) if !post.canStillBeEdited => fufail("Post can no longer be edited") case (_, post) => - val newPost = post.editPost(nowInstant, spam.replace(newText)) - val save = (newPost.text != post.text).so: - for - _ <- postRepo.coll.update.one($id(post.id), newPost) - _ <- newPost.isAnonModPost.so(logAnonPost(newPost, edit = true)) - yield promotion.save(me, newPost.text) - save.inject(newPost) + askApi + .freezeAndCommit(spam.replace(newText), me, s"/forum/redirect/post/${postId}".some) + .flatMap: frozen => + val newPost = post.editPost(nowInstant, frozen) + val save = (newPost.text != post.text).so: + for + _ <- postRepo.coll.update.one($id(post.id), newPost) + _ <- newPost.isAnonModPost.so(logAnonPost(newPost, edit = true)) + yield promotion.save(me, newPost.text) + save.inject(newPost) } def urlData(postId: ForumPostId, forUser: Option[User]): Fu[Option[PostUrlData]] = diff --git a/modules/forum/src/main/ForumTopicApi.scala b/modules/forum/src/main/ForumTopicApi.scala index fed628060f46c..770f12a260c6c 100644 --- a/modules/forum/src/main/ForumTopicApi.scala +++ b/modules/forum/src/main/ForumTopicApi.scala @@ -25,7 +25,8 @@ final private class ForumTopicApi( shutupApi: lila.core.shutup.ShutupApi, detectLanguage: DetectLanguage, cacheApi: CacheApi, - relationApi: lila.core.relation.RelationApi + relationApi: lila.core.relation.RelationApi, + askApi: lila.core.ask.AskApi )(using Executor): import BSONHandlers.given @@ -82,6 +83,7 @@ final private class ForumTopicApi( data: ForumForm.TopicData )(using me: Me): Fu[ForumTopic] = topicRepo.nextSlug(categ, data.name).zip(detectLanguage(data.post.text)).flatMap { (slug, lang) => + val frozen = askApi.freeze(spam.replace(data.post.text), me) val topic = ForumTopic.make( categId = categ.id, slug = slug, @@ -93,7 +95,7 @@ final private class ForumTopicApi( topicId = topic.id, userId = me.some, troll = me.marks.troll, - text = spam.replace(data.post.text), + text = frozen.text, lang = lang.map(_.language), number = 1, categId = categ.id, @@ -106,6 +108,7 @@ final private class ForumTopicApi( _ <- topicRepo.coll.insert.one(topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) _ <- postRepo.coll.insert.one(post) + _ <- askApi.commit(frozen, s"/forum/redirect/post/${post.id}".some) yield promotion.save(me, post.text) val text = s"${topic.name} ${post.text}" diff --git a/modules/forum/src/main/model.scala b/modules/forum/src/main/model.scala index e97097d7cf0b8..8a766c5e682f2 100644 --- a/modules/forum/src/main/model.scala +++ b/modules/forum/src/main/model.scala @@ -32,7 +32,7 @@ case class TopicView( def createdAt = topic.createdAt case class PostView(post: ForumPost, topic: ForumTopic, categ: ForumCateg): - def show = post.showUserIdOrAuthor + " @ " + topic.name + " - " + post.text.take(80) + def show = post.showUserIdOrAuthor + " @ " + topic.name + " - " + lila.core.ask.Ask.strip(post.text, 80) def logFormatted = "%s / %s#%s / %s".format(categ.name, topic.name, post.number, post.text) object PostView: diff --git a/modules/forum/src/main/ui/PostUi.scala b/modules/forum/src/main/ui/PostUi.scala index 1aef5e1f2a324..7771c34a2a3db 100644 --- a/modules/forum/src/main/ui/PostUi.scala +++ b/modules/forum/src/main/ui/PostUi.scala @@ -7,7 +7,10 @@ import lila.ui.* import ScalatagsTemplate.{ *, given } -final class PostUi(helpers: Helpers, bits: ForumBits): +final class PostUi(helpers: Helpers, bits: ForumBits)( + askRender: (Frag) => Context ?=> Frag, + unfreeze: String => String +): import helpers.{ *, given } def show( @@ -102,7 +105,7 @@ final class PostUi(helpers: Helpers, bits: ForumBits): frag: val postFrag = div(cls := s"forum-post__message expand-text")( if post.erased then "" - else body + else askRender(body) ) if hide then div(cls := "forum-post__blocked")( @@ -124,15 +127,13 @@ final class PostUi(helpers: Helpers, bits: ForumBits): cls := "post-text-area edit-post-box", minlength := 3, required - )(post.text), + )(unfreeze(post.text)), div(cls := "edit-buttons")( a( cls := "edit-post-cancel", href := routes.ForumPost.redirect(post.id), style := "margin-left:20px" - ): - trans.site.cancel() - , + )(trans.site.cancel()), submitButton(cls := "button")(trans.site.apply()) ) ) diff --git a/modules/forum/src/main/ui/TopicUi.scala b/modules/forum/src/main/ui/TopicUi.scala index f97913ddb0a59..cb3c7b61c6444 100644 --- a/modules/forum/src/main/ui/TopicUi.scala +++ b/modules/forum/src/main/ui/TopicUi.scala @@ -82,7 +82,8 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( unsub: Option[Boolean], canModCateg: Boolean, formText: Option[String] = None, - replyBlocked: Boolean = false + replyBlocked: Boolean = false, + hasAsks: Boolean = false )(using ctx: Context) = val isDiagnostic = categ.isDiagnostic && (canModCateg || ctx.me.exists(topic.isAuthor)) val headerText = if isDiagnostic then "Diagnostics" else topic.name @@ -96,8 +97,12 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( val pager = paginationByQuery(routes.ForumTopic.show(categ.id, topic.slug, 1), posts, showPost = true) Page(s"${topic.name} • page ${posts.currentPage}/${posts.nbPages} • ${categ.name}") .css("bits.forum") + .css(hasAsks.option("bits.ask")) .csp(_.withInlineIconFont.withTwitter) - .js(Esm("bits.forum") ++ Esm("bits.expandText") ++ formWithCaptcha.isDefined.so(captchaEsm)) + .js(Esm("bits.forum")) + .js(Esm("bits.expandText")) + .js(hasAsks.option(esmInit("bits.ask"))) + .js(formWithCaptcha.isDefined.option(captchaEsm)) .graph( OpenGraph( title = topic.name, diff --git a/modules/lobby/src/main/LobbySocket.scala b/modules/lobby/src/main/LobbySocket.scala index 72650b9a788fe..030214884b8b1 100644 --- a/modules/lobby/src/main/LobbySocket.scala +++ b/modules/lobby/src/main/LobbySocket.scala @@ -185,7 +185,9 @@ final class LobbySocket( } case ("idle", o) => actor ! SetIdle(member.sri, ~(o.boolean("d"))) // entering a pool - case ("poolIn", o) if !member.bot => + case ("poolIn", o) if member.bot => + logger.warn(s"Bot ${member.user.so(_.username.value)} can't enter a pool") + case ("poolIn", o) => HookPoolLimit(member, cost = 1, msg = s"poolIn $o"): for user <- member.user @@ -243,22 +245,18 @@ final class LobbySocket( private val handler: SocketHandler = - case P.In.ConnectSris(cons) => - cons.foreach { case (sri, userId) => - getOrConnect(sri, userId) - } + case P.In.ConnectSris(cons) => cons.foreach(getOrConnect) case P.In.DisconnectSris(sris) => actor ! LeaveBatch(sris) case P.In.TellSri(sri, user, tpe, msg) if messagesHandled(tpe) => - getOrConnect(sri, user).foreach { member => + getOrConnect(sri, user).foreach: member => controller(member).applyOrElse( tpe -> msg, { case _ => logger.warn(s"Can't handle $tpe") }: SocketController ) - } case In.Counters(m, r) => lastCounters = LobbyCounters(m, r) diff --git a/modules/local/src/main/Env.scala b/modules/local/src/main/Env.scala new file mode 100644 index 0000000000000..7e9383dbfd3f4 --- /dev/null +++ b/modules/local/src/main/Env.scala @@ -0,0 +1,28 @@ +package lila.local + +import com.softwaremill.macwire.* +import play.api.Configuration + +import lila.common.autoconfig.{ *, given } +import lila.core.config.* + +@Module +final private class LocalConfig( + @ConfigName("asset_path") val assetPath: String +) + +@Module +final class Env( + appConfig: Configuration, + db: lila.db.Db, + getFile: (String => java.io.File) +)(using + Executor, + akka.stream.Materializer +)(using mode: play.api.Mode, scheduler: Scheduler): + + private val config: LocalConfig = appConfig.get[LocalConfig]("local")(AutoConfig.loader) + + val repo = LocalRepo(db(CollName("local_bots")), db(CollName("local_assets"))) + + val api: LocalApi = wire[LocalApi] diff --git a/modules/local/src/main/LocalApi.scala b/modules/local/src/main/LocalApi.scala new file mode 100644 index 0000000000000..05baf0a82a35d --- /dev/null +++ b/modules/local/src/main/LocalApi.scala @@ -0,0 +1,56 @@ +package lila.local + +import java.nio.file.{ Files as NioFiles, Paths } +import play.api.libs.json.* +import play.api.libs.Files +import play.api.mvc.* +import akka.stream.scaladsl.{ FileIO, Source } +import akka.util.ByteString + +// this stuff is for bot devs + +final private class LocalApi(config: LocalConfig, repo: LocalRepo, getFile: (String => java.io.File))(using + Executor, + akka.stream.Materializer +): + + @volatile private var cachedAssets: Option[JsObject] = None + + def storeAsset( + tpe: "image" | "book" | "sound", + name: String, + file: MultipartFormData.FilePart[Files.TemporaryFile] + ): Fu[Either[String, JsObject]] = + FileIO + .fromPath(file.ref.path) + .runWith(FileIO.toPath(getFile(s"public/lifat/bots/${tpe}/$name").toPath)) + .map: result => + if result.wasSuccessful then Right(updateAssets) + else Left(s"Error uploading asset $tpe $name") + .recover: + case e: Exception => Left(s"Exception: ${e.getMessage}") + + def assetKeys: JsObject = cachedAssets.getOrElse(updateAssets) + + private def listFiles(tpe: String): List[String] = + val path = getFile(s"public/lifat/bots/${tpe}") + if !path.exists() then + NioFiles.createDirectories(path.toPath) + Nil + else + path + .listFiles() + .toList + .map(_.getName) + + def updateAssets: JsObject = + val newAssets = Json.obj( + "image" -> listFiles("image"), + "net" -> listFiles("net"), + "sound" -> listFiles("sound"), + "book" -> listFiles("book") + .filter(_.endsWith(".bin")) + .map(_.dropRight(4)) + ) + cachedAssets = newAssets.some + newAssets diff --git a/modules/local/src/main/LocalRepo.scala b/modules/local/src/main/LocalRepo.scala new file mode 100644 index 0000000000000..bd7bcc7305115 --- /dev/null +++ b/modules/local/src/main/LocalRepo.scala @@ -0,0 +1,76 @@ +package lila.local + +import reactivemongo.api.Cursor +import reactivemongo.api.bson.* +import lila.common.Json.given +import lila.db.JSON +import play.api.libs.json.* + +import lila.db.dsl.{ *, given } + +final private class LocalRepo(private[local] val bots: Coll, private[local] val assets: Coll)(using Executor): + // given botMetaHandler: BSONDocumentHandler[BotMeta] = Macros.handler + given Format[BotMeta] = Json.format + + def getVersions(botId: Option[UserId] = none): Fu[JsArray] = + bots + .find(botId.fold[Bdoc]($doc())(v => $doc("uid" -> v)), $doc("_id" -> 0).some) + .sort($doc("version" -> -1)) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map: docs => + JsArray(docs.map(JSON.jval)) + + def getLatestBots(): Fu[JsArray] = + bots + .aggregateWith[Bdoc](readPreference = ReadPref.sec): framework => + import framework.* + List( + Sort(Descending("version")), + GroupField("uid")("doc" -> FirstField("$ROOT")), + ReplaceRootField("doc"), + Project($doc("_id" -> 0)) + ) + .list(Int.MaxValue) + .map: docs => + JsArray(docs.flatMap(JSON.jval(_).asOpt[JsObject])) + + def putBot(bot: JsObject, author: UserId): Fu[JsObject] = + val botId = (bot \ "uid").as[UserId] + for + nextVersion <- bots + .find($doc("uid" -> botId)) + .sort($doc("version" -> -1)) + .one[Bdoc] + .map(_.flatMap(_.getAsOpt[Int]("version")).getOrElse(-1) + 1) // race condition + botMeta = BotMeta(botId, author, nextVersion) + newBot = bot ++ Json.toJson(botMeta).as[JsObject] + _ <- bots.insert.one(JSON.bdoc(newBot)) + yield newBot + + def getAssets: Fu[Map[String, String]] = + assets + .find($doc()) + .cursor[Bdoc]() + .list(Int.MaxValue) + .map { docs => + docs.flatMap { doc => + for + id <- doc.getAsOpt[String]("_id") + name <- doc.getAsOpt[String]("name") + yield id -> name + }.toMap + } + + def nameAsset(tpe: Option[AssetType], key: String, name: String, author: Option[String]): Funit = + // filter out bookCovers as they share the same key as the book + if !tpe.has("book") || !key.endsWith(".png") then + val id = if tpe.has("book") then key.dropRight(4) else key + val setDoc = author.fold($doc("name" -> name))(a => $doc("name" -> name, "author" -> a)) + assets.update.one($doc("_id" -> id), $doc("$set" -> setDoc), upsert = true).void + else funit + + def deleteAsset(key: String): Funit = + assets.delete.one($doc("_id" -> key)).void + +end LocalRepo diff --git a/modules/local/src/main/LocalUi.scala b/modules/local/src/main/LocalUi.scala new file mode 100644 index 0000000000000..3c78ee1e01a60 --- /dev/null +++ b/modules/local/src/main/LocalUi.scala @@ -0,0 +1,36 @@ +package lila.local +package ui + +import play.api.libs.json.JsObject + +import lila.ui.* +import ScalatagsTemplate.{ *, given } + +final class LocalUi(helpers: Helpers): + import helpers.{ *, given } + + def index(data: JsObject, moduleName: String = "local")(using ctx: Context): Page = + Page("Private Play") + .css(moduleName) + .css("round") + .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) + .css(ctx.pref.hasVoice.option("voice")) + .js( + PageModule( + moduleName, + data + ) + ) + .js(Esm("round")) + .csp(_.withWebAssembly) + .graph( + OpenGraph( + title = "Private Play", + description = "Private Play", + url = netBaseUrl.value + ) + ) + .zoom + .hrefLangs(lila.ui.LangPath("/")) { + emptyFrag + } diff --git a/modules/local/src/main/model.scala b/modules/local/src/main/model.scala new file mode 100644 index 0000000000000..922526d21ba60 --- /dev/null +++ b/modules/local/src/main/model.scala @@ -0,0 +1,18 @@ +package lila.local + +import reactivemongo.api.bson.* +// import reactivemongo.api.bson.collection.BSONCollection +import play.api.libs.json.* + +case class GameSetup( + white: Option[String], + black: Option[String], + fen: Option[String], + initial: Option[Float], + increment: Option[Float], + go: Boolean = false +) + +case class BotMeta(uid: UserId, author: UserId, version: Int) + +type AssetType = "sound" | "image" | "book" diff --git a/modules/local/src/main/package.scala b/modules/local/src/main/package.scala new file mode 100644 index 0000000000000..ecdf64ebda218 --- /dev/null +++ b/modules/local/src/main/package.scala @@ -0,0 +1,6 @@ +package lila.local + +export lila.core.lilaism.Lilaism.{ *, given } +export lila.common.extensions.* + +private val logger = lila.log("local") diff --git a/modules/practice/src/main/UserPractice.scala b/modules/practice/src/main/UserPractice.scala index 2d9897177c9f6..22ad03dc533e2 100644 --- a/modules/practice/src/main/UserPractice.scala +++ b/modules/practice/src/main/UserPractice.scala @@ -25,7 +25,7 @@ case class UserStudy( study: Study.WithChapter, section: PracticeSection ): - def url = s"/practice/${section.id}/${practiceStudy.slug}/${study.study.id}" + def url = routes.Practice.show(section.id, practiceStudy.slug, study.study.id).url case class Completion(done: Int, total: Int): diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 9a45bd68cc190..f5f4b506d0195 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -192,7 +192,7 @@ final class RelayApi( UnwindField("tour"), Match($doc("tour.tier".$exists(false))), Sort(Ascending("sync.nextAt")), - GroupField("tour.ownerId")("relays" -> PushField("$ROOT")), + GroupField("tour.ownerIds")("relays" -> PushField("$ROOT")), Project: $doc( "_id" -> false, @@ -226,7 +226,7 @@ final class RelayApi( "players" -> tour.players, "teams" -> tour.teams, "spotlight" -> tour.spotlight, - "ownerId" -> tour.ownerId.some, + "ownerIds" -> tour.ownerIds.some, "pinnedStream" -> tour.pinnedStream, "note" -> tour.note ) @@ -373,7 +373,7 @@ final class RelayApi( yield rt.tour.some def deleteTourIfOwner(tour: RelayTour)(using me: Me): Fu[Boolean] = - (tour.ownerId.is(me) || Granter(_.StudyAdmin)) + (tour.isOwnedBy(me) || Granter(_.StudyAdmin)) .so: for _ <- tourRepo.delete(tour) @@ -383,7 +383,7 @@ final class RelayApi( yield true def canUpdate(tour: RelayTour)(using me: Me): Fu[Boolean] = - fuccess(Granter(_.StudyAdmin) || me.is(tour.ownerId)) >>| + fuccess(Granter(_.StudyAdmin) || tour.isOwnedBy(me)) >>| roundRepo .studyIdsOf(tour.id) .flatMap: ids => @@ -396,7 +396,7 @@ final class RelayApi( val tour = from.copy( id = RelayTour.makeId, name = RelayTour.Name(s"${from.name} (clone)"), - ownerId = me.userId, + ownerIds = NonEmptyList.one(me.userId), createdAt = nowInstant, syncedAt = none, tier = from.tier.map(_ => RelayTour.Tier.`private`) diff --git a/modules/relay/src/main/RelayTour.scala b/modules/relay/src/main/RelayTour.scala index 595f8560566bc..9631b311c2980 100644 --- a/modules/relay/src/main/RelayTour.scala +++ b/modules/relay/src/main/RelayTour.scala @@ -17,7 +17,7 @@ case class RelayTour( name: RelayTour.Name, info: RelayTour.Info, markup: Option[Markdown] = None, - ownerId: UserId, + ownerIds: NonEmptyList[UserId], createdAt: Instant, tier: Option[RelayTour.Tier], // if present, it's an official broadcast active: Boolean, // a round is scheduled or ongoing @@ -42,10 +42,18 @@ case class RelayTour( def official = tier.isDefined - def giveOfficialToBroadcasterIf(cond: Boolean) = - if cond && official then copy(ownerId = UserId.broadcaster) else this + def isOwnedBy[U: UserIdOf](u: U): Boolean = ownerIds.toList.contains(u.id) - def path: String = s"/broadcast/$slug/$id" + def giveOfficialToBroadcasterIf(cond: Boolean) = + if !cond || official == isOwnedBy(UserId.broadcaster) then this + else + copy( + ownerIds = + if official then ownerIds.append(UserId.broadcaster) + else ownerIds.filterNot(_ == UserId.broadcaster).toNel | ownerIds + ) + + def path = routes.RelayTour.show(slug, id).url def tierIs(selector: RelayTour.Tier.Selector) = tier.has(selector(RelayTour.Tier)) diff --git a/modules/relay/src/main/RelayTourForm.scala b/modules/relay/src/main/RelayTourForm.scala index 95eb2eea4b07b..51d9d9655a108 100644 --- a/modules/relay/src/main/RelayTourForm.scala +++ b/modules/relay/src/main/RelayTourForm.scala @@ -111,7 +111,7 @@ object RelayTourForm: name = name, info = info, markup = markup, - ownerId = me, + ownerIds = NonEmptyList.one(me), tier = tier.ifTrue(Granter(_.Relay)), active = false, live = none, diff --git a/modules/relay/src/main/RelayTourRepo.scala b/modules/relay/src/main/RelayTourRepo.scala index a81289db206c5..af0249c631e65 100644 --- a/modules/relay/src/main/RelayTourRepo.scala +++ b/modules/relay/src/main/RelayTourRepo.scala @@ -55,7 +55,7 @@ final private class RelayTourRepo(val coll: Coll)(using Executor): coll.byOrderedIds[IdName, RelayTourId](ids, $doc("name" -> true).some)(_.id) def isOwnerOfAll(u: UserId, ids: List[RelayTourId]): Fu[Boolean] = - coll.exists($doc($inIds(ids), "ownerId".$ne(u))).not + coll.exists($doc($inIds(ids), "ownerIds".$ne(u))).not def info(tourId: RelayTourId): Fu[Option[RelayTour.Info]] = coll.primitiveOne[RelayTour.Info]($id(tourId), "info") @@ -140,7 +140,7 @@ private object RelayTourRepo: val officialPublic = $doc("tier".$gte(RelayTour.Tier.normal)) val active = $doc("active" -> true) val inactive = $doc("active" -> false) - def ownerId(u: UserId) = $doc("ownerId" -> u) + def ownerId(u: UserId) = $doc("ownerIds" -> u) def subscriberId(u: UserId) = $doc("subscribers" -> u) val officialActive = officialPublic ++ active val officialInactive = officialPublic ++ inactive diff --git a/modules/relay/src/main/ui/RelayFormUi.scala b/modules/relay/src/main/ui/RelayFormUi.scala index a6008a142633f..04db8d3fec0dc 100644 --- a/modules/relay/src/main/ui/RelayFormUi.scala +++ b/modules/relay/src/main/ui/RelayFormUi.scala @@ -218,7 +218,7 @@ final class RelayFormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): strong(a(href := source.path)(source.fullName)), br, "Owner: ", - userIdLink(source.tour.ownerId.some), + fragList(source.tour.ownerIds.toList.map(u => userIdLink(u.some))), br, "Delay: ", source.round.sync.delay.fold("0")(_.toString), diff --git a/modules/security/src/main/Permission.scala b/modules/security/src/main/Permission.scala index 7fdb64cedb49c..e30a16da93406 100644 --- a/modules/security/src/main/Permission.scala +++ b/modules/security/src/main/Permission.scala @@ -65,7 +65,8 @@ object Permission: PuzzleCurator, OpeningWiki, Presets, - Feed + Feed, + BotEditor ), "Dev" -> List( Cli, diff --git a/modules/timeline/src/main/Entry.scala b/modules/timeline/src/main/Entry.scala index fea904b23df58..cb4040d9f049b 100644 --- a/modules/timeline/src/main/Entry.scala +++ b/modules/timeline/src/main/Entry.scala @@ -38,6 +38,7 @@ object Entry: case d: TeamJoin => "team-join" -> toBson(d) case d: TeamCreate => "team-create" -> toBson(d) case d: ForumPost => "forum-post" -> toBson(d) + case d: AskConcluded => "ask-concluded" -> toBson(d) case d: UblogPost => "ublog-post" -> toBson(d) case d: TourJoin => "tour-join" -> toBson(d) case d: GameEnd => "game-end" -> toBson(d) @@ -57,6 +58,7 @@ object Entry: given teamJoinHandler: BSONDocumentHandler[TeamJoin] = Macros.handler given teamCreateHandler: BSONDocumentHandler[TeamCreate] = Macros.handler given forumPostHandler: BSONDocumentHandler[ForumPost] = Macros.handler + given askConcludedHandler: BSONDocumentHandler[AskConcluded] = Macros.handler given ublogPostHandler: BSONDocumentHandler[UblogPost] = Macros.handler given tourJoinHandler: BSONDocumentHandler[TourJoin] = Macros.handler given gameEndHandler: BSONDocumentHandler[GameEnd] = Macros.handler @@ -73,6 +75,7 @@ object Entry: "team-join" -> teamJoinHandler, "team-create" -> teamCreateHandler, "forum-post" -> forumPostHandler, + "ask-concluded" -> askConcludedHandler, "ublog-post" -> ublogPostHandler, "tour-join" -> tourJoinHandler, "game-end" -> gameEndHandler, @@ -90,6 +93,7 @@ object Entry: val teamJoinWrite = Json.writes[TeamJoin] val teamCreateWrite = Json.writes[TeamCreate] val forumPostWrite = Json.writes[ForumPost] + val askConcludedWrite = Json.writes[AskConcluded] val ublogPostWrite = Json.writes[UblogPost] val tourJoinWrite = Json.writes[TourJoin] val gameEndWrite = Json.writes[GameEnd] @@ -105,6 +109,7 @@ object Entry: case d: TeamJoin => teamJoinWrite.writes(d) case d: TeamCreate => teamCreateWrite.writes(d) case d: ForumPost => forumPostWrite.writes(d) + case d: AskConcluded => askConcludedWrite.writes(d) case d: UblogPost => ublogPostWrite.writes(d) case d: TourJoin => tourJoinWrite.writes(d) case d: GameEnd => gameEndWrite.writes(d) diff --git a/modules/timeline/src/main/TimelineUi.scala b/modules/timeline/src/main/TimelineUi.scala index f426f3c20bde6..8f3f2149a2bf2 100644 --- a/modules/timeline/src/main/TimelineUi.scala +++ b/modules/timeline/src/main/TimelineUi.scala @@ -51,6 +51,14 @@ final class TimelineUi(helpers: Helpers)( title := topicName )(shorten(topicName, 30)) ) + case AskConcluded(userId, question, url) => + trans.site.askConcluded( + userLink(userId), + a( + href := url, + title := question + )(shorten(question, 30)) + ) case UblogPost(userId, id, slug, title) => trans.ublog.xPublishedY( userLink(userId), diff --git a/modules/ublog/src/main/Env.scala b/modules/ublog/src/main/Env.scala index ead623fa71e12..881c606eaedf0 100644 --- a/modules/ublog/src/main/Env.scala +++ b/modules/ublog/src/main/Env.scala @@ -18,7 +18,8 @@ final class Env( captcha: lila.core.captcha.CaptchaApi, cacheApi: lila.memo.CacheApi, langList: lila.core.i18n.LangList, - net: NetConfig + net: NetConfig, + askApi: lila.core.ask.AskApi )(using Executor, Scheduler, akka.stream.Materializer, play.api.Mode): export net.{ assetBaseUrl, baseUrl, domain, assetDomain } diff --git a/modules/ublog/src/main/UblogApi.scala b/modules/ublog/src/main/UblogApi.scala index 5c73b9b63f776..10cb075b4fd97 100644 --- a/modules/ublog/src/main/UblogApi.scala +++ b/modules/ublog/src/main/UblogApi.scala @@ -3,6 +3,7 @@ package lila.ublog import reactivemongo.akkastream.{ AkkaStreamCursor, cursorProducer } import reactivemongo.api.* +import lila.common.Markdown import lila.core.shutup.{ PublicSource, ShutupApi } import lila.core.timeline as tl import lila.db.dsl.{ *, given } @@ -14,29 +15,31 @@ final class UblogApi( userApi: lila.core.user.UserApi, picfitApi: PicfitApi, shutupApi: ShutupApi, - irc: lila.core.irc.IrcApi + irc: lila.core.irc.IrcApi, + askApi: lila.core.ask.AskApi )(using Executor) extends lila.core.ublog.UblogApi: import UblogBsonHandlers.{ *, given } def create(data: UblogForm.UblogPostData, author: User): Fu[UblogPost] = - val post = data.create(author) - colls.post.insert - .one( + val frozen = askApi.freeze(data.markdown.value, author.id) + val post = data.create(author, Markdown(frozen.text)) + (askApi.commit(frozen, s"/ublog/${post.id}/redirect".some) >> + colls.post.insert.one( bsonWriteObjTry[UblogPost](post).get ++ $doc("likers" -> List(author.id)) - ) - .inject(post) + )).inject(post) def getByPrismicId(id: String): Fu[Option[UblogPost]] = colls.post.one[UblogPost]($doc("prismicId" -> id)) def update(data: UblogForm.UblogPostData, prev: UblogPost)(using me: Me): Fu[UblogPost] = for + frozen <- askApi.freezeAndCommit(data.markdown.value, me) author <- userApi.byId(prev.created.by).map(_ | me.value) blog <- getUserBlog(author, insertMissing = true) - post = data.update(me.value, prev) + post = data.update(me.value, prev, Markdown(frozen)) _ <- colls.post.update.one($id(prev.id), $set(bsonWriteObjTry[UblogPost](post).get)) _ <- (post.live && prev.lived.isEmpty).so(onFirstPublish(author, blog, post)) - yield post + yield post.copy(markdown = Markdown(askApi.unfreeze(frozen))) private def onFirstPublish(author: User, blog: UblogBlog, post: UblogPost): Funit = for _ <- rank @@ -162,7 +165,9 @@ final class UblogApi( .list(30) def delete(post: UblogPost): Funit = - colls.post.delete.one($id(post.id)) >> image.deleteAll(post) + colls.post.delete.one($id(post.id)) >> + image.deleteAll(post) >> + askApi.repo.deleteAll(post.markdown.value) def setTier(blog: UblogBlog.Id, tier: UblogRank.Tier): Funit = colls.blog.update diff --git a/modules/ublog/src/main/UblogForm.scala b/modules/ublog/src/main/UblogForm.scala index c220f544c82fe..75dc8aa4e97f4 100644 --- a/modules/ublog/src/main/UblogForm.scala +++ b/modules/ublog/src/main/UblogForm.scala @@ -63,13 +63,13 @@ object UblogForm: move: String ) extends WithCaptcha: - def create(user: User) = + def create(user: User, updatedMarkdown: Markdown) = UblogPost( id = UblogPost.randomId, blog = UblogBlog.Id.User(user.id), title = title, intro = intro, - markdown = markdown, + markdown = updatedMarkdown, language = language.orElse(user.realLang.map(Language.apply)) | defaultLanguage, topics = topics.so(UblogTopic.fromStrList), image = none, @@ -85,11 +85,11 @@ object UblogForm: pinned = none ) - def update(user: User, prev: UblogPost) = + def update(user: User, prev: UblogPost, updatedMarkdown: Markdown) = prev.copy( title = title, intro = intro, - markdown = markdown, + markdown = updatedMarkdown, image = prev.image.map: i => i.copy(alt = imageAlt, credit = imageCredit), language = language | prev.language, diff --git a/modules/ublog/src/main/ui/UblogPostUi.scala b/modules/ublog/src/main/ui/UblogPostUi.scala index a8daf1ac3e7cc..d30a55b688f85 100644 --- a/modules/ublog/src/main/ui/UblogPostUi.scala +++ b/modules/ublog/src/main/ui/UblogPostUi.scala @@ -7,7 +7,8 @@ import ScalatagsTemplate.{ *, given } final class UblogPostUi(helpers: Helpers, ui: UblogUi)( ublogRank: UblogRank, - connectLinks: Frag + connectLinks: Frag, + askRender: (Frag) => Context ?=> Frag ): import helpers.{ *, given } @@ -19,11 +20,15 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)( others: List[UblogPost.PreviewPost], liked: Boolean, followable: Boolean, - followed: Boolean + followed: Boolean, + hasAsks: Boolean )(using ctx: Context) = Page(s"${trans.ublog.xBlog.txt(user.username)} • ${post.title}") .css("bits.ublog") - .js(Esm("bits.expandText") ++ ctx.isAuth.so(Esm("bits.ublog"))) + .css(hasAsks.option("bits.ask")) + .js(Esm("bits.expandText")) + .js(ctx.isAuth.option(Esm("bits.ublog"))) + .js(hasAsks.option(esmInit("bits.ask"))) .graph( OpenGraph( `type` = "article", @@ -102,7 +107,7 @@ final class UblogPostUi(helpers: Helpers, ui: UblogUi)( a(href := routes.Ublog.topic(topic.url, 1))(topic.value) ), strong(cls := "ublog-post__intro")(post.intro), - div(cls := "ublog-post__markup expand-text")(markup), + div(cls := "ublog-post__markup expand-text")(askRender(markup)), post.isLichess.option( div(cls := "ublog-post__lichess")( connectLinks, diff --git a/modules/ui/src/main/ContentSecurityPolicy.scala b/modules/ui/src/main/ContentSecurityPolicy.scala index 2b479a5e36344..8e6e12b5bb660 100644 --- a/modules/ui/src/main/ContentSecurityPolicy.scala +++ b/modules/ui/src/main/ContentSecurityPolicy.scala @@ -7,6 +7,7 @@ case class ContentSecurityPolicy( frameSrc: List[String], workerSrc: List[String], imgSrc: List[String], + mediaSrc: List[String], scriptSrc: List[String], fontSrc: List[String], baseUri: List[String] diff --git a/modules/ui/src/main/Icon.scala b/modules/ui/src/main/Icon.scala index c103fba0a836b..587f79dfca73a 100644 --- a/modules/ui/src/main/Icon.scala +++ b/modules/ui/src/main/Icon.scala @@ -143,3 +143,4 @@ object Icon: val AccountCircle: Icon = "" // e079 val Logo: Icon = "" // e07a val Switch: Icon = "" // e07b + val Blindfold: Icon = "" // e07c diff --git a/modules/ui/src/main/ui/bits.scala b/modules/ui/src/main/ui/bits.scala index 1d873d09eec1b..b8b01fde50244 100644 --- a/modules/ui/src/main/ui/bits.scala +++ b/modules/ui/src/main/ui/bits.scala @@ -30,7 +30,9 @@ object bits: ) def fenAnalysisLink(fen: Fen.Full)(using Translate) = - a(href := s"/analysis/${ChessHelper.underscoreFen(fen)}")(lila.core.i18n.I18nKey.site.analysis()) + a(href := routes.UserAnalysis.parseArg(ChessHelper.underscoreFen(fen)))( + lila.core.i18n.I18nKey.site.analysis() + ) private val dataSitekey = attr("data-sitekey") diff --git a/modules/web/src/main/ContentSecurityPolicy.scala b/modules/web/src/main/ContentSecurityPolicy.scala index 549f03d9837ea..c6c960f022f5e 100644 --- a/modules/web/src/main/ContentSecurityPolicy.scala +++ b/modules/web/src/main/ContentSecurityPolicy.scala @@ -1,6 +1,7 @@ package lila.web import lila.core.config.AssetDomain +import org.checkerframework.checker.units.qual.m object ContentSecurityPolicy: @@ -12,6 +13,7 @@ object ContentSecurityPolicy: frameSrc = List("'self'", assetDomain.value, "www.youtube.com", "player.twitch.tv", "player.vimeo.com"), workerSrc = List("'self'", assetDomain.value, "blob:"), imgSrc = List("'self'", "blob:", "data:", "*"), + mediaSrc = List("'self'", "blob:", assetDomain.value), scriptSrc = List("'self'", assetDomain.value), fontSrc = List("'self'", assetDomain.value), baseUri = List("'none'") @@ -25,6 +27,7 @@ object ContentSecurityPolicy: frameSrc = Nil, workerSrc = Nil, imgSrc = List("'self'", "blob:", "data:", "*"), + mediaSrc = Nil, scriptSrc = List("'self'", assetDomain.value), fontSrc = List("'self'", assetDomain.value), baseUri = List("'none'") @@ -39,6 +42,7 @@ object ContentSecurityPolicy: "frame-src " -> frameSrc, "worker-src " -> workerSrc, "img-src " -> imgSrc, + "media-src " -> mediaSrc, "script-src " -> scriptSrc, "font-src " -> fontSrc, "base-uri " -> baseUri diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index 6c41fac0936df..d94ecf7e3f8db 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -121,7 +121,9 @@ final class AuthUi(helpers: Helpers): if form.exists(_.hasErrors) then "error" else "anim" }" )( - boxTop(h1(cls := "is-green text", dataIcon := Icon.Checkmark)(trans.site.checkYourEmail())), + boxTop( + h1(cls := "is-green text", dataIcon := Icon.Checkmark)("All set!") + ) /* trans.site.checkYourEmail())), p(trans.site.weHaveSentYouAnEmailClickTheLink()), h2("Not receiving it?"), ol( @@ -162,7 +164,7 @@ final class AuthUi(helpers: Helpers): a(href := routes.Account.emailConfirmHelp)("proceed to this page to solve the issue"), "." ) - ) + )*/ ) def passwordReset(form: HcaptchaForm[?], fail: Boolean)(using Context) = diff --git a/package.json b/package.json index 8c2546f8d9ae9..c15180429cd0d 100644 --- a/package.json +++ b/package.json @@ -17,26 +17,26 @@ "url": "https://github.com/lichess-org/lila/issues" }, "homepage": "https://lichess.org", - "packageManager": "pnpm@9.15.2+sha512.93e57b0126f0df74ce6bff29680394c0ba54ec47246b9cf321f0121d8d9bb03f750a705f24edc3c1180853afd7c2c3b94196d0a3d53d3e069d9e2793ef11f321", + "packageManager": "pnpm@10.0.0+sha512.b8fef5494bd3fe4cbd4edabd0745df2ee5be3e4b0b8b08fa643aa3e4c6702ccc0f00d68fa8a8c9858a735a0032485a44990ed2810526c875e416f001b17df12b", "engines": { "node": ">=22.6", - "pnpm": "^9" + "pnpm": "10.0.0" }, "dependencies": { "@types/lichess": "workspace:*", - "@types/web": "^0.0.194", - "@typescript-eslint/eslint-plugin": "^8.20.0", - "@typescript-eslint/parser": "^8.20.0", + "@types/web": "^0.0.200", + "@typescript-eslint/eslint-plugin": "^8.22.0", + "@typescript-eslint/parser": "^8.22.0", "ab": "github:lichess-org/ab-stub", "chessground": "^9.1.1", "chessops": "^0.14.2", - "eslint": "^9.18.0", - "lint-staged": "^15.3.0", + "eslint": "^9.19.0", + "lint-staged": "^15.4.3", "onchange": "^7.1.0", "prettier": "^3.4.2", "snabbdom": "3.5.1", "typescript": "^5.7.3", - "vitest": "^3.0.2" + "vitest": "^3.0.4" }, "//": [ "snabbdom pinned to 3.5.1 until https://github.com/snabbdom/snabbdom/issues/1114 is resolved", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9f9603176455..0073e40fd7cd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: workspace:* version: link:ui/@types/lichess '@types/web': - specifier: ^0.0.194 - version: 0.0.194 + specifier: ^0.0.200 + version: 0.0.200 '@typescript-eslint/eslint-plugin': - specifier: ^8.20.0 - version: 8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3))(eslint@9.18.0)(typescript@5.7.3) + specifier: ^8.22.0 + version: 8.22.0(@typescript-eslint/parser@8.22.0(eslint@9.19.0)(typescript@5.7.3))(eslint@9.19.0)(typescript@5.7.3) '@typescript-eslint/parser': - specifier: ^8.20.0 - version: 8.20.0(eslint@9.18.0)(typescript@5.7.3) + specifier: ^8.22.0 + version: 8.22.0(eslint@9.19.0)(typescript@5.7.3) ab: specifier: github:lichess-org/ab-stub version: https://codeload.github.com/lichess-org/ab-stub/tar.gz/94236bf34dbc9c05daf50f4c9842d859b9142be0 @@ -30,11 +30,11 @@ importers: specifier: ^0.14.2 version: 0.14.2 eslint: - specifier: ^9.18.0 - version: 9.18.0 + specifier: ^9.19.0 + version: 9.19.0 lint-staged: - specifier: ^15.3.0 - version: 15.3.0 + specifier: ^15.4.3 + version: 15.4.3 onchange: specifier: ^7.1.0 version: 7.1.0 @@ -48,8 +48,8 @@ importers: specifier: ^5.7.3 version: 5.7.3 vitest: - specifier: ^3.0.2 - version: 3.0.2(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.6.1) + specifier: ^3.0.4 + version: 3.0.4(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.7.0) bin: dependencies: @@ -94,6 +94,9 @@ importers: common: specifier: workspace:* version: link:../common + dasher: + specifier: workspace:* + version: link:../dasher debounce-promise: specifier: ^3.1.2 version: 3.1.2 @@ -351,6 +354,9 @@ importers: ui/game: dependencies: + chess: + specifier: workspace:* + version: link:../chess common: specifier: workspace:* version: link:../common @@ -406,6 +412,45 @@ importers: specifier: workspace:* version: link:../game + ui/local: + dependencies: + '@types/lichess': + specifier: workspace:* + version: link:../@types/lichess + bits: + specifier: workspace:* + version: link:../bits + chart.js: + specifier: 4.4.3 + version: 4.4.3 + chess: + specifier: workspace:* + version: link:../chess + chessops: + specifier: ^0.14.0 + version: 0.14.2 + common: + specifier: workspace:* + version: link:../common + fast-diff: + specifier: ^1.3.0 + version: 1.3.0 + game: + specifier: workspace:* + version: link:../game + json-stringify-pretty-compact: + specifier: 4.0.0 + version: 4.0.0 + round: + specifier: workspace:* + version: link:../round + snabbdom: + specifier: 3.5.1 + version: 3.5.1 + zerofish: + specifier: ^0.0.31 + version: 0.0.31 + ui/mod: dependencies: '@types/debounce-promise': @@ -912,8 +957,8 @@ packages: resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.18.0': - resolution: {integrity: sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==} + '@eslint/js@9.19.0': + resolution: {integrity: sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.4': @@ -1099,6 +1144,9 @@ packages: '@types/dragscroll@0.0.3': resolution: {integrity: sha512-Nti+uvpcVv2VAkqF1+XJivEdE1rxdvWmE4mtMydm9kYBaT0/QsvQSjzqA2G0SE62RoTjVmhC48ap8yfApl8YJw==} + '@types/emscripten@1.39.13': + resolution: {integrity: sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1138,6 +1186,9 @@ packages: '@types/web@0.0.194': resolution: {integrity: sha512-VKseTFF3Y8SNbpZqdVFNWQ677ujwNyrI9LcySEUwZX5iebbcdE235Lq/vqrfCzj1oFsXyVUUBqq4x8enXSakMA==} + '@types/web@0.0.200': + resolution: {integrity: sha512-83EtN8z1w+K9fqIE0bdI87F6Iofm+4h5jN+TbBvumbHnZrdUL/HJRdqtc0qDE18sYp0PxvWVaG0MDx4ahvl3dQ==} + '@types/webrtc@0.0.44': resolution: {integrity: sha512-4BJZdzrApNFeuXgucyqs24k69f7oti3wUcGEbFbaV08QBh7yEe3tnRRuYXlyXJNXiumpZujiZqUZZ2/gMSeO0g==} @@ -1147,58 +1198,58 @@ packages: '@types/zxcvbn@4.4.5': resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==} - '@typescript-eslint/eslint-plugin@8.20.0': - resolution: {integrity: sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==} + '@typescript-eslint/eslint-plugin@8.22.0': + resolution: {integrity: sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/parser@8.20.0': - resolution: {integrity: sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==} + '@typescript-eslint/parser@8.22.0': + resolution: {integrity: sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/scope-manager@8.20.0': - resolution: {integrity: sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==} + '@typescript-eslint/scope-manager@8.22.0': + resolution: {integrity: sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@8.20.0': - resolution: {integrity: sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==} + '@typescript-eslint/type-utils@8.22.0': + resolution: {integrity: sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/types@8.20.0': - resolution: {integrity: sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==} + '@typescript-eslint/types@8.22.0': + resolution: {integrity: sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.20.0': - resolution: {integrity: sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==} + '@typescript-eslint/typescript-estree@8.22.0': + resolution: {integrity: sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/utils@8.20.0': - resolution: {integrity: sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==} + '@typescript-eslint/utils@8.22.0': + resolution: {integrity: sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/visitor-keys@8.20.0': - resolution: {integrity: sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==} + '@typescript-eslint/visitor-keys@8.22.0': + resolution: {integrity: sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@3.0.2': - resolution: {integrity: sha512-dKSHLBcoZI+3pmP5hiZ7I5grNru2HRtEW8Z5Zp4IXog8QYcxhlox7JUPyIIFWfN53+3HW3KPLIl6nSzUGgKSuQ==} + '@vitest/expect@3.0.4': + resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==} - '@vitest/mocker@3.0.2': - resolution: {integrity: sha512-Hr09FoBf0jlwwSyzIF4Xw31OntpO3XtZjkccpcBf8FeVW3tpiyKlkeUzxS/txzHqpUCNIX157NaTySxedyZLvA==} + '@vitest/mocker@3.0.4': + resolution: {integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 @@ -1208,20 +1259,20 @@ packages: vite: optional: true - '@vitest/pretty-format@3.0.2': - resolution: {integrity: sha512-yBohcBw/T/p0/JRgYD+IYcjCmuHzjC3WLAKsVE4/LwiubzZkE8N49/xIQ/KGQwDRA8PaviF8IRO8JMWMngdVVQ==} + '@vitest/pretty-format@3.0.4': + resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==} - '@vitest/runner@3.0.2': - resolution: {integrity: sha512-GHEsWoncrGxWuW8s405fVoDfSLk6RF2LCXp6XhevbtDjdDme1WV/eNmUueDfpY1IX3MJaCRelVCEXsT9cArfEg==} + '@vitest/runner@3.0.4': + resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==} - '@vitest/snapshot@3.0.2': - resolution: {integrity: sha512-h9s67yD4+g+JoYG0zPCo/cLTabpDqzqNdzMawmNPzDStTiwxwkyYM1v5lWE8gmGv3SVJ2DcxA2NpQJZJv9ym3g==} + '@vitest/snapshot@3.0.4': + resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==} - '@vitest/spy@3.0.2': - resolution: {integrity: sha512-8mI2iUn+PJFMT44e3ISA1R+K6ALVs47W6eriDTfXe6lFqlflID05MB4+rIFhmDSLBj8iBsZkzBYlgSkinxLzSQ==} + '@vitest/spy@3.0.4': + resolution: {integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==} - '@vitest/utils@3.0.2': - resolution: {integrity: sha512-Qu01ZYZlgHvDP02JnMBRpX43nRaZtNpIzw3C1clDXmn8eakgX6iQVGzTQ/NjkIr64WD8ioqOjkaYRVvHQI5qiw==} + '@vitest/utils@3.0.4': + resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} '@yaireo/tagify@4.17.9': resolution: {integrity: sha512-x9aZy22hzte7BNmMrFcYNrZH71ombgH5PnzcOVXqPevRV/m/ItSnWIvY5fOHYzpC9Uxy0+h/1P5v62fIvwq2MA==} @@ -1337,6 +1388,10 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chart.js@4.4.3: + resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==} + engines: {pnpm: '>=8'} + chart.js@4.4.6: resolution: {integrity: sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==} engines: {pnpm: '>=8'} @@ -1397,8 +1452,8 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} concat-map@0.0.1: @@ -1516,8 +1571,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.18.0: - resolution: {integrity: sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==} + eslint@9.19.0: + resolution: {integrity: sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -1566,6 +1621,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -1751,6 +1809,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1768,8 +1829,8 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} - lint-staged@15.3.0: - resolution: {integrity: sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==} + lint-staged@15.4.3: + resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==} engines: {node: '>=18.12.0'} hasBin: true @@ -2280,8 +2341,8 @@ packages: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} hasBin: true - vite-node@3.0.2: - resolution: {integrity: sha512-hsEQerBAHvVAbv40m3TFQe/lTEbOp7yDpyqMJqr2Tnd+W58+DEYOt+fluQgekOePcsNBmR77lpVAnIU2Xu4SvQ==} + vite-node@3.0.4: + resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -2325,20 +2386,23 @@ packages: yaml: optional: true - vitest@3.0.2: - resolution: {integrity: sha512-5bzaHakQ0hmVVKLhfh/jXf6oETDBtgPo8tQCHYB+wftNgFJ+Hah67IsWc8ivx4vFL025Ow8UiuTf4W57z4izvQ==} + vitest@3.0.4: + resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.2 - '@vitest/ui': 3.0.2 + '@vitest/browser': 3.0.4 + '@vitest/ui': 3.0.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -2427,8 +2491,8 @@ packages: y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - yaml@2.6.1: - resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} hasBin: true @@ -2444,6 +2508,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zerofish@0.0.31: + resolution: {integrity: sha512-SlGd9oSmILRPdIsOT5VKyBvGw6VTBJDQdVZGdhVlUZYgX7l8utJhv8+L9jQxVoyzlhh0P6jb4q3YUbFy1m3LdA==} + zxcvbn@4.4.2: resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==} @@ -2568,9 +2635,9 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.18.0)': + '@eslint-community/eslint-utils@4.4.1(eslint@9.19.0)': dependencies: - eslint: 9.18.0 + eslint: 9.19.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -2601,7 +2668,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.18.0': {} + '@eslint/js@9.19.0': {} '@eslint/object-schema@2.1.4': {} @@ -2743,6 +2810,8 @@ snapshots: '@types/dragscroll@0.0.3': {} + '@types/emscripten@1.39.13': {} + '@types/estree@1.0.6': {} '@types/fnando__sparkline@0.3.7': {} @@ -2758,7 +2827,6 @@ snapshots: '@types/node@22.10.6': dependencies: undici-types: 6.20.0 - optional: true '@types/node@22.9.1': dependencies: @@ -2781,6 +2849,8 @@ snapshots: '@types/web@0.0.194': {} + '@types/web@0.0.200': {} + '@types/webrtc@0.0.44': {} '@types/yaireo__tagify@4.27.0': @@ -2789,15 +2859,15 @@ snapshots: '@types/zxcvbn@4.4.5': {} - '@typescript-eslint/eslint-plugin@8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3))(eslint@9.18.0)(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@8.22.0(@typescript-eslint/parser@8.22.0(eslint@9.19.0)(typescript@5.7.3))(eslint@9.19.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.20.0(eslint@9.18.0)(typescript@5.7.3) - '@typescript-eslint/scope-manager': 8.20.0 - '@typescript-eslint/type-utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3) - '@typescript-eslint/utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3) - '@typescript-eslint/visitor-keys': 8.20.0 - eslint: 9.18.0 + '@typescript-eslint/parser': 8.22.0(eslint@9.19.0)(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.22.0 + '@typescript-eslint/type-utils': 8.22.0(eslint@9.19.0)(typescript@5.7.3) + '@typescript-eslint/utils': 8.22.0(eslint@9.19.0)(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.22.0 + eslint: 9.19.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -2806,40 +2876,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3)': + '@typescript-eslint/parser@8.22.0(eslint@9.19.0)(typescript@5.7.3)': dependencies: - '@typescript-eslint/scope-manager': 8.20.0 - '@typescript-eslint/types': 8.20.0 - '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3) - '@typescript-eslint/visitor-keys': 8.20.0 + '@typescript-eslint/scope-manager': 8.22.0 + '@typescript-eslint/types': 8.22.0 + '@typescript-eslint/typescript-estree': 8.22.0(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.22.0 debug: 4.4.0 - eslint: 9.18.0 + eslint: 9.19.0 typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.20.0': + '@typescript-eslint/scope-manager@8.22.0': dependencies: - '@typescript-eslint/types': 8.20.0 - '@typescript-eslint/visitor-keys': 8.20.0 + '@typescript-eslint/types': 8.22.0 + '@typescript-eslint/visitor-keys': 8.22.0 - '@typescript-eslint/type-utils@8.20.0(eslint@9.18.0)(typescript@5.7.3)': + '@typescript-eslint/type-utils@8.22.0(eslint@9.19.0)(typescript@5.7.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3) - '@typescript-eslint/utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3) + '@typescript-eslint/typescript-estree': 8.22.0(typescript@5.7.3) + '@typescript-eslint/utils': 8.22.0(eslint@9.19.0)(typescript@5.7.3) debug: 4.4.0 - eslint: 9.18.0 + eslint: 9.19.0 ts-api-utils: 2.0.0(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.20.0': {} + '@typescript-eslint/types@8.22.0': {} - '@typescript-eslint/typescript-estree@8.20.0(typescript@5.7.3)': + '@typescript-eslint/typescript-estree@8.22.0(typescript@5.7.3)': dependencies: - '@typescript-eslint/types': 8.20.0 - '@typescript-eslint/visitor-keys': 8.20.0 + '@typescript-eslint/types': 8.22.0 + '@typescript-eslint/visitor-keys': 8.22.0 debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 @@ -2850,59 +2920,59 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.20.0(eslint@9.18.0)(typescript@5.7.3)': + '@typescript-eslint/utils@8.22.0(eslint@9.19.0)(typescript@5.7.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.18.0) - '@typescript-eslint/scope-manager': 8.20.0 - '@typescript-eslint/types': 8.20.0 - '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3) - eslint: 9.18.0 + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0) + '@typescript-eslint/scope-manager': 8.22.0 + '@typescript-eslint/types': 8.22.0 + '@typescript-eslint/typescript-estree': 8.22.0(typescript@5.7.3) + eslint: 9.19.0 typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.20.0': + '@typescript-eslint/visitor-keys@8.22.0': dependencies: - '@typescript-eslint/types': 8.20.0 + '@typescript-eslint/types': 8.22.0 eslint-visitor-keys: 4.2.0 - '@vitest/expect@3.0.2': + '@vitest/expect@3.0.4': dependencies: - '@vitest/spy': 3.0.2 - '@vitest/utils': 3.0.2 + '@vitest/spy': 3.0.4 + '@vitest/utils': 3.0.4 chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.2(vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1))': + '@vitest/mocker@3.0.4(vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0))': dependencies: - '@vitest/spy': 3.0.2 + '@vitest/spy': 3.0.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1) + vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0) - '@vitest/pretty-format@3.0.2': + '@vitest/pretty-format@3.0.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.0.2': + '@vitest/runner@3.0.4': dependencies: - '@vitest/utils': 3.0.2 + '@vitest/utils': 3.0.4 pathe: 2.0.2 - '@vitest/snapshot@3.0.2': + '@vitest/snapshot@3.0.4': dependencies: - '@vitest/pretty-format': 3.0.2 + '@vitest/pretty-format': 3.0.4 magic-string: 0.30.17 pathe: 2.0.2 - '@vitest/spy@3.0.2': + '@vitest/spy@3.0.4': dependencies: tinyspy: 3.0.2 - '@vitest/utils@3.0.2': + '@vitest/utils@3.0.4': dependencies: - '@vitest/pretty-format': 3.0.2 + '@vitest/pretty-format': 3.0.4 loupe: 3.1.2 tinyrainbow: 2.0.0 @@ -3008,6 +3078,10 @@ snapshots: chalk@5.4.1: {} + chart.js@4.4.3: + dependencies: + '@kurkle/color': 0.3.4 + chart.js@4.4.6: dependencies: '@kurkle/color': 0.3.4 @@ -3075,7 +3149,7 @@ snapshots: delayed-stream: 1.0.0 optional: true - commander@12.1.0: {} + commander@13.1.0: {} concat-map@0.0.1: {} @@ -3187,14 +3261,14 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.18.0: + eslint@9.19.0: dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.18.0) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.19.0 '@eslint/core': 0.10.0 '@eslint/eslintrc': 3.2.0 - '@eslint/js': 9.18.0 + '@eslint/js': 9.19.0 '@eslint/plugin-kit': 0.2.5 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -3268,6 +3342,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3454,6 +3530,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3474,10 +3552,10 @@ snapshots: lilconfig@3.1.3: {} - lint-staged@15.3.0: + lint-staged@15.4.3: dependencies: chalk: 5.4.1 - commander: 12.1.0 + commander: 13.1.0 debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 @@ -3485,7 +3563,7 @@ snapshots: micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.6.1 + yaml: 2.7.0 transitivePeerDependencies: - supports-color @@ -3971,13 +4049,13 @@ snapshots: uuid@9.0.0: {} - vite-node@3.0.2(@types/node@22.10.6)(yaml@2.6.1): + vite-node@3.0.4(@types/node@22.10.6)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.2 - vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1) + vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3992,7 +4070,7 @@ snapshots: - tsx - yaml - vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1): + vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0): dependencies: esbuild: 0.24.2 postcss: 8.5.1 @@ -4000,17 +4078,17 @@ snapshots: optionalDependencies: '@types/node': 22.10.6 fsevents: 2.3.3 - yaml: 2.6.1 + yaml: 2.7.0 - vitest@3.0.2(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.6.1): + vitest@3.0.4(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.7.0): dependencies: - '@vitest/expect': 3.0.2 - '@vitest/mocker': 3.0.2(vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1)) - '@vitest/pretty-format': 3.0.2 - '@vitest/runner': 3.0.2 - '@vitest/snapshot': 3.0.2 - '@vitest/spy': 3.0.2 - '@vitest/utils': 3.0.2 + '@vitest/expect': 3.0.4 + '@vitest/mocker': 3.0.4(vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0)) + '@vitest/pretty-format': 3.0.4 + '@vitest/runner': 3.0.4 + '@vitest/snapshot': 3.0.4 + '@vitest/spy': 3.0.4 + '@vitest/utils': 3.0.4 chai: 5.1.2 debug: 4.4.0 expect-type: 1.1.0 @@ -4021,8 +4099,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1) - vite-node: 3.0.2(@types/node@22.10.6)(yaml@2.6.1) + vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0) + vite-node: 3.0.4(@types/node@22.10.6)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.6 @@ -4109,7 +4187,7 @@ snapshots: y18n@4.0.3: {} - yaml@2.6.1: {} + yaml@2.7.0: {} yargs-parser@18.1.3: dependencies: @@ -4132,4 +4210,12 @@ snapshots: yocto-queue@0.1.0: {} + zerofish@0.0.31: + dependencies: + '@types/emscripten': 1.39.13 + '@types/node': 22.10.6 + '@types/web': 0.0.194 + prettier: 3.4.2 + typescript: 5.7.3 + zxcvbn@4.4.2: {} diff --git a/public/font/lichess.sfd b/public/font/lichess.sfd index df0a8a6ec6bd2..67d005157c284 100644 --- a/public/font/lichess.sfd +++ b/public/font/lichess.sfd @@ -7794,5 +7794,72 @@ SplineSet 332.514648438 263.379882812 332.514648438 263.379882812 338.904296875 260.184570312 c 6,49,-1 EndSplineSet EndChar + +StartChar: blindfold +Encoding: 57468 57468 125 +Width: 512 +Flags: WO +LayerCount: 2 +Fore +SplineSet +220.779296875 492.721679688 m 0,0,1 + 112.879882812 491.724609375 112.879882812 491.724609375 81.1875 425.9375 c 0,2,3 + 63.3408203125 388.690429688 63.3408203125 388.690429688 59.7197265625 369.42578125 c 1,4,-1 + 200.896484375 364.194335938 l 1,5,-1 + 325.41796875 336.1015625 l 1,6,-1 + 369.177734375 321.748046875 l 1,7,-1 + 415.43359375 302.10546875 l 1,8,9 + 419.641601562 322.499023438 419.641601562 322.499023438 419.515625 342.85546875 c 0,10,11 + 418.88671875 377.861328125 418.88671875 377.861328125 378.439453125 430.243164062 c 0,12,13 + 337.668945312 482.540039062 337.668945312 482.540039062 274.637695312 489.733398438 c 0,14,15 + 245.680664062 492.953125 245.680664062 492.953125 220.779296875 492.721679688 c 0,0,1 +280.71484375 309.561523438 m 0,16,17 + 279.702148438 309.53125 279.702148438 309.53125 268.741210938 310.50390625 c 128,-1,18 + 257.78125 311.475585938 257.78125 311.475585938 257.483398438 310.228515625 c 0,19,20 + 253.392578125 308.540039062 253.392578125 308.540039062 247.837890625 302.469726562 c 128,-1,21 + 242.282226562 296.3984375 242.282226562 296.3984375 238.772460938 291.172851562 c 2,22,-1 + 235.264648438 285.948242188 l 1,23,-1 + 143.985351562 280.404296875 l 1,24,-1 + 50.08203125 269.903320312 l 1,25,26 + 46.1953125 263.258789062 46.1953125 263.258789062 40.7822265625 253.876953125 c 0,27,28 + 35.3662109375 245.208984375 35.3662109375 245.208984375 32.201171875 237.891601562 c 0,29,30 + 28.1484375 230.770507812 28.1484375 230.770507812 26.5517578125 224.989257812 c 0,31,32 + 24.5390625 219.942382812 24.5390625 219.942382812 24.994140625 216.754882812 c 0,33,34 + 25.4404296875 211.819335938 25.4404296875 211.819335938 34.8623046875 207.239257812 c 0,35,36 + 43.4033203125 202.840820312 43.4033203125 202.840820312 51.375 200.970703125 c 2,37,-1 + 59.3076171875 199.110351562 l 2,38,39 + 72.2568359375 196.479492188 72.2568359375 196.479492188 56.4150390625 175.698242188 c 0,40,41 + 50.7080078125 168.504882812 50.7080078125 168.504882812 68.392578125 147.732421875 c 0,42,43 + 71.462890625 144.139648438 71.462890625 144.139648438 67.1259765625 130.637695312 c 0,44,45 + 64.2431640625 121.58203125 64.2431640625 121.58203125 65.4912109375 119.200195312 c 0,46,47 + 72.365234375 104.435546875 72.365234375 104.435546875 81.8876953125 103.086914062 c 0,48,49 + 109.364257812 98.1181640625 109.364257812 98.1181640625 110.149414062 98.0234375 c 0,50,51 + 142.75390625 101.727539062 142.75390625 101.727539062 168.194335938 93.41015625 c 1,52,53 + 192.383789062 20.51171875 192.383789062 20.51171875 159.897460938 1.5615234375 c 1,54,-1 + 371.661132812 1.5615234375 l 1,55,56 + 334.962890625 36.75390625 334.962890625 36.75390625 338.572265625 104.434570312 c 0,57,58 + 341.279296875 158.579101562 341.279296875 158.579101562 361.16796875 187.805664062 c 0,59,60 + 380.983398438 215.4296875 380.983398438 215.4296875 400.307617188 256.606445312 c 0,61,62 + 400.7109375 257.473632812 400.7109375 257.473632812 401.399414062 259.08984375 c 128,-1,63 + 402.0859375 260.705078125 402.0859375 260.705078125 402.248046875 261.075195312 c 1,64,-1 + 350.756835938 271.900390625 l 1,65,-1 + 298.178710938 288.208984375 l 1,66,67 + 283.987304688 309.66015625 283.987304688 309.66015625 280.71484375 309.561523438 c 0,16,17 +485.915039062 298.461914062 m 2,68,69 + 493.663085938 296.8984375 493.663085938 296.8984375 494.116210938 289.209960938 c 0,70,71 + 494.580078125 280.40234375 494.580078125 280.40234375 487.515625 278.616210938 c 2,72,-1 + 444.045898438 265.0625 l 1,73,74 + 464.801757812 236.184570312 464.801757812 236.184570312 463.364257812 213.731445312 c 0,75,76 + 462.997070312 207.30859375 462.997070312 207.30859375 457.12890625 204.26171875 c 0,77,78 + 450.475585938 201.93359375 450.475585938 201.93359375 445.947265625 206.157226562 c 2,79,-1 + 395.188476562 253.5 l 2,80,81 + 390.443359375 258.200195312 390.443359375 258.200195312 392.385742188 264.243164062 c 2,82,-1 + 405.5703125 305.2734375 l 2,83,84 + 408.927734375 313.985351562 408.927734375 313.985351562 417.48828125 312.258789062 c 2,85,-1 + 485.915039062 298.461914062 l 2,68,69 +415.43359375 302.10546875 m 1,86,-1 + 402.248046875 261.075195312 l 1025,87,-1 +EndSplineSet +EndChar EndChars EndSplineFont diff --git a/public/font/lichess.ttf b/public/font/lichess.ttf index 0106e97733d04..9f574e75bd4b6 100644 Binary files a/public/font/lichess.ttf and b/public/font/lichess.ttf differ diff --git a/public/font/lichess.woff2 b/public/font/lichess.woff2 index 03f0892cdfbbe..69b59cb7f1e7c 100644 Binary files a/public/font/lichess.woff2 and b/public/font/lichess.woff2 differ diff --git a/public/images/fide-fed-webp/AFG.webp b/public/images/fide-fed-webp/AFG.webp new file mode 100644 index 0000000000000..0ac2799082965 Binary files /dev/null and b/public/images/fide-fed-webp/AFG.webp differ diff --git a/public/images/fide-fed-webp/AHO.webp b/public/images/fide-fed-webp/AHO.webp new file mode 100644 index 0000000000000..12cadcc8b3147 Binary files /dev/null and b/public/images/fide-fed-webp/AHO.webp differ diff --git a/public/images/fide-fed-webp/ALB.webp b/public/images/fide-fed-webp/ALB.webp new file mode 100644 index 0000000000000..ba946fd4603d5 Binary files /dev/null and b/public/images/fide-fed-webp/ALB.webp differ diff --git a/public/images/fide-fed-webp/ALG.webp b/public/images/fide-fed-webp/ALG.webp new file mode 100644 index 0000000000000..ce41588dcb832 Binary files /dev/null and b/public/images/fide-fed-webp/ALG.webp differ diff --git a/public/images/fide-fed-webp/AND.webp b/public/images/fide-fed-webp/AND.webp new file mode 100644 index 0000000000000..1b1d02fca7572 Binary files /dev/null and b/public/images/fide-fed-webp/AND.webp differ diff --git a/public/images/fide-fed-webp/ANG.webp b/public/images/fide-fed-webp/ANG.webp new file mode 100644 index 0000000000000..8ae25401ebd79 Binary files /dev/null and b/public/images/fide-fed-webp/ANG.webp differ diff --git a/public/images/fide-fed-webp/ANT.webp b/public/images/fide-fed-webp/ANT.webp new file mode 100644 index 0000000000000..a8c31173b8856 Binary files /dev/null and b/public/images/fide-fed-webp/ANT.webp differ diff --git a/public/images/fide-fed-webp/ARG.webp b/public/images/fide-fed-webp/ARG.webp new file mode 100644 index 0000000000000..7e99f94aa78b5 Binary files /dev/null and b/public/images/fide-fed-webp/ARG.webp differ diff --git a/public/images/fide-fed-webp/ARM.webp b/public/images/fide-fed-webp/ARM.webp new file mode 100644 index 0000000000000..2ab5b743d078d Binary files /dev/null and b/public/images/fide-fed-webp/ARM.webp differ diff --git a/public/images/fide-fed-webp/ARU.webp b/public/images/fide-fed-webp/ARU.webp new file mode 100644 index 0000000000000..e5e7705c232d2 Binary files /dev/null and b/public/images/fide-fed-webp/ARU.webp differ diff --git a/public/images/fide-fed-webp/AUS.webp b/public/images/fide-fed-webp/AUS.webp new file mode 100644 index 0000000000000..ddaf717fc3c4a Binary files /dev/null and b/public/images/fide-fed-webp/AUS.webp differ diff --git a/public/images/fide-fed-webp/AUT.webp b/public/images/fide-fed-webp/AUT.webp new file mode 100644 index 0000000000000..92dfd5caf0fc2 Binary files /dev/null and b/public/images/fide-fed-webp/AUT.webp differ diff --git a/public/images/fide-fed-webp/AZE.webp b/public/images/fide-fed-webp/AZE.webp new file mode 100644 index 0000000000000..3ba12944150ed Binary files /dev/null and b/public/images/fide-fed-webp/AZE.webp differ diff --git a/public/images/fide-fed-webp/BAH.webp b/public/images/fide-fed-webp/BAH.webp new file mode 100644 index 0000000000000..8412b2f9555ef Binary files /dev/null and b/public/images/fide-fed-webp/BAH.webp differ diff --git a/public/images/fide-fed-webp/BAN.webp b/public/images/fide-fed-webp/BAN.webp new file mode 100644 index 0000000000000..819df659c41fd Binary files /dev/null and b/public/images/fide-fed-webp/BAN.webp differ diff --git a/public/images/fide-fed-webp/BAR.webp b/public/images/fide-fed-webp/BAR.webp new file mode 100644 index 0000000000000..137e37cfdef04 Binary files /dev/null and b/public/images/fide-fed-webp/BAR.webp differ diff --git a/public/images/fide-fed-webp/BDI.webp b/public/images/fide-fed-webp/BDI.webp new file mode 100644 index 0000000000000..f883c1bfce7ac Binary files /dev/null and b/public/images/fide-fed-webp/BDI.webp differ diff --git a/public/images/fide-fed-webp/BEL.webp b/public/images/fide-fed-webp/BEL.webp new file mode 100644 index 0000000000000..37249b03a174e Binary files /dev/null and b/public/images/fide-fed-webp/BEL.webp differ diff --git a/public/images/fide-fed-webp/BER.webp b/public/images/fide-fed-webp/BER.webp new file mode 100644 index 0000000000000..5937bbdf86561 Binary files /dev/null and b/public/images/fide-fed-webp/BER.webp differ diff --git a/public/images/fide-fed-webp/BHU.webp b/public/images/fide-fed-webp/BHU.webp new file mode 100644 index 0000000000000..abc257e8da8e8 Binary files /dev/null and b/public/images/fide-fed-webp/BHU.webp differ diff --git a/public/images/fide-fed-webp/BIH.webp b/public/images/fide-fed-webp/BIH.webp new file mode 100644 index 0000000000000..fac8601e6d3fc Binary files /dev/null and b/public/images/fide-fed-webp/BIH.webp differ diff --git a/public/images/fide-fed-webp/BIZ.webp b/public/images/fide-fed-webp/BIZ.webp new file mode 100644 index 0000000000000..e3a1dd88bf949 Binary files /dev/null and b/public/images/fide-fed-webp/BIZ.webp differ diff --git a/public/images/fide-fed-webp/BLR.webp b/public/images/fide-fed-webp/BLR.webp new file mode 120000 index 0000000000000..c3ade4e69d156 --- /dev/null +++ b/public/images/fide-fed-webp/BLR.webp @@ -0,0 +1 @@ +W.webp \ No newline at end of file diff --git a/public/images/fide-fed-webp/BOL.webp b/public/images/fide-fed-webp/BOL.webp new file mode 100644 index 0000000000000..e1ba7c34627e6 Binary files /dev/null and b/public/images/fide-fed-webp/BOL.webp differ diff --git a/public/images/fide-fed-webp/BOT.webp b/public/images/fide-fed-webp/BOT.webp new file mode 100644 index 0000000000000..3033f76dfa20e Binary files /dev/null and b/public/images/fide-fed-webp/BOT.webp differ diff --git a/public/images/fide-fed-webp/BRA.webp b/public/images/fide-fed-webp/BRA.webp new file mode 100644 index 0000000000000..55925712889e7 Binary files /dev/null and b/public/images/fide-fed-webp/BRA.webp differ diff --git a/public/images/fide-fed-webp/BRN.webp b/public/images/fide-fed-webp/BRN.webp new file mode 100644 index 0000000000000..27872ee0d264d Binary files /dev/null and b/public/images/fide-fed-webp/BRN.webp differ diff --git a/public/images/fide-fed-webp/BRU.webp b/public/images/fide-fed-webp/BRU.webp new file mode 100644 index 0000000000000..a95e3bb1e0f09 Binary files /dev/null and b/public/images/fide-fed-webp/BRU.webp differ diff --git a/public/images/fide-fed-webp/BUL.webp b/public/images/fide-fed-webp/BUL.webp new file mode 100644 index 0000000000000..f815b92d3a2e8 Binary files /dev/null and b/public/images/fide-fed-webp/BUL.webp differ diff --git a/public/images/fide-fed-webp/BUR.webp b/public/images/fide-fed-webp/BUR.webp new file mode 100644 index 0000000000000..f229e6e21cdd6 Binary files /dev/null and b/public/images/fide-fed-webp/BUR.webp differ diff --git a/public/images/fide-fed-webp/CAF.webp b/public/images/fide-fed-webp/CAF.webp new file mode 100644 index 0000000000000..f8569f6e4d314 Binary files /dev/null and b/public/images/fide-fed-webp/CAF.webp differ diff --git a/public/images/fide-fed-webp/CAM.webp b/public/images/fide-fed-webp/CAM.webp new file mode 100644 index 0000000000000..88e408fe51444 Binary files /dev/null and b/public/images/fide-fed-webp/CAM.webp differ diff --git a/public/images/fide-fed-webp/CAN.webp b/public/images/fide-fed-webp/CAN.webp new file mode 100644 index 0000000000000..4fe53cd2bc10b Binary files /dev/null and b/public/images/fide-fed-webp/CAN.webp differ diff --git a/public/images/fide-fed-webp/CAY.webp b/public/images/fide-fed-webp/CAY.webp new file mode 100644 index 0000000000000..aca4f0a833a15 Binary files /dev/null and b/public/images/fide-fed-webp/CAY.webp differ diff --git a/public/images/fide-fed-webp/CHA.webp b/public/images/fide-fed-webp/CHA.webp new file mode 100644 index 0000000000000..036b0019114b2 Binary files /dev/null and b/public/images/fide-fed-webp/CHA.webp differ diff --git a/public/images/fide-fed-webp/CHI.webp b/public/images/fide-fed-webp/CHI.webp new file mode 100644 index 0000000000000..8cdfe00c54a22 Binary files /dev/null and b/public/images/fide-fed-webp/CHI.webp differ diff --git a/public/images/fide-fed-webp/CHN.webp b/public/images/fide-fed-webp/CHN.webp new file mode 100644 index 0000000000000..1c33718f950a5 Binary files /dev/null and b/public/images/fide-fed-webp/CHN.webp differ diff --git a/public/images/fide-fed-webp/CIV.webp b/public/images/fide-fed-webp/CIV.webp new file mode 100644 index 0000000000000..b1129610baf40 Binary files /dev/null and b/public/images/fide-fed-webp/CIV.webp differ diff --git a/public/images/fide-fed-webp/CMR.webp b/public/images/fide-fed-webp/CMR.webp new file mode 100644 index 0000000000000..b5c5f4026a1e3 Binary files /dev/null and b/public/images/fide-fed-webp/CMR.webp differ diff --git a/public/images/fide-fed-webp/COD.webp b/public/images/fide-fed-webp/COD.webp new file mode 100644 index 0000000000000..33ed26bb3074b Binary files /dev/null and b/public/images/fide-fed-webp/COD.webp differ diff --git a/public/images/fide-fed-webp/COL.webp b/public/images/fide-fed-webp/COL.webp new file mode 100644 index 0000000000000..f8d2c198b3bf4 Binary files /dev/null and b/public/images/fide-fed-webp/COL.webp differ diff --git a/public/images/fide-fed-webp/COM.webp b/public/images/fide-fed-webp/COM.webp new file mode 100644 index 0000000000000..1b2663111ccaa Binary files /dev/null and b/public/images/fide-fed-webp/COM.webp differ diff --git a/public/images/fide-fed-webp/CPV.webp b/public/images/fide-fed-webp/CPV.webp new file mode 100644 index 0000000000000..230eeeec506e8 Binary files /dev/null and b/public/images/fide-fed-webp/CPV.webp differ diff --git a/public/images/fide-fed-webp/CRC.webp b/public/images/fide-fed-webp/CRC.webp new file mode 100644 index 0000000000000..362b753a3e3f6 Binary files /dev/null and b/public/images/fide-fed-webp/CRC.webp differ diff --git a/public/images/fide-fed-webp/CRO.webp b/public/images/fide-fed-webp/CRO.webp new file mode 100644 index 0000000000000..1fb109098805f Binary files /dev/null and b/public/images/fide-fed-webp/CRO.webp differ diff --git a/public/images/fide-fed-webp/CUB.webp b/public/images/fide-fed-webp/CUB.webp new file mode 100644 index 0000000000000..309cad6122386 Binary files /dev/null and b/public/images/fide-fed-webp/CUB.webp differ diff --git a/public/images/fide-fed-webp/CYP.webp b/public/images/fide-fed-webp/CYP.webp new file mode 100644 index 0000000000000..beabd29902351 Binary files /dev/null and b/public/images/fide-fed-webp/CYP.webp differ diff --git a/public/images/fide-fed-webp/CZE.webp b/public/images/fide-fed-webp/CZE.webp new file mode 100644 index 0000000000000..4aaaad63e0af0 Binary files /dev/null and b/public/images/fide-fed-webp/CZE.webp differ diff --git a/public/images/fide-fed-webp/DEN.webp b/public/images/fide-fed-webp/DEN.webp new file mode 100644 index 0000000000000..9e780b090db25 Binary files /dev/null and b/public/images/fide-fed-webp/DEN.webp differ diff --git a/public/images/fide-fed-webp/DJI.webp b/public/images/fide-fed-webp/DJI.webp new file mode 100644 index 0000000000000..46180f8cc2028 Binary files /dev/null and b/public/images/fide-fed-webp/DJI.webp differ diff --git a/public/images/fide-fed-webp/DMA.webp b/public/images/fide-fed-webp/DMA.webp new file mode 100644 index 0000000000000..236123cc3de11 Binary files /dev/null and b/public/images/fide-fed-webp/DMA.webp differ diff --git a/public/images/fide-fed-webp/DOM.webp b/public/images/fide-fed-webp/DOM.webp new file mode 100644 index 0000000000000..d0bffb749befb Binary files /dev/null and b/public/images/fide-fed-webp/DOM.webp differ diff --git a/public/images/fide-fed-webp/ECU.webp b/public/images/fide-fed-webp/ECU.webp new file mode 100644 index 0000000000000..b727882b69350 Binary files /dev/null and b/public/images/fide-fed-webp/ECU.webp differ diff --git a/public/images/fide-fed-webp/EGY.webp b/public/images/fide-fed-webp/EGY.webp new file mode 100644 index 0000000000000..0a0fd74ffe66c Binary files /dev/null and b/public/images/fide-fed-webp/EGY.webp differ diff --git a/public/images/fide-fed-webp/ENG.webp b/public/images/fide-fed-webp/ENG.webp new file mode 100644 index 0000000000000..11aa31c60b0f1 Binary files /dev/null and b/public/images/fide-fed-webp/ENG.webp differ diff --git a/public/images/fide-fed-webp/ERI.webp b/public/images/fide-fed-webp/ERI.webp new file mode 100644 index 0000000000000..ac9ae88455d67 Binary files /dev/null and b/public/images/fide-fed-webp/ERI.webp differ diff --git a/public/images/fide-fed-webp/ESA.webp b/public/images/fide-fed-webp/ESA.webp new file mode 100644 index 0000000000000..fb72a5034be9d Binary files /dev/null and b/public/images/fide-fed-webp/ESA.webp differ diff --git a/public/images/fide-fed-webp/ESP.webp b/public/images/fide-fed-webp/ESP.webp new file mode 100644 index 0000000000000..0758794fee74c Binary files /dev/null and b/public/images/fide-fed-webp/ESP.webp differ diff --git a/public/images/fide-fed-webp/EST.webp b/public/images/fide-fed-webp/EST.webp new file mode 100644 index 0000000000000..3de59f88425b1 Binary files /dev/null and b/public/images/fide-fed-webp/EST.webp differ diff --git a/public/images/fide-fed-webp/ETH.webp b/public/images/fide-fed-webp/ETH.webp new file mode 100644 index 0000000000000..72f057ff77d9c Binary files /dev/null and b/public/images/fide-fed-webp/ETH.webp differ diff --git a/public/images/fide-fed-webp/FAI.webp b/public/images/fide-fed-webp/FAI.webp new file mode 100644 index 0000000000000..c4f68823ef7bb Binary files /dev/null and b/public/images/fide-fed-webp/FAI.webp differ diff --git a/public/images/fide-fed-webp/FID.webp b/public/images/fide-fed-webp/FID.webp new file mode 100644 index 0000000000000..eac8291cbdf53 Binary files /dev/null and b/public/images/fide-fed-webp/FID.webp differ diff --git a/public/images/fide-fed-webp/FIJ.webp b/public/images/fide-fed-webp/FIJ.webp new file mode 100644 index 0000000000000..6992433921273 Binary files /dev/null and b/public/images/fide-fed-webp/FIJ.webp differ diff --git a/public/images/fide-fed-webp/FIN.webp b/public/images/fide-fed-webp/FIN.webp new file mode 100644 index 0000000000000..ffb9fa7aaa486 Binary files /dev/null and b/public/images/fide-fed-webp/FIN.webp differ diff --git a/public/images/fide-fed-webp/FRA.webp b/public/images/fide-fed-webp/FRA.webp new file mode 100644 index 0000000000000..cde803312fbe7 Binary files /dev/null and b/public/images/fide-fed-webp/FRA.webp differ diff --git a/public/images/fide-fed-webp/GAB.webp b/public/images/fide-fed-webp/GAB.webp new file mode 100644 index 0000000000000..ff5faf69443dd Binary files /dev/null and b/public/images/fide-fed-webp/GAB.webp differ diff --git a/public/images/fide-fed-webp/GAM.webp b/public/images/fide-fed-webp/GAM.webp new file mode 100644 index 0000000000000..160e2b1c1f0c9 Binary files /dev/null and b/public/images/fide-fed-webp/GAM.webp differ diff --git a/public/images/fide-fed-webp/GCI.webp b/public/images/fide-fed-webp/GCI.webp new file mode 100644 index 0000000000000..0972c5d6b7507 Binary files /dev/null and b/public/images/fide-fed-webp/GCI.webp differ diff --git a/public/images/fide-fed-webp/GEO.webp b/public/images/fide-fed-webp/GEO.webp new file mode 100644 index 0000000000000..74f5c7ccd7731 Binary files /dev/null and b/public/images/fide-fed-webp/GEO.webp differ diff --git a/public/images/fide-fed-webp/GEQ.webp b/public/images/fide-fed-webp/GEQ.webp new file mode 100644 index 0000000000000..9090d3dfe6f8b Binary files /dev/null and b/public/images/fide-fed-webp/GEQ.webp differ diff --git a/public/images/fide-fed-webp/GER.webp b/public/images/fide-fed-webp/GER.webp new file mode 100644 index 0000000000000..fa98edb5f4c4e Binary files /dev/null and b/public/images/fide-fed-webp/GER.webp differ diff --git a/public/images/fide-fed-webp/GHA.webp b/public/images/fide-fed-webp/GHA.webp new file mode 100644 index 0000000000000..ec2d4585f5b8a Binary files /dev/null and b/public/images/fide-fed-webp/GHA.webp differ diff --git a/public/images/fide-fed-webp/GRE.webp b/public/images/fide-fed-webp/GRE.webp new file mode 100644 index 0000000000000..55b4a6755c86f Binary files /dev/null and b/public/images/fide-fed-webp/GRE.webp differ diff --git a/public/images/fide-fed-webp/GRN.webp b/public/images/fide-fed-webp/GRN.webp new file mode 100644 index 0000000000000..8868e83acbd2d Binary files /dev/null and b/public/images/fide-fed-webp/GRN.webp differ diff --git a/public/images/fide-fed-webp/GUA.webp b/public/images/fide-fed-webp/GUA.webp new file mode 100644 index 0000000000000..9b761e251cbf2 Binary files /dev/null and b/public/images/fide-fed-webp/GUA.webp differ diff --git a/public/images/fide-fed-webp/GUM.webp b/public/images/fide-fed-webp/GUM.webp new file mode 100644 index 0000000000000..2f19d043504bf Binary files /dev/null and b/public/images/fide-fed-webp/GUM.webp differ diff --git a/public/images/fide-fed-webp/GUY.webp b/public/images/fide-fed-webp/GUY.webp new file mode 100644 index 0000000000000..c4459dfd8c868 Binary files /dev/null and b/public/images/fide-fed-webp/GUY.webp differ diff --git a/public/images/fide-fed-webp/HAI.webp b/public/images/fide-fed-webp/HAI.webp new file mode 100644 index 0000000000000..51f3cdeff4a6c Binary files /dev/null and b/public/images/fide-fed-webp/HAI.webp differ diff --git a/public/images/fide-fed-webp/HKG.webp b/public/images/fide-fed-webp/HKG.webp new file mode 100644 index 0000000000000..f09e3e4ea8e7f Binary files /dev/null and b/public/images/fide-fed-webp/HKG.webp differ diff --git a/public/images/fide-fed-webp/HON.webp b/public/images/fide-fed-webp/HON.webp new file mode 100644 index 0000000000000..d948fa39df2d3 Binary files /dev/null and b/public/images/fide-fed-webp/HON.webp differ diff --git a/public/images/fide-fed-webp/HUN.webp b/public/images/fide-fed-webp/HUN.webp new file mode 100644 index 0000000000000..2f49d5de27e1f Binary files /dev/null and b/public/images/fide-fed-webp/HUN.webp differ diff --git a/public/images/fide-fed-webp/INA.webp b/public/images/fide-fed-webp/INA.webp new file mode 100644 index 0000000000000..4991cfae054c8 Binary files /dev/null and b/public/images/fide-fed-webp/INA.webp differ diff --git a/public/images/fide-fed-webp/IND.webp b/public/images/fide-fed-webp/IND.webp new file mode 100644 index 0000000000000..1ae46b3295b00 Binary files /dev/null and b/public/images/fide-fed-webp/IND.webp differ diff --git a/public/images/fide-fed-webp/IOM.webp b/public/images/fide-fed-webp/IOM.webp new file mode 100644 index 0000000000000..eff71d998a9ff Binary files /dev/null and b/public/images/fide-fed-webp/IOM.webp differ diff --git a/public/images/fide-fed-webp/IRI.webp b/public/images/fide-fed-webp/IRI.webp new file mode 100644 index 0000000000000..b9a49d45d139f Binary files /dev/null and b/public/images/fide-fed-webp/IRI.webp differ diff --git a/public/images/fide-fed-webp/IRL.webp b/public/images/fide-fed-webp/IRL.webp new file mode 100644 index 0000000000000..213ecb6b91adb Binary files /dev/null and b/public/images/fide-fed-webp/IRL.webp differ diff --git a/public/images/fide-fed-webp/IRQ.webp b/public/images/fide-fed-webp/IRQ.webp new file mode 100644 index 0000000000000..e6094812229f8 Binary files /dev/null and b/public/images/fide-fed-webp/IRQ.webp differ diff --git a/public/images/fide-fed-webp/ISL.webp b/public/images/fide-fed-webp/ISL.webp new file mode 100644 index 0000000000000..9ba8bd1a409c8 Binary files /dev/null and b/public/images/fide-fed-webp/ISL.webp differ diff --git a/public/images/fide-fed-webp/ISR.webp b/public/images/fide-fed-webp/ISR.webp new file mode 100644 index 0000000000000..61157ce91bfb5 Binary files /dev/null and b/public/images/fide-fed-webp/ISR.webp differ diff --git a/public/images/fide-fed-webp/ISV.webp b/public/images/fide-fed-webp/ISV.webp new file mode 100644 index 0000000000000..21ad38713d8a0 Binary files /dev/null and b/public/images/fide-fed-webp/ISV.webp differ diff --git a/public/images/fide-fed-webp/ITA.webp b/public/images/fide-fed-webp/ITA.webp new file mode 100644 index 0000000000000..9f13085e6aa2b Binary files /dev/null and b/public/images/fide-fed-webp/ITA.webp differ diff --git a/public/images/fide-fed-webp/IVB.webp b/public/images/fide-fed-webp/IVB.webp new file mode 100644 index 0000000000000..bd1e5c3ee3bf0 Binary files /dev/null and b/public/images/fide-fed-webp/IVB.webp differ diff --git a/public/images/fide-fed-webp/JAM.webp b/public/images/fide-fed-webp/JAM.webp new file mode 100644 index 0000000000000..c25acec483c40 Binary files /dev/null and b/public/images/fide-fed-webp/JAM.webp differ diff --git a/public/images/fide-fed-webp/JCI.webp b/public/images/fide-fed-webp/JCI.webp new file mode 100644 index 0000000000000..84eafd249d2b5 Binary files /dev/null and b/public/images/fide-fed-webp/JCI.webp differ diff --git a/public/images/fide-fed-webp/JOR.webp b/public/images/fide-fed-webp/JOR.webp new file mode 100644 index 0000000000000..6214461af43a0 Binary files /dev/null and b/public/images/fide-fed-webp/JOR.webp differ diff --git a/public/images/fide-fed-webp/JPN.webp b/public/images/fide-fed-webp/JPN.webp new file mode 100644 index 0000000000000..fcadfc30efd00 Binary files /dev/null and b/public/images/fide-fed-webp/JPN.webp differ diff --git a/public/images/fide-fed-webp/KAZ.webp b/public/images/fide-fed-webp/KAZ.webp new file mode 100644 index 0000000000000..1f1c8d921cbf0 Binary files /dev/null and b/public/images/fide-fed-webp/KAZ.webp differ diff --git a/public/images/fide-fed-webp/KEN.webp b/public/images/fide-fed-webp/KEN.webp new file mode 100644 index 0000000000000..4b68359873ef3 Binary files /dev/null and b/public/images/fide-fed-webp/KEN.webp differ diff --git a/public/images/fide-fed-webp/KGZ.webp b/public/images/fide-fed-webp/KGZ.webp new file mode 100644 index 0000000000000..5ba48b5f9e083 Binary files /dev/null and b/public/images/fide-fed-webp/KGZ.webp differ diff --git a/public/images/fide-fed-webp/KOR.webp b/public/images/fide-fed-webp/KOR.webp new file mode 100644 index 0000000000000..8bf09f32767be Binary files /dev/null and b/public/images/fide-fed-webp/KOR.webp differ diff --git a/public/images/fide-fed-webp/KOS.webp b/public/images/fide-fed-webp/KOS.webp new file mode 100644 index 0000000000000..9812044435028 Binary files /dev/null and b/public/images/fide-fed-webp/KOS.webp differ diff --git a/public/images/fide-fed-webp/KSA.webp b/public/images/fide-fed-webp/KSA.webp new file mode 100644 index 0000000000000..9578df73b7d5c Binary files /dev/null and b/public/images/fide-fed-webp/KSA.webp differ diff --git a/public/images/fide-fed-webp/KUW.webp b/public/images/fide-fed-webp/KUW.webp new file mode 100644 index 0000000000000..008b2ee437a88 Binary files /dev/null and b/public/images/fide-fed-webp/KUW.webp differ diff --git a/public/images/fide-fed-webp/LAO.webp b/public/images/fide-fed-webp/LAO.webp new file mode 100644 index 0000000000000..cecc93be54f16 Binary files /dev/null and b/public/images/fide-fed-webp/LAO.webp differ diff --git a/public/images/fide-fed-webp/LAT.webp b/public/images/fide-fed-webp/LAT.webp new file mode 100644 index 0000000000000..75ed94dc6f103 Binary files /dev/null and b/public/images/fide-fed-webp/LAT.webp differ diff --git a/public/images/fide-fed-webp/LBA.webp b/public/images/fide-fed-webp/LBA.webp new file mode 100644 index 0000000000000..af7abccf354e9 Binary files /dev/null and b/public/images/fide-fed-webp/LBA.webp differ diff --git a/public/images/fide-fed-webp/LBN.webp b/public/images/fide-fed-webp/LBN.webp new file mode 100644 index 0000000000000..aaffa4ab92d2f Binary files /dev/null and b/public/images/fide-fed-webp/LBN.webp differ diff --git a/public/images/fide-fed-webp/LBR.webp b/public/images/fide-fed-webp/LBR.webp new file mode 100644 index 0000000000000..8e1f5ff31e56e Binary files /dev/null and b/public/images/fide-fed-webp/LBR.webp differ diff --git a/public/images/fide-fed-webp/LCA.webp b/public/images/fide-fed-webp/LCA.webp new file mode 100644 index 0000000000000..df6121ce84089 Binary files /dev/null and b/public/images/fide-fed-webp/LCA.webp differ diff --git a/public/images/fide-fed-webp/LES.webp b/public/images/fide-fed-webp/LES.webp new file mode 100644 index 0000000000000..97f130c1c10d7 Binary files /dev/null and b/public/images/fide-fed-webp/LES.webp differ diff --git a/public/images/fide-fed-webp/LIE.webp b/public/images/fide-fed-webp/LIE.webp new file mode 100644 index 0000000000000..913754637e09b Binary files /dev/null and b/public/images/fide-fed-webp/LIE.webp differ diff --git a/public/images/fide-fed-webp/LTU.webp b/public/images/fide-fed-webp/LTU.webp new file mode 100644 index 0000000000000..8dfbc1705265f Binary files /dev/null and b/public/images/fide-fed-webp/LTU.webp differ diff --git a/public/images/fide-fed-webp/LUX.webp b/public/images/fide-fed-webp/LUX.webp new file mode 100644 index 0000000000000..d9d7297e081eb Binary files /dev/null and b/public/images/fide-fed-webp/LUX.webp differ diff --git a/public/images/fide-fed-webp/MAC.webp b/public/images/fide-fed-webp/MAC.webp new file mode 100644 index 0000000000000..f2a1409e10ea9 Binary files /dev/null and b/public/images/fide-fed-webp/MAC.webp differ diff --git a/public/images/fide-fed-webp/MAD.webp b/public/images/fide-fed-webp/MAD.webp new file mode 100644 index 0000000000000..ed50a939cf660 Binary files /dev/null and b/public/images/fide-fed-webp/MAD.webp differ diff --git a/public/images/fide-fed-webp/MAR.webp b/public/images/fide-fed-webp/MAR.webp new file mode 100644 index 0000000000000..e6e48cf1516fa Binary files /dev/null and b/public/images/fide-fed-webp/MAR.webp differ diff --git a/public/images/fide-fed-webp/MAS.webp b/public/images/fide-fed-webp/MAS.webp new file mode 100644 index 0000000000000..1bb1779c59d6b Binary files /dev/null and b/public/images/fide-fed-webp/MAS.webp differ diff --git a/public/images/fide-fed-webp/MAW.webp b/public/images/fide-fed-webp/MAW.webp new file mode 100644 index 0000000000000..243cb7cb15b8c Binary files /dev/null and b/public/images/fide-fed-webp/MAW.webp differ diff --git a/public/images/fide-fed-webp/MDA.webp b/public/images/fide-fed-webp/MDA.webp new file mode 100644 index 0000000000000..0f7fd88352f2e Binary files /dev/null and b/public/images/fide-fed-webp/MDA.webp differ diff --git a/public/images/fide-fed-webp/MDV.webp b/public/images/fide-fed-webp/MDV.webp new file mode 100644 index 0000000000000..431881d451ada Binary files /dev/null and b/public/images/fide-fed-webp/MDV.webp differ diff --git a/public/images/fide-fed-webp/MEX.webp b/public/images/fide-fed-webp/MEX.webp new file mode 100644 index 0000000000000..202907ef812b3 Binary files /dev/null and b/public/images/fide-fed-webp/MEX.webp differ diff --git a/public/images/fide-fed-webp/MGL.webp b/public/images/fide-fed-webp/MGL.webp new file mode 100644 index 0000000000000..3a7a5a77a9df4 Binary files /dev/null and b/public/images/fide-fed-webp/MGL.webp differ diff --git a/public/images/fide-fed-webp/MKD.webp b/public/images/fide-fed-webp/MKD.webp new file mode 100644 index 0000000000000..4fd3e8508b281 Binary files /dev/null and b/public/images/fide-fed-webp/MKD.webp differ diff --git a/public/images/fide-fed-webp/MLI.webp b/public/images/fide-fed-webp/MLI.webp new file mode 100644 index 0000000000000..cbab834546b74 Binary files /dev/null and b/public/images/fide-fed-webp/MLI.webp differ diff --git a/public/images/fide-fed-webp/MLT.webp b/public/images/fide-fed-webp/MLT.webp new file mode 100644 index 0000000000000..041f593d18e57 Binary files /dev/null and b/public/images/fide-fed-webp/MLT.webp differ diff --git a/public/images/fide-fed-webp/MNC.webp b/public/images/fide-fed-webp/MNC.webp new file mode 100644 index 0000000000000..7278c9911a5e8 Binary files /dev/null and b/public/images/fide-fed-webp/MNC.webp differ diff --git a/public/images/fide-fed-webp/MNE.webp b/public/images/fide-fed-webp/MNE.webp new file mode 100644 index 0000000000000..8f199fcbaf628 Binary files /dev/null and b/public/images/fide-fed-webp/MNE.webp differ diff --git a/public/images/fide-fed-webp/MOZ.webp b/public/images/fide-fed-webp/MOZ.webp new file mode 100644 index 0000000000000..55665047bb717 Binary files /dev/null and b/public/images/fide-fed-webp/MOZ.webp differ diff --git a/public/images/fide-fed-webp/MRI.webp b/public/images/fide-fed-webp/MRI.webp new file mode 100644 index 0000000000000..85130dd34787c Binary files /dev/null and b/public/images/fide-fed-webp/MRI.webp differ diff --git a/public/images/fide-fed-webp/MTN.webp b/public/images/fide-fed-webp/MTN.webp new file mode 100644 index 0000000000000..f6f24ce1602d1 Binary files /dev/null and b/public/images/fide-fed-webp/MTN.webp differ diff --git a/public/images/fide-fed-webp/MYA.webp b/public/images/fide-fed-webp/MYA.webp new file mode 100644 index 0000000000000..ea7ffc670033f Binary files /dev/null and b/public/images/fide-fed-webp/MYA.webp differ diff --git a/public/images/fide-fed-webp/NAM.webp b/public/images/fide-fed-webp/NAM.webp new file mode 100644 index 0000000000000..1ff72950a65d3 Binary files /dev/null and b/public/images/fide-fed-webp/NAM.webp differ diff --git a/public/images/fide-fed-webp/NCA.webp b/public/images/fide-fed-webp/NCA.webp new file mode 100644 index 0000000000000..11932f93630a3 Binary files /dev/null and b/public/images/fide-fed-webp/NCA.webp differ diff --git a/public/images/fide-fed-webp/NCL.webp b/public/images/fide-fed-webp/NCL.webp new file mode 100644 index 0000000000000..be3e93763cc19 Binary files /dev/null and b/public/images/fide-fed-webp/NCL.webp differ diff --git a/public/images/fide-fed-webp/NED.webp b/public/images/fide-fed-webp/NED.webp new file mode 100644 index 0000000000000..12cadcc8b3147 Binary files /dev/null and b/public/images/fide-fed-webp/NED.webp differ diff --git a/public/images/fide-fed-webp/NEP.webp b/public/images/fide-fed-webp/NEP.webp new file mode 100644 index 0000000000000..333ef480882dd Binary files /dev/null and b/public/images/fide-fed-webp/NEP.webp differ diff --git a/public/images/fide-fed-webp/NGR.webp b/public/images/fide-fed-webp/NGR.webp new file mode 100644 index 0000000000000..da360dfebf35d Binary files /dev/null and b/public/images/fide-fed-webp/NGR.webp differ diff --git a/public/images/fide-fed-webp/NIG.webp b/public/images/fide-fed-webp/NIG.webp new file mode 100644 index 0000000000000..9f13e824e8f80 Binary files /dev/null and b/public/images/fide-fed-webp/NIG.webp differ diff --git a/public/images/fide-fed-webp/NOR.webp b/public/images/fide-fed-webp/NOR.webp new file mode 100644 index 0000000000000..56b3070000928 Binary files /dev/null and b/public/images/fide-fed-webp/NOR.webp differ diff --git a/public/images/fide-fed-webp/NRU.webp b/public/images/fide-fed-webp/NRU.webp new file mode 100644 index 0000000000000..9c44f2c0d00f5 Binary files /dev/null and b/public/images/fide-fed-webp/NRU.webp differ diff --git a/public/images/fide-fed-webp/NZL.webp b/public/images/fide-fed-webp/NZL.webp new file mode 100644 index 0000000000000..c45257f33d72c Binary files /dev/null and b/public/images/fide-fed-webp/NZL.webp differ diff --git a/public/images/fide-fed-webp/OMA.webp b/public/images/fide-fed-webp/OMA.webp new file mode 100644 index 0000000000000..ed55be232aa51 Binary files /dev/null and b/public/images/fide-fed-webp/OMA.webp differ diff --git a/public/images/fide-fed-webp/PAK.webp b/public/images/fide-fed-webp/PAK.webp new file mode 100644 index 0000000000000..cdfb416617357 Binary files /dev/null and b/public/images/fide-fed-webp/PAK.webp differ diff --git a/public/images/fide-fed-webp/PAN.webp b/public/images/fide-fed-webp/PAN.webp new file mode 100644 index 0000000000000..67a7c49d6ee9f Binary files /dev/null and b/public/images/fide-fed-webp/PAN.webp differ diff --git a/public/images/fide-fed-webp/PAR.webp b/public/images/fide-fed-webp/PAR.webp new file mode 100644 index 0000000000000..a7d149fb011bc Binary files /dev/null and b/public/images/fide-fed-webp/PAR.webp differ diff --git a/public/images/fide-fed-webp/PER.webp b/public/images/fide-fed-webp/PER.webp new file mode 100644 index 0000000000000..f949c113110dd Binary files /dev/null and b/public/images/fide-fed-webp/PER.webp differ diff --git a/public/images/fide-fed-webp/PHI.webp b/public/images/fide-fed-webp/PHI.webp new file mode 100644 index 0000000000000..7f9159d2749c4 Binary files /dev/null and b/public/images/fide-fed-webp/PHI.webp differ diff --git a/public/images/fide-fed-webp/PLE.webp b/public/images/fide-fed-webp/PLE.webp new file mode 100644 index 0000000000000..f30755335acba Binary files /dev/null and b/public/images/fide-fed-webp/PLE.webp differ diff --git a/public/images/fide-fed-webp/PLW.webp b/public/images/fide-fed-webp/PLW.webp new file mode 100644 index 0000000000000..dcd39d65fa15f Binary files /dev/null and b/public/images/fide-fed-webp/PLW.webp differ diff --git a/public/images/fide-fed-webp/PNG.webp b/public/images/fide-fed-webp/PNG.webp new file mode 100644 index 0000000000000..34f11514ca427 Binary files /dev/null and b/public/images/fide-fed-webp/PNG.webp differ diff --git a/public/images/fide-fed-webp/POL.webp b/public/images/fide-fed-webp/POL.webp new file mode 100644 index 0000000000000..abcad07143114 Binary files /dev/null and b/public/images/fide-fed-webp/POL.webp differ diff --git a/public/images/fide-fed-webp/POR.webp b/public/images/fide-fed-webp/POR.webp new file mode 100644 index 0000000000000..c5caba04e6e1f Binary files /dev/null and b/public/images/fide-fed-webp/POR.webp differ diff --git a/public/images/fide-fed-webp/PUR.webp b/public/images/fide-fed-webp/PUR.webp new file mode 100644 index 0000000000000..5c5cf24ced92d Binary files /dev/null and b/public/images/fide-fed-webp/PUR.webp differ diff --git a/public/images/fide-fed-webp/QAT.webp b/public/images/fide-fed-webp/QAT.webp new file mode 100644 index 0000000000000..ef5f30dffa0d6 Binary files /dev/null and b/public/images/fide-fed-webp/QAT.webp differ diff --git a/public/images/fide-fed-webp/ROU.webp b/public/images/fide-fed-webp/ROU.webp new file mode 100644 index 0000000000000..46317dec14ad2 Binary files /dev/null and b/public/images/fide-fed-webp/ROU.webp differ diff --git a/public/images/fide-fed-webp/RSA.webp b/public/images/fide-fed-webp/RSA.webp new file mode 100644 index 0000000000000..e31ee93bc1961 Binary files /dev/null and b/public/images/fide-fed-webp/RSA.webp differ diff --git a/public/images/fide-fed-webp/RUS.webp b/public/images/fide-fed-webp/RUS.webp new file mode 120000 index 0000000000000..c3ade4e69d156 --- /dev/null +++ b/public/images/fide-fed-webp/RUS.webp @@ -0,0 +1 @@ +W.webp \ No newline at end of file diff --git a/public/images/fide-fed-webp/RWA.webp b/public/images/fide-fed-webp/RWA.webp new file mode 100644 index 0000000000000..4609b37e3c380 Binary files /dev/null and b/public/images/fide-fed-webp/RWA.webp differ diff --git a/public/images/fide-fed-webp/SCO.webp b/public/images/fide-fed-webp/SCO.webp new file mode 100644 index 0000000000000..436529d17f897 Binary files /dev/null and b/public/images/fide-fed-webp/SCO.webp differ diff --git a/public/images/fide-fed-webp/SEN.webp b/public/images/fide-fed-webp/SEN.webp new file mode 100644 index 0000000000000..fbb5871e85059 Binary files /dev/null and b/public/images/fide-fed-webp/SEN.webp differ diff --git a/public/images/fide-fed-webp/SEY.webp b/public/images/fide-fed-webp/SEY.webp new file mode 100644 index 0000000000000..8eb606f2b0973 Binary files /dev/null and b/public/images/fide-fed-webp/SEY.webp differ diff --git a/public/images/fide-fed-webp/SGP.webp b/public/images/fide-fed-webp/SGP.webp new file mode 100644 index 0000000000000..11a76b9e9638a Binary files /dev/null and b/public/images/fide-fed-webp/SGP.webp differ diff --git a/public/images/fide-fed-webp/SKN.webp b/public/images/fide-fed-webp/SKN.webp new file mode 100644 index 0000000000000..f0a4bf545acb3 Binary files /dev/null and b/public/images/fide-fed-webp/SKN.webp differ diff --git a/public/images/fide-fed-webp/SLE.webp b/public/images/fide-fed-webp/SLE.webp new file mode 100644 index 0000000000000..b574569bdd648 Binary files /dev/null and b/public/images/fide-fed-webp/SLE.webp differ diff --git a/public/images/fide-fed-webp/SLO.webp b/public/images/fide-fed-webp/SLO.webp new file mode 100644 index 0000000000000..368a408297889 Binary files /dev/null and b/public/images/fide-fed-webp/SLO.webp differ diff --git a/public/images/fide-fed-webp/SMR.webp b/public/images/fide-fed-webp/SMR.webp new file mode 100644 index 0000000000000..b82412e60f308 Binary files /dev/null and b/public/images/fide-fed-webp/SMR.webp differ diff --git a/public/images/fide-fed-webp/SOL.webp b/public/images/fide-fed-webp/SOL.webp new file mode 100644 index 0000000000000..9857b108737b5 Binary files /dev/null and b/public/images/fide-fed-webp/SOL.webp differ diff --git a/public/images/fide-fed-webp/SOM.webp b/public/images/fide-fed-webp/SOM.webp new file mode 100644 index 0000000000000..e62aa1f4b96cf Binary files /dev/null and b/public/images/fide-fed-webp/SOM.webp differ diff --git a/public/images/fide-fed-webp/SRB.webp b/public/images/fide-fed-webp/SRB.webp new file mode 100644 index 0000000000000..6c70a93f99ed0 Binary files /dev/null and b/public/images/fide-fed-webp/SRB.webp differ diff --git a/public/images/fide-fed-webp/SRI.webp b/public/images/fide-fed-webp/SRI.webp new file mode 100644 index 0000000000000..594cc8337703c Binary files /dev/null and b/public/images/fide-fed-webp/SRI.webp differ diff --git a/public/images/fide-fed-webp/SSD.webp b/public/images/fide-fed-webp/SSD.webp new file mode 100644 index 0000000000000..bda4c9cb5e7b0 Binary files /dev/null and b/public/images/fide-fed-webp/SSD.webp differ diff --git a/public/images/fide-fed-webp/STP.webp b/public/images/fide-fed-webp/STP.webp new file mode 100644 index 0000000000000..1475d7dc30e3a Binary files /dev/null and b/public/images/fide-fed-webp/STP.webp differ diff --git a/public/images/fide-fed-webp/SUD.webp b/public/images/fide-fed-webp/SUD.webp new file mode 100644 index 0000000000000..34f31913ba2be Binary files /dev/null and b/public/images/fide-fed-webp/SUD.webp differ diff --git a/public/images/fide-fed-webp/SUI.webp b/public/images/fide-fed-webp/SUI.webp new file mode 100644 index 0000000000000..d28b3baf384bf Binary files /dev/null and b/public/images/fide-fed-webp/SUI.webp differ diff --git a/public/images/fide-fed-webp/SUR.webp b/public/images/fide-fed-webp/SUR.webp new file mode 100644 index 0000000000000..163462af59103 Binary files /dev/null and b/public/images/fide-fed-webp/SUR.webp differ diff --git a/public/images/fide-fed-webp/SVK.webp b/public/images/fide-fed-webp/SVK.webp new file mode 100644 index 0000000000000..7e3fe9b52bbff Binary files /dev/null and b/public/images/fide-fed-webp/SVK.webp differ diff --git a/public/images/fide-fed-webp/SWE.webp b/public/images/fide-fed-webp/SWE.webp new file mode 100644 index 0000000000000..e487289a73d6b Binary files /dev/null and b/public/images/fide-fed-webp/SWE.webp differ diff --git a/public/images/fide-fed-webp/SWZ.webp b/public/images/fide-fed-webp/SWZ.webp new file mode 100644 index 0000000000000..74e8f3110ab66 Binary files /dev/null and b/public/images/fide-fed-webp/SWZ.webp differ diff --git a/public/images/fide-fed-webp/SYR.webp b/public/images/fide-fed-webp/SYR.webp new file mode 100644 index 0000000000000..50aa391a4493e Binary files /dev/null and b/public/images/fide-fed-webp/SYR.webp differ diff --git a/public/images/fide-fed-webp/TAN.webp b/public/images/fide-fed-webp/TAN.webp new file mode 100644 index 0000000000000..76eed56a8d63f Binary files /dev/null and b/public/images/fide-fed-webp/TAN.webp differ diff --git a/public/images/fide-fed-webp/THA.webp b/public/images/fide-fed-webp/THA.webp new file mode 100644 index 0000000000000..1217e3f36e3d9 Binary files /dev/null and b/public/images/fide-fed-webp/THA.webp differ diff --git a/public/images/fide-fed-webp/TJK.webp b/public/images/fide-fed-webp/TJK.webp new file mode 100644 index 0000000000000..43cc76a5ea14d Binary files /dev/null and b/public/images/fide-fed-webp/TJK.webp differ diff --git a/public/images/fide-fed-webp/TKM.webp b/public/images/fide-fed-webp/TKM.webp new file mode 100644 index 0000000000000..7521b662dd137 Binary files /dev/null and b/public/images/fide-fed-webp/TKM.webp differ diff --git a/public/images/fide-fed-webp/TLS.webp b/public/images/fide-fed-webp/TLS.webp new file mode 100644 index 0000000000000..97568d408d492 Binary files /dev/null and b/public/images/fide-fed-webp/TLS.webp differ diff --git a/public/images/fide-fed-webp/TOG.webp b/public/images/fide-fed-webp/TOG.webp new file mode 100644 index 0000000000000..24c83677ea417 Binary files /dev/null and b/public/images/fide-fed-webp/TOG.webp differ diff --git a/public/images/fide-fed-webp/TPE.webp b/public/images/fide-fed-webp/TPE.webp new file mode 100644 index 0000000000000..bb760b5be4ac9 Binary files /dev/null and b/public/images/fide-fed-webp/TPE.webp differ diff --git a/public/images/fide-fed-webp/TTO.webp b/public/images/fide-fed-webp/TTO.webp new file mode 100644 index 0000000000000..fb66d70db266f Binary files /dev/null and b/public/images/fide-fed-webp/TTO.webp differ diff --git a/public/images/fide-fed-webp/TUN.webp b/public/images/fide-fed-webp/TUN.webp new file mode 100644 index 0000000000000..80d5552dcc4f1 Binary files /dev/null and b/public/images/fide-fed-webp/TUN.webp differ diff --git a/public/images/fide-fed-webp/TUR.webp b/public/images/fide-fed-webp/TUR.webp new file mode 100644 index 0000000000000..6639bf110825e Binary files /dev/null and b/public/images/fide-fed-webp/TUR.webp differ diff --git a/public/images/fide-fed-webp/UAE.webp b/public/images/fide-fed-webp/UAE.webp new file mode 100644 index 0000000000000..affb29c80acdb Binary files /dev/null and b/public/images/fide-fed-webp/UAE.webp differ diff --git a/public/images/fide-fed-webp/UGA.webp b/public/images/fide-fed-webp/UGA.webp new file mode 100644 index 0000000000000..78b8f23f1303f Binary files /dev/null and b/public/images/fide-fed-webp/UGA.webp differ diff --git a/public/images/fide-fed-webp/UKR.webp b/public/images/fide-fed-webp/UKR.webp new file mode 100644 index 0000000000000..d8ad23be23862 Binary files /dev/null and b/public/images/fide-fed-webp/UKR.webp differ diff --git a/public/images/fide-fed-webp/URU.webp b/public/images/fide-fed-webp/URU.webp new file mode 100644 index 0000000000000..8209cb3beb1ee Binary files /dev/null and b/public/images/fide-fed-webp/URU.webp differ diff --git a/public/images/fide-fed-webp/USA.webp b/public/images/fide-fed-webp/USA.webp new file mode 100644 index 0000000000000..5af42b8d7712d Binary files /dev/null and b/public/images/fide-fed-webp/USA.webp differ diff --git a/public/images/fide-fed-webp/UZB.webp b/public/images/fide-fed-webp/UZB.webp new file mode 100644 index 0000000000000..ad16ce25a5fb9 Binary files /dev/null and b/public/images/fide-fed-webp/UZB.webp differ diff --git a/public/images/fide-fed-webp/VAN.webp b/public/images/fide-fed-webp/VAN.webp new file mode 100644 index 0000000000000..8731e3205e627 Binary files /dev/null and b/public/images/fide-fed-webp/VAN.webp differ diff --git a/public/images/fide-fed-webp/VEN.webp b/public/images/fide-fed-webp/VEN.webp new file mode 100644 index 0000000000000..6d49d3b9907a5 Binary files /dev/null and b/public/images/fide-fed-webp/VEN.webp differ diff --git a/public/images/fide-fed-webp/VIE.webp b/public/images/fide-fed-webp/VIE.webp new file mode 100644 index 0000000000000..f523011b8d97c Binary files /dev/null and b/public/images/fide-fed-webp/VIE.webp differ diff --git a/public/images/fide-fed-webp/VIN.webp b/public/images/fide-fed-webp/VIN.webp new file mode 100644 index 0000000000000..8c03251840dba Binary files /dev/null and b/public/images/fide-fed-webp/VIN.webp differ diff --git a/public/images/fide-fed-webp/W.webp b/public/images/fide-fed-webp/W.webp new file mode 100644 index 0000000000000..c5bd46bcd301d Binary files /dev/null and b/public/images/fide-fed-webp/W.webp differ diff --git a/public/images/fide-fed-webp/WLS.webp b/public/images/fide-fed-webp/WLS.webp new file mode 100644 index 0000000000000..3892e13cd7199 Binary files /dev/null and b/public/images/fide-fed-webp/WLS.webp differ diff --git a/public/images/fide-fed-webp/YEM.webp b/public/images/fide-fed-webp/YEM.webp new file mode 100644 index 0000000000000..7256ce124a2d1 Binary files /dev/null and b/public/images/fide-fed-webp/YEM.webp differ diff --git a/public/images/fide-fed-webp/ZAM.webp b/public/images/fide-fed-webp/ZAM.webp new file mode 100644 index 0000000000000..6d0d47ead78a6 Binary files /dev/null and b/public/images/fide-fed-webp/ZAM.webp differ diff --git a/public/images/fide-fed-webp/ZIM.webp b/public/images/fide-fed-webp/ZIM.webp new file mode 100644 index 0000000000000..edbbb54120ec6 Binary files /dev/null and b/public/images/fide-fed-webp/ZIM.webp differ diff --git a/public/images/fide-fed/NCL.svg b/public/images/fide-fed/NCL.svg new file mode 100644 index 0000000000000..18440450440df --- /dev/null +++ b/public/images/fide-fed/NCL.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/puzzle-themes/mix.svg b/public/images/puzzle-themes/healthyMix.svg similarity index 100% rename from public/images/puzzle-themes/mix.svg rename to public/images/puzzle-themes/healthyMix.svg diff --git a/public/oops/font.html b/public/oops/font.html index 205b03e47fbc3..17ca2208ab6ad 100644 --- a/public/oops/font.html +++ b/public/oops/font.html @@ -165,5 +165,6 @@ + diff --git a/translation/source/site.xml b/translation/source/site.xml index b8d17e98bece2..6bd1ff7cb5705 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -187,6 +187,7 @@ Games Forum %1$s posted in topic %2$s + %1$s posted results for %2$s Latest forum posts Players Friends diff --git a/ui/.build/package.json b/ui/.build/package.json index 1e902d801869e..4754dc47b03fc 100644 --- a/ui/.build/package.json +++ b/ui/.build/package.json @@ -14,11 +14,13 @@ "sass-embedded-win32-x64": "1.83.3" }, "dependencies": { - "@types/node": "22.10.6", + "@types/micromatch": "^4.0.9", + "@types/node": "22.12.0", "@types/tinycolor2": "1.4.6", "esbuild": "0.24.2", "fast-glob": "3.3.3", "fast-xml-parser": "4.5.1", + "micromatch": "4.0.8", "tinycolor2": "1.6.0", "typescript": "5.7.3" }, diff --git a/ui/.build/pnpm-lock.yaml b/ui/.build/pnpm-lock.yaml index 01e7eb092ea5d..da5cb61f990e4 100644 --- a/ui/.build/pnpm-lock.yaml +++ b/ui/.build/pnpm-lock.yaml @@ -8,9 +8,12 @@ importers: .: dependencies: + '@types/micromatch': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': - specifier: 22.10.6 - version: 22.10.6 + specifier: 22.12.0 + version: 22.12.0 '@types/tinycolor2': specifier: 1.4.6 version: 1.4.6 @@ -23,6 +26,9 @@ importers: fast-xml-parser: specifier: 4.5.1 version: 4.5.1 + micromatch: + specifier: 4.0.8 + version: 4.0.8 tinycolor2: specifier: 1.6.0 version: 1.6.0 @@ -213,8 +219,14 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@types/node@22.10.6': - resolution: {integrity: sha512-qNiuwC4ZDAUNcY47xgaSuS92cjf8JbSUoaKS77bmLG1rU7MlATVSiw/IlrjtIyyskXBZ8KkNfjK/P5na7rgXbQ==} + '@types/braces@3.0.5': + resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + + '@types/micromatch@4.0.9': + resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} + + '@types/node@22.12.0': + resolution: {integrity: sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==} '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} @@ -424,7 +436,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 - '@types/node@22.10.6': + '@types/braces@3.0.5': {} + + '@types/micromatch@4.0.9': + dependencies: + '@types/braces': 3.0.5 + + '@types/node@22.12.0': dependencies: undici-types: 6.20.0 diff --git a/ui/.build/src/build.ts b/ui/.build/src/build.ts index 08e007cf82df8..6d8b7f7bd4dce 100644 --- a/ui/.build/src/build.ts +++ b/ui/.build/src/build.ts @@ -1,110 +1,73 @@ import fs from 'node:fs'; -import cps from 'node:child_process'; -import ps from 'node:process'; -import path from 'node:path'; -import { parsePackages, globArray } from './parse.ts'; -import { tsc, stopTscWatch } from './tsc.ts'; +import { execSync } from 'node:child_process'; +import { chdir } from 'node:process'; +import { parsePackages } from './parse.ts'; +import { task, stopTask } from './task.ts'; +import { tsc, stopTsc } from './tsc.ts'; import { sass, stopSass } from './sass.ts'; -import { esbuild, stopEsbuildWatch } from './esbuild.ts'; -import { sync, stopSync } from './sync.ts'; +import { esbuild, stopEsbuild } from './esbuild.ts'; +import { sync } from './sync.ts'; import { hash } from './hash.ts'; import { stopManifest } from './manifest.ts'; import { env, errorMark, c } from './env.ts'; -import { i18n, stopI18nWatch } from './i18n.ts'; +import { i18n } from './i18n.ts'; import { unique } from './algo.ts'; import { clean } from './clean.ts'; export async function build(pkgs: string[]): Promise { - if (env.install) cps.execSync('pnpm install', { cwd: env.rootDir, stdio: 'inherit' }); + env.startTime = Date.now(); + + chdir(env.rootDir); + + if (env.install) execSync('pnpm install', { stdio: 'inherit' }); if (!pkgs.length) env.log(`Parsing packages in '${c.cyan(env.uiDir)}'`); - ps.chdir(env.uiDir); - await parsePackages(); + await Promise.allSettled([parsePackages(), fs.promises.mkdir(env.buildTempDir)]); pkgs .filter(x => !env.packages.has(x)) .forEach(x => env.exit(`${errorMark} - unknown package '${c.magenta(x)}'`)); - env.building = - pkgs.length === 0 ? [...env.packages.values()] : unique(pkgs.flatMap(p => env.transitiveDeps(p))); + env.building = pkgs.length === 0 ? [...env.packages.values()] : unique(pkgs.flatMap(p => env.deps(p))); if (pkgs.length) env.log(`Building ${c.grey(env.building.map(x => x.name).join(', '))}`); - await Promise.allSettled([ - fs.promises.mkdir(env.jsOutDir), - fs.promises.mkdir(env.cssOutDir), - fs.promises.mkdir(env.hashOutDir), - fs.promises.mkdir(env.themeGenDir), - fs.promises.mkdir(env.buildTempDir), - ]); - - await Promise.all([sass(), sync().then(hash), i18n()]); - await Promise.all([tsc(), esbuild(), monitor(pkgs)]); + await Promise.all([i18n(), sync().then(hash), sass(), tsc(), esbuild()]); + await monitor(pkgs); } -export async function stopBuildWatch(): Promise { - for (const w of watchers) w.close(); - watchers.length = 0; - clearTimeout(tscTimeout); - clearTimeout(packageTimeout); - tscTimeout = packageTimeout = undefined; +function stopBuild(): Promise { + stopTask(); stopSass(); - stopSync(); - stopI18nWatch(); - stopManifest(); - await Promise.allSettled([stopTscWatch(), stopEsbuildWatch()]); + stopManifest(true); + return Promise.allSettled([stopTsc(), stopEsbuild()]); } -const watchers: fs.FSWatcher[] = []; - -let packageTimeout: NodeJS.Timeout | undefined; -let tscTimeout: NodeJS.Timeout | undefined; - -async function monitor(pkgs: string[]): Promise { +function monitor(pkgs: string[]) { if (!env.watch) return; - const [typePkgs, typings] = await Promise.all([ - globArray('*/package.json', { cwd: env.typesDir }), - globArray('*/*.d.ts', { cwd: env.typesDir }), - ]); - const tscChange = async () => { - if (packageTimeout) return; - stopManifest(); - await Promise.allSettled([stopTscWatch(), stopEsbuildWatch()]); - clearTimeout(tscTimeout); - tscTimeout = setTimeout(() => { - if (packageTimeout) return; - tsc().then(esbuild); - }, 2000); - }; - const packageChange = async () => { - if (env.watch && env.install) { - clearTimeout(tscTimeout); - clearTimeout(packageTimeout); - await stopBuildWatch(); - packageTimeout = setTimeout(() => clean().then(() => build(pkgs)), 2000); - return; - } - env.warn('Exiting due to package.json change'); - ps.exit(0); - }; - - watchers.push(await watchModified(path.join(env.rootDir, 'package.json'), packageChange)); - for (const p of typePkgs) watchers.push(await watchModified(p, packageChange)); - for (const t of typings) watchers.push(await watchModified(t, tscChange)); - for (const pkg of env.building) { - watchers.push(await watchModified(path.join(pkg.root, 'package.json'), packageChange)); - watchers.push(await watchModified(path.join(pkg.root, 'tsconfig.json'), tscChange)); - } -} - -async function watchModified(pathname: string, onChange: () => void): Promise { - let stat = await fs.promises.stat(pathname); - - return fs.watch(pathname, async () => { - const newStat = await fs.promises.stat(pathname); - if (stat.mtimeMs === newStat.mtimeMs) return; - - stat = newStat; - onChange(); + return task({ + key: 'monitor', + glob: [ + { cwd: env.rootDir, path: 'package.json' }, + { cwd: env.typesDir, path: '*/package.json' }, + { cwd: env.uiDir, path: '*/package.json' }, + { cwd: env.typesDir, path: '*/*.d.ts' }, + { cwd: env.uiDir, path: '*/tsconfig.json' }, + ], + debounce: 1000, + monitorOnly: true, + execute: async files => { + if (files.some(x => x.endsWith('package.json'))) { + if (!env.install) env.exit('Exiting due to package.json change'); + await stopBuild(); + if (env.clean) await clean(); + build(pkgs); + } else if (files.some(x => x.endsWith('.d.ts') || x.endsWith('tsconfig.json'))) { + stopManifest(); + await Promise.allSettled([stopTsc(), stopEsbuild()]); + tsc(); + esbuild(); + } + }, }); } diff --git a/ui/.build/src/console.ts b/ui/.build/src/console.ts index 41c2298a5216e..e73d853ce5302 100644 --- a/ui/.build/src/console.ts +++ b/ui/.build/src/console.ts @@ -25,9 +25,7 @@ export async function startConsole() { if (val.length <= 1) val = val[0] ?? ''; else if (val.every(x => typeof x === 'string')) val = val.join(' '); - env.log(`${mark}${c.grey(typeof val === 'string' ? val : JSON.stringify(val, undefined, 2))}`, { - ctx: 'web', - }); + env.log(`${mark}${c.grey(typeof val === 'string' ? val : JSON.stringify(val, undefined, 2))}`, 'web'); res .writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST' }) .end(); diff --git a/ui/.build/src/env.ts b/ui/.build/src/env.ts index 546c2355e6c94..46bafa52cf4f6 100644 --- a/ui/.build/src/env.ts +++ b/ui/.build/src/env.ts @@ -1,83 +1,73 @@ -import path from 'node:path'; +import p from 'node:path'; import type { Package } from './parse.ts'; -import { unique, isEquivalent } from './algo.ts'; -import { type Manifest, updateManifest } from './manifest.ts'; +import fs from 'node:fs'; +import ps from 'node:process'; +import { unique, isEquivalent, trimLines } from './algo.ts'; +import { updateManifest } from './manifest.ts'; +import { taskOk } from './task.ts'; -// state, logging, and exit code logic - -type Builder = 'sass' | 'tsc' | 'esbuild'; +// state, logging, status export const env = new (class { - readonly rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..'); - readonly uiDir = path.join(this.rootDir, 'ui'); - readonly outDir = path.join(this.rootDir, 'public'); - readonly cssOutDir = path.join(this.outDir, 'css'); - readonly jsOutDir = path.join(this.outDir, 'compiled'); - readonly hashOutDir = path.join(this.outDir, 'hashed'); - readonly themeDir = path.join(this.uiDir, 'common', 'css', 'theme'); - readonly themeGenDir = path.join(this.themeDir, 'gen'); - readonly buildDir = path.join(this.uiDir, '.build'); - readonly cssTempDir = path.join(this.buildDir, 'build', 'css'); - readonly buildSrcDir = path.join(this.uiDir, '.build', 'src'); - readonly buildTempDir = path.join(this.buildDir, 'build'); - readonly typesDir = path.join(this.uiDir, '@types'); - readonly i18nSrcDir = path.join(this.rootDir, 'translation', 'source'); - readonly i18nDestDir = path.join(this.rootDir, 'translation', 'dest'); - readonly i18nJsDir = path.join(this.rootDir, 'translation', 'js'); + readonly rootDir = p.resolve(p.dirname(new URL(import.meta.url).pathname), '../../..'); + readonly uiDir = p.join(this.rootDir, 'ui'); + readonly outDir = p.join(this.rootDir, 'public'); + readonly cssOutDir = p.join(this.outDir, 'css'); + readonly jsOutDir = p.join(this.outDir, 'compiled'); + readonly hashOutDir = p.join(this.outDir, 'hashed'); + readonly themeDir = p.join(this.uiDir, 'common', 'css', 'theme'); + readonly themeGenDir = p.join(this.themeDir, 'gen'); + readonly buildDir = p.join(this.uiDir, '.build'); + readonly lockFile = p.join(this.buildDir, 'instance.lock'); + readonly cssTempDir = p.join(this.buildDir, 'build', 'css'); + readonly buildSrcDir = p.join(this.uiDir, '.build', 'src'); + readonly buildTempDir = p.join(this.buildDir, 'build'); + readonly typesDir = p.join(this.uiDir, '@types'); + readonly i18nSrcDir = p.join(this.rootDir, 'translation', 'source'); + readonly i18nDestDir = p.join(this.rootDir, 'translation', 'dest'); + readonly i18nJsDir = p.join(this.rootDir, 'translation', 'js'); watch = false; clean = false; prod = false; debug = false; - remoteLog: string | boolean = false; rgb = false; - install = true; - sync = true; - i18n = true; test = false; - exitCode: Map = new Map(); - startTime: number | undefined = Date.now(); + install = true; logTime = true; logCtx = true; logColor = true; + remoteLog: string | boolean = false; + startTime: number | undefined; packages: Map = new Map(); workspaceDeps: Map = new Map(); building: Package[] = []; - manifest: { js: Manifest; i18n: Manifest; css: Manifest; hashed: Manifest; dirty: boolean } = { - i18n: {}, - js: {}, - css: {}, - hashed: {}, - dirty: false, - }; - - get sass(): boolean { - return this.exitCode.get('sass') !== false; - } - get tsc(): boolean { - return this.exitCode.get('tsc') !== false; - } - - get esbuild(): boolean { - return this.exitCode.get('esbuild') !== false; - } + private status: { [key in Context]?: number | false } = {}; - get manifestOk(): boolean { + get manifest(): boolean { return ( isEquivalent(this.building, [...this.packages.values()]) && - this.sync && - this.i18n && - (['tsc', 'esbuild', 'sass'] as const).every(x => this.exitCode.get(x) === 0) + (['tsc', 'esbuild', 'sass', 'i18n'] as const).map(b => this.status[b]).every(x => x === 0) ); } get manifestFile(): string { - return path.join(this.jsOutDir, `manifest.${this.prod ? 'prod' : 'dev'}.json`); + return p.join(this.jsOutDir, `manifest.${this.prod ? 'prod' : 'dev'}.json`); + } + + *tasks( + t: T, + ): Generator<[Package, Package[T] extends Array ? U : never]> { + for (const pkg of this.building) { + for (const item of pkg[t] as (Package[T] extends (infer U)[] ? U : never)[]) { + yield [pkg, item]; + } + } } - transitiveDeps(pkgName: string): Package[] { + deps(pkgName: string): Package[] { const depList = (dep: string): string[] => [ ...(this.workspaceDeps.get(dep) ?? []).flatMap(d => depList(d)), dep, @@ -85,25 +75,8 @@ export const env = new (class { return unique(depList(pkgName).map(name => this.packages.get(name))); } - warn(d: any, ctx = 'build'): void { - this.log(d, { ctx: ctx, warn: true }); - } - - error(d: any, ctx = 'build'): void { - this.log(d, { ctx: ctx, error: true }); - } - - exit(d: any, ctx = 'build'): void { - this.log(d, { ctx: ctx, error: true }); - process.exit(1); - } - - good(ctx = 'build'): void { - this.log(c.good('No errors') + this.watch ? ` - ${c.grey('Watching')}...` : '', { ctx: ctx }); - } - - log(d: any, { ctx = 'build', error = false, warn = false }: any = {}): void { - let text: string = + log(d: any, ctx = 'build'): void { + const text: string = !d || typeof d === 'string' || d instanceof Buffer ? String(d) : Array.isArray(d) @@ -114,42 +87,62 @@ export const env = new (class { (this.logTime ? prettyTime() : '') + (ctx && this.logCtx ? `[${escape(ctx, colorForCtx(ctx))}]` : '') ).trim(); - lines(this.logColor ? text : stripColorEscapes(text)).forEach(line => - console.log( - `${prefix ? prefix + ' - ' : ''}${escape(line, error ? codes.error : warn ? codes.warn : undefined)}`, - ), - ); + for (const line of trimLines(this.logColor ? text : stripColorEscapes(text))) + console.log(`${prefix ? prefix + ' - ' : ''}${line}`); + } + + exit(d?: any, ctx = 'build'): void { + if (d) this.log(d, ctx); + process.exit(1); + } + + begin(ctx: Context, enable?: boolean): boolean { + if (enable === false) this.status[ctx] = false; + else if (enable === true || this.status[ctx] !== false) this.status[ctx] = undefined; + return this.status[ctx] !== false; } - done(code: number, ctx: Builder): void { - this.exitCode.set(ctx, code); - const err = [...this.exitCode.values()].find(x => x); - const allDone = this.exitCode.size === 3; - if (ctx !== 'tsc' || code === 0) + done(ctx: Context, code: number = 0): void { + if (code !== this.status[ctx] && ['tsc', 'esbuild', 'sass', 'i18n'].includes(ctx)) { this.log( `${code === 0 ? 'Done' : c.red('Failed')}` + (this.watch ? ` - ${c.grey('Watching')}...` : ''), - { ctx }, + ctx, ); - if (allDone) { - if (this.startTime && !err) this.log(`Done in ${c.green((Date.now() - this.startTime) / 1000 + '')}s`); - this.startTime = undefined; // it's pointless to time subsequent builds, they are too fast } - if (!this.watch && err) process.exitCode = err; - if (!err) updateManifest(); + this.status[ctx] = code; + if (this.manifest && taskOk()) { + if (this.startTime) this.log(`Done in ${c.green((Date.now() - this.startTime) / 1000 + '')}s`); + updateManifest(); + this.startTime = undefined; + } + if (!this.watch && code) process.exit(code); } -})(); -export const lines = (s: string): string[] => s.split(/[\n\r\f]+/).filter(x => x.trim()); - -const escape = (text: string, code?: string): string => - env.logColor && code ? `\x1b[${code}m${stripColorEscapes(text)}\x1b[0m` : text; + instanceLock(checkStale = true): boolean { + try { + const fd = fs.openSync(env.lockFile, 'wx'); + fs.writeFileSync(fd, String(ps.pid), { flag: 'w' }); + fs.closeSync(fd); + ps.on('exit', () => fs.unlinkSync(env.lockFile)); + } catch { + const pid = parseInt(fs.readFileSync(env.lockFile, 'utf8'), 10); + if (!isNaN(pid) && pid > 0 && ps.platform !== 'win32') { + try { + ps.kill(pid, 0); + return false; + } catch { + fs.unlinkSync(env.lockFile); // it's a craplet + if (checkStale) return this.instanceLock(false); + } + } + } + return true; + } +})(); -const colorLines = (text: string, code: string) => - lines(text) - .map(t => escape(t, code)) - .join('\n'); +export type Context = 'sass' | 'tsc' | 'esbuild' | 'sync' | 'hash' | 'i18n' | 'web'; -const codes: Record = { +const codes = { black: '30', red: '31', green: '32', @@ -157,38 +150,58 @@ const codes: Record = { blue: '34', magenta: '35', cyan: '36', + white: '37', grey: '90', error: '31', warn: '33', + greenBold: '32;1', + yellowBold: '33;1', + blueBold: '34;1', + magentaBold: '35;1', + cyanBold: '36;1', + greyBold: '90;1', }; -export const c: Record string> = { - red: (text: string): string => colorLines(text, codes.red), - green: (text: string): string => colorLines(text, codes.green), - yellow: (text: string): string => colorLines(text, codes.yellow), - blue: (text: string): string => colorLines(text, codes.blue), - magenta: (text: string): string => colorLines(text, codes.magenta), - cyan: (text: string): string => colorLines(text, codes.cyan), - grey: (text: string): string => colorLines(text, codes.grey), - black: (text: string): string => colorLines(text, codes.black), - error: (text: string): string => colorLines(text, codes.error), - warn: (text: string): string => colorLines(text, codes.warn), - good: (text: string): string => colorLines(text, codes.green + ';1'), - cyanBold: (text: string): string => colorLines(text, codes.cyan + ';1'), -}; +function colorForCtx(ctx: string) { + return ( + { + build: codes.green, + sass: codes.magenta, + tsc: codes.yellow, + esbuild: codes.blueBold, + sync: codes.cyan, + hash: codes.blue, + i18n: codes.cyanBold, + manifest: codes.white, + web: codes.magentaBold, + }[ctx] ?? codes.grey + ); +} + +function escape(text: string, code?: string) { + return env.logColor && code ? `\x1b[${code}m${stripColorEscapes(text)}\x1b[0m` : text; +} + +function colorLines(text: string, code: string) { + return trimLines(text) + .map(t => escape(t, code)) + .join('\n'); +} + +export const c: Record string> = Object.keys(codes).reduce( + (acc, key) => { + acc[key as keyof typeof codes] = (text: string) => colorLines(text, codes[key as keyof typeof codes]); + return acc; + }, + {} as Record string>, +); export const errorMark: string = c.red('✘ ') + c.error('[ERROR]'); export const warnMark: string = c.yellow('⚠ ') + c.warn('[WARNING]'); -const colorForCtx = (ctx: string): string => - ({ - build: codes.green, - sass: codes.magenta, - tsc: codes.yellow, - esbuild: codes.blue, - })[ctx] ?? codes.grey; - -const pad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`); +function pad2(n: number) { + return n < 10 ? `0${n}` : `${n}`; +} function stripColorEscapes(text: string) { return text.replace(/\x1b\[[0-9;]*m/, ''); diff --git a/ui/.build/src/esbuild.ts b/ui/.build/src/esbuild.ts index 0cba5f7a6dc8b..ab887fb2d6ff2 100644 --- a/ui/.build/src/esbuild.ts +++ b/ui/.build/src/esbuild.ts @@ -1,27 +1,17 @@ -import path from 'node:path'; +import p from 'node:path'; import es from 'esbuild'; import fs from 'node:fs'; -import { env, errorMark, c } from './env.ts'; +import { env, errorMark, warnMark, c } from './env.ts'; import { type Manifest, updateManifest } from './manifest.ts'; -import { trimAndConsolidateWhitespace, readable } from './parse.ts'; +import { task, stopTask } from './task.ts'; +import { reduceWhitespace } from './algo.ts'; -const esbuildCtx: es.BuildContext[] = []; -const inlineWatch: fs.FSWatcher[] = []; -let inlineTimer: NodeJS.Timeout; +let esbuildCtx: es.BuildContext | undefined; -export async function esbuild(): Promise { - if (!env.esbuild) return; - env.exitCode.delete('esbuild'); +export async function esbuild(): Promise { + if (!env.begin('esbuild')) return; - const entryPoints = []; - for (const pkg of env.building) { - for (const { module } of pkg.bundle) { - if (module) entryPoints.push(path.join(pkg.root, module)); - } - } - entryPoints.sort(); - const ctx = await es.context({ - entryPoints, + const options: es.BuildOptions = { bundle: true, metafile: true, treeShaking: true, @@ -35,86 +25,93 @@ export async function esbuild(): Promise { entryNames: '[name].[hash]', chunkNames: 'common.[hash]', plugins, - }); - if (env.watch) { - ctx.watch(); - esbuildCtx.push(ctx); - } else { - await ctx.rebuild(); - await ctx.dispose(); - } -} + }; -export async function stopEsbuildWatch(): Promise { - const proof = Promise.allSettled(esbuildCtx.map(x => x.dispose())); - for (const w of inlineWatch) w.close(); - inlineWatch.length = 0; - esbuildCtx.length = 0; - await proof; + await fs.promises.mkdir(env.jsOutDir).catch(() => {}); + return Promise.all([ + inlineTask(), + task({ + key: 'bundle', + ctx: 'esbuild', + debounce: 300, + noEnvStatus: true, + globListOnly: true, + glob: env.building.flatMap(pkg => + pkg.bundle + .map(bundle => bundle.module) + .filter((module): module is string => Boolean(module)) + .map(path => ({ cwd: pkg.root, path })), + ), + execute: async entryPoints => { + await esbuildCtx?.dispose(); + entryPoints.sort(); + esbuildCtx = await es.context({ ...options, entryPoints }); + if (env.watch) esbuildCtx.watch(); + else { + await esbuildCtx.rebuild(); + await esbuildCtx.dispose(); + } + }, + }), + ]); } -const plugins = [ - { - // our html minifier will only process characters between the first two backticks encountered - // so: - // $html`
${ x ? `<- 2nd backtick ${y}${z}` : '' }
` - // - // minifies (partially) to: - // `
${ x ? `<- 2nd backtick ${y}${z}` : '' }
` - // - // nested template literals in interpolations are unchanged and still work, but they - // won't be minified. this is fine, we don't need an ast parser as it's pretty rare - name: '$html', - setup(build: es.PluginBuild) { - build.onLoad({ filter: /\.ts$/ }, async (args: es.OnLoadArgs) => ({ - loader: 'ts', - contents: (await fs.promises.readFile(args.path, 'utf8')).replace( - /\$html`([^`]*)`/g, - (_, s) => `\`${trimAndConsolidateWhitespace(s)}\``, - ), - })); - }, - }, - { - name: 'onBundleDone', - setup(build: es.PluginBuild) { - build.onEnd(async (result: es.BuildResult) => { - esbuildLog(result.errors, true); - esbuildLog(result.warnings); - env.done(result.errors.length, 'esbuild'); - if (result.errors.length === 0) jsManifest(result.metafile!); - }); - }, - }, -]; - -function esbuildLog(msgs: es.Message[], error = false): void { - for (const msg of msgs) { - const file = msg.location?.file.replace(/^[./]*/, '') ?? ''; - const line = msg.location?.line - ? `:${msg.location.line}` - : '' + (msg.location?.column ? `:${msg.location.column}` : ''); - const srcText = msg.location?.lineText; - env.log(`${error ? errorMark : c.warn('WARNING')} - '${c.cyan(file + line)}' - ${msg.text}`, { - ctx: 'esbuild', - }); - if (srcText) env.log(' ' + c.magenta(srcText), { ctx: 'esbuild' }); - } +export async function stopEsbuild(): Promise { + stopTask(['bundle', 'inline']); + await esbuildCtx?.dispose(); + esbuildCtx = undefined; } -async function jsManifest(meta: es.Metafile = { inputs: {}, outputs: {} }) { - for (const w of inlineWatch) w.close(); - inlineWatch.length = 0; - clearTimeout(inlineTimer); +function inlineTask() { + const js: Manifest = {}; + const inlineToModule: Record = {}; + for (const [pkg, bundle] of env.tasks('bundle')) + if (bundle.inline) + inlineToModule[p.join(pkg.root, bundle.inline)] = bundle.module + ? p.basename(bundle.module, '.ts') + : p.basename(bundle.inline, '.inline.ts'); + return task({ + key: 'inline', + ctx: 'esbuild', + debounce: 300, + noEnvStatus: true, + glob: env.building.flatMap(pkg => + pkg.bundle + .map(b => b.inline) + .filter((i): i is string => Boolean(i)) + .map(i => ({ cwd: pkg.root, path: i })), + ), + execute: (_, inlines) => + Promise.all( + inlines.map(async inlineSrc => { + const moduleName = inlineToModule[inlineSrc]; + try { + const res = await es.transform(await fs.promises.readFile(inlineSrc), { + minify: true, + loader: 'ts', + }); + esbuildLog(res.warnings); + js[moduleName] ??= {}; + js[moduleName].inline = res.code; + } catch (e) { + if (e && typeof e === 'object' && 'errors' in e) + esbuildLog((e as es.TransformFailure).errors, true); + throw ''; + } + }), + ).then(() => updateManifest({ js })), + }); +} - const newJsManifest: Manifest = {}; +function bundleManifest(meta: es.Metafile = { inputs: {}, outputs: {} }) { + const js: Manifest = {}; for (const [filename, info] of Object.entries(meta.outputs)) { const out = splitPath(filename); if (!out) continue; if (out.name === 'common') { out.name = `common.${out.hash}`; - newJsManifest[out.name] = {}; - } else newJsManifest[out.name] = { hash: out.hash }; + js[out.name] = {}; + } else js[out.name] = { hash: out.hash }; const imports: string[] = []; for (const imp of info.imports) { if (imp.kind === 'import-statement') { @@ -122,57 +119,61 @@ async function jsManifest(meta: es.Metafile = { inputs: {}, outputs: {} }) { if (path) imports.push(`${path.name}.${path.hash}.js`); } } - newJsManifest[out.name].imports = imports; + js[out.name].imports = imports; } - await inlineManifest(newJsManifest); + updateManifest({ js }); } -async function inlineManifest(js: Manifest) { - const makeWatchers = env.watch && inlineWatch.length === 0; - let success = true; - for (const pkg of env.building) { - for (const bundle of pkg.bundle ?? []) { - if (!bundle.inline) continue; - const inlineSrc = path.join(pkg.root, bundle.inline); - const moduleName = bundle.module - ? path.basename(bundle.module, '.ts') - : path.basename(bundle.inline, '.inline.ts'); - const packageError = `[${c.grey(pkg.name)}] - ${errorMark} - Package error ${c.blue(JSON.stringify(bundle))}`; - - if (!(await readable(inlineSrc))) { - env.log(packageError); - for (const w of inlineWatch) w.close(); - inlineWatch.length = 0; - if (!env.watch) env.exit('Failed'); // all inline sources must exist - } - - try { - const res = await es.transform(await fs.promises.readFile(inlineSrc), { - minify: true, - loader: 'ts', - }); - esbuildLog(res.warnings); - js[moduleName] ??= {}; - js[moduleName].inline = res.code; - } catch (e) { - if (e && typeof e === 'object' && 'errors' in e) esbuildLog((e as es.TransformFailure).errors, true); - else env.log(`${packageError} - ${JSON.stringify(e)}`); - if (env.watch) success = false; - else env.exit('Failed'); - } - if (makeWatchers) - inlineWatch.push( - fs.watch(inlineSrc, () => { - clearTimeout(inlineTimer); - inlineTimer = setTimeout(() => inlineManifest(js), 200); - }), - ); - } +function esbuildLog(msgs: es.Message[], error = false): void { + for (const msg of msgs) { + const file = msg.location?.file.replace(/^[./]*/, '') ?? ''; + const line = msg.location?.line + ? `:${msg.location.line}` + : '' + (msg.location?.column ? `:${msg.location.column}` : ''); + const srcText = msg.location?.lineText; + env.log(`${error ? errorMark : warnMark} - '${c.cyan(file + line)}' - ${msg.text}`, 'esbuild'); + if (srcText) env.log(' ' + c.magenta(srcText), 'esbuild'); } - if (success) updateManifest({ js }); } function splitPath(path: string) { const match = path.match(/\/public\/compiled\/(.*)\.([A-Z0-9]+)\.js$/); return match ? { name: match[1], hash: match[2] } : undefined; } + +// our html minifier will only process characters between the first two backticks encountered +// so: +// $html`
${ x ? `<- 2nd backtick ${y}${z}` : '' }
` +// +// minifies (partially) to: +// `
${ x ? `<- 2nd backtick ${y}${z}` : '' }
` +// +// nested template literals in interpolations are unchanged and still work, but they +// won't be minified. this is fine, we don't need an ast parser as it's pretty rare + +const plugins = [ + { + name: '$html', + setup(build: es.PluginBuild) { + build.onLoad({ filter: /\.ts$/ }, async (args: es.OnLoadArgs) => ({ + loader: 'ts', + contents: (await fs.promises.readFile(args.path, 'utf8')).replace( + /\$html`([^`]*)`/g, + (_, s) => `\`${reduceWhitespace(s)}\``, + ), + })); + }, + }, + { + name: 'onBundleDone', + setup(build: es.PluginBuild) { + build.onEnd(async (result: es.BuildResult) => { + esbuildLog(result.errors, true); + esbuildLog(result.warnings); + env.begin('esbuild'); + env.done('esbuild', result.errors.length > 0 ? -3 : 0); + if (result.errors.length === 0) bundleManifest(result.metafile!); + }); + }, + }, +]; diff --git a/ui/.build/src/hash.ts b/ui/.build/src/hash.ts index 61b0d5b48af5a..9c28701e4aabb 100644 --- a/ui/.build/src/hash.ts +++ b/ui/.build/src/hash.ts @@ -1,72 +1,79 @@ import fs from 'node:fs'; -import path from 'node:path'; +import p from 'node:path'; import crypto from 'node:crypto'; -import { globArray } from './parse.ts'; -import { updateManifest } from './manifest.ts'; -import { env } from './env.ts'; +import { task } from './task.ts'; +import { type Manifest, updateManifest } from './manifest.ts'; +import { env, c } from './env.ts'; +import type { Package } from './parse.ts'; +import { isEquivalent } from './algo.ts'; export async function hash(): Promise { - const newHashLinks = new Map(); - const alreadyHashed = new Map(); - const hashed = ( - await Promise.all( - env.building.flatMap(pkg => - pkg.hash.map(async hash => - (await globArray(hash.glob, { cwd: env.outDir })).map(path => ({ - path, - update: hash.update, - root: pkg.root, - })), - ), - ), - ) - ).flat(); + if (!env.begin('hash')) return; + const hashed: Manifest = {}; + const pathOnly: { glob: string[] } = { glob: [] }; + const hashRuns: { glob: string | string[]; update?: string; pkg?: Package }[] = []; - const sourceStats = await Promise.all(hashed.map(hash => fs.promises.stat(hash.path))); - - for (const [i, stat] of sourceStats.entries()) { - const name = hashed[i].path.slice(env.outDir.length + 1); - if (stat.mtimeMs === env.manifest.hashed[name]?.mtime) - alreadyHashed.set(name, env.manifest.hashed[name].hash!); - else newHashLinks.set(name, stat.mtimeMs); - } - await Promise.allSettled([...alreadyHashed].map(([name, hash]) => link(name, hash))); - - for await (const { name, hash } of [...newHashLinks.keys()].map(hashLink)) { - env.manifest.hashed[name] = Object.defineProperty({ hash }, 'mtime', { value: newHashLinks.get(name) }); - } - if (newHashLinks.size === 0 && alreadyHashed.size === Object.keys(env.manifest.hashed).length) return; - - for (const key of Object.keys(env.manifest.hashed)) { - if (!hashed.some(x => x.path.endsWith(key))) delete env.manifest.hashed[key]; + for (const [pkg, { glob, update, ...rest }] of env.tasks('hash')) { + update ? hashRuns.push({ glob, update, pkg, ...rest }) : pathOnly.glob.push(glob); + env.log(`${c.grey(pkg.name)} '${c.cyan(glob)}' -> '${c.cyan('public/hashed')}'`, 'hash'); } + if (pathOnly.glob.length) hashRuns.push(pathOnly); - const updates: Map }> = new Map(); - for (const { root, path, update } of hashed) { - if (!update) continue; - const updateFile = updates.get(update) ?? { root, mapping: {} }; - const from = path.slice(env.outDir.length + 1); - updateFile.mapping[from] = asHashed(from, env.manifest.hashed[from].hash!); - updates.set(update, updateFile); - } - for await (const { name, hash } of [...updates].map(([n, r]) => update(n, r.root, r.mapping))) { - env.manifest.hashed[name] = { hash }; - } - updateManifest({ dirty: true }); + await fs.promises.mkdir(env.hashOutDir).catch(() => {}); + await Promise.all( + hashRuns.map(({ glob, update, pkg }) => + task({ + ctx: 'hash', + debounce: 300, + root: env.rootDir, + glob: Array() + .concat(glob) + .map(path => ({ cwd: env.rootDir, path })), + execute: async (files, fullList) => { + const shouldLog = !isEquivalent(files, fullList); + await Promise.all( + files.map(async src => { + const { name, hash } = await hashLink(p.relative(env.outDir, src)); + hashed[name] = { hash }; + if (shouldLog) + env.log( + `'${c.cyan(src)}' -> '${c.cyan(p.join('public', 'hashed', asHashed(name, hash)))}'`, + 'hash', + ); + }), + ); + if (update && pkg?.root) { + const updates: Record = {}; + for (const src of fullList.map(f => p.relative(env.outDir, f))) { + updates[src] = asHashed(src, hashed[src].hash!); + } + const { name, hash } = await replaceHash(p.relative(pkg.root, update), pkg.root, updates); + hashed[name] = { hash }; + if (shouldLog) + env.log( + `${c.grey(pkg.name)} '${c.cyan(name)}' -> '${c.cyan(p.join('public', 'hashed', asHashed(name, hash)))}'`, + 'hash', + ); + } + updateManifest({ hashed }); + }, + }), + ), + ); } -async function update(name: string, root: string, files: Record) { +async function replaceHash(name: string, root: string, files: Record) { const result = Object.entries(files).reduce( (data, [from, to]) => data.replaceAll(from, to), - await fs.promises.readFile(path.join(root, name), 'utf8'), + await fs.promises.readFile(p.join(root, name), 'utf8'), ); const hash = crypto.createHash('sha256').update(result).digest('hex').slice(0, 8); - await fs.promises.writeFile(path.join(env.hashOutDir, asHashed(name, hash)), result); + await fs.promises.writeFile(p.join(env.hashOutDir, asHashed(name, hash)), result); return { name, hash }; } async function hashLink(name: string) { - const src = path.join(env.outDir, name); + const src = p.join(env.outDir, name); const hash = crypto .createHash('sha256') .update(await fs.promises.readFile(src)) @@ -77,8 +84,8 @@ async function hashLink(name: string) { } async function link(name: string, hash: string) { - const link = path.join(env.hashOutDir, asHashed(name, hash)); - return fs.promises.symlink(path.join('..', name), link).catch(() => {}); + const link = p.join(env.hashOutDir, asHashed(name, hash)); + return fs.promises.symlink(p.join('..', name), link).catch(() => {}); } function asHashed(path: string, hash: string) { diff --git a/ui/.build/src/i18n.ts b/ui/.build/src/i18n.ts index 90d960a1c2b05..c577e5901921c 100644 --- a/ui/.build/src/i18n.ts +++ b/ui/.build/src/i18n.ts @@ -1,9 +1,11 @@ -import path from 'node:path'; +import p from 'node:path'; import crypto from 'node:crypto'; import fs from 'node:fs'; +import fg from 'fast-glob'; import { XMLParser } from 'fast-xml-parser'; -import { env, c } from './env.ts'; -import { globArray, readable } from './parse.ts'; +import { env } from './env.ts'; +import { readable } from './parse.ts'; +import { task } from './task.ts'; import { type Manifest, updateManifest } from './manifest.ts'; import { quantize, zip } from './algo.ts'; import { transform } from 'esbuild'; @@ -11,59 +13,52 @@ import { transform } from 'esbuild'; type Plural = { [key in 'zero' | 'one' | 'two' | 'few' | 'many' | 'other']?: string }; type Dict = Map; -let dicts: Map = new Map(); -let locales: string[], cats: string[]; -let watchTimeout: NodeJS.Timeout | undefined; -const i18nWatch: fs.FSWatcher[] = []; const formatStringRe = /%(?:[\d]\$)?s/; -export async function i18n(): Promise { - if (!env.i18n) return; - if (!watchTimeout) env.log(`Building ${c.grey('i18n')}`); - - [locales, cats] = ( - await Promise.all([ - globArray('*.xml', { cwd: path.join(env.i18nDestDir, 'site'), absolute: false }), - globArray('*.xml', { cwd: env.i18nSrcDir, absolute: false }), - ]) - ).map(list => list.map(x => x.split('.')[0])); - - await compileTypings(); - compileJavascripts(!watchTimeout); // !watchTimeout means first time run - - if (i18nWatch.length || !env.watch) return; +let dicts: Map = new Map(); +let locales: string[]; +let cats: string[]; - const onChange = () => { - clearTimeout(watchTimeout); - watchTimeout = setTimeout(() => i18n(), 2000); - }; - i18nWatch.push(fs.watch(env.i18nSrcDir, onChange)); - for (const d of cats) { - await fs.promises.mkdir(path.join(env.i18nDestDir, d)).catch(() => {}); - i18nWatch.push(fs.watch(path.join(env.i18nDestDir, d), onChange)); - } -} +export async function i18n(): Promise { + if (!env.begin('i18n')) return; -export function stopI18nWatch(): void { - clearTimeout(watchTimeout); - for (const watcher of i18nWatch) watcher.close(); - i18nWatch.length = 0; + return task({ + glob: [ + { cwd: env.i18nSrcDir, path: '*.xml' }, + { cwd: p.join(env.i18nDestDir, 'site'), path: '*.xml' }, + ], + ctx: 'i18n', + debounce: 500, + execute: async () => { + env.log(`Building`, 'i18n'); + [locales, cats] = ( + await Promise.all([ + fg.glob('*.xml', { cwd: p.join(env.i18nDestDir, 'site') }), + fg.glob('*.xml', { cwd: env.i18nSrcDir }), + ]) + ).map(list => list.map(x => x.split('.')[0])); + await Promise.allSettled(cats.map(async cat => fs.promises.mkdir(p.join(env.i18nDestDir, cat)))); + await compileTypings(); + await compileJavascripts(); + await i18nManifest(); + }, + }); } async function compileTypings(): Promise { - const typingsPathname = path.join(env.typesDir, 'lichess', `i18n.d.ts`); - const [tstat] = await Promise.all([ + const typingsPathname = p.join(env.typesDir, 'lichess', `i18n.d.ts`); + const [tstat, catStats] = await Promise.all([ fs.promises.stat(typingsPathname).catch(() => undefined), + Promise.all(cats.map(cat => updated(cat))), fs.promises.mkdir(env.i18nJsDir).catch(() => {}), ]); - const catStats = await Promise.all(cats.map(d => updated(d))); if (!tstat || catStats.some(x => x)) { dicts = new Map( zip( cats, await Promise.all( - cats.map(d => fs.promises.readFile(path.join(env.i18nSrcDir, `${d}.xml`), 'utf8').then(parseXml)), + cats.map(d => fs.promises.readFile(p.join(env.i18nSrcDir, `${d}.xml`), 'utf8').then(parseXml)), ), ), ); @@ -96,40 +91,26 @@ async function compileTypings(): Promise { } } -async function compileJavascripts(withManifest: boolean = false): Promise { - const reportOnce = () => { - if (!withManifest) env.log(`Building ${c.grey('i18n')}`); - withManifest = true; - }; - for (const cat of cats) { - const u = await updated(cat); - if (u) { - reportOnce(); - await writeJavascript(cat, undefined, u); - } - await Promise.all( - locales.map(locale => - updated(cat, locale).then(xstat => { - if (!u && !xstat) return; - reportOnce(); - return writeJavascript(cat, locale, xstat); - }), - ), - ); - } - if (withManifest) i18nManifest(); +function compileJavascripts(): Promise { + return Promise.all( + cats.map(async cat => { + const u = await updated(cat); + if (u) await writeJavascript(cat, undefined, u); + for (const locale of locales) { + const xstat = await updated(cat, locale); + if (u || xstat) await writeJavascript(cat, locale, xstat); + } + }), + ); } async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | false = false) { if (!dicts.has(cat)) - dicts.set( - cat, - await fs.promises.readFile(path.join(env.i18nSrcDir, `${cat}.xml`), 'utf8').then(parseXml), - ); + dicts.set(cat, await fs.promises.readFile(p.join(env.i18nSrcDir, `${cat}.xml`), 'utf8').then(parseXml)); const localeSpecific = locale ? await fs.promises - .readFile(path.join(env.i18nDestDir, cat, `${locale}.xml`), 'utf-8') + .readFile(p.join(env.i18nDestDir, cat, `${locale}.xml`), 'utf-8') .catch(() => '') .then(parseXml) : new Map(); @@ -161,7 +142,7 @@ async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | f .join(';') + '})()'; - const filename = path.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`); + const filename = p.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`); await fs.promises.writeFile(filename, code); if (!xstat) return; @@ -170,14 +151,14 @@ async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | f async function updated(cat: string, locale?: string): Promise { const xmlPath = locale - ? path.join(env.i18nDestDir, cat, `${locale}.xml`) - : path.join(env.i18nSrcDir, `${cat}.xml`); - const jsPath = path.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`); + ? p.join(env.i18nDestDir, cat, `${locale}.xml`) + : p.join(env.i18nSrcDir, `${cat}.xml`); + const jsPath = p.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`); const [xml, js] = await Promise.allSettled([fs.promises.stat(xmlPath), fs.promises.stat(jsPath)]); return xml.status === 'rejected' || (js.status !== 'rejected' && quantize(xml.value.mtimeMs, 2000) <= quantize(js.value.mtimeMs, 2000)) ? false - : xml.value; + : xml.value.size > 64 && xml.value; } function parseXml(xmlData: string): Map { @@ -203,28 +184,28 @@ async function min(js: string): Promise { } export async function i18nManifest(): Promise { - const i18nManifest: Manifest = {}; - fs.mkdirSync(path.join(env.jsOutDir, 'i18n'), { recursive: true }); + const i18n: Manifest = {}; + fs.mkdirSync(p.join(env.jsOutDir, 'i18n'), { recursive: true }); await Promise.all( - (await globArray('*.js', { cwd: env.i18nJsDir })).map(async file => { - const name = `i18n/${path.basename(file, '.js')}`; + (await fg.glob('*.js', { cwd: env.i18nJsDir, absolute: true })).map(async file => { + const name = `i18n/${p.basename(file, '.js')}`; const content = await fs.promises.readFile(file, 'utf-8'); const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 12); - const destPath = path.join(env.jsOutDir, `${name}.${hash}.js`); + const destPath = p.join(env.jsOutDir, `${name}.${hash}.js`); - i18nManifest[name] = { hash }; + i18n[name] = { hash }; if (!(await readable(destPath))) await fs.promises.writeFile(destPath, content); }), ); await Promise.all( cats.map(cat => { - const path = `i18n/${cat}.en-GB.${i18nManifest[`i18n/${cat}.en-GB`].hash}.js`; - return Promise.all(locales.map(locale => (i18nManifest[`i18n/${cat}.${locale}`] ??= { path }))); + const path = `i18n/${cat}.en-GB.${i18n[`i18n/${cat}.en-GB`].hash}.js`; + return Promise.all(locales.map(locale => (i18n[`i18n/${cat}.${locale}`] ??= { path }))); }), ); - updateManifest({ i18n: i18nManifest }); + updateManifest({ i18n }); } const tsPrelude = `// Generated diff --git a/ui/.build/src/main.ts b/ui/.build/src/main.ts index 87118937030f4..800e8532a7015 100644 --- a/ui/.build/src/main.ts +++ b/ui/.build/src/main.ts @@ -2,17 +2,15 @@ import ps from 'node:process'; import { deepClean } from './clean.ts'; import { build } from './build.ts'; import { startConsole } from './console.ts'; -import { env } from './env.ts'; +import { env, errorMark } from './env.ts'; -// process arguments and kick off the build -// it more or less fits on one page so no need for an args library +// main entry point +['SIGINT', 'SIGTERM', 'SIGHUP'].forEach(sig => ps.on(sig, () => ps.exit(2))); -// readme should be up to date but this is the definitive list of flags const args: Record = { '--tsc': '', '--sass': '', '--esbuild': '', - '--sync': '', '--i18n': '', '--no-color': '', '--no-time': '', @@ -54,7 +52,6 @@ Exclusive Options: (any of these will disable other functions) --tsc run tsc on {package}/tsconfig.json and dependencies --sass run sass on {package}/css/build/*.scss and dependencies --esbuild run esbuild (given in {package}/package.json/lichess/bundles array) - --sync run sync copies (given in {package}/package.json/lichess/sync objects) --i18n build @types/lichess/i18n.d.ts and translation/js files Recommended: @@ -67,6 +64,7 @@ Other Examples: to \${location.origin}/x. ui/build watch process displays messages received via http(s) on this endpoint as 'web' in build logs `; + const argv = ps.argv.slice(2); const oneDashRe = /^-([a-z]+)(?:=[a-zA-Z0-9-_:./]+)?$/; @@ -86,13 +84,14 @@ argv .filter(x => x.startsWith('--') && !Object.keys(args).includes(x.split('=')[0])) .forEach(arg => env.exit(`Unknown argument '${arg}'`)); -if (['--tsc', '--sass', '--esbuild', '--sync', '--i18n'].filter(x => argv.includes(x)).length) { +if (['--tsc', '--sass', '--esbuild', '--i18n'].filter(x => argv.includes(x)).length) { // including one or more of these disables the others - if (!argv.includes('--sass')) env.exitCode.set('sass', false); - if (!argv.includes('--tsc')) env.exitCode.set('tsc', false); - if (!argv.includes('--esbuild')) env.exitCode.set('esbuild', false); - env.i18n = argv.includes('--i18n'); - env.sync = argv.includes('--sync'); + env.begin('sass', argv.includes('--sass')); + env.begin('tsc', argv.includes('--tsc')); + env.begin('esbuild', argv.includes('--esbuild')); + env.begin('i18n', argv.includes('--i18n')); + env.begin('sync', false); + env.begin('hash', false); } env.logTime = !argv.includes('--no-time'); @@ -107,12 +106,18 @@ env.install = !argv.includes('--no-install') && !oneDashArgs.includes('n'); env.rgb = argv.includes('--rgb'); env.test = argv.includes('--test') || oneDashArgs.includes('t'); -if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { +if (argv.includes('--help') || oneDashArgs.includes('h')) { console.log(usage); - process.exit(0); -} else if (env.clean) { + ps.exit(0); +} + +if (!env.instanceLock()) env.exit(`${errorMark} - Another instance is already running`); + +if (env.clean) { await deepClean(); - if (argv.includes('--clean-exit')) process.exit(0); + if (argv.includes('--clean-exit')) ps.exit(0); } + startConsole(); + build(argv.filter(x => !x.startsWith('-'))); diff --git a/ui/.build/src/manifest.ts b/ui/.build/src/manifest.ts index 2eed58d381bdb..77b89eddd47f4 100644 --- a/ui/.build/src/manifest.ts +++ b/ui/.build/src/manifest.ts @@ -1,41 +1,60 @@ import cps from 'node:child_process'; -import path from 'node:path'; +import p from 'node:path'; import fs from 'node:fs'; import crypto from 'node:crypto'; -import { env, c, warnMark } from './env.ts'; -import { allSources as allCssSources } from './sass.ts'; +import { env, c } from './env.ts'; import { jsLogger } from './console.ts'; -import { shallowSort, isEquivalent } from './algo.ts'; - -type SplitAsset = { hash?: string; path?: string; imports?: string[]; inline?: string; mtime?: number }; -export type Manifest = { [key: string]: SplitAsset }; +import { taskOk } from './task.ts'; +import { shallowSort, isContained } from './algo.ts'; +const manifest = { + i18n: {} as Manifest, + js: {} as Manifest, + css: {} as Manifest, + hashed: {} as Manifest, + dirty: false, +}; let writeTimer: NodeJS.Timeout; -export function stopManifest(): void { +type SplitAsset = { hash?: string; path?: string; imports?: string[]; inline?: string }; + +export type Manifest = { [key: string]: SplitAsset }; +export type ManifestUpdate = Partial>; + +export function stopManifest(clear = false): void { clearTimeout(writeTimer); + if (clear) { + manifest.i18n = {}; + manifest.js = {}; + manifest.css = {}; + manifest.hashed = {}; + manifest.dirty = false; + } } -export function updateManifest(update: Partial = {}): void { - if (update?.dirty) env.manifest.dirty = true; - for (const key of Object.keys(update ?? {}) as (keyof typeof env.manifest)[]) { - if (key === 'dirty' || isEquivalent(env.manifest[key], update?.[key])) continue; - env.manifest[key] = shallowSort({ ...env.manifest[key], ...update?.[key] }); - env.manifest.dirty = true; +export function updateManifest(update: ManifestUpdate = {}): void { + for (const [key, partial] of Object.entries(update) as [keyof ManifestUpdate, Manifest][]) { + const full = manifest[key]; + if (isContained(full, partial)) continue; + for (const [name, entry] of Object.entries(partial)) { + full[name] = shallowSort({ ...full[name], ...entry }); + } + manifest[key] = shallowSort(full); + manifest.dirty = true; + } + if (manifest.dirty) { + clearTimeout(writeTimer); + writeTimer = setTimeout(writeManifest, env.watch && !env.startTime ? 750 : 0); } - if (!env.manifest.dirty) return; - clearTimeout(writeTimer); - writeTimer = setTimeout(writeManifest, 500); } async function writeManifest() { - if (!env.manifestOk || !(await isComplete())) return; - + if (!(env.manifest && taskOk())) return; const commitMessage = cps .execSync('git log -1 --pretty=%s', { encoding: 'utf-8' }) .trim() - .replace(/'/g, ''') - .replace(/"/g, '"'); + .replaceAll("'", ''') + .replaceAll('"', '"'); const clientJs: string[] = [ 'if (!window.site) window.site={};', @@ -47,59 +66,40 @@ async function writeManifest() { if (env.remoteLog) clientJs.push(jsLogger()); const pairLine = ([name, info]: [string, SplitAsset]) => `'${name.replaceAll("'", "\\'")}':'${info.hash}'`; - const jsLines = Object.entries(env.manifest.js) + const jsLines = Object.entries(manifest.js) .filter(([name, _]) => !/common\.[A-Z0-9]{8}/.test(name)) .map(pairLine) .join(','); - const cssLines = Object.entries(env.manifest.css).map(pairLine).join(','); - const hashedLines = Object.entries(env.manifest.hashed).map(pairLine).join(','); + const cssLines = Object.entries(manifest.css).map(pairLine).join(','); + const hashedLines = Object.entries(manifest.hashed).map(pairLine).join(','); clientJs.push(`window.site.manifest={\ncss:{${cssLines}},\njs:{${jsLines}},\nhashed:{${hashedLines}}\n};`); const hashable = clientJs.join('\n'); const hash = crypto.createHash('sha256').update(hashable).digest('hex').slice(0, 8); - // add the date after hashing + const clientManifest = hashable + `\nwindow.site.info.date='${ new Date(new Date().toUTCString()).toISOString().split('.')[0] + '+00:00' }';\n`; - const serverManifest = { - js: { manifest: { hash }, ...env.manifest.js, ...env.manifest.i18n }, - css: { ...env.manifest.css }, - hashed: { ...env.manifest.hashed }, - }; - + const serverManifest = JSON.stringify( + { + js: { manifest: { hash }, ...manifest.js, ...manifest.i18n }, + css: { ...manifest.css }, + hashed: { ...manifest.hashed }, + }, + null, + env.prod ? undefined : 2, + ); await Promise.all([ - fs.promises.writeFile(path.join(env.jsOutDir, `manifest.${hash}.js`), clientManifest), - fs.promises.writeFile( - path.join(env.jsOutDir, `manifest.${env.prod ? 'prod' : 'dev'}.json`), - JSON.stringify(serverManifest, null, env.prod ? undefined : 2), - ), + fs.promises.writeFile(p.join(env.jsOutDir, `manifest.${hash}.js`), clientManifest), + fs.promises.writeFile(p.join(env.jsOutDir, `manifest.${env.prod ? 'prod' : 'dev'}.json`), serverManifest), ]); - env.manifest.dirty = false; + manifest.dirty = false; + const serverHash = crypto.createHash('sha256').update(serverManifest).digest('hex').slice(0, 8); env.log( - `Manifest '${c.cyan(`public/compiled/manifest.${env.prod ? 'prod' : 'dev'}.json`)}' -> '${c.cyan( - `public/compiled/manifest.${hash}.js`, - )}'`, + `'${c.cyan(`public/compiled/manifest.${hash}.js`)}', '${c.cyan(`public/compiled/manifest.${env.prod ? 'prod' : 'dev'}.json`)}' ${c.grey(serverHash)}`, + 'manifest', ); } - -async function isComplete() { - for (const bundle of [...env.packages.values()].map(x => x.bundle ?? []).flat()) { - if (!bundle.module) continue; - const name = path.basename(bundle.module, '.ts'); - if (!env.manifest.js[name]) { - env.log(`${warnMark} - No manifest without building '${c.cyan(name + '.ts')}'`); - return false; - } - } - for (const css of await allCssSources()) { - const name = path.basename(css, '.scss'); - if (!env.manifest.css[name]) { - env.log(`${warnMark} - No manifest without building '${c.cyan(name + '.scss')}'`); - return false; - } - } - return Object.keys(env.manifest.i18n).length > 0; -} diff --git a/ui/.build/src/parse.ts b/ui/.build/src/parse.ts index 0d939aa8c05d5..7d71a88636157 100644 --- a/ui/.build/src/parse.ts +++ b/ui/.build/src/parse.ts @@ -1,27 +1,34 @@ import fs from 'node:fs'; -import path from 'node:path'; +import p from 'node:path'; import fg from 'fast-glob'; -import { env, errorMark, c } from './env.ts'; - -export type Bundle = { module?: string; inline?: string }; +import { env } from './env.ts'; export interface Package { - root: string; // absolute path to package.json parentdir (package root) + root: string; // absolute path to package.json parentdir name: string; // dirname of package root - pkg: any; // the entire package.json object - bundle: { module?: string; inline?: string }[]; // TODO doc - hash: { glob: string; update?: string }[]; // TODO doc + pkg: any; // package.json object + bundle: Bundle[]; // esbuild bundling + hash: Hash[]; // files to symlink hash sync: Sync[]; // pre-bundle filesystem copies from package json } -export interface Sync { - src: string; // src must be a file or a glob expression, use /** to sync entire directories - dest: string; // TODO doc - pkg: Package; +interface Bundle { + module?: string; // file glob for esm modules (esbuild entry points) + inline?: string; // inject this script into response html +} + +interface Hash { + glob: string; // glob for assets + update?: string; // file to update with hashed filenames +} + +interface Sync { + src: string; // file glob expression, use /** to sync entire directories + dest: string; // directory to copy into } export async function parsePackages(): Promise { - for (const dir of (await globArray('[^@.]*/package.json')).map(pkg => path.dirname(pkg))) { + for (const dir of (await glob('ui/[^@.]*/package.json')).map(pkg => p.dirname(pkg))) { const pkgInfo = await parsePackage(dir); env.packages.set(pkgInfo.name, pkgInfo); } @@ -35,18 +42,14 @@ export async function parsePackages(): Promise { } } -export async function globArray(glob: string, opts: fg.Options = {}): Promise { - const files: string[] = []; - for await (const f of fg.stream(glob, { cwd: env.uiDir, absolute: true, onlyFiles: true, ...opts })) { - files.push(f.toString('utf8')); - } - return files; -} - -export async function globArrays(globs: string[] | undefined, opts: fg.Options = {}): Promise { - if (!globs) return []; - const globResults = await Promise.all(globs.map(g => globArray(g, opts))); - return [...new Set(globResults.flat())]; +export async function glob(glob: string[] | string | undefined, opts: fg.Options = {}): Promise { + if (!glob) return []; + const results = await Promise.all( + Array() + .concat(glob) + .map(async g => fg.glob(g, { cwd: env.rootDir, absolute: true, ...opts })), + ); + return [...new Set(results.flat())]; } export async function folderSize(folder: string): Promise { @@ -55,7 +58,7 @@ export async function folderSize(folder: string): Promise { const sizes = await Promise.all( entries.map(async entry => { - const fullPath = path.join(dir, entry.name); + const fullPath = p.join(dir, entry.name); if (entry.isDirectory()) return getSize(fullPath); if (entry.isFile()) return (await fs.promises.stat(fullPath)).size; return 0; @@ -73,11 +76,33 @@ export async function readable(file: string): Promise { .catch(() => false); } -async function parsePackage(packageDir: string): Promise { +export async function subfolders(folder: string, depth = 1): Promise { + const folders: string[] = []; + if (depth > 0) + await Promise.all( + (await fs.promises.readdir(folder).catch(() => [])).map(f => + fs.promises.stat(p.join(folder, f)).then(s => s.isDirectory() && folders.push(p.join(folder, f))), + ), + ); + return folders; +} + +export function isFolder(file: string): Promise { + return fs.promises + .stat(file) + .then(s => s.isDirectory()) + .catch(() => false); +} + +export function isGlob(path: string): boolean { + return /[*?!{}[\]()]/.test(path); +} + +async function parsePackage(root: string): Promise { const pkgInfo: Package = { - pkg: JSON.parse(await fs.promises.readFile(path.join(packageDir, 'package.json'), 'utf8')), - name: path.basename(packageDir), - root: packageDir, + pkg: JSON.parse(await fs.promises.readFile(p.join(root, 'package.json'), 'utf8')), + name: p.basename(root), + root, bundle: [], sync: [], hash: [], @@ -85,34 +110,23 @@ async function parsePackage(packageDir: string): Promise { if (!('build' in pkgInfo.pkg)) return pkgInfo; const build = pkgInfo.pkg.build; + // 'hash' and 'sync' paths beginning with '/' are repo relative, otherwise they are package relative + const normalize = (file: string) => (file[0] === '/' ? file.slice(1) : p.join('ui', pkgInfo.name, file)); + const normalizeObject = >(o: T) => + Object.fromEntries(Object.entries(o).map(([k, v]) => [k, typeof v === 'string' ? normalize(v) : v])); + if ('hash' in build) - pkgInfo.hash = [].concat(build.hash).map(glob => (typeof glob === 'string' ? { glob } : glob)); - - if ('bundle' in build) { - for (const one of [].concat(build.bundle).map(b => (typeof b === 'string' ? { module: b } : b))) { - const src = one.module ?? one.inline; - if (!src) continue; - - if (await readable(path.join(pkgInfo.root, src))) pkgInfo.bundle.push(one); - else if (one.module) - pkgInfo.bundle.push( - ...(await globArray(one.module, { cwd: pkgInfo.root, absolute: false })) - .filter(m => !m.endsWith('.inline.ts')) // no globbed inline sources - .map(module => ({ ...one, module })), - ); - else env.log(`[${c.grey(pkgInfo.name)}] - ${errorMark} - Bundle error ${c.blue(JSON.stringify(one))}`); - } - } - if ('sync' in build) { + pkgInfo.hash = [] + .concat(build.hash) + .map(g => (typeof g === 'string' ? { glob: normalize(g) } : normalizeObject(g))) as Hash[]; + + if ('sync' in build) pkgInfo.sync = Object.entries(build.sync).map(x => ({ - src: x[0], - dest: x[1], - pkg: pkgInfo, + src: normalize(x[0]), + dest: normalize(x[1]), })); - } - return pkgInfo; -} -export function trimAndConsolidateWhitespace(text: string): string { - return text.trim().replace(/\s+/g, ' '); + if ('bundle' in build) + pkgInfo.bundle = [].concat(build.bundle).map(b => (typeof b === 'string' ? { module: b } : b)); + return pkgInfo; } diff --git a/ui/.build/src/sass.ts b/ui/.build/src/sass.ts index 9a21d3b05300f..49780804053e2 100644 --- a/ui/.build/src/sass.ts +++ b/ui/.build/src/sass.ts @@ -1,245 +1,164 @@ import cps from 'node:child_process'; import fs from 'node:fs'; import ps from 'node:process'; -import path from 'node:path'; +import p from 'node:path'; import crypto from 'node:crypto'; import clr from 'tinycolor2'; -import { env, c, lines, errorMark } from './env.ts'; -import { globArray, readable } from './parse.ts'; +import { env, c, errorMark } from './env.ts'; +import { readable, glob } from './parse.ts'; +import { task } from './task.ts'; import { updateManifest } from './manifest.ts'; -import { clamp } from './algo.ts'; +import { clamp, isEquivalent, trimLines } from './algo.ts'; +const importMap = new Map>(); const colorMixMap = new Map(); const themeColorMap = new Map>(); -const importMap = new Map>(); -const processed = new Set(); + let sassPs: cps.ChildProcessWithoutNullStreams | undefined; -let watcher: SassWatch | undefined; -let awaitingFullBuild: boolean | undefined = undefined; export function stopSass(): void { sassPs?.removeAllListeners(); sassPs?.kill(); sassPs = undefined; - watcher?.destroy(); - watcher = undefined; importMap.clear(); - processed.clear(); colorMixMap.clear(); themeColorMap.clear(); } export async function sass(): Promise { - if (!env.sass) return; - env.exitCode.delete('sass'); - - await fs.promises.mkdir(path.join(env.buildTempDir, 'css')).catch(() => {}); - - awaitingFullBuild ??= env.watch && env.building.length === env.packages.size; - - const sources = await allSources(); - await Promise.allSettled([parseThemeColorDefs(), ...sources.map(src => parseScss(src))]); - await buildColorMixes().then(buildColorWrap); - - if (env.watch) watcher = new SassWatch(); - - compile(sources, env.building.length !== env.packages.size); - - if (!sources.length) env.done(0, 'sass'); -} - -export async function allSources(): Promise { - return (await globArray('./*/css/**/[^_]*.scss', { absolute: false })).filter(x => !x.includes('/gen/')); -} + if (!env.begin('sass')) return; -async function unbuiltSources(): Promise { - return Promise.all( - (await allSources()).filter( - src => !readable(path.join(env.cssTempDir, `${path.basename(src, '.scss')}.css`)), - ), - ); -} + await Promise.allSettled([ + fs.promises.mkdir(env.cssOutDir), + fs.promises.mkdir(env.themeGenDir), + fs.promises.mkdir(p.join(env.buildTempDir, 'css')), + ]); -class SassWatch { - dependencies = new Set(); - touched = new Set(); - timeout: NodeJS.Timeout | undefined; - watchers: fs.FSWatcher[] = []; - watchDirs = new Set(); - constructor() { - this.watch(); - } + let remaining: Set; - async watch() { - if (!env.watch) return; - const watchDirs = new Set([...importMap.keys()].map(path.dirname)); - (await allSources()).forEach(s => watchDirs.add(path.dirname(s))); - if (this.watchDirs.size === watchDirs.size || [...watchDirs].every(d => this.watchDirs.has(d))) return; - if (this.watchDirs.size) env.log('Rebuilding watchers...', { ctx: 'sass' }); - for (const x of this.watchers) x.close(); - this.watchers.length = 0; - this.watchDirs = watchDirs; - for (const dir of this.watchDirs) { - const fsWatcher = fs.watch(dir); - fsWatcher.on('change', (event: string, srcFile: string) => this.onChange(dir, event, srcFile)); - fsWatcher.on('error', (err: Error) => env.error(err, 'sass')); - this.watchers.push(fsWatcher); - } - } + return task({ + ctx: 'sass', + glob: { cwd: env.uiDir, path: '*/css/**/*.scss' }, + debounce: 300, + root: env.rootDir, + execute: async (modified, fullList) => { + const concreteAll = new Set(fullList.filter(isConcrete)); + const partialTouched = modified.filter(isPartial); + const transitiveTouched = partialTouched.flatMap(p => [...importersOf(p)]); + const concreteTouched = [...new Set([...transitiveTouched, ...modified])].filter(isConcrete); - destroy() { - this.clear(); - for (const x of this.watchers) x.close(); - this.watchers.length = 0; - } + remaining = remaining + ? new Set([...remaining, ...concreteTouched].filter(x => concreteAll.has(x))) + : concreteAll; - clear() { - clearTimeout(this.timeout); - this.timeout = undefined; - this.dependencies.clear(); - this.touched.clear(); - } + if (modified.some(src => src.startsWith('ui/common/css/theme/_'))) { + await parseThemeColorDefs(); + } - add(files: string[]): boolean { - clearTimeout(this.timeout); - this.timeout = setTimeout(() => this.fire(), 200); - if (files.every(f => this.touched.has(f))) return false; - files.forEach(src => { - this.touched.add(src); - if (!/[^_].*\.scss/.test(path.basename(src))) { - this.dependencies.add(src); - } else importersOf(src).forEach(dest => this.dependencies.add(dest)); - }); - return true; - } + const oldMixes = Object.fromEntries(colorMixMap); - onChange(dir: string, event: string, srcFile: string) { - if (event === 'change') { - if (this.add([path.join(dir, srcFile)])) env.log(`File '${c.cyanBold(srcFile)}' changed`); - } else if (event === 'rename') { - globArray('*.scss', { cwd: dir, absolute: false }).then(files => { - if (this.add(files.map(f => path.join(dir, f)))) { - env.log(`Cross your fingers - directory '${c.cyanBold(dir)}' changed`, { ctx: 'sass' }); - } - }); - } - } + const processed = new Set(); + await Promise.all(concreteTouched.map(src => parseScss(src, processed))); - async fire() { - const sources = [...this.dependencies].filter(src => /\/[^_][^/]+\.scss$/.test(src)); - const touched = [...this.touched]; - this.clear(); - this.watch(); // experimental - let rebuildColors = false; - - for (const src of touched) { - processed.delete(src); - if (src.includes('common/css/theme/_')) { - rebuildColors = true; - await parseThemeColorDefs(); + if (!isEquivalent(oldMixes, Object.fromEntries(colorMixMap))) { + await buildColorMixes(); + await buildColorWrap(); + remaining.add('ui/common/css/build/common.theme.all.scss'); + remaining.add('ui/common/css/build/common.theme.embed.scss'); } - } - const oldMixSet = new Set([...colorMixMap.keys()]); - for (const src of touched) await parseScss(src); - const newMixSet = new Set([...colorMixMap.keys()]); - if ([...newMixSet].some(mix => !oldMixSet.has(mix))) rebuildColors = true; - if (rebuildColors) { - buildColorMixes() - .then(buildColorWrap) - .then(() => compile(sources)); - } else compile(sources); - } -} - -// compile an array of concrete scss files to ui/.build/dist/css/*.css (css temp dir prior to hashMove) -function compile(sources: string[], logAll = true) { - if (!sources.length) return sources.length; - const sassExec = - process.env.SASS_PATH || - fs.realpathSync( - path.join(env.buildDir, 'node_modules', `sass-embedded-${ps.platform}-${ps.arch}`, 'dart-sass', 'sass'), - ); + remaining = new Set(await compile([...remaining], remaining.size < concreteAll.size)); - if (!fs.existsSync(sassExec)) { - env.error(`Sass executable not found '${c.cyan(sassExec)}'`, 'sass'); - env.done(1, 'sass'); - } - if (logAll) sources.forEach(src => env.log(`Building '${c.cyan(src)}'`, { ctx: 'sass' })); - else env.log('Building', { ctx: 'sass' }); + if (!remaining.size) + updateManifest({ + css: Object.fromEntries( + await Promise.all((await glob(p.join(env.cssTempDir, '*.css'))).map(hashMoveCss)), + ), + }); + }, + }); +} - const sassArgs = ['--no-error-css', '--stop-on-error', '--no-color', '--quiet', '--quiet-deps']; - sassPs?.removeAllListeners(); - sassPs = cps.spawn( - sassExec, - sassArgs.concat( - env.prod ? ['--style=compressed', '--no-source-map'] : ['--embed-sources'], - sources.map( - (src: string) => - `${src}:${path.join(env.cssTempDir, path.basename(src).replace(/(.*)scss$/, '$1css'))}`, +// compile an array of concrete scss files, return any that error +async function compile(sources: string[], logAll = true): Promise { + const sassBin = + process.env.SASS_PATH ?? + (await fs.promises.realpath( + p.join(env.buildDir, 'node_modules', `sass-embedded-${ps.platform}-${ps.arch}`, 'dart-sass', 'sass'), + )); + if (!(await readable(sassBin))) env.exit(`Sass executable not found '${c.cyan(sassBin)}'`, 'sass'); + + return new Promise(resolve => { + if (!sources.length) return resolve([]); + if (logAll) sources.forEach(src => env.log(`Building '${c.cyan(src)}'`, 'sass')); + else env.log('Building', 'sass'); + + const sassArgs = ['--no-error-css', '--stop-on-error', '--no-color', '--quiet', '--quiet-deps']; + sassPs?.removeAllListeners(); + sassPs = cps.spawn( + sassBin, + sassArgs.concat( + env.prod ? ['--style=compressed', '--no-source-map'] : ['--embed-sources'], + sources.map( + (src: string) => `${src}:${p.join(env.cssTempDir, p.basename(src).replace(/(.*)scss$/, '$1css'))}`, + ), ), - ), - ); + ); - sassPs.stderr?.on('data', (buf: Buffer) => sassError(buf.toString('utf8'))); - sassPs.stdout?.on('data', (buf: Buffer) => { - const txts = lines(buf.toString('utf8')); - for (const txt of txts) env.log(c.red(txt), { ctx: 'sass' }); - }); - sassPs.on('close', async (code: number) => { - if (code !== 0) return env.done(code, 'sass'); - if (awaitingFullBuild && compile(await unbuiltSources()) > 0) return; - awaitingFullBuild = false; // now we are ready to make manifests - cssManifest(); - env.done(0, 'sass'); + sassPs.stderr?.on('data', (buf: Buffer) => sassError(buf.toString('utf8'))); + sassPs.stdout?.on('data', (buf: Buffer) => sassError(buf.toString('utf8'))); + sassPs.on('close', async (code: number) => { + if (code === 0) resolve([]); + else + failed(sources) + .then(resolve) + .catch(() => resolve(sources)); + }); }); - return sources.length; } // recursively parse scss file and its imports to build dependency and color maps -async function parseScss(src: string) { - if (path.dirname(src).endsWith('/gen')) return; +async function parseScss(src: string, processed: Set) { + if (p.dirname(src).endsWith('/gen')) return; if (processed.has(src)) return; processed.add(src); - try { - const text = await fs.promises.readFile(src, 'utf8'); - for (const match of text.matchAll(/\$m-([-_a-z0-9]+)/g)) { - const [str, mix] = [match[1], parseColor(match[1])]; - if (!mix) { - env.log(`${errorMark} - invalid color mix: '${c.magenta(str)}' in '${c.cyan(src)}'`, { - ctx: 'sass', - }); - continue; - } - colorMixMap.set(str, mix); + + const text = await fs.promises.readFile(src, 'utf8'); + + for (const match of text.matchAll(/\$m-([-_a-z0-9]+)/g)) { + const [str, mix] = [match[1], parseColor(match[1])]; + if (!mix) { + env.log(`${errorMark} Invalid color mix: '${c.magenta(str)}' in '${c.cyan(src)}'`, 'sass'); + continue; } - for (const match of text.matchAll(/^@(?:import|use)\s+['"](.*)['"]/gm)) { - if (match.length !== 2) continue; + colorMixMap.set(str, mix); + } - const absDep = (await readable(path.resolve(path.dirname(src), match[1]) + '.scss')) - ? path.resolve(path.dirname(src), match[1] + '.scss') - : path.resolve(path.dirname(src), resolvePartial(match[1])); + for (const match of text.matchAll(/^@(?:import|use)\s+['"](.*)['"]/gm)) { + if (match.length !== 2) continue; - if (!absDep.startsWith(env.uiDir) || /node_modules.*\.css/.test(absDep)) continue; + const absDep = (await readable(p.resolve(p.dirname(src), match[1]) + '.scss')) + ? p.resolve(p.dirname(src), match[1] + '.scss') + : p.resolve(p.dirname(src), resolvePartial(match[1])); - const dep = absDep.slice(env.uiDir.length + 1); - if (!importMap.get(dep)?.add(src)) importMap.set(dep, new Set([src])); - await parseScss(dep); - } - } catch (e) { - env.log(`${errorMark} failed to read ${src} - ${JSON.stringify(e, undefined, 2)}`); + if (/node_modules.*\.css/.test(absDep)) continue; + else if (!absDep.startsWith(env.uiDir)) throw `Bad import '${match[1]}`; + + const dep = p.relative(env.rootDir, absDep); + if (!importMap.get(dep)?.add(src)) importMap.set(dep, new Set([src])); + await parseScss(dep, processed); } } // collect mixable scss color definitions from theme files async function parseThemeColorDefs() { - const themeFiles = await globArray('./common/css/theme/_*.scss', { absolute: false }); + const themeFiles = await glob('ui/common/css/theme/_*.scss', { absolute: false }); const themes: string[] = ['dark']; const capturedColors = new Map(); for (const themeFile of themeFiles ?? []) { const theme = /_([^/]+)\.scss/.exec(themeFile)?.[1]; if (!theme) { - env.log(`${errorMark} - invalid theme filename '${c.cyan(themeFile)}'`, { ctx: 'sass' }); + env.log(`${errorMark} Invalid theme filename '${c.cyan(themeFile)}'`, 'sass'); continue; } const text = fs.readFileSync(themeFile, 'utf8'); @@ -274,7 +193,7 @@ async function parseThemeColorDefs() { // given color definitions and mix instructions, build mixed color css variables in themed scss mixins async function buildColorMixes() { - const out = fs.createWriteStream(path.join(env.themeGenDir, '_mix.scss')); + const out = fs.createWriteStream(p.join(env.themeGenDir, '_mix.scss')); for (const theme of themeColorMap.keys()) { const colorMap = themeColorMap.get(theme)!; out.write(`@mixin ${theme}-mix {\n`); @@ -295,7 +214,7 @@ async function buildColorMixes() { } })(); if (mixed) colors.push(` --m-${colorMix}: ${env.rgb ? mixed.toRgbString() : mixed.toHslString()};`); - else env.log(`${errorMark} - invalid mix op: '${c.magenta(colorMix)}'`, { ctx: 'sass' }); + else env.log(`${errorMark} Invalid mix op: '${c.magenta(colorMix)}'`, 'sass'); } out.write(colors.sort().join('\n') + '\n}\n\n'); } @@ -307,7 +226,7 @@ async function buildColorWrap() { const cssVars = new Set(); for (const color of colorMixMap.keys()) cssVars.add(`m-${color}`); - for (const file of await globArray(path.join(env.themeDir, '_*.scss'))) { + for (const file of await glob(p.join(env.themeDir, '_*.scss'))) { for (const line of (await fs.promises.readFile(file, 'utf8')).split('\n')) { if (line.indexOf('--') === -1) continue; const commentIndex = line.indexOf('//'); @@ -322,8 +241,8 @@ async function buildColorWrap() { .map(variable => `$${variable}: var(--${variable});`) .join('\n') + '\n'; - const wrapFile = path.join(env.themeDir, 'gen', '_wrap.scss'); - await fs.promises.mkdir(path.dirname(wrapFile), { recursive: true }); + const wrapFile = p.join(env.themeDir, 'gen', '_wrap.scss'); + await fs.promises.mkdir(p.dirname(wrapFile), { recursive: true }); if (await readable(wrapFile)) { if ((await fs.promises.readFile(wrapFile, 'utf8')) === scssWrap) return; // don't touch wrap if no changes } @@ -345,18 +264,23 @@ function parseColor(colorMix: string) { : undefined; } -function resolvePartial(partial: string): string { - const nameBegin = partial.lastIndexOf(path.sep) + 1; - return `${partial.slice(0, nameBegin)}_${partial.slice(nameBegin)}.scss`; +async function failed(attempted: string[]): Promise { + return Promise.all( + attempted.filter(src => !readable(p.join(env.cssTempDir, `${p.basename(src, '.scss')}.css`))), + ); } -function sassError(error: string) { - for (const err of lines(error)) { - if (err.startsWith('Error:')) { - env.log(c.grey('-'.repeat(75)), { ctx: 'sass' }); - env.log(`${errorMark} - ${err.slice(7)}`, { ctx: 'sass' }); - } else env.log(err, { ctx: 'sass' }); - } +function isConcrete(src: string) { + return !p.basename(src).startsWith('_'); +} + +function isPartial(src: string) { + return p.basename(src).startsWith('_'); +} + +function resolvePartial(partial: string): string { + const nameBegin = partial.lastIndexOf(p.sep) + 1; + return `${partial.slice(0, nameBegin)}_${partial.slice(nameBegin)}.scss`; } function importersOf(srcFile: string, bset = new Set()): Set { @@ -366,18 +290,22 @@ function importersOf(srcFile: string, bset = new Set()): Set { return bset; } -export async function cssManifest(): Promise { - const files = await globArray(path.join(env.cssTempDir, '*.css')); - updateManifest({ css: Object.fromEntries(await Promise.all(files.map(hashMoveCss))) }); -} - async function hashMoveCss(src: string) { const content = await fs.promises.readFile(src, 'utf-8'); const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 8); - const basename = path.basename(src, '.css'); + const basename = p.basename(src, '.css'); await Promise.allSettled([ - env.prod ? undefined : fs.promises.rename(`${src}.map`, path.join(env.cssOutDir, `${basename}.css.map`)), - fs.promises.rename(src, path.join(env.cssOutDir, `${basename}.${hash}.css`)), + env.prod ? undefined : fs.promises.rename(`${src}.map`, p.join(env.cssOutDir, `${basename}.css.map`)), + fs.promises.rename(src, p.join(env.cssOutDir, `${basename}.${hash}.css`)), ]); - return [path.basename(src, '.css'), { hash }]; + return [p.basename(src, '.css'), { hash }]; +} + +function sassError(error: string) { + for (const err of trimLines(error)) { + if (err.startsWith('Error:')) { + env.log(c.grey('-'.repeat(75)), 'sass'); + env.log(`${errorMark} - ${err.slice(7)}`, 'sass'); + } else env.log(err, 'sass'); + } } diff --git a/ui/.build/src/sync.ts b/ui/.build/src/sync.ts index 4e067d86b8164..c8c3a6344976d 100644 --- a/ui/.build/src/sync.ts +++ b/ui/.build/src/sync.ts @@ -1,104 +1,58 @@ import fs from 'node:fs'; -import path from 'node:path'; -import { type Sync, globArray, globArrays } from './parse.ts'; -import { hash } from './hash.ts'; -import { env, errorMark, c } from './env.ts'; -import { quantize } from './algo.ts'; - -const syncWatch: fs.FSWatcher[] = []; -let watchTimeout: NodeJS.Timeout | undefined; - -export function stopSync(): void { - clearTimeout(watchTimeout); - for (const watcher of syncWatch) watcher.close(); - syncWatch.length = 0; -} - -export async function sync(): Promise { - if (!env.sync) return; - const watched = new Map(); - const updated = new Set(); - - for (const pkg of env.building) { - for (const sync of pkg.sync) { - for (const src of await syncGlob(sync)) { - if (env.watch) watched.set(src, [...(watched.get(src) ?? []), sync]); - } - } - if (!env.watch) continue; - const sources = await globArrays( - pkg.hash.map(x => x.glob), - { cwd: env.outDir }, - ); - - for (const src of sources.filter(isUnmanagedAsset)) { - if (!watched.has(path.dirname(src))) watched.set(path.dirname(src), []); - } - } - if (env.watch) - for (const dir of watched.keys()) { - const watcher = fs.watch(dir); - - watcher.on('change', () => { - updated.add(dir); - clearTimeout(watchTimeout); - watchTimeout = setTimeout(() => { - Promise.all([...updated].flatMap(d => (watched.get(d) ?? []).map(x => syncGlob(x)))).then(hash); - updated.clear(); - }, 2000); +import p from 'node:path'; +import { task } from './task.ts'; +import { env, c } from './env.ts'; +import { isGlob, isFolder } from './parse.ts'; +import { quantize, isEquivalent } from './algo.ts'; + +export async function sync(): Promise { + if (!env.begin('sync')) return; + return Promise.all( + [...env.tasks('sync')].map(async ([pkg, sync]) => { + const root = await syncRoot(env.rootDir, sync.src); + await task({ + glob: { path: sync.src, cwd: env.rootDir }, + ctx: 'sync', + debounce: 300, + execute: (files, fullList) => { + const logEvery = !isEquivalent(files, fullList); + if (!logEvery) + env.log(`${c.grey(pkg.name)} '${c.cyan(sync.src)}' -> '${c.cyan(sync.dest)}'`, 'sync'); + return Promise.all( + files.map(async f => { + if ((await syncOne(f, p.join(env.rootDir, sync.dest, f.slice(root.length)))) && logEvery) + env.log( + `${c.grey(pkg.name)} '${c.cyan(f.slice(root.length))}' -> '${c.cyan(sync.dest)}'`, + 'sync', + ); + }), + ); + }, }); - watcher.on('error', (err: Error) => env.error(err)); - syncWatch.push(watcher); - } + }), + ); } -export function isUnmanagedAsset(absfile: string): boolean { - if (!absfile.startsWith(env.outDir)) return false; - const name = absfile.slice(env.outDir.length + 1); - if (['compiled/', 'hashed/', 'css/'].some(dir => name.startsWith(dir))) return false; - return true; -} - -async function syncGlob(cp: Sync): Promise> { - const watchDirs = new Set(); - const dest = path.join(env.rootDir, cp.dest) + path.sep; - - const globIndex = cp.src.search(/[*?!{}[\]()]|\*\*|\[[^[\]]*\]/); - const globRoot = - globIndex > 0 && cp.src[globIndex - 1] === path.sep - ? cp.src.slice(0, globIndex - 1) - : path.dirname(cp.src.slice(0, globIndex)); - - const srcs = await globArray(cp.src, { cwd: cp.pkg.root, absolute: false }); - - watchDirs.add(path.join(cp.pkg.root, globRoot)); - env.log(`[${c.grey(cp.pkg.name)}] - Sync '${c.cyan(cp.src)}' to '${c.cyan(cp.dest)}'`); - const fileCopies = []; - - for (const src of srcs) { - const srcPath = path.join(cp.pkg.root, src); - watchDirs.add(path.dirname(srcPath)); - const destPath = path.join(dest, src.slice(globRoot.length)); - fileCopies.push(syncOne(srcPath, destPath, cp.pkg.name)); +async function syncOne(absSrc: string, absDest: string): Promise { + // TODO are these stats unnecessary now? + const [src, dest] = ( + await Promise.allSettled([ + fs.promises.stat(absSrc), + fs.promises.stat(absDest), + fs.promises.mkdir(p.dirname(absDest), { recursive: true }), + ]) + ).map(x => (x.status === 'fulfilled' ? (x.value as fs.Stats) : undefined)); + if (src && (!dest || quantize(src.mtimeMs, 300) !== quantize(dest.mtimeMs, 300))) { + await fs.promises.copyFile(absSrc, absDest); + await fs.promises.utimes(absDest, src.atime, src.mtime); + return true; } - await Promise.allSettled(fileCopies); - return watchDirs; + return false; } -async function syncOne(absSrc: string, absDest: string, pkgName: string) { - try { - const [src, dest] = ( - await Promise.allSettled([ - fs.promises.stat(absSrc), - fs.promises.stat(absDest), - fs.promises.mkdir(path.dirname(absDest), { recursive: true }), - ]) - ).map(x => (x.status === 'fulfilled' ? (x.value as fs.Stats) : undefined)); - if (src && (!dest || quantize(src.mtimeMs, 2000) !== quantize(dest.mtimeMs, 2000))) { - await fs.promises.copyFile(absSrc, absDest); - fs.utimes(absDest, src.atime, src.mtime, () => {}); - } - } catch (_) { - env.log(`[${c.grey(pkgName)}] - ${errorMark} - failed sync '${c.cyan(absSrc)}' to '${c.cyan(absDest)}'`); - } +async function syncRoot(cwd: string, path: string): Promise { + if (!(isGlob(path) || (await isFolder(p.join(cwd, path))))) return p.join(cwd, p.dirname(path)); + const [head, ...tail] = path.split(p.sep); + if (isGlob(head)) return cwd; + return syncRoot(p.join(cwd, head), tail.join(p.sep)); } diff --git a/ui/.build/src/task.ts b/ui/.build/src/task.ts new file mode 100644 index 0000000000000..ba061284a0c3f --- /dev/null +++ b/ui/.build/src/task.ts @@ -0,0 +1,194 @@ +import fg from 'fast-glob'; +import mm from 'micromatch'; +import fs from 'node:fs'; +import p from 'node:path'; +import { glob, isFolder, subfolders } from './parse.ts'; +import { randomToken } from './algo.ts'; +import { type Context, env, c, errorMark } from './env.ts'; + +const fsWatches = new Map(); +const tasks = new Map(); +const fileTimes = new Map(); + +type Path = string; +type AbsPath = string; +type CwdPath = { cwd: AbsPath; path: Path }; +type Debounce = { time: number; timeout?: NodeJS.Timeout; rename: boolean; files: Set }; +type TaskKey = string; +type FSWatch = { watcher: fs.FSWatcher; cwd: AbsPath; keys: Set }; +type Task = Omit & { + glob: CwdPath[]; + key: TaskKey; + debounce: Debounce; + fileTimes: Map; + status: 'ok' | 'error' | undefined; +}; +type TaskOpts = { + glob: CwdPath | CwdPath[]; + execute: (touched: AbsPath[], fullList: AbsPath[]) => Promise; + key?: TaskKey; // optional key for replace & cancel + ctx?: Context; // optional context for logging + pkg?: string; // optional package for logging + debounce?: number; // optional number in ms + root?: AbsPath; // optional relative root for file lists, otherwise all paths are absolute + globListOnly?: boolean; // default false - ignore file mods, only execute when glob list changes + monitorOnly?: boolean; // default false - do not execute on initial traverse, only on future changes + noEnvStatus?: boolean; // default false - don't inform env.done of task status +}; + +export async function task(o: TaskOpts): Promise { + const { monitorOnly: noInitial, debounce, key: inKey } = o; + const glob = Array().concat(o.glob ?? []); + if (glob.length === 0) return; + if (inKey) stopTask(inKey); + const newWatch: Task = { + ...o, + glob, + key: inKey ?? randomToken(), + status: noInitial ? 'ok' : undefined, + debounce: { time: debounce ?? 0, rename: !noInitial, files: new Set() }, + fileTimes: noInitial ? await globTimes(glob) : new Map(), + }; + tasks.set(newWatch.key, newWatch); + if (env.watch) newWatch.glob.forEach(g => watchGlob(g, newWatch.key)); + if (!noInitial) return execute(newWatch); +} + +export function stopTask(keys?: TaskKey | TaskKey[]) { + const stopKeys = Array().concat(keys ?? [...tasks.keys()]); + for (const key of stopKeys) { + clearTimeout(tasks.get(key)?.debounce.timeout); + tasks.delete(key); + for (const [folder, fw] of fsWatches) { + if (fw.keys.delete(key) && fw.keys.size === 0) { + fw.watcher.close(); + fsWatches.delete(folder); + } + } + } +} + +export function taskOk(ctx?: Context): boolean { + const all = [...tasks.values()].filter(w => (ctx ? w.ctx === ctx : true)); + return all.filter(w => !w.monitorOnly).length > 0 && all.every(w => w.status === 'ok'); +} + +export async function watchGlob({ cwd, path: globPath }: CwdPath, key: TaskKey): Promise { + if (!(await isFolder(cwd))) return; + const [head, ...tail] = globPath.split(p.sep); + const path = tail.join(p.sep); + + if (head.includes('**')) { + await subfolders(cwd, 10).then(folders => folders.forEach(f => addFsWatch(f, key))); + } else if (/[*?!{}[\]()]/.test(head) && path) { + await subfolders(cwd, 1).then(folders => + Promise.all( + folders.filter(f => mm.isMatch(p.basename(f), head)).map(f => watchGlob({ cwd: f, path }, key)), + ), + ); + } else if (path) { + return watchGlob({ cwd: p.join(cwd, head), path }, key); + } + addFsWatch(cwd, key); +} + +async function onFsChange(fsw: FSWatch, event: string, filename: string | null) { + const fullpath = p.join(fsw.cwd, filename ?? ''); + + if (event === 'change') fileTimes.set(fullpath, await cachedFileTime(fullpath, true)); + for (const watch of [...fsw.keys].map(k => tasks.get(k)!)) { + const fullglobs = watch.glob.map(({ cwd, path }) => p.join(cwd, path)); + if (!mm.isMatch(fullpath, fullglobs)) { + if (event === 'change') continue; + try { + if (!(await fs.promises.stat(fullpath)).isDirectory()) continue; + } catch { + fsWatches.get(fullpath)?.watcher.close(); + fsWatches.delete(fullpath); + continue; + } + addFsWatch(fullpath, watch.key); + if (!(await glob(fullglobs, { cwd: undefined, absolute: true })).some(x => x.includes(fullpath))) + continue; + } + if (event === 'rename') watch.debounce.rename = true; + if (event === 'change') watch.debounce.files.add(fullpath); + clearTimeout(watch.debounce.timeout); + watch.debounce.timeout = setTimeout(() => execute(watch), watch.debounce.time); + } +} + +async function execute(watch: Task): Promise { + const relative = (files: AbsPath[]) => (watch.root ? files.map(f => p.relative(watch.root!, f)) : files); + const debounced = Object.freeze([...watch.debounce.files]); + const modified: AbsPath[] = []; + watch.debounce.files.clear(); + + if (watch.debounce.rename) { + watch.debounce.rename = false; + const files = await globTimes(watch.glob); + const keys = [...files.keys()]; + if ( + watch.globListOnly && + (watch.fileTimes.size !== files.size || !keys.every(f => watch.fileTimes.has(f))) + ) { + modified.push(...keys); + } else if (!watch.globListOnly) { + for (const [fullpath, time] of [...files]) { + if (watch.fileTimes.get(fullpath) !== time) modified.push(fullpath); + } + } + watch.fileTimes = files; + } else if (!watch.globListOnly) { + await Promise.all( + debounced.map(async file => { + const fileTime = await cachedFileTime(file); + if (watch.fileTimes.get(file) === fileTime) return; + watch.fileTimes.set(file, fileTime); + modified.push(file); + }), + ); + } + if (!modified.length) return; + + if (watch.ctx) env.begin(watch.ctx); + watch.status = undefined; + try { + await watch.execute(relative(modified), relative([...watch.fileTimes.keys()])); + watch.status = 'ok'; + if (watch.ctx && !watch.noEnvStatus && taskOk(watch.ctx)) env.done(watch.ctx); + } catch (e) { + watch.status = 'error'; + const message = e instanceof Error ? (e.stack ?? e.message) : String(e); + if (!env.watch) env.exit(`${errorMark} ${message}`, watch.ctx); + else if (e) + env.log(`${errorMark} ${watch.pkg ? `[${c.grey(watch.pkg)}] ` : ''}- ${c.grey(message)}`, watch.ctx); + if (watch.ctx && !watch.noEnvStatus) env.done(watch.ctx, -1); + } +} + +function addFsWatch(root: AbsPath, key: TaskKey) { + if (fsWatches.has(root)) { + fsWatches.get(root)?.keys.add(key); + return; + } + const fsWatch = { watcher: fs.watch(root), cwd: root, keys: new Set([key]) }; + fsWatch.watcher.on('change', (event, f) => onFsChange(fsWatch, event, String(f))); + fsWatches.set(root, fsWatch); +} + +async function cachedFileTime(file: AbsPath, update = false): Promise { + if (fileTimes.has(file) && !update) return fileTimes.get(file)!; + const stat = (await fs.promises.stat(file)).mtimeMs; + fileTimes.set(file, stat); + return stat; +} + +async function globTimes(paths: CwdPath[]): Promise> { + const globs = paths.map(({ path, cwd }) => fg.glob(path, { cwd, absolute: true })); + return new Map( + await Promise.all( + (await Promise.all(globs)).flat().map(async f => [f, await cachedFileTime(f)] as [string, number]), + ), + ); +} diff --git a/ui/.build/src/tsc.ts b/ui/.build/src/tsc.ts index 64c00cbf4fd05..d43fc454d7f4e 100644 --- a/ui/.build/src/tsc.ts +++ b/ui/.build/src/tsc.ts @@ -1,25 +1,25 @@ import fs from 'node:fs'; import os from 'node:os'; -import path from 'node:path'; +import p from 'node:path'; import ts from 'typescript'; +import fg from 'fast-glob'; import { Worker } from 'node:worker_threads'; import { env, c, errorMark } from './env.ts'; -import { globArray, folderSize, readable } from './parse.ts'; +import { folderSize, readable } from './parse.ts'; import { clamp } from './algo.ts'; -import type { WorkerData, Message } from './tscWorker.ts'; +import type { WorkerData, Message, ErrorMessage } from './tscWorker.ts'; const workers: Worker[] = []; +const spamGuard = new Map(); // dedup tsc errors export async function tsc(): Promise { - if (!env.tsc) return; - env.exitCode.delete('tsc'); - + if (!env.begin('tsc')) return; await Promise.allSettled([ - fs.promises.mkdir(path.join(env.buildTempDir, 'noCheck')), - fs.promises.mkdir(path.join(env.buildTempDir, 'noEmit')), + fs.promises.mkdir(p.join(env.buildTempDir, 'noCheck')), + fs.promises.mkdir(p.join(env.buildTempDir, 'noEmit')), ]); - const buildPaths = (await globArray('*/tsconfig*.json', { cwd: env.uiDir })) + const buildPaths = (await fg.glob('*/tsconfig*.json', { cwd: env.uiDir, absolute: true })) .sort((a, b) => a.localeCompare(b)) // repeatable build order .filter(x => env.building.some(pkg => x.startsWith(`${pkg.root}/`))); @@ -39,19 +39,19 @@ export async function tsc(): Promise { ) .push(cfg); - tscLog(`Typing ${c.grey('noCheck')} (${workBuckets.noCheck.length} workers)`); + tscLog(`Typing ${c.grey('--noCheck')} (${workBuckets.noCheck.length} workers)`); await assignWork(workBuckets.noCheck, 'noCheck'); - tscLog(`Typechecking ${c.grey('noEmit')} (${workBuckets.noEmit.length} workers)`); + tscLog(`Checking ${c.grey('--noEmit')} (${workBuckets.noEmit.length} workers)`); await assignWork(workBuckets.noEmit, 'noEmit'); } -export async function stopTscWatch(): Promise { +export async function stopTsc(): Promise { await Promise.allSettled(workers.map(w => w.terminate())); workers.length = 0; } -function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit') { +function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit'): Promise { let resolve: (() => void) | undefined = undefined; const status: ('ok' | 'busy' | 'error')[] = []; const ok = new Promise(res => (resolve = res)); @@ -62,11 +62,11 @@ function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit') { status[msg.index] = msg.type; - if (msg.type === 'error') return tscError(msg); + if (msg.type === 'error') return tscError(msg.data); if (status.some(s => s !== 'ok')) return; resolve?.(); - if (key === 'noEmit' && env.exitCode.get('tsc') !== 0) env.done(0, 'tsc'); // TODO - no more exitCode + if (key === 'noEmit') env.done('tsc'); }; for (const bucket of buckets) { @@ -76,7 +76,7 @@ function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit') { index: status.length, }; status.push('busy'); - const worker = new Worker(path.resolve(env.buildSrcDir, 'tscWorker.ts'), { workerData }); + const worker = new Worker(p.resolve(env.buildSrcDir, 'tscWorker.ts'), { workerData }); workers.push(worker); worker.on('message', onMessage); worker.on('error', onError); @@ -97,14 +97,14 @@ interface SplitConfig { } async function splitConfig(cfgPath: string): Promise { - const root = path.dirname(cfgPath); - const pkgName = path.basename(root); + const root = p.dirname(cfgPath); + const pkgName = p.basename(root); const { config, error } = ts.readConfigFile(cfgPath, ts.sys.readFile); const io: Promise[] = []; if (error) throw new Error(`'${cfgPath}': ${error.messageText}`); - const co: any = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(cfgPath)).options; + const co: any = ts.parseJsonConfigFileContent(config, ts.sys, p.dirname(cfgPath)).options; if (co.moduleResolution) co.moduleResolution = ts.ModuleResolutionKind[co.moduleResolution]; if (co.module) co.module = ts.ModuleKind[co.module]; @@ -114,32 +114,26 @@ async function splitConfig(cfgPath: string): Promise { co.incremental = true; config.compilerOptions = co; - config.size = await folderSize(path.join(root, 'src')); - config.include = config.include?.map((glob: string) => - path.resolve(root, glob.replace('${configDir}', '.')), - ); + config.size = await folderSize(p.join(root, 'src')); + config.include = config.include?.map((glob: string) => p.resolve(root, glob.replace('${configDir}', '.'))); config.include ??= [co.rootDir ? `${co.rootDir}/**/*` : `${root}/src/**/*`]; config.exclude = config.exclude ?.filter((glob: string) => !env.test || !glob.includes('tests')) - .map((glob: string) => path.resolve(root, glob.replace('${configDir}', '.'))); + .map((glob: string) => p.resolve(root, glob.replace('${configDir}', '.'))); config.extends = undefined; config.references = env.workspaceDeps .get(pkgName) - ?.map(ref => ({ path: path.join(env.buildTempDir, 'noCheck', `${ref}.tsconfig.json`) })); + ?.map(ref => ({ path: p.join(env.buildTempDir, 'noCheck', `${ref}.tsconfig.json`) })); const noEmitData = structuredClone(config); - const noEmit = path.join(env.buildTempDir, 'noEmit', `${pkgName}.tsconfig.json`); + const noEmit = p.join(env.buildTempDir, 'noEmit', `${pkgName}.tsconfig.json`); noEmitData.compilerOptions.noEmit = true; - noEmitData.compilerOptions.tsBuildInfoFile = path.join( - env.buildTempDir, - 'noEmit', - `${pkgName}.tsbuildinfo`, - ); - if (env.test && (await readable(path.join(root, 'tests')))) { - noEmitData.include.push(path.join(root, 'tests')); + noEmitData.compilerOptions.tsBuildInfoFile = p.join(env.buildTempDir, 'noEmit', `${pkgName}.tsbuildinfo`); + if (env.test && (await readable(p.join(root, 'tests')))) { + noEmitData.include.push(p.join(root, 'tests')); noEmitData.compilerOptions.rootDir = root; noEmitData.compilerOptions.skipLibCheck = true; - noEmitData.size += await folderSize(path.join(root, 'tests')); + noEmitData.size += await folderSize(p.join(root, 'tests')); } io.push(fs.promises.writeFile(noEmit, JSON.stringify(noEmitData, undefined, 2))); @@ -147,10 +141,10 @@ async function splitConfig(cfgPath: string): Promise { if (!co.noEmit) { const noCheckData = structuredClone(config); - const noCheck = path.join(env.buildTempDir, 'noCheck', `${pkgName}.tsconfig.json`); + const noCheck = p.join(env.buildTempDir, 'noCheck', `${pkgName}.tsconfig.json`); noCheckData.compilerOptions.noCheck = true; noCheckData.compilerOptions.emitDeclarationOnly = true; - noCheckData.compilerOptions.tsBuildInfoFile = path.join( + noCheckData.compilerOptions.tsBuildInfoFile = p.join( env.buildTempDir, 'noCheck', `${pkgName}.tsbuildinfo`, @@ -163,19 +157,22 @@ async function splitConfig(cfgPath: string): Promise { } function tscLog(msg: string): void { - env.log(msg, { ctx: 'tsc' }); + env.log(msg, 'tsc'); } -function tscError(msg: Message): void { - const { code, text, file, line, col } = msg.data; - const prelude = `${errorMark} ts${code} `; - const message = `${ts.flattenDiagnosticMessageText(text, '\n', 0)}`; - let location = ''; - if (file) { - location = `${c.grey('in')} '${c.cyan(path.relative(env.uiDir, file))}`; - if (line !== undefined) location += c.grey(`:${line + 1}:${col + 1}`); - location += `' - `; +function tscError({ code, text, file, line, col }: ErrorMessage['data']): void { + const key = `${code}:${text}:${file}`; + if (performance.now() > (spamGuard.get(key) ?? -Infinity)) { + const prelude = `${errorMark} ts${code} `; + const message = `${ts.flattenDiagnosticMessageText(text, '\n', 0)}`; + let location = ''; + if (file) { + location = `${c.grey('in')} '${c.cyan(p.relative(env.uiDir, file))}`; + if (line !== undefined) location += c.grey(`:${line + 1}:${col + 1}`); + location += `' - `; + } + tscLog(`${prelude}${location}${message}`); } - tscLog(`${prelude}${location}${message}`); - if (!env.exitCode.get('tsc')) env.done(1, 'tsc'); // TODO - no more exitCode + spamGuard.set(key, performance.now() + 1000); + env.done('tsc', -2); } diff --git a/ui/.build/tsconfig.json b/ui/.build/tsconfig.json index d2e0e2f292d0c..9fdf67000c5b0 100644 --- a/ui/.build/tsconfig.json +++ b/ui/.build/tsconfig.json @@ -10,10 +10,10 @@ "strict": true, "esModuleInterop": true, "isolatedModules": true, - "moduleResolution": "Node", - "module": "ES2022", - "target": "ES2022", - "lib": ["ES2022"], + "moduleResolution": "node", + "module": "es2022", + "target": "es2023", + "lib": ["es2023"], "types": ["node"] } } diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts index cb9be5755f35b..4dd83c75ad72b 100644 --- a/ui/@types/lichess/i18n.d.ts +++ b/ui/@types/lichess/i18n.d.ts @@ -2771,6 +2771,8 @@ interface I18n { asBlack: string; /** As free as Lichess */ asFreeAsLichess: string; + /** %1$s posted results for %2$s */ + askConcluded: I18nFormat; /** Your account is managed. Ask your chess teacher about lifting kid mode. */ askYourChessTeacherAboutLiftingKidMode: string; /** as white */ diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts index 97e374c58a267..ea9680482b4c4 100644 --- a/ui/@types/lichess/index.d.ts +++ b/ui/@types/lichess/index.d.ts @@ -132,6 +132,12 @@ interface AssetUrlOpts { version?: false | string; } +interface Dictionary { + [key: string]: T | undefined; +} + +type SocketHandlers = Dictionary<(d: any) => void>; + type Timeout = ReturnType; type SocketSend = (type: string, data?: any, opts?: any, noRetry?: boolean) => void; @@ -217,7 +223,6 @@ type Perf = Exclude | Speed; type Uci = string; type San = string; -type AlmostSan = string; type Ply = number; type Seconds = number; type Centis = number; @@ -286,12 +291,6 @@ declare namespace PowerTip { } } -interface Dictionary { - [key: string]: T | undefined; -} - -type SocketHandlers = Dictionary<(d: any) => void>; - declare const site: Site; declare const fipr: Fipr; declare const i18n: I18n; diff --git a/ui/README.md b/ui/README.md index cef844555992d..16304d125c2e3 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,24 +1,35 @@ -# Getting Started +# Building the client -Client assets are built with the [/ui/build](./build) script. +Use the [/ui/build](./build) script. ```bash ui/build --help ``` -You can usually start up `ui/build -wc` and leave it running. This performs a clean build and continuously rebuilds source files when changes are detected. Keep an eye on stdout for build errors. On success, reload the browser page for the results. +You can start up `ui/build -w` in watch mode to continuously rebuild when changes are detected. Keep an eye on stdout for build errors. On success, reload the browser page for the results. -# UI packages +# About packages -A package.json file describes the hierarchy of source files, dependencies, and build steps for each folder in the /ui pnpm monorepo. +Our client source code is arranged as a pnpm monorepo workspace described by [/pnpm-workspace.yaml](../pnpm-workspace.yaml). A separate package.json file describes source files, dependencies, and build steps for each ui package. -We must tell tsc how to resolve static workspace imports from external sources back to declaration (\*.d.ts) files in the imported package's dist folder. When a single declaration barrel describes all package types, we use the "types" property as shown in this example from [/ui/voice/package.json](./voice/package.json): +One workspace package (such as /ui/analyse) may depend on another (such as /ui/common) using a "dependencies" property keyed by the package name with "workspace:\*" as the version. That tells [pnpm](https://pnpm.io) and our build script that the dependency should be resolved by [/pnpm-workspace.yaml](../pnpm-workspace.yaml) (spoiler - it's in ui/common). + +```json + "dependencies": { + "common": "workspace:*" + } +``` + +We do not use devDependencies because no package artifacts are published to npm. There is no useful distinction between dependencies and devDependencies when we're always building assets for the lila server. + +## tsc import resolution +tsc type checking uses package.json properties to resolve static import declarations in external sources to the correct declaration (\*.d.ts) files in the imported package. When a single declaration barrel describes all package types, we use the "types" property as shown in this example from [/ui/voice/package.json](./voice/package.json): ```json "types": "dist/voice.d.ts", ``` -When more granular access to a package is needed, we use the "typesVersion" property to expose selective imports within the dist folder to tsc along with an optional "typings" property to identify any main barrel within that "typesVersion" mapping. +When more granular access to the imported package is needed, we use the "typesVersion" property to expose selective imports within the dist folder along with an optional "typings" property to identify a main barrel within the "typesVersion" mapping. ```json "typings": "common", @@ -31,16 +42,17 @@ When more granular access to a package is needed, we use the "typesVersion" prop }, ``` -The [esbuild bundler](https://esbuild.github.io/getting-started/#your-first-bundle) works in tandem with tsc on typescript sources but resolves imports differently. It reads an "exports" object to resolve static workspace imports from other packages directly to the imported package's typescript source files. +## esbuild import resolution +The [esbuild bundler](https://esbuild.github.io/getting-started/#your-first-bundle) does things a bit differently. It uses an "exports" object to resolve static workspace imports from other packages directly to the imported package's typescript source files. Declaration (*.d.ts) files are not used. -For example, imports of common package code from a dependent package like this example from [/ui/opening/src/opening.ts](./opening/src/opening.ts): +In this example from [/ui/opening/src/opening.ts](./opening/src/opening.ts): ```typescript import { initMiniBoards } from 'common/miniBoard'; import { requestIdleCallback } from 'common'; ``` -Are mapped back to common/src by this snippet from [/ui/common/package.json](./common/package.json): +The above 'common' and 'common/miniBoard' import declarations are mapped to the correct typescript sources by this snippet from [/ui/common/package.json](./common/package.json): ```json "exports": { @@ -53,107 +65,88 @@ Are mapped back to common/src by this snippet from [/ui/common/package.json](./c While esbuild may bundle imported code directly into the entry point module, it may also split imported code into "common" chunk modules that are shared and imported by other workspace modules. This chunked approach is called code splitting and reduces the overall footprint of asset transfers over the wire and within the browser cache. -Because the client is a monorepo consisting of many packages, it is necessary to express dependencies on another workspace package with a "dependencies" object property keyed by the package name with "workspace:\*" as the value. That tells [pnpm](https://pnpm.io) and ui/build that the dependency is a workspace package folder located directly within /ui. - -```json - "dependencies": { - "common": "workspace:*", - "some-npm-package": "^1.0.0", - "local-override-of-an-npm-package": "link:../local-override-of-an-npm-package" - }, -``` +## "build" property (top-level in package.json) -We do not classify devDependencies because these workspace packages are not built and published to npm for others to use without building. There is no useful distinction between dependencies and devDependencies when we're always building static assets directly for lila. +We define a custom "build" property object to describe how [/ui/build](./build) generates assets for the website. -## Build & Bundle - -We define a custom, top-level "build" object to describe how [/ui/build](./build) generates assets for the website. - -```json - "build": { -``` - -Understanding the mechanisms expressed by properties of the "build" object will help you create / edit packages and define how dependencies are managed. +Properties within build come in three flavors - "bundle", "sync", and "hash". Each of these can have one or more entries containing pathnames or globs. For bundles, pathnames are resolved relative to the package folder. For sync and hash objects, paths that start with `/` are resolved relative to the git repo root. Otherwise, they are resolved relative to the package. +## "bundle" property The "bundle" property tells esbuild which javascript modules should be created as named entry points. Most correspond to a server controller and scala module found in [/app](../app) and [/modules](../modules) respectively. These usually generate the html DOM on which the javascript operates. -Path elements inside the "bundle" property are resolved relative to the folder where the package.json resides. A path may specify a glob pattern to match multiple module sources and bundle each into named javascript entry points. +Path elements within bundle may specify a glob pattern to match multiple module sources and bundle each into named javascript entry points. -This example from [/ui/analyse/package.json](./analyse/package.json) matches analyse.ts, analyse.nvui.ts, analyse.study.ts, analyse.study.topic.form.ts, and analyse.study.tour.ts from various places in the folder hierarchy within analyse/src: +This excerpt from [/ui/analyse/package.json](./analyse/package.json) matches analyse.ts, analyse.nvui.ts, analyse.study.ts, analyse.study.topic.form.ts, and analyse.study.tour.ts from various places in the folder hierarchy within analyse/src: ```json + "build": { "bundle": "src/**/analyse.*ts", + ... + } ``` -All bundled output is transpiled to esm modules in the /public/compiled folder. Filenames are composed with the module source basename, followed by an 8 char content hash, and ending with .js. +Bundle outputs are produced by esbuild as javascript esm modules in the /public/compiled folder. Filenames are composed with the module source basename, followed by an 8 char content hash, and ending with .js. -Multiple bundles specs may be given in an array, elements of which may be globs like above or bare paths as shown in this [/ui/site/package.json](./site/package.json) example: +Bundles may also be described by objects containing a "module" path and an "inline" path. The "module" path serves the same purpose as a bare string - naming the source module for an entry point. The "inline" path identifies a special typescript source from which ui/build will emit javascript statements into a manifest.\*.json entry for the server. -```json - "bundle": [ - "src/site.lpvEmbed.ts", - "src/site.puzzleEmbed.ts", - "src/site.tvEmbed.ts", -``` - -A bundle may also be described with an object containing a "module" path and an "inline" path. The "module" value serves the same purpose as in the direct path examples above - naming the source module for an entry point. But the "inline" value identifies a secondary ts source from which ui/build will emit javascript statements into a manifest.\*.json entry for the parent module. - -When that parent module is requested by a browser, the lila server injects its inline statements into a \