From 75c5ea27d9d9c8a2825341ba6d26b7f5e5a48380 Mon Sep 17 00:00:00 2001 From: Allan Joseph Date: Wed, 19 Jun 2024 18:22:09 +0000 Subject: [PATCH 001/260] relay stats WIP --- modules/relay/src/main/ui/RelayTourUi.scala | 7 +- pnpm-lock.yaml | 8 +- ui/analyse/src/study/relay/relayTourView.ts | 14 +- ui/bits/css/build/bits.relay.stats.scss | 3 + ui/bits/css/relay/_stats.scss | 9 + ui/bits/package.json | 1 - ui/bits/src/bits.relayStats.ts | 3 - ui/chart/package.json | 3 +- ui/chart/src/chart.game.ts | 4 +- ui/chart/src/chart.relayStats.ts | 217 ++++++++++++++++++++ ui/chart/src/common.ts | 13 ++ ui/chart/src/interface.ts | 19 ++ ui/insight/src/chart.ts | 18 +- 13 files changed, 281 insertions(+), 38 deletions(-) create mode 100644 ui/bits/css/build/bits.relay.stats.scss create mode 100644 ui/bits/css/relay/_stats.scss delete mode 100644 ui/bits/src/bits.relayStats.ts create mode 100644 ui/chart/src/chart.relayStats.ts diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 54defb524d4d6..1cbb90bf72df7 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -105,11 +105,12 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): def stats(t: RelayTour, stats: List[RelayStats.RoundStats])(using Context) = import JsonView.given Page(s"${t.name.value} - Stats") - .css("bits.relay.index") - .js(PageModule("bits.relayStats", Json.obj("rounds" -> stats))): + .css("bits.relay.stats") + .js(PageModule("chart.relayStats", Json.obj("rounds" -> stats))): main(cls := "relay-tour page box box-pad")( boxTop(h1(a(href := routes.RelayTour.show(t.slug, t.id).url)(t.name), " - Stats")), - "Here, a graph shows the number of viewers over time." + div(id := "round-selector"), + div(id := "relay-stats-container")(canvas(id := "relay-stats")) ) def page(title: String, pageBody: Frag, active: String)(using Context): Page = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b12793ea45865..f01ddcff5a25b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,10 +381,10 @@ importers: version: link:../chart chart.js: specifier: ^4.4.0 - version: 4.4.0 + version: 4.4.3 chartjs-plugin-datalabels: specifier: ^2.2.0 - version: 2.2.0(chart.js@4.4.0) + version: 2.2.0(chart.js@4.4.3) common: specifier: workspace:* version: link:../common @@ -3052,10 +3052,6 @@ snapshots: chart.js: 4.4.3 dayjs: 1.11.10 - chartjs-plugin-datalabels@2.2.0(chart.js@4.4.0): - dependencies: - chart.js: 4.4.0 - chartjs-plugin-datalabels@2.2.0(chart.js@4.4.3): dependencies: chart.js: 4.4.3 diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index e79ddb1317860..edac7327fd2af 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -285,13 +285,13 @@ const makeTabs = (ctrl: AnalyseCtrl) => { makeTab('boards', 'Boards'), relay.teams && makeTab('teams', 'Teams'), relay.data.tour.leaderboard ? makeTab('leaderboard', 'Leaderboard') : undefined, - // study.members.myMember() - // ? h( - // 'a.text', - // { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, - // 'Popularity stats', - // ) - // : undefined, + study.members.myMember() + ? h( + 'a.text', + { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, + 'Popularity stats', + ) + : undefined, ]); }; diff --git a/ui/bits/css/build/bits.relay.stats.scss b/ui/bits/css/build/bits.relay.stats.scss new file mode 100644 index 0000000000000..eadcfdb506213 --- /dev/null +++ b/ui/bits/css/build/bits.relay.stats.scss @@ -0,0 +1,3 @@ +@import '../../../common/css/plugin'; +@import '../../../common/css/component/mselect'; +@import '../relay/stats'; diff --git a/ui/bits/css/relay/_stats.scss b/ui/bits/css/relay/_stats.scss new file mode 100644 index 0000000000000..e7bb7208db5df --- /dev/null +++ b/ui/bits/css/relay/_stats.scss @@ -0,0 +1,9 @@ +.mselect { + width: fit-content; + font-size: 1.6em; + padding: 0 0 1.2em 1.4em; +} + +#relay-stats-container { + height: 700px; +} diff --git a/ui/bits/package.json b/ui/bits/package.json index cc1cbc6100f36..4dfd6503ed721 100644 --- a/ui/bits/package.json +++ b/ui/bits/package.json @@ -72,7 +72,6 @@ "src/bits.publicChats.ts", "src/bits.qrcode.ts", "src/bits.relayForm.ts", - "src/bits.relayStats.ts", "src/bits.soundMove.ts", "src/bits.streamer.ts", "src/bits.team.ts", diff --git a/ui/bits/src/bits.relayStats.ts b/ui/bits/src/bits.relayStats.ts deleted file mode 100644 index 04fd2c9080fcb..0000000000000 --- a/ui/bits/src/bits.relayStats.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function initModule(data: any) { - console.log(data); -} diff --git a/ui/chart/package.json b/ui/chart/package.json index 47c82da99ac7f..72a26e03fc0da 100644 --- a/ui/chart/package.json +++ b/ui/chart/package.json @@ -25,7 +25,8 @@ "src/chart.ratingHistory.ts", "src/chart.game.ts", "src/chart.resizePolyfill.ts", - "src/chart.lag.ts" + "src/chart.lag.ts", + "src/chart.relayStats.ts" ] } } diff --git a/ui/chart/src/chart.game.ts b/ui/chart/src/chart.game.ts index 55bb802e02a43..2548fef632714 100644 --- a/ui/chart/src/chart.game.ts +++ b/ui/chart/src/chart.game.ts @@ -1,11 +1,11 @@ import { ChartGame, AcplChart } from './interface'; import movetime from './movetime'; import acpl from './acpl'; -import { gridColor, tooltipBgColor, fontFamily, maybeChart, resizePolyfill } from './common'; +import { gridColor, tooltipBgColor, fontFamily, maybeChart, resizePolyfill, colorSeries } from './common'; export { type ChartGame, type AcplChart }; -export { gridColor, tooltipBgColor, fontFamily, maybeChart, resizePolyfill }; +export { gridColor, colorSeries, tooltipBgColor, fontFamily, maybeChart, resizePolyfill }; export function initModule(): ChartGame { return { diff --git a/ui/chart/src/chart.relayStats.ts b/ui/chart/src/chart.relayStats.ts new file mode 100644 index 0000000000000..2648b1d01624e --- /dev/null +++ b/ui/chart/src/chart.relayStats.ts @@ -0,0 +1,217 @@ +import { RelayStats, RoundStats } from './interface'; +import * as chart from 'chart.js'; +import 'chartjs-adapter-dayjs-4'; +import { + hoverBorderColor, + gridColor, + tooltipBgColor, + fontColor, + fontFamily, + maybeChart, + animation, +} from './common'; +import { memoize } from 'common'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; + +chart.Chart.register( + chart.PointElement, + chart.TimeScale, + chart.Tooltip, + chart.LinearScale, + chart.LineController, + chart.LineElement, + chart.Filler, + chart.Title, + ChartDataLabels, +); + +chart.Chart.defaults.font = fontFamily(); + +interface RelayChart extends chart.Chart { + updateData(d: RoundStats): void; +} + +const dateFormat = memoize(() => + window.Intl && Intl.DateTimeFormat + ? new Intl.DateTimeFormat( + document.documentElement.lang.startsWith('ar-') ? 'ar-ly' : document.documentElement.lang, + { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }, + ).format + : (d: Date) => d.toLocaleDateString(), +); + +export default function initModule(data: RelayStats) { + const $el = $('#relay-stats'); + const container = $('#round-selector')[0]!; + container.innerHTML = `
`; + const possibleChart = maybeChart($el[0] as HTMLCanvasElement); + const relayChart = (possibleChart as RelayChart) ?? makeChart(data, $el); + $('#round-select').on('change', function (this: HTMLSelectElement) { + const selected = data.rounds.find(r => r.round.id == this.value)!; + relayChart.updateData(selected); + }); +} + +const makeDataset = (data: RoundStats, el: HTMLCanvasElement): chart.ChartDataset<'line'>[] => { + const blue = 'hsl(209, 76%, 56%)'; + const gradient = el.getContext('2d')?.createLinearGradient(0, 0, 0, 400); + gradient?.addColorStop(0, 'rgba(119, 152, 191, 0.4)'); + gradient?.addColorStop(1, 'rgba(119, 152, 191, 0.05)'); + const plot: chart.ChartDataset<'line'>[] = [ + { + indexAxis: 'x', + type: 'line', + data: data.viewers.map(v => ({ x: v[0] * 1000, y: v[1] })), + label: `${data.round.name}`, + pointBorderColor: '#fff', + pointBackgroundColor: blue, + backgroundColor: gradient, + fill: true, + borderColor: blue, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 5, + hoverBorderColor: hoverBorderColor, + tension: 0, + datalabels: { display: false }, + }, + ]; + if (data.round.startsAt && data.viewers.length) { + const pink = 'hsl(317, 74%, 73%)'; + plot.push({ + indexAxis: 'x', + yAxisID: 'y2', + type: 'line', + data: [ + { x: data.round.startsAt, y: 0 }, + { x: data.round.startsAt, y: 100 }, + ], + borderColor: pink, + borderDash: [5, 5], + datalabels: { + align: 'top', + offset: -5, + display: 'auto', + formatter: (value: chart.Point) => (value.y == 0 ? '' : 'Round Start'), + color: pink, + }, + pointRadius: 0, + pointHoverRadius: 0, + }); + } + return plot; +}; + +const makeChart = (data: RelayStats, $el: Cash) => { + const last = data.rounds[data.rounds.length - 1]; + const ds = makeDataset(last, $el[0] as HTMLCanvasElement); + const config: chart.ChartConfiguration<'line'> = { + type: 'line', + data: { + datasets: ds, + }, + options: { + parsing: false, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, + locale: document.documentElement.lang, + maintainAspectRatio: false, + responsive: true, + animations: animation(500 / ds[0].data.length), + scales: { + x: { + type: 'time', + grid: { + color: gridColor, + }, + border: { + display: false, + }, + ticks: { + maxTicksLimit: 20, + major: { + enabled: true, + }, + }, + title: { + display: true, + text: 'Time', + color: fontColor, + }, + time: { + minUnit: 'minute', + }, + }, + y: { + type: 'linear', + grid: { + color: gridColor, + }, + border: { + display: false, + }, + ticks: { + stepSize: 1, + maxTicksLimit: 20, + }, + title: { + display: true, + text: 'Spectators', + color: fontColor, + }, + suggestedMin: 0, + }, + y2: { + display: false, + }, + }, + plugins: { + tooltip: { + filter: i => i.datasetIndex == 0, + backgroundColor: tooltipBgColor, + bodyColor: fontColor, + titleColor: fontColor, + borderColor: fontColor, + borderWidth: 1, + caretPadding: 5, + usePointStyle: true, + callbacks: { + title: items => (items.length ? dateFormat()(items[0].parsed.x) : ''), + }, + }, + title: { + display: true, + text: titleText(last), + color: fontColor, + }, + }, + }, + }; + const relayChart = new chart.Chart($el[0] as HTMLCanvasElement, config) as RelayChart; + relayChart.updateData = (data: RoundStats) => { + relayChart.data.datasets = makeDataset(data, $el[0] as HTMLCanvasElement); + relayChart.options.plugins!.title!.text = titleText(data); + relayChart.update(); + }; + return relayChart; +}; + +const titleText = (data: RoundStats): string => + `${data.round.name} • Start - ${dateFormat()(data.round.startsAt)}`; diff --git a/ui/chart/src/common.ts b/ui/chart/src/common.ts index 10d12ddb83981..3fa17fb863f07 100644 --- a/ui/chart/src/common.ts +++ b/ui/chart/src/common.ts @@ -110,3 +110,16 @@ export function animation(duration: number): ChartOptions<'line'>['animations'] export function resizePolyfill() { if ('ResizeObserver' in window === false) site.asset.loadEsm('chart.resizePolyfill'); } +export const colorSeries = [ + '#2b908f', + '#90ee7e', + '#f45b5b', + '#7798BF', + '#aaeeee', + '#ff0066', + '#eeaaee', + '#55BF3B', + '#DF5353', + '#7798BF', + '#aaeeee', +]; \ No newline at end of file diff --git a/ui/chart/src/interface.ts b/ui/chart/src/interface.ts index 374506df0b3fa..0418aeca7ecd6 100644 --- a/ui/chart/src/interface.ts +++ b/ui/chart/src/interface.ts @@ -72,3 +72,22 @@ export interface PerfRatingHistory { name: string; points: [number, number, number, number][]; } + +interface RelayRound { + id: string; + name: string; + slug: string; + finished?: boolean; + ongoing?: boolean; + createdAt?: number; + startsAt?: number; +} + +export interface RoundStats { + round: RelayRound; + viewers: [number, number][]; +} + +export interface RelayStats { + rounds: RoundStats[]; +} diff --git a/ui/insight/src/chart.ts b/ui/insight/src/chart.ts index c8f3ce1caa3b4..f4fc56f65d90f 100644 --- a/ui/insight/src/chart.ts +++ b/ui/insight/src/chart.ts @@ -15,7 +15,7 @@ import { ChartOptions, } from 'chart.js'; import { currentTheme } from 'common/theme'; -import { gridColor, tooltipBgColor, fontFamily, maybeChart, resizePolyfill } from 'chart'; +import { gridColor, tooltipBgColor, fontFamily, maybeChart, resizePolyfill, colorSeries } from 'chart'; import ChartDataLabels from 'chartjs-plugin-datalabels'; import { formatNumber } from './table'; @@ -30,19 +30,7 @@ const resultColors = { Draw: '#007599', Defeat: '#dc322f', }; -const theme = [ - '#2b908f', - '#90ee7e', - '#f45b5b', - '#7798BF', - '#aaeeee', - '#ff0066', - '#eeaaee', - '#55BF3B', - '#DF5353', - '#7798BF', - '#aaeeee', -]; + const sizeColor = 'rgba(120,120,120,0.2)'; const tooltipFontColor = light ? '#4d4d4d' : '#cccccc'; @@ -96,7 +84,7 @@ function datasetBuilder(d: InsightData) { const color = (i: number, name: string, stack: boolean) => { if (d.valueYaxis.name == 'Game result') return resultColors[name as 'Victory' | 'Draw' | 'Defeat']; else if (!stack && light) return '#7cb5ec'; - return theme[i % theme.length]; + return colorSeries[i % colorSeries.length]; }; return [ ...d.series.map((serie, i) => From c0661c1292af6361218de6003ce4758d51fc1b47 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 19:16:10 +0200 Subject: [PATCH 002/260] fix broadcast lcc delay --- modules/relay/src/main/RelayFetch.scala | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 0271e303e391b..564863483bfa0 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -167,13 +167,12 @@ final private class RelayFetch( private def fetchGames(rt: RelayRound.WithTour): Fu[RelayGames] = given CanProxy = CanProxy(rt.tour.official) rt.round.sync.upstream.so: - case Sync.UpstreamIds(ids) => fetchFromGameIds(rt.tour, ids) - case lcc: Sync.UpstreamLcc => fetchFromUpstream(lcc, RelayFetch.maxChapters) - case url: Sync.UpstreamUrl => delayer(url, rt.round, fetchFromUpstream) + case Sync.UpstreamIds(ids) => fetchFromGameIds(rt.tour, ids) + case urlOrLcc: Sync.FetchableUpstream => delayer(urlOrLcc, rt.round, fetchFromUpstream) case Sync.UpstreamUrls(urls) => urls .traverse: url => - delayer(url, rt.round, fetchFromUpstream(using CanProxy(rt.tour.official))) + delayer(url, rt.round, fetchFromUpstream) .map(_.flatten.toVector) private def fetchFromGameIds(tour: RelayTour, ids: List[GameId]): Fu[RelayGames] = From 1dd521ca2f123fd0e58508c7485a5b925f30f4c5 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 19:23:58 +0200 Subject: [PATCH 003/260] no spellcheck there --- modules/relay/src/main/ui/FormUi.scala | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index f423b2fd6be4a..339eecb6073ec 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -196,7 +196,9 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): "Multiple source URLs, one per line.", help = frag("The games will be combined in the order of the URLs.").some, half = false - )(form3.textarea(_)(rows := 5))(cls := "relay-form__sync relay-form__sync-urls none"), + )(form3.textarea(_)(rows := 5, spellcheck := "false"))( + cls := "relay-form__sync relay-form__sync-urls none" + ), form3.group( form("syncIds"), trb.sourceGameIds(), @@ -413,7 +415,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): YouGotLittUp / 1890 / / Louis Litt""") ).some, half = true - )(form3.textarea(_)(rows := 3)), + )(form3.textarea(_)(rows := 3, spellcheck := "false")), form3.group( form("teams"), "Optional: assign players to teams", @@ -426,7 +428,7 @@ Team Dogs ; Scooby Doo"""), "By default the PGN tags WhiteTeam and BlackTeam are used." ).some, half = true - )(form3.textarea(_)(rows := 3)) + )(form3.textarea(_)(rows := 3, spellcheck := "false")) ), if Granter.opt(_.Relay) then frag( @@ -526,7 +528,7 @@ Team Dogs ; Scooby Doo"""), form("grouping"), "Optional: assign tournaments to a group", half = true - )(form3.textarea(_)(rows := 5)), + )(form3.textarea(_)(rows := 5, spellcheck := "false")), div(cls := "form-group form-half form-help")( // do not translate "First line is the group name. Subsequent lines are the tournament IDs and names in the group. Names are facultative and only used for display in this textarea.", br, From b8f06507d8cb4dd0cdb6191d32b9d856195e2af7 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 19:38:22 +0200 Subject: [PATCH 004/260] show delay in broadcast manager --- .../src/study/relay/relayManagerView.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/ui/analyse/src/study/relay/relayManagerView.ts b/ui/analyse/src/study/relay/relayManagerView.ts index 7cc2bd15db661..e9de014b60a23 100644 --- a/ui/analyse/src/study/relay/relayManagerView.ts +++ b/ui/analyse/src/study/relay/relayManagerView.ts @@ -57,20 +57,17 @@ function stateOn(ctrl: RelayCtrl) { 'div.state.on.clickable', { hook: bind('click', _ => ctrl.setSync(false)), attrs: dataIcon(licon.ChasingArrows) }, [ - h( - 'div', - url - ? [ - sync.delay ? `Connected with ${sync.delay}s delay` : 'Connected to source', - h('br'), - url.replace(/https?:\/\//, ''), - ] + h('div', [ + 'Connected ', + sync?.delay ? `with ${sync.delay}s delay ` : null, + ...(url + ? ['to source', h('br'), url.replace(/https?:\/\//, '')] : ids - ? ['Connected to', h('br'), ids.length, ' game(s)'] + ? ['to', h('br'), ids.length, ' game(s)'] : urls - ? ['Connected to', h('br'), urls.length, ' urls'] - : [], - ), + ? ['to', h('br'), urls.length, ' sources'] + : []), + ]), ], ); } From 5f66ab452d666b67fb4788bee0a41a60f5f49aba Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 20:29:54 +0200 Subject: [PATCH 005/260] fix flash-success color --- ui/common/css/component/_flash.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/common/css/component/_flash.scss b/ui/common/css/component/_flash.scss index 6896606ac7bdd..cd5cb0e684a89 100644 --- a/ui/common/css/component/_flash.scss +++ b/ui/common/css/component/_flash.scss @@ -19,6 +19,10 @@ } } + &-success { + color: $c-over; + } + &-warning { background: $c-warn; color: $c-over; From 4501321bf963eb2b48007ad13d939cdc96d514a2 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 20:30:50 +0200 Subject: [PATCH 006/260] better form-fieldset that also serves as
we can't use
because it has its own rendering rules which don't play well with flex so we implement our own
:shrug: --- modules/ui/src/main/helper/Form3.scala | 10 ++++++++-- ui/common/css/form/_form3.scss | 24 ++++++++++++++++++++++++ ui/site/src/boot.ts | 4 ++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index e7a014b499010..1430d89156abc 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -192,8 +192,14 @@ final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi): form.globalError.map: err => div(cls := "form-group is-invalid")(error(err)) - def fieldset(legend: Frag): Tag = - st.fieldset(cls := "form-fieldset")(st.legend(legend)) + def fieldset(legend: Frag, toggle: Option[Boolean] = none): Tag = + st.fieldset( + cls := List( + "form-fieldset" -> true, + "form-fieldset--toggle" -> toggle.isDefined, + "form-fieldset--toggle-off" -> toggle.has(false) + ) + )(st.legend(legend)) private val dataEnableTime = attr("data-enable-time") private val dataTime24h = attr("data-time_24h") diff --git a/ui/common/css/form/_form3.scss b/ui/common/css/form/_form3.scss index 6b49d14c12ca6..d0c92b3062ccb 100644 --- a/ui/common/css/form/_form3.scss +++ b/ui/common/css/form/_form3.scss @@ -144,3 +144,27 @@ textarea.form-control { font-size: 1.2em; } } +.form-fieldset--toggle { + legend { + cursor: pointer; + &::after { + content: '▲'; + margin-left: 1ch; + } + } +} +.form-fieldset--toggle-off { + border-width: 1px 0 0 0; + legend { + &:hover { + color: $c-brag; + } + &::after { + color: $c-brag; + content: '▼'; + } + } + > *:not(legend) { + display: none; + } +} diff --git a/ui/site/src/boot.ts b/ui/site/src/boot.ts index 511384742f0b6..5f1261af207d0 100644 --- a/ui/site/src/boot.ts +++ b/ui/site/src/boot.ts @@ -115,6 +115,10 @@ export function boot() { el.setAttribute('content', el.getAttribute('content') + ',maximum-scale=1.0'); } + $('.form-fieldset--toggle legend').on('click', function (this: HTMLElement) { + $(this).closest('.form-fieldset--toggle').toggleClass('form-fieldset--toggle-off'); + }); + if (setBlind && !site.blindMode) setTimeout(() => $('#blind-mode button').trigger('click'), 1500); if (showDebug) site.asset.loadEsm('bits.diagnosticDialog'); From efa8edc80c45369e98008c10c49a7d2a7adb66dd Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 20:30:56 +0200 Subject: [PATCH 007/260] make the broadcast tournament form less scary --- app/controllers/RelayTour.scala | 2 +- modules/relay/src/main/ui/FormUi.scala | 221 +++++++++++++------------ ui/bits/css/relay/_form.scss | 9 +- 3 files changed, 120 insertions(+), 112 deletions(-) diff --git a/app/controllers/RelayTour.scala b/app/controllers/RelayTour.scala index 1ea846a2b2e83..31d533a2a0e50 100644 --- a/app/controllers/RelayTour.scala +++ b/app/controllers/RelayTour.scala @@ -115,7 +115,7 @@ final class RelayTour(env: Env, apiC: => Api) extends LilaController(env): setup => env.relay.api.tourUpdate(nav.tour, setup) >> negotiate( - Redirect(routes.RelayTour.show(nav.tour.slug, nav.tour.id)), + Redirect(routes.RelayTour.edit(nav.tour.id)).flashSuccess, jsonOkResult ) ) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 339eecb6073ec..c07121881faa9 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -321,6 +321,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): page(nav.tour.name.value, menu = Right(nav)): frag( boxTop(h1(a(href := routes.RelayTour.show(nav.tour.slug, nav.tour.id))(nav.tour.name))), + standardFlash, image(nav.tour), postForm(cls := "form3", action := routes.RelayTour.update(nav.tour.id))( inner(form, nav.tourWithGroup.some), @@ -354,19 +355,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): frag( (!Granter.opt(_.StudyAdmin)).option(div(cls := "form-group")(ui.howToUse)), form3.globalError(form), - form3.split( - form3.group(form("name"), trb.tournamentName(), half = true)(form3.input(_)(autofocus)), - Granter - .opt(_.StudyAdmin) - .option( - form3.group( - form("spotlight.title"), - "Homepage spotlight custom tournament name", - help = raw("Leave empty to use the tournament name").some, - half = true - )(form3.input(_)) - ) - ), + form3.group(form("name"), trb.tournamentName())(form3.input(_)(autofocus)), form3.group(form("description"), trb.tournamentDescription())(form3.textarea(_)(rows := 2)), form3.group( form("markdown"), @@ -393,116 +382,136 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): help = frag("Show a team leaderboard. Requires WhiteTeam and BlackTeam PGN tags.").some ) ), - form3.split( - form3.group( - form("players"), - trb.replacePlayerTags(), - help = frag( // do not translate - "One line per player, formatted as such:", - pre("player name = FIDE ID"), - "Example:", - pre("""Magnus Carlsen = 1503014"""), - "Player names ignore case and punctuation, and match all possible combinations of 2 words:", - br, - """"Jorge Rick Vito" will match "Jorge Rick", "jorge vito", "Rick, Vito", etc.""", - br, - "If the player is NM or WNM, you can:", - pre("""Player Name = FIDE ID / Title"""), - "Alternatively, you may set tags manually, like so:", - pre("player name / rating / title / new name"), - "All values are optional. Example:", - pre("""Magnus Carlsen / 2863 / GM + form3.fieldset( + "Players & Teams", + toggle = (form("players").value.isDefined || form("teams").value.isDefined).some + )( + form3.split( + form3.group( + form("players"), + trb.replacePlayerTags(), + help = frag( // do not translate + "One line per player, formatted as such:", + pre("player name = FIDE ID"), + "Example:", + pre("""Magnus Carlsen = 1503014"""), + "Player names ignore case and punctuation, and match all possible combinations of 2 words:", + br, + """"Jorge Rick Vito" will match "Jorge Rick", "jorge vito", "Rick, Vito", etc.""", + br, + "If the player is NM or WNM, you can:", + pre("""Player Name = FIDE ID / Title"""), + "Alternatively, you may set tags manually, like so:", + pre("player name / rating / title / new name"), + "All values are optional. Example:", + pre("""Magnus Carlsen / 2863 / GM YouGotLittUp / 1890 / / Louis Litt""") - ).some, - half = true - )(form3.textarea(_)(rows := 3, spellcheck := "false")), - form3.group( - form("teams"), - "Optional: assign players to teams", - help = frag( // do not translate - "One line per player, formatted as such:", - pre("Team name; Fide Id or Player name"), - "Example:", - pre("""Team Cats ; 3408230 + ).some, + half = true + )(form3.textarea(_)(rows := 3, spellcheck := "false")), + form3.group( + form("teams"), + "Optional: assign players to teams", + help = frag( // do not translate + "One line per player, formatted as such:", + pre("Team name; Fide Id or Player name"), + "Example:", + pre("""Team Cats ; 3408230 Team Dogs ; Scooby Doo"""), - "By default the PGN tags WhiteTeam and BlackTeam are used." - ).some, - half = true - )(form3.textarea(_)(rows := 3, spellcheck := "false")) + "By default the PGN tags WhiteTeam and BlackTeam are used." + ).some, + half = true + )(form3.textarea(_)(rows := 3, spellcheck := "false")) + ) ), if Granter.opt(_.Relay) then frag( - tg.isDefined.option(grouping(form)), - form3.split( - form3.group( - form("tier"), - raw("Official Lichess broadcast tier"), - help = raw("Feature on /broadcast - for admins only").some, - half = true - )(form3.select(_, RelayTour.Tier.options)) - ) - ) - else form3.hidden(form("tier")), - Granter - .opt(_.StudyAdmin) - .option( - frag( + form3.fieldset("Broadcast admin")( + tg.isDefined.option(grouping(form)), form3.split( - form3.checkbox( - form("spotlight.enabled"), - "Show a homepage spotlight", - help = raw("As a Big Blue Button - for admins only").some, - half = true - ), form3.group( - form("spotlight.lang"), - "Homepage spotlight language", - help = - raw("Only show to users who speak this language. English is shown to everyone.").some, + form("tier"), + raw("Official Lichess broadcast tier"), + help = raw("Feature on /broadcast - for admins only").some, half = true - ): - form3.select(_, langList.popularLanguagesForm.choices) + )(form3.select(_, RelayTour.Tier.options)), + Granter + .opt(_.StudyAdmin) + .option( + form3.checkbox( + form("spotlight.enabled"), + "Show a homepage spotlight", + help = raw("As a Big Blue Button - for admins only").some, + half = true + ) + ) ), - tg.map: t => - details( - summary("Pinned streamer"), - div( - cls := "relay-pinned-streamer-edit", - data("post-url") := routes.RelayTour.image(t.tour.id, "pinnedStreamerImage".some) - )( - div( + Granter + .opt(_.StudyAdmin) + .option( + frag( + form3.split( form3.group( - form("pinnedStreamer"), - "Pinned streamer", - help = frag( - p("The pinned streamer is featured even when they're not watching the broadcast."), - p("An optional placeholder image will embed their stream when clicked."), - p( - "To upload one, you must first submit this form with a pinned streamer. " - + "Then return to this page and choose an image." - ) - ).some + form("spotlight.title"), + "Homepage spotlight custom tournament name", + help = raw("Leave empty to use the tournament name").some, + half = true )(form3.input(_)), - span( - button(tpe := "button", cls := "button streamer-select-image")("select image"), - button( - tpe := "button", - cls := "button button-empty button-red streamer-delete-image", + form3.group( + form("spotlight.lang"), + "Homepage spotlight language", + help = raw( + "Only show to users who speak this language. English is shown to everyone." + ).some, + half = true + ): + form3.select(_, langList.popularLanguagesForm.choices) + ), + tg.map: t => + form3.fieldset("Pinned streamer", toggle = form("pinnedStreamer").value.isDefined.some)( + div( + cls := "relay-pinned-streamer-edit", data("post-url") := routes.RelayTour.image(t.tour.id, "pinnedStreamerImage".some) - )("delete image") + )( + div( + form3.group( + form("pinnedStreamer"), + "Pinned streamer", + help = frag( + p( + "The pinned streamer is featured even when they're not watching the broadcast." + ), + p("An optional placeholder image will embed their stream when clicked."), + p( + "To upload one, you must first submit this form with a pinned streamer. " + + "Then return to this page and choose an image." + ) + ).some + )(form3.input(_)), + span( + button(tpe := "button", cls := "button streamer-select-image")("select image"), + button( + tpe := "button", + cls := "button button-empty button-red streamer-delete-image", + data("post-url") := routes.RelayTour + .image(t.tour.id, "pinnedStreamerImage".some) + )("delete image") + ) + ), + ui.thumbnail(t.tour.pinnedStreamerImage, _.Size.Small16x9)( + cls := List( + "streamer-drop-target" -> true, + "user-image" -> t.tour.pinnedStreamerImage.isDefined + ), + attr("draggable") := "true" + ) + ) ) - ), - ui.thumbnail(t.tour.pinnedStreamerImage, _.Size.Small16x9)( - cls := List( - "streamer-drop-target" -> true, - "user-image" -> t.tour.pinnedStreamerImage.isDefined - ), - attr("draggable") := "true" - ) ) ) ) ) + else form3.hidden(form("tier")) ) private def image(t: RelayTour)(using ctx: Context) = diff --git a/ui/bits/css/relay/_form.scss b/ui/bits/css/relay/_form.scss index ea2fb0d1e2b7f..67504f5e64677 100644 --- a/ui/bits/css/relay/_form.scss +++ b/ui/bits/css/relay/_form.scss @@ -52,14 +52,13 @@ .relay-image-edit { @extend %image-preview-and-upload, %box-neat; - margin-bottom: 2em; + margin-bottom: 3em; } .relay-pinned-streamer-edit { - @extend %image-preview-and-upload, %box-neat; - > div { - flex-basis: 300px; - } + @extend %image-preview-and-upload; + background: none; + padding: 0; span { @extend %flex-between; } From 8ad5a1bd549f8b3a182d850a094c006bc6948d95 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 20:44:00 +0200 Subject: [PATCH 008/260] broadcast lcc form convenience --- modules/relay/src/main/RelayRound.scala | 2 +- modules/relay/src/main/RelayRoundForm.scala | 7 +- modules/relay/src/main/ui/FormUi.scala | 72 ++++++++++++--------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 6308fe4d0f26c..6a4bfa2f8000a 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -132,7 +132,7 @@ object RelayRound: def formUrl = s"$viewUrl $round" object UpstreamLcc: private val idRegex = """.*view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r - def findId(url: UpstreamUrl): Option[String] = url.url match + def findId(url: String): Option[String] = url match case idRegex(id) => id.some case _ => none def find(url: String): Option[UpstreamLcc] = url.split(' ').map(_.trim).filter(_.nonEmpty) match diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index ec0a821b282e2..25ad79a81e728 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -43,7 +43,10 @@ final class RelayRoundForm(using mode: Mode): ) val lccMapping = mapping( - "id" -> cleanText(minLength = 10, maxLength = 40), + "id" -> cleanText(minLength = 10, maxLength = 100).transform( + str => Sync.UpstreamLcc.findId(str).getOrElse(str), + identity + ), "round" -> number(min = 1, max = 999) )(Sync.UpstreamLcc.apply)(unapply) @@ -214,7 +217,7 @@ object RelayRoundForm: .map: case url: Sync.UpstreamUrl => val foundLcc = for - lccId <- Sync.UpstreamLcc.findId(url) + lccId <- Sync.UpstreamLcc.findId(url.url) round <- roundNumberIn(name.value) yield Sync.UpstreamLcc(lccId, round) foundLcc | url diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index c07121881faa9..6618517c0c7a6 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -99,7 +99,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): ) ), standardFlash, - inner(form, routes.RelayRound.create(nav.tour.id), nav.tour, create = true) + inner(form, routes.RelayRound.create(nav.tour.id), nav.tour, round = none) ) def edit(r: RelayRound, form: Form[RelayRoundForm.Data], nav: FormNavigation)(using Context) = @@ -108,7 +108,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): frag( boxTop(h1(a(href := rt.path)(rt.fullName))), standardFlash, - inner(form, routes.RelayRound.update(r.id), nav.tour, create = false), + inner(form, routes.RelayRound.update(r.id), nav.tour, round = r.some), div(cls := "relay-form__actions")( postForm(action := routes.RelayRound.reset(r.id))( submitButton( @@ -126,14 +126,17 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): ) ) - private def inner(form: Form[RelayRoundForm.Data], url: play.api.mvc.Call, t: RelayTour, create: Boolean)( - using ctx: Context - ) = + private def inner( + form: Form[RelayRoundForm.Data], + url: play.api.mvc.Call, + t: RelayTour, + round: Option[RelayRound] + )(using ctx: Context) = postForm(cls := "form3", action := url)( (!Granter.opt(_.StudyAdmin)).option: div(cls := "form-group")( div(cls := "form-group")(ui.howToUse), - (create && t.createdAt.isBefore(nowInstant.minusMinutes(1))).option: + (round.isEmpty && t.createdAt.isBefore(nowInstant.minusMinutes(1))).option: p(dataIcon := Icon.InfoCircle, cls := "text"): trb.theNewRoundHelp() ) @@ -163,34 +166,41 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): trb.sourceSingleUrl(), help = trb.sourceUrlHelp().some )(form3.input(_))(cls := "relay-form__sync relay-form__sync-url"), - div(cls := "relay-form__sync relay-form__sync-lcc none")( - (!Granter.opt(_.Relay)).option( - flashMessage("box")( - p(strong("Please use the ", a(href := broadcasterUrl)("Lichess Broadcaster App"))), - p( - "LiveChessCloud support is deprecated and will be removed soon.", - br, - "If you need help, please contact us at broadcast@lichess.org." + div(cls := "relay-form__sync relay-form__sync-lcc none"): + val lccUrl = round + .flatMap(_.sync.upstream) + .collect: + case lcc: RelayRound.Sync.UpstreamLcc => lcc.viewUrl + frag( + (!Granter.opt(_.Relay)).option( + flashMessage("box")( + p(strong("Please use the ", a(href := broadcasterUrl)("Lichess Broadcaster App"))), + p( + "LiveChessCloud support is deprecated and will be removed soon.", + br, + "If you need help, please contact us at broadcast@lichess.org." + ) ) + ), + lccUrl.map(url => div(cls := "form-group")(a(href := url, targetBlank)(url))), + form3.split( + form3.group( + form("syncLcc.id"), + "Tournament ID", + help = frag( + "From the LCC page URL. The ID looks like this: ", + pre("f1943ec6-4992-45d9-969d-a0aff688b404") + ).some, + half = true + )(form3.input(_)), + form3.group( + form("syncLcc.round"), + trb.roundNumber(), + half = true + )(form3.input(_, typ = "number")) ) - ), - form3.split( - form3.group( - form("syncLcc.id"), - "Tournament ID", - help = frag( - "From the LCC page URL. The ID looks like this: ", - pre("f1943ec6-4992-45d9-969d-a0aff688b404") - ).some, - half = true - )(form3.input(_)), - form3.group( - form("syncLcc.round"), - trb.roundNumber(), - half = true - )(form3.input(_, typ = "number")) ) - ), + , form3.group( form("syncUrls"), "Multiple source URLs, one per line.", From 930d8b85b04028e79abd41968295d17353386c59 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 20 Jun 2024 22:48:32 +0200 Subject: [PATCH 009/260] fix half the TS lint --- ui/chat/tests/spam.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/chat/tests/spam.test.ts b/ui/chat/tests/spam.test.ts index 1d43e1d0b5aa7..366cbdbe15ed8 100644 --- a/ui/chat/tests/spam.test.ts +++ b/ui/chat/tests/spam.test.ts @@ -15,7 +15,7 @@ test('self report', () => { }, }); - const spy = vi.spyOn(xhr, 'text').mockImplementation((url: string, init?: RequestInit) => { + const spy = vi.spyOn(xhr, 'text').mockImplementation((url: string) => { expect(url).toBe('/jslog/abcdef123456?n=spam'); return Promise.resolve('ok'); }); From 0311f2dca22104eaae2b423839f6a4a2eeb342da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:16:47 +0100 Subject: [PATCH 010/260] FIX broadcasterURL --- modules/relay/src/main/ui/FormUi.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 6618517c0c7a6..121cea1a61745 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -216,8 +216,8 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): )(form3.input(_))(cls := "relay-form__sync relay-form__sync-ids none"), div(cls := "form-group relay-form__sync relay-form__sync-push none")( p( - "Send your local games to Lichess using ", - a(href := "https://github.com/lichess-org/broadcaster")(lila.relay.broadcasterUrl), + "Send your local games to Lichess using the ", + a(href := broadcasterUrl)("Lichess Broadcaster App"), "." ) ), From 55fa62b7ce2dbaf8cbde765ffe4aee4ccded6a70 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 07:48:41 +0200 Subject: [PATCH 011/260] New Crowdin updates (#15569) * New translations: broadcast.xml (Catalan) * New translations: broadcast.xml (Albanian) * New translations: broadcast.xml (Swiss German) * New translations: coordinates.xml (Ukrainian) * New translations: site.xml (Ukrainian) * New translations: broadcast.xml (Ukrainian) * New translations: broadcast.xml (Slovak) * New translations: broadcast.xml (Arabic) * New translations: broadcast.xml (Japanese) --- translation/dest/broadcast/ar-SA.xml | 2 ++ translation/dest/broadcast/ca-ES.xml | 2 ++ translation/dest/broadcast/gsw-CH.xml | 2 ++ translation/dest/broadcast/ja-JP.xml | 2 ++ translation/dest/broadcast/sk-SK.xml | 1 + translation/dest/broadcast/sq-AL.xml | 2 ++ translation/dest/broadcast/uk-UA.xml | 2 ++ translation/dest/coordinates/uk-UA.xml | 1 + translation/dest/site/uk-UA.xml | 2 ++ 9 files changed, 16 insertions(+) diff --git a/translation/dest/broadcast/ar-SA.xml b/translation/dest/broadcast/ar-SA.xml index fa854c94789a4..232fe19818bdc 100644 --- a/translation/dest/broadcast/ar-SA.xml +++ b/translation/dest/broadcast/ar-SA.xml @@ -27,7 +27,9 @@ وصف موجز للبطولة الوصف الكامل الوصف الاختياري الطويل للبث. %1$s متوفر. يجب أن لا يتجاوز طول النص %2$s حرفاً. + رابط مصدر PGN URL الذي سيتحقق منه Lichess للحصول على تحديثات PGN. يجب أن يكون متاحًا للجميع على الإنترنت. + حتى 64 معرف لُعْبَة ليتشيس، مفصولة بمسافات. تاريخ البدء في المنطقة الزمنية الخاصة بك اختياري، إذا كنت تعرف متى يبدأ الحدث ائتمن المصدر diff --git a/translation/dest/broadcast/ca-ES.xml b/translation/dest/broadcast/ca-ES.xml index a57524541db57..023fd03b37e10 100644 --- a/translation/dest/broadcast/ca-ES.xml +++ b/translation/dest/broadcast/ca-ES.xml @@ -23,7 +23,9 @@ Breu descripció del torneig Descripció total de l\'esdeveniment Opció de llarga descripció de l\'esdeveniment. %1$s és disponible. Ha de tenir menys de %2$s lletres. + URL origen del PGN URL que Lichess comprovarà per a obtenir actualitzacions PGN. Ha de ser públicament accessible des d\'Internet. + Fins a 64 identificadors de partides de Lichess, separades per espais. Data d\'inici en la teva zona horària Opcional, si saps quan comença l\'esdeveniment Cita la font diff --git a/translation/dest/broadcast/gsw-CH.xml b/translation/dest/broadcast/gsw-CH.xml index 7a3b2f16d796b..7716acc3660c7 100644 --- a/translation/dest/broadcast/gsw-CH.xml +++ b/translation/dest/broadcast/gsw-CH.xml @@ -23,7 +23,9 @@ Churzi Turnier Beschribig Vollschtändigi Ereignisbeschribig Optionali, usfüehrlichi Beschribig vu de Überträgig. %1$s isch verfügbar. Die Beschribig muess chürzer als %2$s Zeiche si. + PGN Quälle URL URL wo Lichess abfrögt, für PGN Aktualisierige z\'erhalte. Sie muess öffentlich im Internet zuegänglich si. + Bis zu 64 Lichess Partie - IDs, trännt dur en Leerschlag. Startdatum in dinere eigene Zitzone Optional, falls du weisch, wänn das Ereignis afangt Erwähn die Quälle diff --git a/translation/dest/broadcast/ja-JP.xml b/translation/dest/broadcast/ja-JP.xml index 53bdbd425abe8..86805286f79c1 100644 --- a/translation/dest/broadcast/ja-JP.xml +++ b/translation/dest/broadcast/ja-JP.xml @@ -22,7 +22,9 @@ 大会の短い説明 長い説明 内容の詳しい説明(オプション)。%1$s が利用できます。長さは [欧文換算で] %2$s 字まで。 + PGN のソース URL Lichess が PGN を取得するための URL。インターネット上に公表されているもののみ。 + Lichess ゲーム ID、半角スペースで区切って最大 64 個まで。 開始日付(あなたの現地時間) イベント開始時刻(オプション) ソースを表示する diff --git a/translation/dest/broadcast/sk-SK.xml b/translation/dest/broadcast/sk-SK.xml index 75d8978539a7a..92d64b09eb599 100644 --- a/translation/dest/broadcast/sk-SK.xml +++ b/translation/dest/broadcast/sk-SK.xml @@ -21,6 +21,7 @@ Krátky popis turnaja Úplný popis turnaja Voliteľný dlhý popis vysielania. %1$s je dostupný. Dĺžka musí byť menej ako %2$s znakov. + Zdrojová URL pre PGN súbor URL, ktorú bude Lichess kontrolovať, aby získal aktualizácie PGN. Musí byť verejne prístupná z internetu. Dátum a čas začiatku, vo vašej časovej zóne Voliteľné, ak viete kedy sa udalosť začne diff --git a/translation/dest/broadcast/sq-AL.xml b/translation/dest/broadcast/sq-AL.xml index ff873de32923a..d64e22cc2914e 100644 --- a/translation/dest/broadcast/sq-AL.xml +++ b/translation/dest/broadcast/sq-AL.xml @@ -23,7 +23,9 @@ Përshkrim i shkurtër i turneut Përshkrim i plotë i turneut Përshkrim i gjatë opsional i turneut. %1$s është e disponueshme. Gjatësia duhet të jetë më pak se %2$s shenja. + URL Burimi PNG-je URL-ja që do të kontrollojë Lichess-i për të marrë përditësime PGN-sh. Duhet të jetë e përdorshme publikisht që nga Interneti. + Deri në 64 ID lojërash Lichess, ndarë me hapësira. Datë fillimi në zonën tuaj kohore Opsionale, nëse e dini kur fillon veprimtaria Atriboji merita burimit diff --git a/translation/dest/broadcast/uk-UA.xml b/translation/dest/broadcast/uk-UA.xml index d8cbe8d1075de..46d8ed6b9d551 100644 --- a/translation/dest/broadcast/uk-UA.xml +++ b/translation/dest/broadcast/uk-UA.xml @@ -25,7 +25,9 @@ Короткий опис турніру Повний опис події Необов\'язковий довгий опис трансляції. Наявна розмітка %1$s. Довжина має бути менша ніж %2$s символів. + Адреса джерела PGN Посилання, яке Lichess перевірятиме, щоб отримати оновлення PGN. Воно має бути загальнодоступним в Інтернеті. + До 64 ігрових ID Lichess, відокремлені пробілами. Дата початку у вашому часовому поясі За бажанням, якщо ви знаєте, коли починається подія Вдячність джерелу diff --git a/translation/dest/coordinates/uk-UA.xml b/translation/dest/coordinates/uk-UA.xml index 2aa8a13ef823a..aa1b48441ed20 100644 --- a/translation/dest/coordinates/uk-UA.xml +++ b/translation/dest/coordinates/uk-UA.xml @@ -13,6 +13,7 @@ Ви маєте 30 секунд для того, щоб відмітити якнайбільше полів! Витрачайте стільки часу, скільки забажаєте - жодних лімітів! Відображати координати + Координати на кожному полі Показувати фігури Розпочати тренування Знайти поле diff --git a/translation/dest/site/uk-UA.xml b/translation/dest/site/uk-UA.xml index 316293925f55b..746279c7b4f16 100644 --- a/translation/dest/site/uk-UA.xml +++ b/translation/dest/site/uk-UA.xml @@ -72,6 +72,8 @@ Підвищити пріоритет варіанта Зробити варіант основним Видалити з цього місця + Згорнути варіанти + Розгорнути варіанти Зробити варіантом Скопіювати PGN варіанту Хід From cb437e68dd013db8fb2fa5f19c7f42d1a40790f3 Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Tue, 18 Jun 2024 07:54:13 +0700 Subject: [PATCH 012/260] Clean up teamSearch indexing in favor of lila-search-ingestor --- modules/api/src/main/Cli.scala | 3 -- modules/core/src/main/team.scala | 2 - modules/team/src/main/TeamApi.scala | 3 +- modules/teamSearch/src/main/Env.scala | 17 +-------- .../teamSearch/src/main/TeamSearchApi.scala | 37 ++----------------- modules/teamSearch/src/main/package.scala | 1 - 6 files changed, 5 insertions(+), 58 deletions(-) diff --git a/modules/api/src/main/Cli.scala b/modules/api/src/main/Cli.scala index da551708e9062..ba31e32da96ff 100644 --- a/modules/api/src/main/Cli.scala +++ b/modules/api/src/main/Cli.scala @@ -5,8 +5,6 @@ import lila.web.AnnounceApi final private[api] class Cli( security: lila.security.Env, - teamSearch: lila.teamSearch.Env, - forumSearch: lila.forumSearch.Env, tournament: lila.tournament.Env, fishnet: lila.fishnet.Env, study: lila.study.Env, @@ -55,7 +53,6 @@ final private[api] class Cli( private def processors = security.cli.process - .orElse(teamSearch.cli.process) .orElse(tournament.cli.process) .orElse(fishnet.cli.process) .orElse(study.cli.process) diff --git a/modules/core/src/main/team.scala b/modules/core/src/main/team.scala index f5dc3d3bb5984..1b2dc5536440f 100644 --- a/modules/core/src/main/team.scala +++ b/modules/core/src/main/team.scala @@ -56,8 +56,6 @@ case class TeamData( ) case class TeamCreate(team: TeamData) case class TeamUpdate(team: TeamData, byMod: Boolean)(using val me: MyId) -case class TeamDelete(id: TeamId) -case class TeamDisable(id: TeamId) case class JoinTeam(id: TeamId, userId: UserId) case class IsLeader(id: TeamId, userId: UserId, promise: Promise[Boolean]) case class IsLeaderOf(leaderId: UserId, memberId: UserId, promise: Promise[Boolean]) diff --git a/modules/team/src/main/TeamApi.scala b/modules/team/src/main/TeamApi.scala index e8a1720cf72bc..68999a31ebfcf 100644 --- a/modules/team/src/main/TeamApi.scala +++ b/modules/team/src/main/TeamApi.scala @@ -326,7 +326,7 @@ final class TeamApi( users <- memberRepo.userIdsByTeam(team.id) _ = users.foreach(cached.invalidateTeamIds) _ <- requestRepo.removeByTeam(team.id) - yield publish(TeamDisable(team.id)) + yield () else teamRepo .enable(team) @@ -353,7 +353,6 @@ final class TeamApi( (teamRepo.coll.delete.one($id(team.id)) >> memberRepo.removeByTeam(team.id)).andDo { logger.info(s"delete team ${team.id} by @${by.id}: $explain") - publish(TeamDelete(team.id)) } def syncBelongsTo(teamId: TeamId, userId: UserId): Boolean = diff --git a/modules/teamSearch/src/main/Env.scala b/modules/teamSearch/src/main/Env.scala index aded2a9415838..306435a7f4137 100644 --- a/modules/teamSearch/src/main/Env.scala +++ b/modules/teamSearch/src/main/Env.scala @@ -1,6 +1,5 @@ package lila.teamSearch -import akka.actor.* import com.softwaremill.macwire.* import scalalib.paginator.Paginator @@ -8,10 +7,7 @@ import lila.core.config.ConfigName import lila.search.client.SearchClient import lila.search.spec.Query -final class Env( - client: SearchClient, - teamApi: lila.core.team.TeamApi -)(using Executor, akka.stream.Materializer): +final class Env(client: SearchClient)(using Executor): private val maxPerPage = MaxPerPage(15) @@ -20,14 +16,3 @@ final class Env( lazy val api: TeamSearchApi = wire[TeamSearchApi] def apply(text: String, page: Int): Fu[Paginator[TeamId]] = paginatorBuilder(Query.team(text), page) - - def cli: lila.common.Cli = new: - def process = { case "team" :: "search" :: "reset" :: Nil => - api.reset.inject("done") - } - - lila.common.Bus.subscribeFun("team"): - case lila.core.team.TeamCreate(team) => api.store(team) - case lila.core.team.TeamUpdate(team, _) => api.store(team) - case lila.core.team.TeamDelete(id) => client.deleteById(index, id.value) - case lila.core.team.TeamDisable(id) => client.deleteById(index, id.value) diff --git a/modules/teamSearch/src/main/TeamSearchApi.scala b/modules/teamSearch/src/main/TeamSearchApi.scala index bd7a7c95c14c0..a79ba1d6dc735 100644 --- a/modules/teamSearch/src/main/TeamSearchApi.scala +++ b/modules/teamSearch/src/main/TeamSearchApi.scala @@ -1,45 +1,14 @@ package lila.teamSearch -import akka.stream.scaladsl.* - -import lila.core.team.TeamData import lila.search.* import lila.search.client.SearchClient -import lila.search.spec.{ Query, TeamSource } +import lila.search.spec.Query -final class TeamSearchApi( - client: SearchClient, - teamApi: lila.core.team.TeamApi -)(using Executor, akka.stream.Materializer) - extends SearchReadApi[TeamId, Query.Team]: +final class TeamSearchApi(client: SearchClient)(using Executor) extends SearchReadApi[TeamId, Query.Team]: def search(query: Query.Team, from: From, size: Size) = client .search(query, from, size) - .map: res => - res.hitIds.map(TeamId.apply) + .map(_.hitIds.map(TeamId.apply)) def count(query: Query.Team) = client.count(query).dmap(_.count) - - def store(team: TeamData) = client.storeTeam(team.id.value, toDoc(team)) - - private def toDoc(team: TeamData) = - TeamSource( - name = team.name, - description = team.description.value.take(10000), - nbMembers = team.nbMembers - ) - - def reset = - client.mapping(index) >> { - - logger.info(s"Index to ${index}") - - teamApi.cursor - .documentSource() - .via(lila.common.LilaStream.logRate[TeamData]("team index")(logger)) - .map(t => t.id.value -> toDoc(t)) - .grouped(200) - .mapAsync(1)(xs => client.storeBulkTeam(xs.toList)) - .runWith(Sink.ignore) - } >> client.refresh(index) diff --git a/modules/teamSearch/src/main/package.scala b/modules/teamSearch/src/main/package.scala index 9e3f5b1de4cfb..ac5f2552b6d3c 100644 --- a/modules/teamSearch/src/main/package.scala +++ b/modules/teamSearch/src/main/package.scala @@ -1,7 +1,6 @@ package lila.teamSearch export lila.core.lilaism.Lilaism.{ *, given } -export lila.common.extensions.* private val logger = lila.log("teamSearch") From 335ae05ef87fb67aa469827c3c67dbdf04c26c07 Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Fri, 21 Jun 2024 13:39:32 +0700 Subject: [PATCH 013/260] Another forumSearch cleanup --- modules/forumSearch/src/main/Env.scala | 6 +----- modules/forumSearch/src/main/package.scala | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/modules/forumSearch/src/main/Env.scala b/modules/forumSearch/src/main/Env.scala index d9b08fa2af131..9cf51e151072a 100644 --- a/modules/forumSearch/src/main/Env.scala +++ b/modules/forumSearch/src/main/Env.scala @@ -13,11 +13,7 @@ import lila.search.spec.Query @Module private class ForumSearchConfig(@ConfigName("paginator.max_per_page") val maxPerPage: MaxPerPage) -final class Env( - appConfig: Configuration, - postApi: lila.core.forum.ForumPostApi, - client: SearchClient -)(using Executor): +final class Env(appConfig: Configuration, client: SearchClient)(using Executor): private val config = appConfig.get[ForumSearchConfig]("forumSearch")(AutoConfig.loader) diff --git a/modules/forumSearch/src/main/package.scala b/modules/forumSearch/src/main/package.scala index 3604898cd0680..d4301e043437d 100644 --- a/modules/forumSearch/src/main/package.scala +++ b/modules/forumSearch/src/main/package.scala @@ -1,7 +1,6 @@ package lila.forumSearch export lila.core.lilaism.Lilaism.{ *, given } -export lila.common.extensions.* private val logger = lila.log("forumSearch") From 43519d65011f8631bde2319d2028cdf6a0df47d0 Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Fri, 21 Jun 2024 14:01:49 +0700 Subject: [PATCH 014/260] Limit forum post length to 10k charaters --- modules/forum/src/main/ForumForm.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/forum/src/main/ForumForm.scala b/modules/forum/src/main/ForumForm.scala index e6e014dd0fe8e..88ed0281d821d 100644 --- a/modules/forum/src/main/ForumForm.scala +++ b/modules/forum/src/main/ForumForm.scala @@ -43,13 +43,13 @@ final private[forum] class ForumForm( single("reason" -> optional(nonEmptyText)) private def userTextMapping(inOwnTeam: Boolean, previousText: Option[String] = None)(using me: Me) = - cleanText(minLength = 3) + cleanText(minLength = 3, 10_000) .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 = 100000))) + val diagnostic = Form(single("text" -> nonEmptyText(maxLength = 100_000))) object ForumForm: From 0c4724c7c1ce19b9ca61dd91567b905dfd01ed33 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 11:13:36 +0200 Subject: [PATCH 015/260] simplify bson handler --- modules/challenge/src/main/BSONHandlers.scala | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/modules/challenge/src/main/BSONHandlers.scala b/modules/challenge/src/main/BSONHandlers.scala index 28109b9c1d9bd..6a01127ee7c7c 100644 --- a/modules/challenge/src/main/BSONHandlers.scala +++ b/modules/challenge/src/main/BSONHandlers.scala @@ -13,18 +13,14 @@ private object BSONHandlers: import Challenge.* import lila.game.BSONHandlers.given - given BSONHandler[ColorChoice] = BSONIntegerHandler.as[ColorChoice]( - { - case 1 => ColorChoice.White - case 2 => ColorChoice.Black - case _ => ColorChoice.Random - }, - { - case ColorChoice.White => 1 - case ColorChoice.Black => 2 - case ColorChoice.Random => 0 - } - ) + given BSONHandler[ColorChoice] = + val map = Map( + 0 -> ColorChoice.Random, + 1 -> ColorChoice.White, + 2 -> ColorChoice.Black + ) + valueMapHandler[Int, ColorChoice](map)(i => map.find(_._2 == i).so(_._1)) + given BSON[TimeControl] with import chess.Clock def reads(r: Reader) = From 2e18b6821e6b4e71ea96922931e5af20ab011687 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 11:22:26 +0200 Subject: [PATCH 016/260] show check in study multiboard, and checkmate in mini eval gauges --- modules/study/src/main/BSONHandlers.scala | 6 ++ modules/study/src/main/Chapter.scala | 14 +++- .../study/src/main/StudyChapterPreview.scala | 7 ++ modules/study/src/main/package.scala | 2 - ui/analyse/src/study/interfaces.ts | 1 + ui/analyse/src/study/multiBoard.ts | 74 +++++++++++-------- ui/analyse/src/study/studyChapters.ts | 1 + 7 files changed, 71 insertions(+), 34 deletions(-) diff --git a/modules/study/src/main/BSONHandlers.scala b/modules/study/src/main/BSONHandlers.scala index edcc7b54f735a..b627f7dbbaaec 100644 --- a/modules/study/src/main/BSONHandlers.scala +++ b/modules/study/src/main/BSONHandlers.scala @@ -327,15 +327,21 @@ object BSONHandlers: private val clockPair: BSONHandler[PairOf[Option[Centis]]] = optionPairHandler given BSONHandler[Chapter.BothClocks] = clockPair.as[Chapter.BothClocks](ByColor.fromPair, _.toPair) + given BSONHandler[Chapter.Check] = quickHandler[Chapter.Check]( + { case BSONString(v) => if v == "#" then Chapter.Check.Mate else Chapter.Check.Check }, + v => BSONString(if v == Chapter.Check.Mate then "#" else "+") + ) given BSON[Chapter.LastPosDenorm] with def reads(r: Reader) = Chapter.LastPosDenorm( fen = r.getO[Fen.Full]("fen") | Fen.initial, uci = r.getO[Uci]("uci"), + check = r.getO[Chapter.Check]("check"), clocks = ~r.getO[Chapter.BothClocks]("clocks") ) def writes(w: Writer, l: Chapter.LastPosDenorm) = $doc( "fen" -> l.fen.some.filterNot(Fen.Full.isInitial), "uci" -> l.uci, + "check" -> l.check, "clocks" -> l.clocks.some.filter(_.exists(_.isDefined)) ) diff --git a/modules/study/src/main/Chapter.scala b/modules/study/src/main/Chapter.scala index 6f0a89152dffb..a872576631424 100644 --- a/modules/study/src/main/Chapter.scala +++ b/modules/study/src/main/Chapter.scala @@ -41,7 +41,13 @@ case class Chapter( val parentNode = parentPath.flatMap(root.nodeAt) val clockSwap = ByColor(node.clock, parentNode.flatMap(_.clock).orElse(node.clock)) if node.color.black then clockSwap else clockSwap.swap - Chapter.LastPosDenorm(node.fen, node.moveOption.map(_.uci), clocks = clocks) + val uci = node.moveOption.map(_.uci) + val check = node.moveOption + .flatMap(_.san.value.lastOption) + .collect: + case '+' => Chapter.Check.Check + case '#' => Chapter.Check.Mate + Chapter.LastPosDenorm(node.fen, uci, check, clocks) copy(denorm = newDenorm) def updateRoot(f: Root => Option[Root]) = @@ -104,6 +110,7 @@ case class Chapter( fen = denorm.fold(Fen.initial)(_.fen), lastMove = denorm.flatMap(_.uci), lastMoveAt = relay.map(_.lastMoveAt), + check = denorm.flatMap(_.check), result = tags.outcome.isDefined.option(tags.outcome) ) @@ -149,9 +156,12 @@ object Chapter: type BothClocks = ByColor[Option[Centis]] + enum Check: + case Check, Mate + /* Last position of the main line. * Used for chapter previews. */ - case class LastPosDenorm(fen: Fen.Full, uci: Option[Uci], clocks: BothClocks) + case class LastPosDenorm(fen: Fen.Full, uci: Option[Uci], check: Option[Check], clocks: BothClocks) case class IdName(@Key("_id") id: StudyChapterId, name: StudyChapterName) diff --git a/modules/study/src/main/StudyChapterPreview.scala b/modules/study/src/main/StudyChapterPreview.scala index 55948613e399e..f0eecc17605e4 100644 --- a/modules/study/src/main/StudyChapterPreview.scala +++ b/modules/study/src/main/StudyChapterPreview.scala @@ -17,6 +17,7 @@ case class ChapterPreview( fen: Fen.Full, lastMove: Option[Uci], lastMoveAt: Option[Instant], + check: Option[Chapter.Check], /* None = No Result PGN tag, the chapter may not be a game * Some(None) = Result PGN tag is "*", the game is ongoing * Some(Some(Outcome)) = Game is over with a result @@ -122,6 +123,10 @@ object ChapterPreview: .add("clock" -> p.clock) .add("fed" -> p.fideId.flatMap(federations.get)) + private given Writes[Chapter.Check] = Writes: + case Chapter.Check.Check => JsString("+") + case Chapter.Check.Mate => JsString("#") + private def writesWithFederations(using Federation.ByFideIds): OWrites[ChapterPreview] = c => Json .obj( @@ -132,6 +137,7 @@ object ChapterPreview: .add("players", c.players.map(_.mapList(playerWithFederations))) .add("orientation", c.orientation.some.filter(_.black)) .add("lastMove", c.lastMove) + .add("check", c.check) .add("thinkTime", c.thinkTime) .add("status", c.result.map(o => Outcome.showResult(o).replace("1/2", "½"))) @@ -163,5 +169,6 @@ object ChapterPreview: fen = lastPos.map(_.fen).orElse(doc.getAsOpt[Fen.Full]("rootFen")).getOrElse(Fen.initial), lastMove = lastPos.flatMap(_.uci), lastMoveAt = lastMoveAt, + check = lastPos.flatMap(_.check), result = tags.flatMap(_(_.Result)).map(Outcome.fromResult) ) diff --git a/modules/study/src/main/package.scala b/modules/study/src/main/package.scala index 93ddc484696c5..e874850eaeaed 100644 --- a/modules/study/src/main/package.scala +++ b/modules/study/src/main/package.scala @@ -5,5 +5,3 @@ export lila.common.extensions.* export lila.core.study.data.{ StudyName, StudyChapterName } private val logger = lila.log("study") - -private type ChapterMap = Map[lila.study.StudyChapterId, lila.study.Chapter] diff --git a/ui/analyse/src/study/interfaces.ts b/ui/analyse/src/study/interfaces.ts index f025d570e1f24..e67cf397bfd2b 100644 --- a/ui/analyse/src/study/interfaces.ts +++ b/ui/analyse/src/study/interfaces.ts @@ -175,6 +175,7 @@ export interface ChapterPreviewBase { name: string; status?: StatusStr; lastMove?: string; + check?: '+' | '#'; } export interface ChapterPreviewFromServer extends ChapterPreviewBase { diff --git a/ui/analyse/src/study/multiBoard.ts b/ui/analyse/src/study/multiBoard.ts index 9bffc5185e7de..3dc56c0eee772 100644 --- a/ui/analyse/src/study/multiBoard.ts +++ b/ui/analyse/src/study/multiBoard.ts @@ -117,6 +117,13 @@ const renderPlayingToggle = (ctrl: MultiBoardCtrl): MaybeVNode => ctrl.trans.noarg('playing'), ]); +const previewToCgConfig = (cp: ChapterPreview): CgConfig => ({ + fen: cp.fen, + lastMove: uciToMove(cp.lastMove), + turnColor: fenColor(cp.fen), + check: !!cp.check, +}); + const makePreview = (basePath: string, current: ChapterId, cloudEval?: MultiCloudEval) => (preview: ChapterPreview) => { const orientation = preview.orientation || 'white'; @@ -137,11 +144,10 @@ const makePreview = insert(vnode) { const el = vnode.elm as HTMLElement; vnode.data!.cg = site.makeChessground(el, { + ...previewToCgConfig(preview), coordinates: false, viewOnly: true, - fen: preview.fen, orientation, - lastMove: uciToMove(preview.lastMove), drawable: { enabled: false, visible: false, @@ -151,10 +157,7 @@ const makePreview = }, postpatch(old, vnode) { if (old.data!.fen !== preview.fen) { - old.data!.cg?.set({ - fen: preview.fen, - lastMove: uciToMove(preview.lastMove), - }); + old.data!.cg?.set(previewToCgConfig(preview)); } vnode.data!.fen = preview.fen; vnode.data!.cg = old.data!.cg; @@ -169,31 +172,42 @@ const makePreview = }; export const verticalEvalGauge = (chap: ChapterPreview, cloudEval: MultiCloudEval): MaybeVNode => - h( - 'span.mini-game__gauge', - { - attrs: { 'data-id': chap.id }, - hook: { - ...onInsert(cloudEval.observe), - postpatch(old, vnode) { - const prevNodeCloud: CloudEval | undefined = old.data?.cloud; - const cev = cloudEval.getCloudEval(chap.fen) || prevNodeCloud; - if (cev?.chances != prevNodeCloud?.chances) { - const elm = vnode.elm as HTMLElement; - (elm.firstChild as HTMLElement).style.height = `${Math.round( - ((1 - (cev?.chances || 0)) / 2) * 100, - )}%`; - if (cev) { - elm.title = renderScoreAtDepth(cev); - elm.classList.add('mini-game__gauge--set'); - } - } - vnode.data!.cloud = cev; + chap.check == '#' + ? h( + 'span.mini-game__gauge.mini-game__gauge--set', + { attrs: { 'data-id': chap.id, title: 'Checkmate' } }, + [ + h('span.mini-game__gauge__black', { + attrs: { style: `height: ${fenColor(chap.fen) == 'white' ? 100 : 0}%` }, + }), + h('tick'), + ], + ) + : h( + 'span.mini-game__gauge', + { + attrs: { 'data-id': chap.id }, + hook: { + ...onInsert(cloudEval.observe), + postpatch(old, vnode) { + const elm = vnode.elm as HTMLElement; + const prevNodeCloud: CloudEval | undefined = old.data?.cloud; + const cev = cloudEval.getCloudEval(chap.fen) || prevNodeCloud; + if (cev?.chances != prevNodeCloud?.chances) { + (elm.firstChild as HTMLElement).style.height = `${Math.round( + ((1 - (cev?.chances || 0)) / 2) * 100, + )}%`; + if (cev) { + elm.title = renderScoreAtDepth(cev); + elm.classList.add('mini-game__gauge--set'); + } + } + vnode.data!.cloud = cev; + }, + }, }, - }, - }, - [h('span.mini-game__gauge__black'), h('tick')], - ); + [h('span.mini-game__gauge__black'), h('tick')], + ); const renderUser = (player: ChapterPreviewPlayer): VNode => h('span.mini-game__user', [ diff --git a/ui/analyse/src/study/studyChapters.ts b/ui/analyse/src/study/studyChapters.ts index 5a0134c293ba1..15be731c77667 100644 --- a/ui/analyse/src/study/studyChapters.ts +++ b/ui/analyse/src/study/studyChapters.ts @@ -98,6 +98,7 @@ export default class StudyChaptersCtrl { if (onRelayPath || !d.relayPath) { cp.fen = node.fen; cp.lastMove = node.uci; + cp.check = node.san?.includes('#') ? '#' : node.san?.includes('+') ? '+' : undefined; } if (onRelayPath) { cp.lastMoveAt = Date.now(); From 7bb68eead6e74293f6028ee64ba9c2b579da26cb Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 11:27:16 +0200 Subject: [PATCH 017/260] don't set study.updatedAt twice --- modules/study/src/main/StudyApi.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/study/src/main/StudyApi.scala b/modules/study/src/main/StudyApi.scala index 2981e1385d6aa..9192f3041284a 100644 --- a/modules/study/src/main/StudyApi.scala +++ b/modules/study/src/main/StudyApi.scala @@ -632,8 +632,7 @@ final class StudyApi( def doAddChapter(study: Study, chapter: Chapter, sticky: Boolean, who: Who): Funit = for _ <- chapterRepo.insert(chapter) newStudy = study.withChapter(chapter) - _ <- sticky.so(studyRepo.updateSomeFields(newStudy)) - _ <- studyRepo.updateNow(study) + _ <- if sticky then studyRepo.updateSomeFields(newStudy) else studyRepo.updateNow(study) yield sendTo(study.id)(_.addChapter(newStudy.position, sticky, who)) indexStudy(study) From 56008b2ef0fc38848bf959fdcf591ffee9750db6 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 11:40:07 +0200 Subject: [PATCH 018/260] fix load newly created study chapter --- ui/analyse/src/study/studyCtrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index df4fe42f4a42e..6866c4091c0ea 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -702,7 +702,7 @@ export default class StudyCtrl { this.setMemberActive(d.w); if (d.s && !this.vm.mode.sticky) this.vm.behind++; if (d.s) this.data.position = d.p; - else if (d.w && d.w.s === site.sri) { + if (d.w?.s === site.sri) { this.vm.mode.write = this.relayData ? this.relayRecProp() : this.nonRelayRecMapProp(this.data.id); this.vm.chapterId = d.p.chapterId; } From 58ff9bd924c3a59b800ac7db466dcf26cbfab0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Fri, 21 Jun 2024 13:46:47 +0100 Subject: [PATCH 019/260] Direct to defined round as well. the view can also be defined like this `#uiid/round/board --- modules/relay/src/main/RelayRound.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 6a4bfa2f8000a..8d257dbb2b1df 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -128,7 +128,7 @@ object RelayRound: override def isLcc = true def id = lcc def fetchUrl = s"http://1.pool.livechesscloud.com/get/$id/round-$round/index.json" - def viewUrl = s"https://view.livechesscloud.com/#$id" + def viewUrl = s"https://view.livechesscloud.com/#$id/$round" def formUrl = s"$viewUrl $round" object UpstreamLcc: private val idRegex = """.*view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r From 1cb5eb2905d94ebc2c0dafb9ea2321e604e5f6ac Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 16:27:05 +0200 Subject: [PATCH 020/260] better lcc regex --- modules/relay/src/main/RelayRound.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 8d257dbb2b1df..1da841b08689d 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -131,7 +131,7 @@ object RelayRound: def viewUrl = s"https://view.livechesscloud.com/#$id/$round" def formUrl = s"$viewUrl $round" object UpstreamLcc: - private val idRegex = """.*view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r + private val idRegex = """view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r.unanchored def findId(url: String): Option[String] = url match case idRegex(id) => id.some case _ => none From 5192ca02a121a0260c783aa31709e81146978466 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 16:27:16 +0200 Subject: [PATCH 021/260] use / as lcc round/id separator --- modules/relay/src/main/RelayRound.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 1da841b08689d..d3cbbd6237a72 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -135,7 +135,7 @@ object RelayRound: def findId(url: String): Option[String] = url match case idRegex(id) => id.some case _ => none - def find(url: String): Option[UpstreamLcc] = url.split(' ').map(_.trim).filter(_.nonEmpty) match + def find(url: String): Option[UpstreamLcc] = url.split('/').map(_.trim).filter(_.nonEmpty) match case Array(idRegex(id), round) => round.toIntOption.map(UpstreamLcc(id, _)) case _ => none From 0daf44b0b479c620d79d32e30663c9360e500a24 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 16:28:22 +0200 Subject: [PATCH 022/260] compat for space separator --- modules/relay/src/main/RelayRound.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index d3cbbd6237a72..6b9844d1378aa 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -135,9 +135,10 @@ object RelayRound: def findId(url: String): Option[String] = url match case idRegex(id) => id.some case _ => none - def find(url: String): Option[UpstreamLcc] = url.split('/').map(_.trim).filter(_.nonEmpty) match - case Array(idRegex(id), round) => round.toIntOption.map(UpstreamLcc(id, _)) - case _ => none + def find(url: String): Option[UpstreamLcc] = + url.trim.replace(" ", "/").split('/') match + case Array(idRegex(id), round) => round.toIntOption.map(UpstreamLcc(id, _)) + case _ => none trait AndTour: val tour: RelayTour From 5679f1ac4eef3fe4154d67ee13c3bfefafb8e01c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 21 Jun 2024 16:34:13 +0200 Subject: [PATCH 023/260] monospace textareas --- modules/relay/src/main/ui/FormUi.scala | 8 ++++---- ui/bits/css/relay/_form.scss | 6 ------ ui/common/css/base/_typography.scss | 4 ++++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 121cea1a61745..5b32781e2512a 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -206,7 +206,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): "Multiple source URLs, one per line.", help = frag("The games will be combined in the order of the URLs.").some, half = false - )(form3.textarea(_)(rows := 5, spellcheck := "false"))( + )(form3.textarea(_)(rows := 5, spellcheck := "false", cls := "monospace"))( cls := "relay-form__sync relay-form__sync-urls none" ), form3.group( @@ -418,7 +418,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): YouGotLittUp / 1890 / / Louis Litt""") ).some, half = true - )(form3.textarea(_)(rows := 3, spellcheck := "false")), + )(form3.textarea(_)(rows := 3, spellcheck := "false", cls := "monospace")), form3.group( form("teams"), "Optional: assign players to teams", @@ -431,7 +431,7 @@ Team Dogs ; Scooby Doo"""), "By default the PGN tags WhiteTeam and BlackTeam are used." ).some, half = true - )(form3.textarea(_)(rows := 3, spellcheck := "false")) + )(form3.textarea(_)(rows := 3, spellcheck := "false", cls := "monospace")) ) ), if Granter.opt(_.Relay) then @@ -547,7 +547,7 @@ Team Dogs ; Scooby Doo"""), form("grouping"), "Optional: assign tournaments to a group", half = true - )(form3.textarea(_)(rows := 5, spellcheck := "false")), + )(form3.textarea(_)(rows := 5, spellcheck := "false", cls := "monospace")), div(cls := "form-group form-half form-help")( // do not translate "First line is the group name. Subsequent lines are the tournament IDs and names in the group. Names are facultative and only used for display in this textarea.", br, diff --git a/ui/bits/css/relay/_form.scss b/ui/bits/css/relay/_form.scss index 67504f5e64677..e61f12a8163b4 100644 --- a/ui/bits/css/relay/_form.scss +++ b/ui/bits/css/relay/_form.scss @@ -29,12 +29,6 @@ } } -#form3-grouping, -#form3-players, -#form3-teams { - font-family: monospace; -} - .flash-box { border: 5px solid $m-brag_bg--mix-70; diff --git a/ui/common/css/base/_typography.scss b/ui/common/css/base/_typography.scss index b1f77d7c53e0a..f01eaa3e8e248 100644 --- a/ui/common/css/base/_typography.scss +++ b/ui/common/css/base/_typography.scss @@ -33,6 +33,10 @@ h2 { @include fluid-size('font-size', 16px, 30px); } +.monospace { + font-family: monospace; +} + .ninja-title { @extend %base-font; From a2fbc2fd81492f607600f5c63477fcf08055e31b Mon Sep 17 00:00:00 2001 From: Jonathan Gamble Date: Fri, 21 Jun 2024 11:12:44 -0500 Subject: [PATCH 024/260] fix type checking for rebuilds on global *.d.ts changes --- ui/.build/src/clean.ts | 4 ++-- ui/.build/src/monitor.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/.build/src/clean.ts b/ui/.build/src/clean.ts index 4a0e54a2bd788..441188b18b0e5 100644 --- a/ui/.build/src/clean.ts +++ b/ui/.build/src/clean.ts @@ -9,7 +9,7 @@ const globOpts: fg.Options = { markDirectories: true, }; -const globs = [ +const allGlobs = [ '**/node_modules', '**/css/**/gen', 'ui/.build/dist/css', @@ -20,7 +20,7 @@ const globs = [ 'public/css', ]; -export async function clean() { +export async function clean(globs: string[] = allGlobs) { if (!env.clean) return; for (const glob of globs) { diff --git a/ui/.build/src/monitor.ts b/ui/.build/src/monitor.ts index 85d5e31682764..96b7ca5375cc9 100644 --- a/ui/.build/src/monitor.ts +++ b/ui/.build/src/monitor.ts @@ -4,6 +4,7 @@ import * as ps from 'node:process'; import { build, stop } from './build'; import { env } from './main'; import { globArray } from './parse'; +import { clean } from './clean'; import { stopTsc, tsc } from './tsc'; import { stopEsbuild, esbuild } from './esbuild'; @@ -30,7 +31,9 @@ export async function startMonitor(mods: string[]) { stopEsbuild(); clearTimeout(tscTimeout); tscTimeout = setTimeout(() => { - if (!reinitTimeout) esbuild(tsc()); + if (reinitTimeout) return; + clean(['ui/*/tsconfig.tsbuildinfo']); + esbuild(tsc()); }, 2000); }; const packageChange = async () => { From 6ece6e0001cb59c71172bbc213fb030e9a3f2cf6 Mon Sep 17 00:00:00 2001 From: Jonathan Gamble <101470903+schlawg@users.noreply.github.com> Date: Sat, 22 Jun 2024 08:00:29 -0500 Subject: [PATCH 025/260] fix off center ublog youtube embed --- ui/common/css/component/_markdown.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/common/css/component/_markdown.scss b/ui/common/css/component/_markdown.scss index cd0607f123c96..31f75e18e8f4b 100644 --- a/ui/common/css/component/_markdown.scss +++ b/ui/common/css/component/_markdown.scss @@ -168,8 +168,10 @@ @mixin rendered-markdown--embed { .embed { @extend %video; - margin: $block-gap auto; + > iframe { + left: 0; + } } .twitter-tweet { From 153be793b8dcb81176a1d9b736018d5e61b77b30 Mon Sep 17 00:00:00 2001 From: Allan Joseph Date: Sat, 22 Jun 2024 22:41:43 +0000 Subject: [PATCH 026/260] Fill in >1 min gaps. Select last active round. --- pnpm-lock.yaml | 2 +- ui/chart/src/chart.relayStats.ts | 43 ++++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 279a60ecfd725..47e38a009a786 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,7 +383,7 @@ importers: specifier: workspace:^ version: link:../chart chart.js: - specifier: ^4.4.0 + specifier: 4.4.3 version: 4.4.3 chartjs-plugin-datalabels: specifier: ^2.2.0 diff --git a/ui/chart/src/chart.relayStats.ts b/ui/chart/src/chart.relayStats.ts index 2648b1d01624e..ab59f9f419709 100644 --- a/ui/chart/src/chart.relayStats.ts +++ b/ui/chart/src/chart.relayStats.ts @@ -49,17 +49,18 @@ const dateFormat = memoize(() => export default function initModule(data: RelayStats) { const $el = $('#relay-stats'); + const last = data.rounds.reverse().find(r => !!r.viewers.length); const container = $('#round-selector')[0]!; container.innerHTML = `
`; const possibleChart = maybeChart($el[0] as HTMLCanvasElement); - const relayChart = (possibleChart as RelayChart) ?? makeChart(data, $el); + const relayChart = (possibleChart as RelayChart) ?? makeChart($el, last); $('#round-select').on('change', function (this: HTMLSelectElement) { const selected = data.rounds.find(r => r.round.id == this.value)!; relayChart.updateData(selected); @@ -75,7 +76,7 @@ const makeDataset = (data: RoundStats, el: HTMLCanvasElement): chart.ChartDatase { indexAxis: 'x', type: 'line', - data: data.viewers.map(v => ({ x: v[0] * 1000, y: v[1] })), + data: fillData(data.viewers), label: `${data.round.name}`, pointBorderColor: '#fff', pointBackgroundColor: blue, @@ -116,9 +117,8 @@ const makeDataset = (data: RoundStats, el: HTMLCanvasElement): chart.ChartDatase return plot; }; -const makeChart = (data: RelayStats, $el: Cash) => { - const last = data.rounds[data.rounds.length - 1]; - const ds = makeDataset(last, $el[0] as HTMLCanvasElement); +const makeChart = ($el: Cash, last?: RoundStats) => { + const ds = last ? makeDataset(last, $el[0] as HTMLCanvasElement) : []; const config: chart.ChartConfiguration<'line'> = { type: 'line', data: { @@ -176,7 +176,7 @@ const makeChart = (data: RelayStats, $el: Cash) => { text: 'Spectators', color: fontColor, }, - suggestedMin: 0, + min: 0, }, y2: { display: false, @@ -198,7 +198,7 @@ const makeChart = (data: RelayStats, $el: Cash) => { }, title: { display: true, - text: titleText(last), + text: last ? titleText(last) : 'No viewership stats yet', color: fontColor, }, }, @@ -208,6 +208,7 @@ const makeChart = (data: RelayStats, $el: Cash) => { relayChart.updateData = (data: RoundStats) => { relayChart.data.datasets = makeDataset(data, $el[0] as HTMLCanvasElement); relayChart.options.plugins!.title!.text = titleText(data); + relayChart.options.animations = updateAnimations(data); relayChart.update(); }; return relayChart; @@ -215,3 +216,25 @@ const makeChart = (data: RelayStats, $el: Cash) => { const titleText = (data: RoundStats): string => `${data.round.name} • Start - ${dateFormat()(data.round.startsAt)}`; + +const updateAnimations = (data?: RoundStats) => + data && data.viewers.length > 30 ? animation(1000 / data.viewers.length) : undefined; + +const fillData = (viewers: RoundStats['viewers']) => { + const points: chart.Point[] = []; + if (!viewers.length) return []; + const last = viewers[viewers.length - 1]; + points.push({ x: last[0], y: last[1] }); + viewers + .slice(0, viewers.length - 2) + .reverse() + .forEach(([behind, v]) => { + const minuteGap = points.find(({ x }) => x - behind <= 60); + if (!minuteGap) { + for (let i = behind; i < points[points.length - 1].x; i += 60) { + points.push({ x: i, y: v }); + } + } else points.push({ x: behind, y: v }); + }); + return points.map(p => ({ x: p.x * 1000, y: p.y })).reverse(); +}; From 01737e95667358174efd5ad0fdf3d5774d073f29 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 09:46:30 +0200 Subject: [PATCH 027/260] fix sync url display --- modules/relay/src/main/JsonView.scala | 2 +- modules/relay/src/main/RelayDelay.scala | 2 +- modules/relay/src/main/RelayRound.scala | 5 ++--- modules/relay/src/main/RelayRoundForm.scala | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/relay/src/main/JsonView.scala b/modules/relay/src/main/JsonView.scala index 40110e054f143..a55f881f95913 100644 --- a/modules/relay/src/main/JsonView.scala +++ b/modules/relay/src/main/JsonView.scala @@ -181,5 +181,5 @@ object JsonView: s.upstream.so: case Sync.UpstreamUrl(url) => Json.obj("url" -> url) case Sync.UpstreamLcc(url, round) => Json.obj("url" -> url, "round" -> round) - case Sync.UpstreamUrls(urls) => Json.obj("urls" -> urls.map(_.formUrl)) + case Sync.UpstreamUrls(urls) => Json.obj("urls" -> urls.map(_.viewUrl)) case Sync.UpstreamIds(ids) => Json.obj("ids" -> ids) diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index 2666e314861bb..192442efa09d9 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -51,7 +51,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): private object store: - private def idOf(upstream: FetchableUpstream, at: Instant) = s"${upstream.formUrl} ${at.toSeconds}" + private def idOf(upstream: FetchableUpstream, at: Instant) = s"${upstream.viewUrl} ${at.toSeconds}" private val longPast = java.time.Instant.ofEpochMilli(0) def putIfNew(upstream: FetchableUpstream, games: RelayGames): Funit = diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 6b9844d1378aa..fda69e667438a 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -118,10 +118,10 @@ object RelayRound: def isLcc = false sealed trait FetchableUpstream extends Upstream: def fetchUrl: String - def formUrl: String + def viewUrl: String case class UpstreamUrl(url: String) extends FetchableUpstream: def fetchUrl = url - def formUrl = url + def viewUrl = url case class UpstreamUrls(urls: List[FetchableUpstream]) extends Upstream case class UpstreamIds(ids: List[GameId]) extends Upstream case class UpstreamLcc(lcc: String, round: Int) extends FetchableUpstream: @@ -129,7 +129,6 @@ object RelayRound: def id = lcc def fetchUrl = s"http://1.pool.livechesscloud.com/get/$id/round-$round/index.json" def viewUrl = s"https://view.livechesscloud.com/#$id/$round" - def formUrl = s"$viewUrl $round" object UpstreamLcc: private val idRegex = """view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r.unanchored def findId(url: String): Option[String] = url match diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index 25ad79a81e728..6260a9c1cb81c 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -27,7 +27,7 @@ final class RelayRoundForm(using mode: Mode): .traverse(validateUpstreamUrlOrLcc) .map(_.distinct) .map(Sync.UpstreamUrls.apply), - _.urls.map(_.formUrl).mkString("\n") + _.urls.map(_.viewUrl).mkString("\n") ) private given Formatter[Sync.UpstreamIds] = formatter.stringTryFormatter( _.split(' ').toList From 9b36de2a9b03e557f7282fd1bfc1746ddf02a927 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 11:47:35 +0200 Subject: [PATCH 028/260] refactor relay fetch --- modules/relay/src/main/RelayFetch.scala | 37 ++++++++++++------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 564863483bfa0..a8af78b678c2e 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -50,27 +50,26 @@ final private class RelayFetch( private val maxRelaysToSync = Max(50) - private def syncRelays(official: Boolean): Funit = - val relays = + private def syncRelays(official: Boolean): Funit = for + relays <- if official then api.toSyncOfficial(maxRelaysToSync, onlyIds) else api.toSyncUser(maxRelaysToSync, onlyIds) - relays - .flatMap: relays => - lila.mon.relay.ongoing(official).update(relays.size) - relays - .parallelVoid: rt => - if rt.round.sync.ongoing then - processRelay(rt).flatMap: updating => - api.reFetchAndUpdate(rt.round)(updating.reRun) - else if rt.round.hasStarted then - logger.info(s"Finish by lack of activity ${rt.round}") - api.update(rt.round)(_.finish) - else if rt.round.shouldGiveUp then - val msg = "Finish for lack of start" - logger.info(s"$msg ${rt.round}") - if rt.tour.official then irc.broadcastError(rt.round.id, rt.fullName, msg) - api.update(rt.round)(_.finish) - else funit + _ <- relays.parallelVoid(syncRelay) + yield lila.mon.relay.ongoing(official).update(relays.size) + + private def syncRelay(rt: RelayRound.WithTour): Funit = + if rt.round.sync.ongoing then + processRelay(rt).flatMap: updating => + api.reFetchAndUpdate(rt.round)(updating.reRun).void + else if rt.round.hasStarted then + logger.info(s"Finish by lack of activity ${rt.round}") + api.update(rt.round)(_.finish).void + else if rt.round.shouldGiveUp then + val msg = "Finish for lack of start" + logger.info(s"$msg ${rt.round}") + if rt.tour.official then irc.broadcastError(rt.round.id, rt.fullName, msg) + api.update(rt.round)(_.finish).void + else funit // no writing the relay; only reading! // this can take a long time if the source is slow From 2137c43f550506eef188c179110f0572ac73b580 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 12:13:20 +0200 Subject: [PATCH 029/260] also use mailcheck.ai, it's free --- modules/common/src/main/mon.scala | 3 +++ modules/security/src/main/VerifyMail.scala | 27 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala index 6fcfe9420ec23..fbabcd2bbb8d6 100644 --- a/modules/common/src/main/mon.scala +++ b/modules/common/src/main/mon.scala @@ -328,6 +328,9 @@ object mon: object verifyMailApi: def fetch(success: Boolean, ok: Boolean) = timer("verifyMail.fetch").withTags(tags("success" -> successTag(success), "ok" -> ok)) + object mailcheckApi: + def fetch(success: Boolean, ok: Boolean) = + timer("mailcheck.fetch").withTags(tags("success" -> successTag(success), "ok" -> ok)) def usersAlikeTime(field: String) = timer("security.usersAlike.time").withTag("field", field) def usersAlikeFound(field: String) = histogram("security.usersAlike.found").withTag("field", field) object hCaptcha: diff --git a/modules/security/src/main/VerifyMail.scala b/modules/security/src/main/VerifyMail.scala index 238c974b505f5..bb63ea998d783 100644 --- a/modules/security/src/main/VerifyMail.scala +++ b/modules/security/src/main/VerifyMail.scala @@ -53,6 +53,33 @@ final private class VerifyMail( export cache.invalidate private def fetch(domain: Domain.Lower): Fu[Boolean] = + fetchFree(domain) + .logFailure(logger) + .recover(_ => true) + .flatMap: + case false => fuccess(false) + case true => fetchPaid(domain) + + private def fetchFree(domain: Domain.Lower): Fu[Boolean] = + val url = s"https://api.mailcheck.ai/domain/$domain" + ws.url(url) + .get() + .withTimeout(8.seconds, "mailcheck.fetch") + .map: res => + (for + js <- res.body[JsValue].asOpt[JsObject] + if res.status == 200 + disposable <- js.boolean("disposable") + yield + val ok = !disposable + logger.info: + s"Mailcheck $domain = $ok {disposable:$disposable}" + ok + ).getOrElse: + throw lila.core.lilaism.LilaException(s"$url ${res.status} ${res.body[String].take(200)}") + .monTry(res => _.security.mailcheckApi.fetch(res.isSuccess, res.getOrElse(true))) + + private def fetchPaid(domain: Domain.Lower): Fu[Boolean] = val url = s"https://verifymail.io/api/$domain" ws.url(url) .withQueryStringParameters("key" -> config.key.value) From f894ee67d5ea088e8f1eab6ccccf9f4e7c62470f Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 12:22:51 +0200 Subject: [PATCH 030/260] run email domain checks in parallel --- modules/security/src/main/VerifyMail.scala | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/modules/security/src/main/VerifyMail.scala b/modules/security/src/main/VerifyMail.scala index bb63ea998d783..d40b3e42b458c 100644 --- a/modules/security/src/main/VerifyMail.scala +++ b/modules/security/src/main/VerifyMail.scala @@ -53,12 +53,10 @@ final private class VerifyMail( export cache.invalidate private def fetch(domain: Domain.Lower): Fu[Boolean] = - fetchFree(domain) - .logFailure(logger) - .recover(_ => true) - .flatMap: - case false => fuccess(false) - case true => fetchPaid(domain) + List(fetchFree(domain), fetchPaid(domain)) + .map(_.logFailure(logger).recover(_ => true)) // fetch fail = domain ok + .parallel + .map(_.forall(identity)) // ok if both say the domain is ok private def fetchFree(domain: Domain.Lower): Fu[Boolean] = val url = s"https://api.mailcheck.ai/domain/$domain" From 081d1850cc9107dac6a58af2bdca2064f613ca37 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 12:41:13 +0200 Subject: [PATCH 031/260] auto-start broadcast round even if it has been connected before - set a start date in the future - connect now to fetch initial boards - automatic disconnection after inactivity - should re-start at start date --- modules/relay/src/main/RelayApi.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index ca32574e5ab97..88aad15d82c20 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -397,8 +397,8 @@ final class RelayApi( .$lt(nowInstant.plusSeconds(RelayDelay.maxSeconds.value)) .$gt(nowInstant.minusDays(1)), // bit late now "startedAt".$exists(false), - "sync.until".$exists(false), - "sync.upstream".$exists(true) + "sync.upstream".$exists(true), + $or("sync.until".$exists(false), "sync.until".$lt(nowInstant)) ) ) .flatMap: From 37396270996a0c41f080c37e092a4fb0319fb6f9 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 13:36:53 +0200 Subject: [PATCH 032/260] broadcast scala tweaks --- modules/relay/src/main/RelayFetch.scala | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index a8af78b678c2e..53e21d097c330 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -170,7 +170,7 @@ final private class RelayFetch( case urlOrLcc: Sync.FetchableUpstream => delayer(urlOrLcc, rt.round, fetchFromUpstream) case Sync.UpstreamUrls(urls) => urls - .traverse: url => + .parallel: url => delayer(url, rt.round, fetchFromUpstream) .map(_.flatten.toVector) @@ -193,9 +193,7 @@ final private class RelayFetch( } .flatMap(multiPgnToGames(_).toFuture) - private def fetchFromUpstream(using - canProxy: CanProxy - )(upstream: Sync.FetchableUpstream, max: Max): Fu[RelayGames] = + private def fetchFromUpstream(upstream: Sync.FetchableUpstream, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* formatApi .get(upstream) @@ -322,7 +320,7 @@ private object RelayFetch: CacheApi .scaffeineNoScheduler(using scala.concurrent.ExecutionContextOpportunistic) .expireAfterAccess(2 minutes) - .maximumSize(512) + .maximumSize(1024) .build(compute) private def compute(pgn: PgnStr): Either[LilaInvalid, RelayGame] = From 1e9845fcc60d8c00bf075d449a9c0d911d7beeb3 Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Sun, 23 Jun 2024 15:56:52 +0200 Subject: [PATCH 033/260] Display challenge rules --- .../challenge/src/main/ui/ChallengeUi.scala | 60 +++++++++++----- ui/challenge/css/_page.scss | 71 +++++++++++++------ 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/modules/challenge/src/main/ui/ChallengeUi.scala b/modules/challenge/src/main/ui/ChallengeUi.scala index f49a310b9633f..38792341732d0 100644 --- a/modules/challenge/src/main/ui/ChallengeUi.scala +++ b/modules/challenge/src/main/ui/ChallengeUi.scala @@ -8,6 +8,7 @@ import ScalatagsTemplate.{ *, given } import lila.core.LightUser import lila.challenge.Challenge.Status import lila.core.user.WithPerf +import lila.core.game.GameRule final class ChallengeUi(helpers: Helpers): import helpers.{ *, given } @@ -50,29 +51,54 @@ final class ChallengeUi(helpers: Helpers): s"$speed$variant ${c.mode.name} Chess • $players" private def details(c: Challenge, requestedColor: Option[Color])(using ctx: Context) = - div(cls := "details")( - div( - cls := "variant", - dataIcon := (if c.initialFen.isDefined then Icon.Feather else c.perfType.icon) - )( + val rulesSeq = c.rules.toSeq; + div(cls := "details-wrapper")( + div(cls := "content")( div( - variantLink(c.variant, c.perfType, c.initialFen), - br, - span(cls := "clock"): - c.daysPerTurn - .fold(shortClockName(c.clock.map(_.config))): days => - if days.value == 1 then trans.site.oneDay() - else trans.site.nbDays.pluralSame(days.value) + cls := "variant", + dataIcon := (if c.initialFen.isDefined then Icon.Feather else c.perfType.icon) + )( + div( + variantLink(c.variant, c.perfType, c.initialFen), + br, + span(cls := "clock"): + c.daysPerTurn + .fold(shortClockName(c.clock.map(_.config))): days => + if days.value == 1 then trans.site.oneDay() + else trans.site.nbDays.pluralSame(days.value) + ) + ), + div(cls := "mode")( + c.open.fold(c.colorChoice.some)(_.colorFor(requestedColor)).map { colorChoice => + frag(colorChoice.trans(), br) + }, + modeName(c.mode) ) ), - div(cls := "mode")( - c.open.fold(c.colorChoice.some)(_.colorFor(requestedColor)).map { colorChoice => - frag(colorChoice.trans(), br) - }, - modeName(c.mode) + div(cls := "rules")( + h6("Rules"), + div( + rulesSeq.zipWithIndex.map { case (r, i) => rule(r, i == rulesSeq.length - 1) } + ) ) ) + private def rule(r: GameRule, isLast: Boolean) = + val (text, icon) = getRuleStyle(r); + div(cls := "challenge-rule")( + span(cls := "text", dataIcon := icon)(text), + span(text), + if !isLast then span("/", cls := "separator") else span() + ) + + private def getRuleStyle(r: GameRule): (String, Icon) = + r match + case GameRule.noAbort => ("Abort not allowed", Icon.X); + case GameRule.noRematch => ("No rematch", Icon.InfoCircle); + case GameRule.noGiveTime => ("No giving of time", Icon.Clock); + case GameRule.noClaimWin => ("No claiming of win", Icon.InfoCircle); + case GameRule.noEarlyDraw => ("Early draw not allowed", Icon.OneHalf); + def mine( c: Challenge, json: JsObject, diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index a71e709c74c21..fb2fb07cc0810 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -4,7 +4,7 @@ gap: $block-gap; grid-template-columns: repeat(auto-fill, minmax(25em, 1fr)); - > div { + >div { @extend %box-neat; padding: $block-gap; background: $c-bg-zebra; @@ -56,6 +56,7 @@ .correspondence-waiting { font-size: 1.5em; margin: 2em auto; + &::before { color: $c-good; } @@ -67,8 +68,8 @@ text-align: center; } - .details { - @extend %flex-between, %box-neat; + .details-wrapper { + @extend %box-neat; ---font: #{$c-secondary}; ---bg: #{$m-secondary_bg--mix-10}; @@ -84,34 +85,62 @@ background: var(---bg); border: 1px solid var(---font); - > div { - flex: 0 1 auto; - @extend %flex-center, %roboto; + .content { + @extend %flex-between; - &::before { - color: var(---font); - font-size: 6rem; - margin-inline-end: 0.2em; + >div { + flex: 0 1 auto; + @extend %flex-center, %roboto; + + &::before { + color: var(---font); + font-size: 6rem; + margin-inline-end: 0.2em; - @media (max-width: at-most($xx-small)) { - display: none; + @media (max-width: at-most($xx-small)) { + display: none; + } + } + + div { + line-height: 1.4; } - } - div { - line-height: 1.4; + .clock { + font-weight: bold; + } } - .clock { + .mode { + text-align: end; font-weight: bold; + font-size: 0.8em; + color: var(---font); } } - .mode { - text-align: end; - font-weight: bold; - font-size: 0.8em; - color: var(---font); + .rules { + margin-top: 1.5rem; + + >div { + @extend %flex-center; + margin-top: 1rem; + + .challenge-rule { + @extend %flex-center; + margin-bottom: 0.75rem; + + .text { + font-size: 0.575em; + } + + .separator { + padding: 0 0.5rem 0 0.5rem; + font-weight: bold; + font-size: 0.625em; + } + } + } } } From 5cd5079c4003c3ffbae45e115668c081f2645dbc Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Sun, 23 Jun 2024 16:13:31 +0200 Subject: [PATCH 034/260] Apply formatting --- ui/challenge/css/_page.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index fb2fb07cc0810..a08603016d460 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -4,7 +4,7 @@ gap: $block-gap; grid-template-columns: repeat(auto-fill, minmax(25em, 1fr)); - >div { + > div { @extend %box-neat; padding: $block-gap; background: $c-bg-zebra; @@ -88,7 +88,7 @@ .content { @extend %flex-between; - >div { + > div { flex: 0 1 auto; @extend %flex-center, %roboto; @@ -122,7 +122,7 @@ .rules { margin-top: 1.5rem; - >div { + > div { @extend %flex-center; margin-top: 1rem; From 4e174a02eed3f0c9dea79957e6a8d5762bdf604c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 16:27:26 +0200 Subject: [PATCH 035/260] refactor relay sync - requires db migration --- bin/mongodb/relay-lcc-migrate-2.js | 21 ++++ modules/common/src/main/Json.scala | 4 +- modules/relay/src/main/BSONHandlers.scala | 27 ++--- modules/relay/src/main/JsonView.scala | 7 +- modules/relay/src/main/RelayApi.scala | 3 +- modules/relay/src/main/RelayDelay.scala | 35 +++--- modules/relay/src/main/RelayFetch.scala | 43 +++---- modules/relay/src/main/RelayFormat.scala | 97 ++++----------- modules/relay/src/main/RelayRound.scala | 44 +++---- modules/relay/src/main/RelayRoundForm.scala | 128 ++++++++------------ modules/relay/src/main/ui/FormUi.scala | 53 +++----- modules/ui/src/main/scalatags.scala | 3 + 12 files changed, 181 insertions(+), 284 deletions(-) create mode 100644 bin/mongodb/relay-lcc-migrate-2.js diff --git a/bin/mongodb/relay-lcc-migrate-2.js b/bin/mongodb/relay-lcc-migrate-2.js new file mode 100644 index 0000000000000..e22fabf0e32f8 --- /dev/null +++ b/bin/mongodb/relay-lcc-migrate-2.js @@ -0,0 +1,21 @@ +const lccUrl = (id, round) => `https://view.livechesscloud.com/#${id}/${round}`; + +db.relay.find({ 'sync.upstream.lcc': { $exists: 1 } }).sort({ $natural: -1 }).limit(30).forEach(relay => { + + db.relay.updateOne({ _id: relay._id }, { + $set: { + 'sync.upstream': { url: lccUrl(relay.sync.upstream.lcc, relay.sync.upstream.round) } + } + }); +}); + +db.relay.find({ 'sync.upstream.urls': { $exists: 1 } }).sort({ $natural: -1 }).limit(30).forEach(relay => { + + db.relay.updateOne({ _id: relay._id }, { + $set: { + 'sync.upstream.urls': relay.sync.upstream.urls.map(url => + url.lcc ? lccUrl(url.lcc, url.round) : url.url + ) + } + }); +}); diff --git a/modules/common/src/main/Json.scala b/modules/common/src/main/Json.scala index 56b24cdfc8026..a995bb66e7883 100644 --- a/modules/common/src/main/Json.scala +++ b/modules/common/src/main/Json.scala @@ -1,8 +1,8 @@ package lila.common import play.api.libs.json.{ Json as PlayJson, * } - import scala.util.NotGiven +import io.mola.galimatias.URL object Json: @@ -18,6 +18,8 @@ object Json: given Writes[PerfKey] = pk => JsString(PerfKey.value(pk)) + given Writes[URL] = url => JsString(url.toString) + given [A](using Show[A]): KeyWrites[A] with def writeKey(key: A) = key.show diff --git a/modules/relay/src/main/BSONHandlers.scala b/modules/relay/src/main/BSONHandlers.scala index 4c6da447cce59..e7401f87f95d8 100644 --- a/modules/relay/src/main/BSONHandlers.scala +++ b/modules/relay/src/main/BSONHandlers.scala @@ -10,34 +10,21 @@ object BSONHandlers: given BSONHandler[RelayTeamsTextarea] = stringAnyValHandler(_.text, RelayTeamsTextarea(_)) import RelayRound.Sync - import Sync.{ Upstream, UpstreamIds, UpstreamUrl, UpstreamLcc, UpstreamUrls, FetchableUpstream } - given upstreamUrlHandler: BSONDocumentHandler[UpstreamUrl] = Macros.handler - given upstreamLccHandler: BSONDocumentHandler[UpstreamLcc] = Macros.handler - given BSONHandler[FetchableUpstream] = tryHandler( - { - case d: BSONDocument if d.contains("url") => upstreamUrlHandler.readTry(d) - case d: BSONDocument if d.contains("lcc") => upstreamLccHandler.readTry(d) - }, - { - case url: UpstreamUrl => upstreamUrlHandler.writeTry(url).get - case lcc: UpstreamLcc => upstreamLccHandler.writeTry(lcc).get - } - ) - given upstreamUrlsHandler: BSONDocumentHandler[UpstreamUrls] = Macros.handler - given upstreamIdsHandler: BSONDocumentHandler[UpstreamIds] = Macros.handler + import Sync.Upstream + given upstreamUrlHandler: BSONDocumentHandler[Upstream.Url] = Macros.handler + given upstreamUrlsHandler: BSONDocumentHandler[Upstream.Urls] = Macros.handler + given upstreamIdsHandler: BSONDocumentHandler[Upstream.Ids] = Macros.handler given BSONHandler[Upstream] = tryHandler( { case d: BSONDocument if d.contains("url") => upstreamUrlHandler.readTry(d) - case d: BSONDocument if d.contains("lcc") => upstreamLccHandler.readTry(d) case d: BSONDocument if d.contains("urls") => upstreamUrlsHandler.readTry(d) case d: BSONDocument if d.contains("ids") => upstreamIdsHandler.readTry(d) }, { - case url: UpstreamUrl => upstreamUrlHandler.writeTry(url).get - case lcc: UpstreamLcc => upstreamLccHandler.writeTry(lcc).get - case urls: UpstreamUrls => upstreamUrlsHandler.writeTry(urls).get - case ids: UpstreamIds => upstreamIdsHandler.writeTry(ids).get + case url: Upstream.Url => upstreamUrlHandler.writeTry(url).get + case urls: Upstream.Urls => upstreamUrlsHandler.writeTry(urls).get + case ids: Upstream.Ids => upstreamIdsHandler.writeTry(ids).get } ) diff --git a/modules/relay/src/main/JsonView.scala b/modules/relay/src/main/JsonView.scala index a55f881f95913..2b1ca1bae56b6 100644 --- a/modules/relay/src/main/JsonView.scala +++ b/modules/relay/src/main/JsonView.scala @@ -179,7 +179,6 @@ object JsonView: ) .add("delay" -> s.delay) ++ s.upstream.so: - case Sync.UpstreamUrl(url) => Json.obj("url" -> url) - case Sync.UpstreamLcc(url, round) => Json.obj("url" -> url, "round" -> round) - case Sync.UpstreamUrls(urls) => Json.obj("urls" -> urls.map(_.viewUrl)) - case Sync.UpstreamIds(ids) => Json.obj("ids" -> ids) + case Sync.Upstream.Url(url) => Json.obj("url" -> url) + case Sync.Upstream.Urls(urls) => Json.obj("urls" -> urls) + case Sync.Upstream.Ids(ids) => Json.obj("ids" -> ids) diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 88aad15d82c20..328b99f7737c0 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -245,7 +245,8 @@ final class RelayApi( def requestPlay(id: RelayRoundId, v: Boolean): Funit = WithRelay(id): relay => relay.sync.upstream.collect: - case f: Sync.FetchableUpstream => formatApi.refresh(f) + case Sync.Upstream.Url(url) => formatApi.refresh(url) + case Sync.Upstream.Urls(urls) => urls.foreach(formatApi.refresh) isOfficial(relay.id).flatMap: official => update(relay): r => if v diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index 192442efa09d9..b03a49cb55cc6 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -1,12 +1,11 @@ package lila.relay import chess.format.pgn.PgnStr - +import io.mola.galimatias.URL import scalalib.model.Seconds import lila.db.dsl.{ *, given } import lila.memo.CacheApi -import lila.relay.RelayRound.Sync.FetchableUpstream import lila.study.MultiPgn final private class RelayDelay(colls: RelayColls)(using Executor): @@ -14,9 +13,9 @@ final private class RelayDelay(colls: RelayColls)(using Executor): import RelayDelay.* def apply( - url: FetchableUpstream, + url: URL, round: RelayRound, - doFetchUrl: (FetchableUpstream, Max) => Fu[RelayGames] + doFetchUrl: (URL, Max) => Fu[RelayGames] ): Fu[RelayGames] = dedupCache(url, round, () => doFetchUrl(url, RelayFetch.maxChapters)) .flatMap: latest => @@ -31,13 +30,13 @@ final private class RelayDelay(colls: RelayColls)(using Executor): private val cache = CacheApi.scaffeineNoScheduler .initialCapacity(8) .maximumSize(128) - .build[FetchableUpstream, GamesSeenBy]() + .build[String, GamesSeenBy]() .underlying - def apply(url: FetchableUpstream, round: RelayRound, doFetch: () => Fu[RelayGames]) = + def apply(url: URL, round: RelayRound, doFetch: () => Fu[RelayGames]) = cache.asMap .compute( - url, + url.toString, (_, v) => Option(v) match case Some(GamesSeenBy(games, seenBy)) if !seenBy(round.id) => @@ -51,31 +50,31 @@ final private class RelayDelay(colls: RelayColls)(using Executor): private object store: - private def idOf(upstream: FetchableUpstream, at: Instant) = s"${upstream.viewUrl} ${at.toSeconds}" - private val longPast = java.time.Instant.ofEpochMilli(0) + private def idOf(url: URL, at: Instant) = s"$url ${at.toSeconds}" + private val longPast = java.time.Instant.ofEpochMilli(0) - def putIfNew(upstream: FetchableUpstream, games: RelayGames): Funit = + def putIfNew(url: URL, games: RelayGames): Funit = val newPgn = RelayGame.iso.from(games).toPgnStr - getLatestPgn(upstream).flatMap: + getLatestPgn(url).flatMap: case Some(latestPgn) if latestPgn == newPgn => funit case _ => val now = nowInstant - val doc = $doc("_id" -> idOf(upstream, now), "at" -> now, "pgn" -> newPgn) + val doc = $doc("_id" -> idOf(url, now), "at" -> now, "pgn" -> newPgn) colls.delay: _.insert.one(doc).void - def get(upstream: FetchableUpstream, delay: Seconds): Fu[Option[RelayGames]] = - getPgn(upstream, delay).map2: pgn => + def get(url: URL, delay: Seconds): Fu[Option[RelayGames]] = + getPgn(url, delay).map2: pgn => RelayGame.iso.to(MultiPgn.split(pgn, Max(999))) - private def getLatestPgn(upstream: FetchableUpstream): Fu[Option[PgnStr]] = - getPgn(upstream, Seconds(0)) + private def getLatestPgn(url: URL): Fu[Option[PgnStr]] = + getPgn(url, Seconds(0)) - private def getPgn(upstream: FetchableUpstream, delay: Seconds): Fu[Option[PgnStr]] = + private def getPgn(url: URL, delay: Seconds): Fu[Option[PgnStr]] = colls.delay: _.find( $doc( - "_id".$gt(idOf(upstream, longPast)).$lte(idOf(upstream, nowInstant.minusSeconds(delay.value))) + "_id".$gt(idOf(url, longPast)).$lte(idOf(url, nowInstant.minusSeconds(delay.value))) ), $doc("pgn" -> true).some ).sort($sort.desc("_id")) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 53e21d097c330..8a3c1f61e3fda 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -166,9 +166,9 @@ final private class RelayFetch( private def fetchGames(rt: RelayRound.WithTour): Fu[RelayGames] = given CanProxy = CanProxy(rt.tour.official) rt.round.sync.upstream.so: - case Sync.UpstreamIds(ids) => fetchFromGameIds(rt.tour, ids) - case urlOrLcc: Sync.FetchableUpstream => delayer(urlOrLcc, rt.round, fetchFromUpstream) - case Sync.UpstreamUrls(urls) => + case Sync.Upstream.Ids(ids) => fetchFromGameIds(rt.tour, ids) + case Sync.Upstream.Url(url) => delayer(url, rt.round, fetchFromUpstream) + case Sync.Upstream.Urls(urls) => urls .parallel: url => delayer(url, rt.round, fetchFromUpstream) @@ -193,45 +193,32 @@ final private class RelayFetch( } .flatMap(multiPgnToGames(_).toFuture) - private def fetchFromUpstream(upstream: Sync.FetchableUpstream, max: Max)(using CanProxy): Fu[RelayGames] = + private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* formatApi - .get(upstream) + .get(url) .flatMap { - case RelayFormat.SingleFile(doc) => - doc.format match - // all games in a single PGN file - case RelayFormat.DocFormat.Pgn => httpGetPgn(doc.url).map { MultiPgn.split(_, max) } - // maybe a single JSON game? Why not - case RelayFormat.DocFormat.Json => - httpGetJson[GameJson](doc.url).map: game => - MultiPgn(List(game.toPgn())) - case RelayFormat.ManyFiles(indexUrl, makeGameDoc) => - httpGetJson[RoundJson](indexUrl).flatMap: round => + case RelayFormat.SingleFile(url) => httpGetPgn(url).map { MultiPgn.split(_, max) } + case RelayFormat.LccWithGames(lcc) => + httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => round.pairings.zipWithIndex .map: (pairing, i) => - val number = i + 1 - val gameDoc = makeGameDoc(number) - gameDoc.format - .match - case RelayFormat.DocFormat.Pgn => httpGetPgn(gameDoc.url) - case RelayFormat.DocFormat.Json => - httpGetJson[GameJson](gameDoc.url) - .recover: - case _: Exception => GameJson(moves = Nil, result = none) - .map { _.toPgn(pairing.tags) } + val number = i + 1 + httpGetJson[GameJson](lcc.gameUrl(number)) + .recover: + case _: Exception => GameJson(moves = Nil, result = none) + .map { _.toPgn(pairing.tags) } .recover: _ => PgnStr(s"${pairing.tags}\n\n${pairing.result}") .map(number -> _) .parallel .map: results => MultiPgn(results.sortBy(_._1).map(_._2)) - case RelayFormat.ManyFilesLater(indexUrl) => - httpGetJson[RoundJson](indexUrl).map: round => + case RelayFormat.LccWithoutGames(lcc) => + httpGetJson[RoundJson](lcc.indexUrl).map: round => MultiPgn: round.pairings.map: pairing => PgnStr(s"${pairing.tags}\n\n${pairing.result}") - } .flatMap { multiPgnToGames(_).toFuture } diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index 0270c7a0d8436..daf2cd5874a3a 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -29,68 +29,35 @@ final private class RelayFormatApi( )(using Executor): import RelayFormat.* - import RelayRound.Sync.{ FetchableUpstream, UpstreamUrl, UpstreamLcc } - private val cache = cacheApi[(FetchableUpstream, CanProxy), RelayFormat](64, "relay.format"): + private val cache = cacheApi[(URL, CanProxy), RelayFormat](64, "relay.format"): _.expireAfterWrite(5 minutes) .buildAsyncFuture: (url, proxy) => guessFormat(url)(using proxy) - def get(upstream: FetchableUpstream)(using proxy: CanProxy): Fu[RelayFormat] = - cache.get(upstream -> proxy) + def get(url: URL)(using proxy: CanProxy): Fu[RelayFormat] = + cache.get(url -> proxy) - def refresh(upstream: FetchableUpstream): Unit = + def refresh(url: URL): Unit = CanProxy .from(List(false, true)) .foreach: proxy => - cache.invalidate(upstream -> proxy) - - private def guessFormat(upstream: FetchableUpstream)(using CanProxy): Fu[RelayFormat] = { - - def parsedUrl = URL.parse(upstream.fetchUrl) - - def guessLcc: Fu[Option[RelayFormat]] = upstream.isLcc.so(guessManyFiles(parsedUrl)) - - def guessSingleFile(url: URL): Fu[Option[RelayFormat]] = - List( - url.some, - (!url.pathSegments.contains(mostCommonSingleFileName)).option( - addPart(url, mostCommonSingleFileName) - ) - ).flatten.distinct - .findM(looksLikePgn) - .dmap2: (u: URL) => - SingleFile(pgnDoc(u)) - - def guessManyFiles(url: URL): Fu[Option[RelayFormat]] = - (List(url) ::: mostCommonIndexNames - .filterNot(url.pathSegments.contains) - .map(addPart(url, _))) - .findM(looksLikeJson) - .flatMapz: index => - val jsonUrl = (n: Int) => jsonDoc(replaceLastPart(index, s"game-$n.json")) - val pgnUrl = (n: Int) => pgnDoc(replaceLastPart(index, s"game-$n.pgn")) - looksLikeJson(jsonUrl(1).url) - .recover: - case NotFound(_) => false - .map(_.option(jsonUrl)) - .orElse: - looksLikePgn(pgnUrl(1).url) - .recover: - case NotFound(_) => false - .map(_.option(pgnUrl)) - .dmap2: - ManyFiles(index, _) - .dmap(_.orElse(ManyFilesLater(index).some)) - - guessLcc - .orElse(guessSingleFile(parsedUrl)) - .orElse(guessManyFiles(parsedUrl)) - .orFailWith(LilaInvalid(s"No games found at $upstream")) - - }.addEffect { format => - logger.info(s"guessed format of $upstream: $format") - } + cache.invalidate(url -> proxy) + + private def guessFormat(url: URL)(using CanProxy): Fu[RelayFormat] = + RelayRound.Sync.Upstream + .Url(url) + .lcc + .match + case Some(lcc) => + looksLikeJson(lcc.indexUrl).flatMapz: + looksLikeJson(lcc.gameUrl(1)).recoverDefault.map: + if _ then LccWithGames(lcc).some + else LccWithoutGames(lcc).some + case None => looksLikePgn(url).mapz(SingleFile(url).some) + .orFailWith(LilaInvalid(s"No games found at $url")) + .addEffect: format => + logger.info(s"guessed format of $url: $format") private[relay] def httpGet(url: URL)(using CanProxy): Fu[String] = httpGetResponse(url).map(_.body) @@ -158,30 +125,12 @@ private object RelayFormat: opaque type CanProxy = Boolean object CanProxy extends YesNo[CanProxy] - enum DocFormat: - case Json, Pgn + case class SingleFile(url: URL) extends RelayFormat - case class RemoteDoc(url: URL, format: DocFormat) - - def jsonDoc(url: URL) = RemoteDoc(url, DocFormat.Json) - def pgnDoc(url: URL) = RemoteDoc(url, DocFormat.Pgn) - - case class SingleFile(doc: RemoteDoc) extends RelayFormat - - type GameNumberToDoc = Int => RemoteDoc - - case class ManyFiles(jsonIndex: URL, game: GameNumberToDoc) extends RelayFormat: - override def toString = s"Manyfiles($jsonIndex, ${game(0)})" + case class LccWithGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat // there will be game files with names like "game-1.json" or "game-1.pgn" // but not at the moment. The index is still useful. - case class ManyFilesLater(jsonIndex: URL) extends RelayFormat: - override def toString = s"ManyfilesLater($jsonIndex)" - - def addPart(url: URL, part: String) = url.withPath(s"${url.path}/$part") - def replaceLastPart(url: URL, withPart: String) = url.withPath(s"${url.path}/../$withPart") - - val mostCommonSingleFileName = "games.pgn" - val mostCommonIndexNames = List("round.json", "index.json") + case class LccWithoutGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat case class NotFound(message: String) extends LilaException diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index fda69e667438a..781d88444563f 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -5,6 +5,7 @@ import reactivemongo.api.bson.Macros.Annotations.Key import scalalib.model.Seconds import lila.study.Study +import io.mola.galimatias.URL case class RelayRound( /* Same as the Study id it refers to */ @@ -114,30 +115,25 @@ object RelayRound: override def toString = upstream.toString object Sync: - sealed trait Upstream: - def isLcc = false - sealed trait FetchableUpstream extends Upstream: - def fetchUrl: String - def viewUrl: String - case class UpstreamUrl(url: String) extends FetchableUpstream: - def fetchUrl = url - def viewUrl = url - case class UpstreamUrls(urls: List[FetchableUpstream]) extends Upstream - case class UpstreamIds(ids: List[GameId]) extends Upstream - case class UpstreamLcc(lcc: String, round: Int) extends FetchableUpstream: - override def isLcc = true - def id = lcc - def fetchUrl = s"http://1.pool.livechesscloud.com/get/$id/round-$round/index.json" - def viewUrl = s"https://view.livechesscloud.com/#$id/$round" - object UpstreamLcc: - private val idRegex = """view\.livechesscloud\.com/?#?([0-9a-f\-]+)""".r.unanchored - def findId(url: String): Option[String] = url match - case idRegex(id) => id.some - case _ => none - def find(url: String): Option[UpstreamLcc] = - url.trim.replace(" ", "/").split('/') match - case Array(idRegex(id), round) => round.toIntOption.map(UpstreamLcc(id, _)) - case _ => none + enum Upstream: + case Url(url: URL) extends Upstream + case Urls(urls: List[URL]) extends Upstream + case Ids(ids: List[GameId]) extends Upstream + def lcc: Option[Lcc] = this match + case Url(url) => + url.toString match + case lccRegex(id, round) => round.toIntOption.map(Lcc(id, _)) + case _ => none + case _ => none + def isLcc = lcc.isDefined + + case class Lcc(id: String, round: Int): + def pageUrl = URL.parse(s"https://view.livechesscloud.com/#$id/$round") + def indexUrl = URL.parse(s"http://1.pool.livechesscloud.com/get/$id/round-$round/index.json") + def gameUrl(game: Int) = + URL.parse(s"http://1.pool.livechesscloud.com/get/$id/round-$round/game-$game.json") + + private val lccRegex = """view\.livechesscloud\.com/?#?([0-9a-f\-]+)/(\d+)""".r.unanchored trait AndTour: val tour: RelayTour diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index 6260a9c1cb81c..f9182510b8d11 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -12,24 +12,25 @@ import lila.common.Form.{ cleanText, into, stringIn, formatter } import lila.core.perm.Granter import lila.relay.RelayRound.Sync -import lila.relay.RelayRound.Sync.UpstreamUrl +import lila.relay.RelayRound.Sync.Upstream final class RelayRoundForm(using mode: Mode): import RelayRoundForm.* import lila.common.Form.ISOInstantOrTimestamp - private given Formatter[Sync.UpstreamUrl] = formatter.stringTryFormatter(validateUpstreamUrl, _.fetchUrl) - private given Formatter[Sync.UpstreamUrls] = formatter.stringTryFormatter( + private given Formatter[Upstream.Url] = + formatter.stringTryFormatter(str => validateUpstreamUrl(str).map(Upstream.Url.apply), _.url.toString) + private given Formatter[Upstream.Urls] = formatter.stringTryFormatter( _.linesIterator.toList .map(_.trim) .filter(_.nonEmpty) - .traverse(validateUpstreamUrlOrLcc) + .traverse(validateUpstreamUrl) .map(_.distinct) - .map(Sync.UpstreamUrls.apply), - _.urls.map(_.viewUrl).mkString("\n") + .map(Upstream.Urls.apply), + _.urls.mkString("\n") ) - private given Formatter[Sync.UpstreamIds] = formatter.stringTryFormatter( + private given Formatter[Upstream.Ids] = formatter.stringTryFormatter( _.split(' ').toList .map(_.trim) .traverse: i => @@ -38,32 +39,29 @@ final class RelayRoundForm(using mode: Mode): .map(_.mkString(", ")) .filterOrElse(_.sizeIs <= RelayFetch.maxChapters.value, s"Max games: ${RelayFetch.maxChapters}") .map(_.distinct) - .map(Sync.UpstreamIds.apply), + .map(Upstream.Ids.apply), _.ids.mkString(" ") ) - val lccMapping = mapping( - "id" -> cleanText(minLength = 10, maxLength = 100).transform( - str => Sync.UpstreamLcc.findId(str).getOrElse(str), - identity - ), - "round" -> number(min = 1, max = 999) - )(Sync.UpstreamLcc.apply)(unapply) + private def lccIsComplete(url: Upstream.Url) = + url.isLcc || !url.url.host.toString.contains("livechesscloud.com") val roundMapping = mapping( "name" -> cleanText(minLength = 3, maxLength = 80).into[RelayRound.Name], "caption" -> optional(cleanText(minLength = 3, maxLength = 80).into[RelayRound.Caption]), "syncSource" -> optional(stringIn(sourceTypes.map(_._1).toSet)), - "syncUrl" -> optional(of[Sync.UpstreamUrl]), - "syncUrls" -> optional(of[Sync.UpstreamUrls]), - "syncLcc" -> optional(lccMapping), - "syncIds" -> optional(of[Sync.UpstreamIds]), - "startsAt" -> optional(ISOInstantOrTimestamp.mapping), - "finished" -> optional(boolean), - "period" -> optional(number(min = 2, max = 60).into[Seconds]), - "delay" -> optional(number(min = 0, max = RelayDelay.maxSeconds.value).into[Seconds]), - "onlyRound" -> optional(number(min = 1, max = 999)), + "syncUrl" -> optional( + of[Upstream.Url] + .verifying("LCC URLs must end with /{round-number}, e.g. /5 for round 5", lccIsComplete) + ), + "syncUrls" -> optional(of[Upstream.Urls]), + "syncIds" -> optional(of[Upstream.Ids]), + "startsAt" -> optional(ISOInstantOrTimestamp.mapping), + "finished" -> optional(boolean), + "period" -> optional(number(min = 2, max = 60).into[Seconds]), + "delay" -> optional(number(min = 0, max = RelayDelay.maxSeconds.value).into[Seconds]), + "onlyRound" -> optional(number(min = 1, max = 999)), "slices" -> optional: nonEmptyText .transform[List[RelayGame.Slice]](RelayGame.Slices.parse, RelayGame.Slices.show) @@ -84,7 +82,6 @@ object RelayRoundForm: val sourceTypes = List( "url" -> "Single PGN URL", "urls" -> "Combine several PGN URLs", - "lcc" -> "LiveChessCloud page", "ids" -> "Lichess game IDs", "push" -> "Push local games" ) @@ -111,22 +108,23 @@ object RelayRoundForm: roundNumberIn(old.name.value).contains(n - 1) p <- prev yield replaceRoundNumber(p.name.value, nextNumber) - val nextLcc: Option[Sync.UpstreamLcc] = prev - .flatMap(_.sync.upstream) - .flatMap: - case lcc: Sync.UpstreamLcc => lcc.copy(round = nextNumber).some - case _ => none val guessDate = for (prev, old) <- prevs prevDate <- prev.startsAt oldDate <- old.startsAt delta = prevDate.toEpochMilli - oldDate.toEpochMilli yield prevDate.plusMillis(delta) + val nextUrl: Option[Upstream.Url] = for + p <- prev + up <- p.sync.upstream + lcc <- up.lcc + if prevNumber.contains(lcc.round) + yield Upstream.Url(lcc.copy(round = nextNumber).pageUrl) Data( name = RelayRound.Name(guessName | s"Round ${nextNumber}"), caption = prev.flatMap(_.caption), syncSource = prev.map(Data.make).flatMap(_.syncSource), - syncLcc = nextLcc, + syncUrl = nextUrl, startsAt = guessDate, period = prev.flatMap(_.sync.period), delay = prev.flatMap(_.sync.delay), @@ -140,7 +138,7 @@ object RelayRoundForm: val list = ids.split(' ').view.flatMap(i => GameId.from(i.trim)).toList (list.sizeIs > 0 && list.sizeIs <= RelayFetch.maxChapters.value).option(GameIds(list)) - private def cleanUrl(source: String)(using mode: Mode): Option[String] = + private def cleanUrl(source: String)(using mode: Mode): Option[URL] = for url <- Try(URL.parse(source)).toOption if url.scheme == "http" || url.scheme == "https" @@ -148,26 +146,18 @@ object RelayRoundForm: // prevent common mistakes (not for security) if mode.notProd || !blocklist.exists(subdomain(host, _)) if !subdomain(host, "chess.com") || url.toString.startsWith("https://api.chess.com/pub") - yield url.toString.stripSuffix("/") + yield url - private def validateUpstreamUrlOrLcc(s: String)(using Mode): Either[String, Sync.FetchableUpstream] = - Sync.UpstreamLcc.find(s) match - case Some(lcc) => Right(lcc) - case None => validateUpstreamUrl(s) - - private def validateUpstreamUrl(s: String)(using Mode): Either[String, Sync.UpstreamUrl] = for + private def validateUpstreamUrl(s: String)(using Mode): Either[String, URL] = for url <- cleanUrl(s).toRight("Invalid source URL") url <- if !validSourcePort(url) then Left("The source URL cannot specify a port") else Right(url) - yield Sync.UpstreamUrl(url) + yield url - private def cleanUrls(source: String)(using mode: Mode): Option[List[String]] = + private def cleanUrls(source: String)(using mode: Mode): Option[List[URL]] = source.linesIterator.toList.flatMap(cleanUrl).some.filter(_.nonEmpty) - private val validPorts = Set(-1, 80, 443, 8080, 8491) - private def validSourcePort(source: String)(using mode: Mode): Boolean = - mode.notProd || - Try(URL.parse(source)).toOption.forall: url => - validPorts(url.port) + private val validPorts = Set(-1, 80, 443, 8080, 8491) + private def validSourcePort(url: URL)(using mode: Mode): Boolean = mode.notProd || validPorts(url.port) private def subdomain(host: String, domain: String) = s".$host".endsWith(s".$domain") @@ -195,10 +185,9 @@ object RelayRoundForm: name: RelayRound.Name, caption: Option[RelayRound.Caption], syncSource: Option[String], - syncUrl: Option[Sync.UpstreamUrl] = None, - syncUrls: Option[Sync.UpstreamUrls] = None, - syncLcc: Option[Sync.UpstreamLcc] = None, - syncIds: Option[Sync.UpstreamIds] = None, + syncUrl: Option[Upstream.Url] = None, + syncUrls: Option[Upstream.Urls] = None, + syncIds: Option[Upstream.Ids] = None, startsAt: Option[Instant] = None, finished: Option[Boolean] = None, period: Option[Seconds] = None, @@ -206,22 +195,12 @@ object RelayRoundForm: onlyRound: Option[Int] = None, slices: Option[List[RelayGame.Slice]] = None ): - def upstream: Option[Sync.Upstream] = syncSource - .match - case None => syncUrl.orElse(syncUrls).orElse(syncIds) - case Some("url") => syncUrl - case Some("urls") => syncUrls - case Some("lcc") => syncLcc - case Some("ids") => syncIds - case _ => None - .map: - case url: Sync.UpstreamUrl => - val foundLcc = for - lccId <- Sync.UpstreamLcc.findId(url.url) - round <- roundNumberIn(name.value) - yield Sync.UpstreamLcc(lccId, round) - foundLcc | url - case up => up + def upstream: Option[Upstream] = syncSource.match + case None => syncUrl.orElse(syncUrls).orElse(syncIds) + case Some("url") => syncUrl + case Some("urls") => syncUrls + case Some("ids") => syncIds + case _ => None def update(official: Boolean)(relay: RelayRound)(using me: Me)(using mode: Mode) = val sync = makeSync(me) @@ -267,20 +246,17 @@ object RelayRoundForm: caption = relay.caption, syncSource = relay.sync.upstream .fold("push"): - case _: Sync.UpstreamUrl => "url" - case _: Sync.UpstreamUrls => "urls" - case _: Sync.UpstreamLcc => "lcc" - case _: Sync.UpstreamIds => "ids" + case _: Upstream.Url => "url" + case _: Upstream.Urls => "urls" + case _: Upstream.Ids => "ids" .some, syncUrl = relay.sync.upstream.collect: - case url: Sync.UpstreamUrl => url, + case url: Upstream.Url => url, syncUrls = relay.sync.upstream.collect: - case url: Sync.UpstreamUrl => Sync.UpstreamUrls(List(url)) - case urls: Sync.UpstreamUrls => urls, - syncLcc = relay.sync.upstream.collect: - case lcc: Sync.UpstreamLcc => lcc, + case url: Upstream.Url => Upstream.Urls(List(url.url)) + case urls: Upstream.Urls => urls, syncIds = relay.sync.upstream.collect: - case ids: Sync.UpstreamIds => ids, + case ids: Upstream.Ids => ids, startsAt = relay.startsAt, finished = relay.finished.option(true), period = relay.sync.period, diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 5b32781e2512a..982321ece21c3 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -161,46 +161,23 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): form("syncSource"), "Where do the games come from?" )(form3.select(_, RelayRoundForm.sourceTypes)), - form3.group( - form("syncUrl"), - trb.sourceSingleUrl(), - help = trb.sourceUrlHelp().some - )(form3.input(_))(cls := "relay-form__sync relay-form__sync-url"), - div(cls := "relay-form__sync relay-form__sync-lcc none"): - val lccUrl = round - .flatMap(_.sync.upstream) - .collect: - case lcc: RelayRound.Sync.UpstreamLcc => lcc.viewUrl - frag( - (!Granter.opt(_.Relay)).option( - flashMessage("box")( - p(strong("Please use the ", a(href := broadcasterUrl)("Lichess Broadcaster App"))), - p( - "LiveChessCloud support is deprecated and will be removed soon.", - br, - "If you need help, please contact us at broadcast@lichess.org." - ) + div(cls := "relay-form__sync relay-form__sync-url")( + (round.flatMap(_.sync.upstream).exists(_.isLcc) && Granter.opt(_.Relay)).option( + flashMessage("box")( + p(strong("Please use the ", a(href := broadcasterUrl)("Lichess Broadcaster App"))), + p( + "LiveChessCloud support is deprecated and will be removed soon.", + br, + "If you need help, please contact us at broadcast@lichess.org." ) - ), - lccUrl.map(url => div(cls := "form-group")(a(href := url, targetBlank)(url))), - form3.split( - form3.group( - form("syncLcc.id"), - "Tournament ID", - help = frag( - "From the LCC page URL. The ID looks like this: ", - pre("f1943ec6-4992-45d9-969d-a0aff688b404") - ).some, - half = true - )(form3.input(_)), - form3.group( - form("syncLcc.round"), - trb.roundNumber(), - half = true - )(form3.input(_, typ = "number")) ) - ) - , + ), + form3.group( + form("syncUrl"), + trb.sourceSingleUrl(), + help = trb.sourceUrlHelp().some + )(form3.input(_)) + ), form3.group( form("syncUrls"), "Multiple source URLs, one per line.", diff --git a/modules/ui/src/main/scalatags.scala b/modules/ui/src/main/scalatags.scala index c787f6f03c46d..74dd0feed5603 100644 --- a/modules/ui/src/main/scalatags.scala +++ b/modules/ui/src/main/scalatags.scala @@ -7,6 +7,7 @@ import scalalib.Render import scalatags.Text.all.* import scalatags.Text.{ Aggregate, Cap, GenericAttr } import scalatags.text.Builder +import io.mola.galimatias.URL // collection of lila attrs trait ScalatagsAttrs: @@ -108,6 +109,7 @@ trait ScalatagsTemplate /* Convert play URLs to scalatags attributes with toString */ given GenericAttr[Call] = GenericAttr[Call] + given GenericAttr[URL] = GenericAttr[URL] object ScalatagsTemplate extends ScalatagsTemplate @@ -118,6 +120,7 @@ trait ScalatagsExtensions: export lila.core.perm.Granter given Render[Icon] = _.value + given Render[URL] = _.toString given [A](using Render[A]): Conversion[A, Frag] = a => StringFrag(a.render) From 8e4733f7de4c13fb9f22be888d80a3f01ac769cf Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 16:36:27 +0200 Subject: [PATCH 036/260] set the lcc round.game pgn tag --- modules/relay/src/main/RelayFetch.scala | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 8a3c1f61e3fda..5f47a6e14760d 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -201,24 +201,25 @@ final private class RelayFetch( case RelayFormat.SingleFile(url) => httpGetPgn(url).map { MultiPgn.split(_, max) } case RelayFormat.LccWithGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => - round.pairings.zipWithIndex - .map: (pairing, i) => - val number = i + 1 - httpGetJson[GameJson](lcc.gameUrl(number)) + round.pairings + .mapWithIndex: (pairing, i) => + val game = i + 1 + val tags = pairing.tags(lcc.round, game) + httpGetJson[GameJson](lcc.gameUrl(game)) .recover: case _: Exception => GameJson(moves = Nil, result = none) - .map { _.toPgn(pairing.tags) } + .map { _.toPgn(tags) } .recover: _ => - PgnStr(s"${pairing.tags}\n\n${pairing.result}") - .map(number -> _) + PgnStr(s"${tags}\n\n${pairing.result}") + .map(game -> _) .parallel .map: results => MultiPgn(results.sortBy(_._1).map(_._2)) case RelayFormat.LccWithoutGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).map: round => MultiPgn: - round.pairings.map: pairing => - PgnStr(s"${pairing.tags}\n\n${pairing.result}") + round.pairings.mapWithIndex: (pairing, i) => + PgnStr(s"${pairing.tags(lcc.round, i + 1)}\n\n${pairing.result}") } .flatMap { multiPgnToGames(_).toFuture } @@ -252,7 +253,7 @@ private object RelayFetch: result: Option[String] ): import chess.format.pgn.* - def tags = Tags: + def tags(round: Int, game: Int) = Tags: List( white.flatMap(_.fullName).map { Tag(_.White, _) }, white.flatMap(_.title).map { Tag(_.WhiteTitle, _) }, @@ -260,7 +261,8 @@ private object RelayFetch: black.flatMap(_.fullName).map { Tag(_.Black, _) }, black.flatMap(_.title).map { Tag(_.BlackTitle, _) }, black.flatMap(_.fideid).map { Tag(_.BlackFideId, _) }, - result.map(Tag(_.Result, _)) + result.map(Tag(_.Result, _)), + Tag(_.Round, s"$round.$game").some ).flatten case class RoundJson(pairings: List[RoundJsonPairing]) given Reads[PairingPlayer] = Json.reads From e82d276a5176538875b34747eeff1bb7dbff4a7d Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 16:56:39 +0200 Subject: [PATCH 037/260] fix /patron with currency not supported by paypal --- modules/plan/src/main/ui/PlanUi.scala | 23 ++++++++++++----------- ui/bits/src/bits.checkout.ts | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/modules/plan/src/main/ui/PlanUi.scala b/modules/plan/src/main/ui/PlanUi.scala index ee745b61324b6..5dcb2afb87cb8 100644 --- a/modules/plan/src/main/ui/PlanUi.scala +++ b/modules/plan/src/main/ui/PlanUi.scala @@ -32,18 +32,19 @@ final class PlanUi(helpers: Helpers)(contactEmail: EmailAddress): ctx.isAuth.option( frag( stripeScript, - frag( - // gotta load the paypal SDK twice, for onetime and subscription :facepalm: - // https://stackoverflow.com/questions/69024268/how-can-i-show-a-paypal-smart-subscription-button-and-a-paypal-smart-capture-but/69024269 - script( - src := s"https://www.paypal.com/sdk/js?client-id=${payPalPublicKey}¤cy=${pricing.currency}$localeParam", - namespaceAttr := "paypalOrder" - ), - script( - src := s"https://www.paypal.com/sdk/js?client-id=${payPalPublicKey}&vault=true&intent=subscription¤cy=${pricing.currency}$localeParam", - namespaceAttr := "paypalSubscription" + pricing.payPalSupportsCurrency.option: + frag( + // gotta load the paypal SDK twice, for onetime and subscription :facepalm: + // https://stackoverflow.com/questions/69024268/how-can-i-show-a-paypal-smart-subscription-button-and-a-paypal-smart-capture-but/69024269 + script( + src := s"https://www.paypal.com/sdk/js?client-id=${payPalPublicKey}¤cy=${pricing.currency}$localeParam", + namespaceAttr := "paypalOrder" + ), + script( + src := s"https://www.paypal.com/sdk/js?client-id=${payPalPublicKey}&vault=true&intent=subscription¤cy=${pricing.currency}$localeParam", + namespaceAttr := "paypalSubscription" + ) ) - ) ) ) .js(ctx.isAuth.option(embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey", $pricingJson)"""))) diff --git a/ui/bits/src/bits.checkout.ts b/ui/bits/src/bits.checkout.ts index b5df753870893..8ab608bb2e2fd 100644 --- a/ui/bits/src/bits.checkout.ts +++ b/ui/bits/src/bits.checkout.ts @@ -145,6 +145,7 @@ const payPalStyle = { }; function payPalOrderStart($checkout: Cash, pricing: Pricing, getAmount: () => number | undefined) { + if (!window.paypalOrder) return; (window.paypalOrder as any) .Buttons({ style: payPalStyle, @@ -173,6 +174,7 @@ function payPalOrderStart($checkout: Cash, pricing: Pricing, getAmount: () => nu } function payPalSubscriptionStart($checkout: Cash, pricing: Pricing, getAmount: () => number | undefined) { + if (!window.paypalSubscription) return; (window.paypalSubscription as any) .Buttons({ style: payPalStyle, From ec316c7aca3520d2d64eec54730007945c96f74d Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 17:05:55 +0200 Subject: [PATCH 038/260] pnpm format --- bin/mongodb/relay-lcc-migrate-2.js | 44 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/bin/mongodb/relay-lcc-migrate-2.js b/bin/mongodb/relay-lcc-migrate-2.js index e22fabf0e32f8..3abb84a21b373 100644 --- a/bin/mongodb/relay-lcc-migrate-2.js +++ b/bin/mongodb/relay-lcc-migrate-2.js @@ -1,21 +1,33 @@ const lccUrl = (id, round) => `https://view.livechesscloud.com/#${id}/${round}`; -db.relay.find({ 'sync.upstream.lcc': { $exists: 1 } }).sort({ $natural: -1 }).limit(30).forEach(relay => { - - db.relay.updateOne({ _id: relay._id }, { - $set: { - 'sync.upstream': { url: lccUrl(relay.sync.upstream.lcc, relay.sync.upstream.round) } - } +db.relay + .find({ 'sync.upstream.lcc': { $exists: 1 } }) + .sort({ $natural: -1 }) + .limit(30) + .forEach(relay => { + db.relay.updateOne( + { _id: relay._id }, + { + $set: { + 'sync.upstream': { url: lccUrl(relay.sync.upstream.lcc, relay.sync.upstream.round) }, + }, + }, + ); }); -}); - -db.relay.find({ 'sync.upstream.urls': { $exists: 1 } }).sort({ $natural: -1 }).limit(30).forEach(relay => { - db.relay.updateOne({ _id: relay._id }, { - $set: { - 'sync.upstream.urls': relay.sync.upstream.urls.map(url => - url.lcc ? lccUrl(url.lcc, url.round) : url.url - ) - } +db.relay + .find({ 'sync.upstream.urls': { $exists: 1 } }) + .sort({ $natural: -1 }) + .limit(30) + .forEach(relay => { + db.relay.updateOne( + { _id: relay._id }, + { + $set: { + 'sync.upstream.urls': relay.sync.upstream.urls.map(url => + url.lcc ? lccUrl(url.lcc, url.round) : url.url, + ), + }, + }, + ); }); -}); From e4edd82cd78a3228aeaffd2e22d62aa5566bc834 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 17:06:12 +0200 Subject: [PATCH 039/260] New Crowdin updates (#15578) * New translations: broadcast.xml (Persian) * New translations: broadcast.xml (Norwegian Bokmal) * New translations: broadcast.xml (Luxembourgish) * New translations: broadcast.xml (Afrikaans) * New translations: broadcast.xml (Romanian) * New translations: broadcast.xml (Portuguese, Brazilian) * New translations: onboarding.xml (German) * New translations: timeago.xml (German) --- translation/dest/broadcast/af-ZA.xml | 1 + translation/dest/broadcast/fa-IR.xml | 3 ++- translation/dest/broadcast/lb-LU.xml | 1 + translation/dest/broadcast/nb-NO.xml | 2 ++ translation/dest/broadcast/pt-BR.xml | 1 + translation/dest/broadcast/ro-RO.xml | 2 ++ translation/dest/onboarding/de-DE.xml | 2 +- translation/dest/timeago/de-DE.xml | 4 ++-- 8 files changed, 12 insertions(+), 4 deletions(-) diff --git a/translation/dest/broadcast/af-ZA.xml b/translation/dest/broadcast/af-ZA.xml index d0e649c0eeae4..425bc4a3a05fc 100644 --- a/translation/dest/broadcast/af-ZA.xml +++ b/translation/dest/broadcast/af-ZA.xml @@ -14,6 +14,7 @@ Kort beskrywing van die toernooi Volle geleentheid beskrywing Opsionele lang beskrywing van die uitsending. %1$s is beskikbaar. Lengte moet minder as %2$s karakters. + PGN-Bronskakel URL wat Lichess sal nagaan vir PGN opdaterings. Dit moet openbaar beskikbaar wees vanaf die Internet. Begin datum in jou eie tydsone Optioneel, indien jy weet wanner die geleentheid begin diff --git a/translation/dest/broadcast/fa-IR.xml b/translation/dest/broadcast/fa-IR.xml index 9c0bb795b9ed1..075c2c9908079 100644 --- a/translation/dest/broadcast/fa-IR.xml +++ b/translation/dest/broadcast/fa-IR.xml @@ -23,8 +23,9 @@ توضیحات کوتاه مسابقات توضیحات کامل مسابقات توضیحات بلند و اختیاری پخش همگانی. %1$s قابل‌استفاده است. طول متن باید کمتر از %2$s نویسه باشد. + وب‌نشانیِ PGN وب‌نشانی‌ای که Lichess برای دریافت به‌روزرسانی‌های PGN می‌بررسد. آن باید از راه اینترنت در دسترس همگان باشد. - تا ۶۴ نشانه بازی لیچس٬ جداشده با فاصله. + تا ۶۴ شناسه بازی لیچس٬ جداشده با فاصله. تاریخ شروع، در منطقه زمانی خودتان اختیاری است، اگر می‌دانید چه زمانی رویداد شروع می‌شود به منبع اعتبار دهید diff --git a/translation/dest/broadcast/lb-LU.xml b/translation/dest/broadcast/lb-LU.xml index fe30e6accdee3..4ce96f05567d8 100644 --- a/translation/dest/broadcast/lb-LU.xml +++ b/translation/dest/broadcast/lb-LU.xml @@ -18,6 +18,7 @@ Komplett Turnéierbeschreiwung Optional laang Beschreiwung vum Turnéier. %1$s ass disponibel. Längt muss manner wéi %2$s Buschtawen sinn. URL déi Lichess checkt fir PGN à jour ze halen. Muss ëffentlech iwwer Internet zougänglech sinn. + Bis zu 64 Lichess-Partie-IDen, duerch Espacë getrennt. Startdatum an denger eegener Zäitzon Optional, wann du wees wéini den Turnéier ufänkt Quell kreditéieren diff --git a/translation/dest/broadcast/nb-NO.xml b/translation/dest/broadcast/nb-NO.xml index 55d652db79fa9..7f960e0cd4471 100644 --- a/translation/dest/broadcast/nb-NO.xml +++ b/translation/dest/broadcast/nb-NO.xml @@ -23,7 +23,9 @@ Kort beskrivelse av turneringen Full beskrivelse av turneringen Valgfri lang beskrivelse av turneringen. %1$s er tilgjengelig. Beskrivelsen må være kortere enn %2$s tegn. + URL til PGN-kilden Lenke som Lichess vil hente PGN-oppdateringer fra. Den må være offentlig tilgjengelig på internett. + Opptil 64 ID-er for partier hos Lichess. De må være adskilt med mellomrom. Startdato i din egen tidssone Valgfritt, hvis du vet når arrangementet starter Krediter kilden diff --git a/translation/dest/broadcast/pt-BR.xml b/translation/dest/broadcast/pt-BR.xml index 827ac3fd43520..75d283a5ccfa9 100644 --- a/translation/dest/broadcast/pt-BR.xml +++ b/translation/dest/broadcast/pt-BR.xml @@ -24,6 +24,7 @@ Descrição completa do evento Descrição longa e opcional da transmissão. %1$s está disponível. O tamanho deve ser menor que %2$s caracteres. URL que Lichess irá verificar para obter atualizações PGN. Deve ser acessível ao público a partir da Internet. + Até 64 IDs de partidas do Lichess, separados por espaços. Data de início em seu próprio fuso horário Opcional, se você sabe quando o evento começa Crédito a fonte diff --git a/translation/dest/broadcast/ro-RO.xml b/translation/dest/broadcast/ro-RO.xml index b1df2049de7e2..b73890ef9c568 100644 --- a/translation/dest/broadcast/ro-RO.xml +++ b/translation/dest/broadcast/ro-RO.xml @@ -23,7 +23,9 @@ O descriere scurtă a turneului Întreaga descriere a evenimentului Descriere lungă, opțională, a difuzării. %1$s este disponibil. Lungimea trebuie să fie mai mică decât %2$s caractere. + URL sursă PGN URL-ul pe care Lichess îl va verifica pentru a obține actualizări al PGN-ului. Trebuie să fie public accesibil pe Internet. + Până la 64 de ID-uri de joc Lichess, separate prin spații. Data de începere conform fusului tău orar Opțional, dacă știi când va începe evenimentul Creditează sursa diff --git a/translation/dest/onboarding/de-DE.xml b/translation/dest/onboarding/de-DE.xml index a903f81ba50a5..3245c3c8ff04d 100644 --- a/translation/dest/onboarding/de-DE.xml +++ b/translation/dest/onboarding/de-DE.xml @@ -6,7 +6,7 @@ Wird ein Kind dieses Konto verwenden? Vielleicht möchtest du den %s aktivieren. Was nun? Hier sind ein paar Vorschläge: Schachregeln lernen - Verbessere dein Schach mit Taktikaufgaben. + Verbessere dein Schach mit Taktik-Aufgaben. Spiele gegen die künstliche Intelligenz. Spiele gegen Gegner aus der ganzen Welt. Folge deinen Freunden auf Lichess. diff --git a/translation/dest/timeago/de-DE.xml b/translation/dest/timeago/de-DE.xml index 3f4be9ab072bd..f0004f2e69bf6 100644 --- a/translation/dest/timeago/de-DE.xml +++ b/translation/dest/timeago/de-DE.xml @@ -59,8 +59,8 @@ %s Minuten verbleibend - %s Stunden verbleiben - %s Stunden übrig + %s Stunde verbleiben + %s Stunden verbleiben abgeschlossen From cd283bc110f370f01dad12c5f09182fb75b4356e Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 17:34:58 +0200 Subject: [PATCH 040/260] pnpm format --- ui/chart/src/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/chart/src/common.ts b/ui/chart/src/common.ts index 3fa17fb863f07..8d062a147e0f6 100644 --- a/ui/chart/src/common.ts +++ b/ui/chart/src/common.ts @@ -122,4 +122,4 @@ export const colorSeries = [ '#DF5353', '#7798BF', '#aaeeee', -]; \ No newline at end of file +]; From 8a4909307641097aa9267bb686f515f03350ff17 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 17:43:48 +0200 Subject: [PATCH 041/260] fix relay migration script --- bin/mongodb/relay-lcc-migrate-2.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bin/mongodb/relay-lcc-migrate-2.js b/bin/mongodb/relay-lcc-migrate-2.js index 3abb84a21b373..f6ba297a92817 100644 --- a/bin/mongodb/relay-lcc-migrate-2.js +++ b/bin/mongodb/relay-lcc-migrate-2.js @@ -3,7 +3,6 @@ const lccUrl = (id, round) => `https://view.livechesscloud.com/#${id}/${round}`; db.relay .find({ 'sync.upstream.lcc': { $exists: 1 } }) .sort({ $natural: -1 }) - .limit(30) .forEach(relay => { db.relay.updateOne( { _id: relay._id }, @@ -18,15 +17,14 @@ db.relay db.relay .find({ 'sync.upstream.urls': { $exists: 1 } }) .sort({ $natural: -1 }) - .limit(30) .forEach(relay => { db.relay.updateOne( { _id: relay._id }, { $set: { - 'sync.upstream.urls': relay.sync.upstream.urls.map(url => - url.lcc ? lccUrl(url.lcc, url.round) : url.url, - ), + 'sync.upstream.urls': relay.sync.upstream.urls + .filter(u => !!u) + .map(url => (url.lcc ? lccUrl(url.lcc, url.round) : url.url)), }, }, ); From 2f6923b4e4ff4e56b2b5be80fac7e24c416e9b43 Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Sun, 23 Jun 2024 19:12:06 +0200 Subject: [PATCH 042/260] Use flairs instead of font icons --- .../challenge/src/main/ui/ChallengeUi.scala | 19 ++++++++++--------- ui/challenge/css/_page.scss | 4 ++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/modules/challenge/src/main/ui/ChallengeUi.scala b/modules/challenge/src/main/ui/ChallengeUi.scala index 38792341732d0..812d1dcddebc3 100644 --- a/modules/challenge/src/main/ui/ChallengeUi.scala +++ b/modules/challenge/src/main/ui/ChallengeUi.scala @@ -84,20 +84,21 @@ final class ChallengeUi(helpers: Helpers): ) private def rule(r: GameRule, isLast: Boolean) = - val (text, icon) = getRuleStyle(r); + val (text, flair) = getRuleStyle(r); div(cls := "challenge-rule")( - span(cls := "text", dataIcon := icon)(text), - span(text), + iconFlair(flair), + span(cls := "text")(text), if !isLast then span("/", cls := "separator") else span() ) - private def getRuleStyle(r: GameRule): (String, Icon) = + private def getRuleStyle(r: GameRule): (String, Flair) = r match - case GameRule.noAbort => ("Abort not allowed", Icon.X); - case GameRule.noRematch => ("No rematch", Icon.InfoCircle); - case GameRule.noGiveTime => ("No giving of time", Icon.Clock); - case GameRule.noClaimWin => ("No claiming of win", Icon.InfoCircle); - case GameRule.noEarlyDraw => ("Early draw not allowed", Icon.OneHalf); + case GameRule.noAbort => ("Abort not allowed", Flair("symbols.cross-mark")); + case GameRule.noRematch => + ("No rematch", Flair("people.hand-with-index-finger-and-thumb-crossed-light-skin-tone")); + case GameRule.noGiveTime => ("No giving of time", Flair("objects.hourglass-done")); + case GameRule.noClaimWin => ("No claiming of win", Flair("people.raised-hand-light-skin-tone")); + case GameRule.noEarlyDraw => ("Early draw not allowed", Flair("people.handshake-light-skin-tone")); def mine( c: Challenge, diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index a08603016d460..3d9d817986c0d 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -130,6 +130,10 @@ @extend %flex-center; margin-bottom: 0.75rem; + .icon-flair { + height: 0.65em; + } + .text { font-size: 0.575em; } From 1110c53879735ec7a8ceac367a63269a867299ef Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 22:20:53 +0200 Subject: [PATCH 043/260] challenge rules: simplify scalatags and css --- modules/challenge/src/main/ui/ChallengeUi.scala | 12 ++++-------- ui/challenge/css/_page.scss | 17 ++++------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/modules/challenge/src/main/ui/ChallengeUi.scala b/modules/challenge/src/main/ui/ChallengeUi.scala index 812d1dcddebc3..e106c6d377a10 100644 --- a/modules/challenge/src/main/ui/ChallengeUi.scala +++ b/modules/challenge/src/main/ui/ChallengeUi.scala @@ -51,7 +51,6 @@ final class ChallengeUi(helpers: Helpers): s"$speed$variant ${c.mode.name} Chess • $players" private def details(c: Challenge, requestedColor: Option[Color])(using ctx: Context) = - val rulesSeq = c.rules.toSeq; div(cls := "details-wrapper")( div(cls := "content")( div( @@ -76,19 +75,16 @@ final class ChallengeUi(helpers: Helpers): ) ), div(cls := "rules")( - h6("Rules"), - div( - rulesSeq.zipWithIndex.map { case (r, i) => rule(r, i == rulesSeq.length - 1) } - ) + h2("Custom rules:"), + div(fragList(c.rules.toList.map(showRule), "/")) ) ) - private def rule(r: GameRule, isLast: Boolean) = + private def showRule(r: GameRule) = val (text, flair) = getRuleStyle(r); div(cls := "challenge-rule")( iconFlair(flair), - span(cls := "text")(text), - if !isLast then span("/", cls := "separator") else span() + text ) private def getRuleStyle(r: GameRule): (String, Flair) = diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index 3d9d817986c0d..68e68165bb0ae 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -121,27 +121,18 @@ .rules { margin-top: 1.5rem; + font-size: 1rem; > div { @extend %flex-center; - margin-top: 1rem; + gap: 1em; + margin-top: 1em; .challenge-rule { @extend %flex-center; - margin-bottom: 0.75rem; .icon-flair { - height: 0.65em; - } - - .text { - font-size: 0.575em; - } - - .separator { - padding: 0 0.5rem 0 0.5rem; - font-weight: bold; - font-size: 0.625em; + height: 1em; } } } From 9d9afcdac81791e14caa9f4c3281e23bb6730eb6 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Sun, 23 Jun 2024 22:26:18 +0200 Subject: [PATCH 044/260] challenge rules: tweak icons --- modules/challenge/src/main/ui/ChallengeUi.scala | 11 +++++------ ui/challenge/css/_page.scss | 3 ++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/challenge/src/main/ui/ChallengeUi.scala b/modules/challenge/src/main/ui/ChallengeUi.scala index e106c6d377a10..604f877ba12f8 100644 --- a/modules/challenge/src/main/ui/ChallengeUi.scala +++ b/modules/challenge/src/main/ui/ChallengeUi.scala @@ -89,12 +89,11 @@ final class ChallengeUi(helpers: Helpers): private def getRuleStyle(r: GameRule): (String, Flair) = r match - case GameRule.noAbort => ("Abort not allowed", Flair("symbols.cross-mark")); - case GameRule.noRematch => - ("No rematch", Flair("people.hand-with-index-finger-and-thumb-crossed-light-skin-tone")); - case GameRule.noGiveTime => ("No giving of time", Flair("objects.hourglass-done")); - case GameRule.noClaimWin => ("No claiming of win", Flair("people.raised-hand-light-skin-tone")); - case GameRule.noEarlyDraw => ("Early draw not allowed", Flair("people.handshake-light-skin-tone")); + case GameRule.noAbort => ("No abort", Flair("symbols.cross-mark")); + case GameRule.noRematch => ("No rematch", Flair("symbols.recycling-symbol")); + case GameRule.noGiveTime => ("No giving of time", Flair("objects.alarm-clock")); + case GameRule.noClaimWin => ("No claiming of win", Flair("objects.hourglass-done")); + case GameRule.noEarlyDraw => ("No early draw", Flair("people.handshake-light-skin-tone")); def mine( c: Challenge, diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index 68e68165bb0ae..d970764d2d660 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -132,7 +132,8 @@ @extend %flex-center; .icon-flair { - height: 1em; + height: 1.5em; + margin: 0.2em 0.8em 0 0; } } } From e5aac1b77ad99038df33e48a1e38281478edb973 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 00:55:54 +0200 Subject: [PATCH 045/260] relay tweaks --- modules/relay/src/main/RelayFetch.scala | 9 ++++----- modules/relay/src/main/RelayFormat.scala | 15 ++++++--------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 5f47a6e14760d..de56354f25587 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -169,18 +169,18 @@ final private class RelayFetch( case Sync.Upstream.Ids(ids) => fetchFromGameIds(rt.tour, ids) case Sync.Upstream.Url(url) => delayer(url, rt.round, fetchFromUpstream) case Sync.Upstream.Urls(urls) => - urls + urls.toVector .parallel: url => delayer(url, rt.round, fetchFromUpstream) - .map(_.flatten.toVector) + .map(_.flatten) private def fetchFromGameIds(tour: RelayTour, ids: List[GameId]): Fu[RelayGames] = gameRepo .gamesFromSecondary(ids) .flatMap(gameProxy.upgradeIfPresent) .flatMap(gameRepo.withInitialFens) - .flatMap { games => - if games.size == ids.size then + .flatMap: games => + if games.sizeIs == ids.size then val pgnFlags = gameIdsUpstreamPgnFlags.copy(delayMoves = !tour.official) given play.api.i18n.Lang = lila.core.i18n.defaultLang games @@ -190,7 +190,6 @@ final private class RelayFetch( else throw LilaInvalid: s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)).mkString(", ")}" - } .flatMap(multiPgnToGames(_).toFuture) private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index daf2cd5874a3a..563507d2924b7 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -118,19 +118,16 @@ final private class RelayFormatApi( catch case _: Exception => false private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url).map(looksLikeJson) -sealed private trait RelayFormat +private enum RelayFormat: + case SingleFile(url: URL) extends RelayFormat + case LccWithGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat + // there will be game files with names like "game-1.json" or "game-1.pgn" + // but not at the moment. The index is still useful. + case LccWithoutGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat private object RelayFormat: opaque type CanProxy = Boolean object CanProxy extends YesNo[CanProxy] - case class SingleFile(url: URL) extends RelayFormat - - case class LccWithGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat - - // there will be game files with names like "game-1.json" or "game-1.pgn" - // but not at the moment. The index is still useful. - case class LccWithoutGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat - case class NotFound(message: String) extends LilaException From 2da0786963f56425837554c94f4e423f6911bdc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Mon, 24 Jun 2024 01:01:25 +0100 Subject: [PATCH 046/260] Relay perms not need see LCC warn --- modules/relay/src/main/ui/FormUi.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 982321ece21c3..21609be40b09b 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -162,7 +162,7 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): "Where do the games come from?" )(form3.select(_, RelayRoundForm.sourceTypes)), div(cls := "relay-form__sync relay-form__sync-url")( - (round.flatMap(_.sync.upstream).exists(_.isLcc) && Granter.opt(_.Relay)).option( + (round.flatMap(_.sync.upstream).exists(_.isLcc) && !Granter.opt(_.Relay)).option( flashMessage("box")( p(strong("Please use the ", a(href := broadcasterUrl)("Lichess Broadcaster App"))), p( From c10c572901219b183ea3b070540016468f8fbd97 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 07:50:38 +0200 Subject: [PATCH 047/260] New translations: broadcast.xml (Portuguese, Brazilian) (#15583) --- translation/dest/broadcast/pt-BR.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/translation/dest/broadcast/pt-BR.xml b/translation/dest/broadcast/pt-BR.xml index 75d283a5ccfa9..b3ebb7257f5c9 100644 --- a/translation/dest/broadcast/pt-BR.xml +++ b/translation/dest/broadcast/pt-BR.xml @@ -23,6 +23,7 @@ Descrição curta do torneio Descrição completa do evento Descrição longa e opcional da transmissão. %1$s está disponível. O tamanho deve ser menor que %2$s caracteres. + URL de origem de PGN URL que Lichess irá verificar para obter atualizações PGN. Deve ser acessível ao público a partir da Internet. Até 64 IDs de partidas do Lichess, separados por espaços. Data de início em seu próprio fuso horário From 1c6bd69e128b8258de6737fc1c35e924d1300986 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 08:55:23 +0200 Subject: [PATCH 048/260] recover silently --- modules/relay/src/main/RelayFormat.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index 563507d2924b7..b3d903de85b7b 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -51,9 +51,11 @@ final private class RelayFormatApi( .match case Some(lcc) => looksLikeJson(lcc.indexUrl).flatMapz: - looksLikeJson(lcc.gameUrl(1)).recoverDefault.map: - if _ then LccWithGames(lcc).some - else LccWithoutGames(lcc).some + looksLikeJson(lcc.gameUrl(1)) + .recoverDefault(false)(_ => ()) + .map: + if _ then LccWithGames(lcc).some + else LccWithoutGames(lcc).some case None => looksLikePgn(url).mapz(SingleFile(url).some) .orFailWith(LilaInvalid(s"No games found at $url")) .addEffect: format => From a799d59e1bff1e00118c3a94dd5bb752005f5b9c Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Mon, 24 Jun 2024 14:28:10 +0700 Subject: [PATCH 049/260] Fix topic creation insert order Fix the insert order by: topic then category then post. If We insert post first, sometime lila-search-ingestor is received change event before We finished insert topic which causes some fail ingestion. --- modules/forum/src/main/ForumTopicApi.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/forum/src/main/ForumTopicApi.scala b/modules/forum/src/main/ForumTopicApi.scala index 70721331e6915..a78a33bdbcb28 100644 --- a/modules/forum/src/main/ForumTopicApi.scala +++ b/modules/forum/src/main/ForumTopicApi.scala @@ -107,9 +107,9 @@ final private class ForumTopicApi( case Some(dup) => fuccess(dup) case None => for - _ <- postRepo.coll.insert.one(post) _ <- topicRepo.coll.insert.one(topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) + _ <- postRepo.coll.insert.one(post) yield promotion.save(me, post.text) val text = s"${topic.name} ${post.text}" From f1da9505becf6ce51c346f00c71c5e62e0c648d5 Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Mon, 24 Jun 2024 14:33:26 +0700 Subject: [PATCH 050/260] Fix another forum post insert order --- modules/forum/src/main/ForumTopicApi.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/forum/src/main/ForumTopicApi.scala b/modules/forum/src/main/ForumTopicApi.scala index a78a33bdbcb28..2425c606ba1b1 100644 --- a/modules/forum/src/main/ForumTopicApi.scala +++ b/modules/forum/src/main/ForumTopicApi.scala @@ -155,9 +155,9 @@ final private class ForumTopicApi( } private def makeNewTopic(categ: ForumCateg, topic: ForumTopic, post: ForumPost) = for - _ <- postRepo.coll.insert.one(post) _ <- topicRepo.coll.insert.one(topic.withPost(post)) _ <- categRepo.coll.update.one($id(categ.id), categ.withPost(topic, post)) + _ <- postRepo.coll.insert.one(post) yield Bus.pub(CreatePost(post.mini)) def getSticky(categ: ForumCateg, forUser: Option[User]): Fu[List[TopicView]] = From 5774f7d5db39ccedf0f044498c2b0b7f836a3a1c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 11:27:02 +0200 Subject: [PATCH 051/260] allow hitting the broadcast PGN URL every second --- app/controllers/Study.scala | 5 +++-- modules/study/src/main/Study.scala | 2 +- modules/web/src/main/Limiters.scala | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index 13d7f269a664c..9f7a6bbdbfe23 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -423,7 +423,8 @@ final class Study( env.study.api.byId(id).flatMap { _.fold(studyNotFoundText.toFuccess): study => HeadLastModifiedAt(study.updatedAt): - limit.studyPgn[Fu[Result]](req.ipAddress, rateLimited, msg = id.value): + val limiter = if study.isRelay then limit.relayPgn else limit.studyPgn + limiter[Fu[Result]](req.ipAddress, rateLimited, msg = id.value): CanView(study, study.settings.shareable.some)(doPgn(study))( privateUnauthorizedText, privateForbiddenText @@ -431,7 +432,7 @@ final class Study( } private def doPgn(study: StudyModel)(using RequestHeader, Option[Me]) = - Ok.chunked(env.study.pgnDump.chaptersOf(study, requestPgnFlags).throttle(16, 1.second)) + Ok.chunked(env.study.pgnDump.chaptersOf(study, requestPgnFlags).throttle(20, 1.second)) .pipe(asAttachmentStream(s"${env.study.pgnDump.filename(study)}.pgn")) .as(pgnContentType) .withDateHeaders(lastModified(study.updatedAt)) diff --git a/modules/study/src/main/Study.scala b/modules/study/src/main/Study.scala index 086d61735f53e..b2b779bca13db 100644 --- a/modules/study/src/main/Study.scala +++ b/modules/study/src/main/Study.scala @@ -58,7 +58,7 @@ case class Study( def isOld = (nowSeconds - updatedAt.toSeconds) > 20 * 60 def isRelay = from match - case From.Relay(_) => true + case _: From.Relay => true case _ => false def cloneFor(user: User): Study = diff --git a/modules/web/src/main/Limiters.scala b/modules/web/src/main/Limiters.scala index 3bbfb32c617fe..d928b4ba29cb4 100644 --- a/modules/web/src/main/Limiters.scala +++ b/modules/web/src/main/Limiters.scala @@ -143,5 +143,7 @@ final class Limiters(using Executor, lila.core.config.RateLimit): val studyPgn = RateLimit[IpAddress](credits = 31, duration = 1.minute, key = "export.study.pgn.ip") + val relayPgn = RateLimit[IpAddress](credits = 61, duration = 1.minute, key = "export.relay.pgn.ip") + val teamKick = RateLimit.composite[IpAddress](key = "team.kick.api.ip")(("fast", 10, 2.minutes), ("slow", 50, 1.day)) From 8f667bcac7e3e7dfb1dde7980deb614520fdecfa Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 11:36:23 +0200 Subject: [PATCH 052/260] broadcast share links including the public PGN source for other websites to relay the moves --- modules/coreI18n/src/main/key.scala | 2 -- modules/relay/src/main/ui/RelayUi.scala | 2 -- translation/source/broadcast.xml | 2 -- ui/analyse/css/study/relay/_tour.scss | 9 +++++++ ui/analyse/src/study/relay/relayTourView.ts | 28 +++++++++++++++++++++ ui/analyse/src/study/studyShare.ts | 4 +-- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index 00da5602b6fc9..c346242ae85b0 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -1671,8 +1671,6 @@ object I18nKey: val `startDate`: I18nKey = "broadcast:startDate" val `startDateHelp`: I18nKey = "broadcast:startDateHelp" val `credits`: I18nKey = "broadcast:credits" - val `broadcastUrl`: I18nKey = "broadcast:broadcastUrl" - val `currentRoundUrl`: I18nKey = "broadcast:currentRoundUrl" val `currentGameUrl`: I18nKey = "broadcast:currentGameUrl" val `downloadAllRounds`: I18nKey = "broadcast:downloadAllRounds" val `resetRound`: I18nKey = "broadcast:resetRound" diff --git a/modules/relay/src/main/ui/RelayUi.scala b/modules/relay/src/main/ui/RelayUi.scala index 7d551ed7ae657..d05a3fd8211bf 100644 --- a/modules/relay/src/main/ui/RelayUi.scala +++ b/modules/relay/src/main/ui/RelayUi.scala @@ -119,8 +119,6 @@ final class RelayUi(helpers: Helpers)( import trans.broadcast as trb List( trb.addRound, - trb.broadcastUrl, - trb.currentRoundUrl, trb.currentGameUrl, trb.downloadAllRounds, trb.editRoundStudy diff --git a/translation/source/broadcast.xml b/translation/source/broadcast.xml index daa6188910282..95da0bc497da4 100644 --- a/translation/source/broadcast.xml +++ b/translation/source/broadcast.xml @@ -29,8 +29,6 @@ Start date in your own timezone Optional, if you know when the event starts Credit the source - Broadcast URL - Current round URL Current game URL Download all rounds Reset this round diff --git a/ui/analyse/css/study/relay/_tour.scss b/ui/analyse/css/study/relay/_tour.scss index efaa4b2d0d335..50ad1b4dacc5f 100644 --- a/ui/analyse/css/study/relay/_tour.scss +++ b/ui/analyse/css/study/relay/_tour.scss @@ -290,6 +290,15 @@ $hover-bg: $m-primary_bg--mix-30; @include rendered-markdown(2em, 50vh); margin: 3em var(---box-padding); } + &__share { + @extend %box-neat; + background: $c-bg-zebra; + margin: 3em var(---box-padding); + padding: 3em var(---box-padding) 1em var(---box-padding); + h2 { + margin-bottom: 3rem; + } + } &__leaderboard { width: auto; diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index edac7327fd2af..d2226c79ee336 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -17,6 +17,8 @@ import { renderStreamerMenu, renderPinnedImage } from './relayView'; import { renderVideoPlayer } from './videoPlayerView'; import { leaderboardView } from './relayLeaderboard'; import { gameLinksListener } from '../studyChapters'; +import { copyMeInput } from 'common/copyMe'; +import { baseUrl } from '../../view/util'; export function renderRelayTour(ctx: RelayViewContext): VNode | undefined { const tab = ctx.relay.tab(); @@ -105,6 +107,32 @@ const overview = (ctx: RelayViewContext) => [ hook: innerHTML(ctx.relay.data.tour.markup, () => ctx.relay.data.tour.markup!), }) : h('div.relay-tour__markup', ctx.relay.data.tour.description), + h('div.relay-tour__share', [ + h('h2.text', { attrs: dataIcon(licon.Heart) }, 'Sharing is caring'), + ...[ + [ctx.relay.data.tour.name, ctx.relay.tourPath()], + [ctx.study.data.name, ctx.relay.roundPath()], + [ + `${ctx.study.data.name} PGN`, + `${ctx.relay.roundPath()}.pgn`, + h('div.form-help', [ + 'A public, real-time PGN source for this round. We also offer a ', + h( + 'a', + { attrs: { href: 'https://lichess.org/api#tag/Broadcasts/operation/broadcastStreamRoundPgn' } }, + 'streaming API', + ), + ' for faster and more efficient synchronisation.', + ]), + ], + ].map(([i18n, path, help]: [string, string, VNode]) => + h('div.form-group', [ + h('label.form-label', ctx.ctrl.trans.noarg(i18n)), + copyMeInput(`${baseUrl()}${path}`), + help, + ]), + ), + ]), ]; const groupSelect = (relay: RelayCtrl, group: RelayGroup) => { diff --git a/ui/analyse/src/study/studyShare.ts b/ui/analyse/src/study/studyShare.ts index 8c31e0f848870..27bf036164c58 100644 --- a/ui/analyse/src/study/studyShare.ts +++ b/ui/analyse/src/study/studyShare.ts @@ -186,8 +186,8 @@ export function view(ctrl: StudyShare): VNode { h('form.form3', [ ...(ctrl.relay ? [ - ['broadcastUrl', ctrl.relay.tourPath()], - ['currentRoundUrl', ctrl.relay.roundPath()], + [ctrl.relay.data.tour.name, ctrl.relay.tourPath()], + [ctrl.data.name, ctrl.relay.roundPath()], ['currentGameUrl', addPly(`${ctrl.relay.roundPath()}/${chapter.id}`), true], ] : [ From df1b2f44298f4ab85bab7031637d7cab781dd706 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 12:23:10 +0200 Subject: [PATCH 053/260] monitor broadcast viewers for a longer duration --- modules/relay/src/main/RelayStatsApi.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayStatsApi.scala b/modules/relay/src/main/RelayStatsApi.scala index 42fdd86a7eaef..a413ea1a5b507 100644 --- a/modules/relay/src/main/RelayStatsApi.scala +++ b/modules/relay/src/main/RelayStatsApi.scala @@ -48,8 +48,8 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc def setActive(id: RelayRoundId) = activeRounds.put(id) - // keep monitoring rounds for 30m after they stopped syncing - private val activeRounds = ExpireSetMemo[RelayRoundId](30 minutes) + // keep monitoring rounds for some time after they stopped syncing + private val activeRounds = ExpireSetMemo[RelayRoundId](1 hour) private def record(): Funit = for crowds <- fetchRoundCrowds From e42f39ce06768e7682e47c9da56dceb7d034fe51 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 12:26:20 +0200 Subject: [PATCH 054/260] improve study & broadcats PGN exports --- app/controllers/RelayRound.scala | 17 +++++++++-- app/controllers/Study.scala | 33 ++++++++++----------- conf/routes | 4 +-- modules/relay/src/main/RelayGame.scala | 3 +- modules/relay/src/main/RelayPgnStream.scala | 3 +- modules/study/src/main/PgnDump.scala | 7 +++-- modules/study/src/test/Helpers.scala | 4 +-- 7 files changed, 42 insertions(+), 29 deletions(-) diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index 57586cd408e68..1f8061a742b24 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -127,8 +127,21 @@ final class RelayRound( yield JsonOk(env.relay.jsonView.withUrlAndPreviews(rt.withStudy(study), previews, group)) )(studyC.privateUnauthorizedJson, studyC.privateForbiddenJson) - def pgn(ts: String, rs: String, id: StudyId) = studyC.pgn(id) - def apiPgn = studyC.apiPgn + def pgn(ts: String, rs: String, id: RelayRoundId) = Open: + pgnWithFlags(ts, rs, id) + + def apiPgn(id: RelayRoundId) = AnonOrScoped(_.Study.Read): ctx ?=> + pgnWithFlags("-", "-", id) + + private def pgnWithFlags(ts: String, rs: String, id: RelayRoundId)(using Context): Fu[Result] = + studyC.pgnWithFlags( + id.into(StudyId), + _.copy( + site = s"${env.net.baseUrl}${routes.RelayRound.show(ts, rs, id)}".some, + comments = false, + variations = false + ) + ) def apiMyRounds = Scoped(_.Study.Read) { ctx ?=> _ ?=> val source = env.relay.api.myRounds(MaxPerSecond(20), getIntAs[Max]("nb")).map(env.relay.jsonView.myRound) diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index 9f7a6bbdbfe23..23401b4da5e76 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -15,6 +15,7 @@ import lila.study.JsonView.JsData import lila.study.Study.WithChapter import lila.study.actorApi.{ BecomeStudyAdmin, Who } import lila.study.{ Chapter, Settings, Orders, Study as StudyModel, StudyForm } +import lila.study.PgnDump.WithFlags import lila.tree.Node.partitionTreeJsonWriter import lila.core.misc.lpv.LpvEmbed import lila.core.net.IpAddress @@ -411,28 +412,23 @@ final class Study( } def pgn(id: StudyId) = Open: + pgnWithFlags(id, identity) + + def apiPgn(id: StudyId) = AnonOrScoped(_.Study.Read): ctx ?=> + pgnWithFlags(id, identity) + + def pgnWithFlags(id: StudyId, flags: Update[WithFlags])(using Context) = Found(env.study.api.byId(id)): study => HeadLastModifiedAt(study.updatedAt): - limit.studyPgn(ctx.ip, rateLimited, msg = id.value): - CanView(study, study.settings.shareable.some)(doPgn(study))( + val limiter = if study.isRelay then limit.relayPgn else limit.studyPgn + limiter[Fu[Result]](req.ipAddress, rateLimited, msg = id.value): + CanView(study, study.settings.shareable.some)(doPgn(study, flags))( privateUnauthorizedFu(study), privateForbiddenFu(study) ) - def apiPgn(id: StudyId) = AnonOrScoped(_.Study.Read): ctx ?=> - env.study.api.byId(id).flatMap { - _.fold(studyNotFoundText.toFuccess): study => - HeadLastModifiedAt(study.updatedAt): - val limiter = if study.isRelay then limit.relayPgn else limit.studyPgn - limiter[Fu[Result]](req.ipAddress, rateLimited, msg = id.value): - CanView(study, study.settings.shareable.some)(doPgn(study))( - privateUnauthorizedText, - privateForbiddenText - ) - } - - private def doPgn(study: StudyModel)(using RequestHeader, Option[Me]) = - Ok.chunked(env.study.pgnDump.chaptersOf(study, requestPgnFlags).throttle(20, 1.second)) + private def doPgn(study: StudyModel, flags: Update[WithFlags])(using RequestHeader, Option[Me]) = + Ok.chunked(env.study.pgnDump.chaptersOf(study, flags(requestPgnFlags)).throttle(20, 1.second)) .pipe(asAttachmentStream(s"${env.study.pgnDump.filename(study)}.pgn")) .as(pgnContentType) .withDateHeaders(lastModified(study.updatedAt)) @@ -495,12 +491,13 @@ final class Study( .map(lila.study.JsonView.metadata) private def requestPgnFlags(using RequestHeader) = - lila.study.PgnDump.WithFlags( + WithFlags( comments = getBoolOpt("comments") | true, variations = getBoolOpt("variations") | true, clocks = getBoolOpt("clocks") | true, source = getBool("source"), - orientation = getBool("orientation") + orientation = getBool("orientation"), + site = none ) def chapterGif(id: StudyId, chapterId: StudyChapterId, theme: Option[String], piece: Option[String]) = Open: diff --git a/conf/routes b/conf/routes index 8cc00de17428e..5049421ef8e10 100644 --- a/conf/routes +++ b/conf/routes @@ -279,10 +279,10 @@ POST /broadcast/round/$roundId<\w{8}>/edit controllers.RelayRound.update(roun POST /broadcast/round/$roundId<\w{8}>/reset controllers.RelayRound.reset(roundId: RelayRoundId) POST /broadcast/round/$roundId<\w{8}>/push controllers.RelayRound.push(roundId: RelayRoundId) POST /api/broadcast/round/$roundId<\w{8}>/push controllers.RelayRound.push(roundId: RelayRoundId) -GET /broadcast/:ts/:rs/$roundId<\w{8}>.pgn controllers.RelayRound.pgn(ts, rs, roundId: StudyId) +GET /broadcast/:ts/:rs/$roundId<\w{8}>.pgn controllers.RelayRound.pgn(ts, rs, roundId: RelayRoundId) GET /broadcast/$roundId<\w{8}>/teams controllers.RelayRound.teamsView(roundId: RelayRoundId) GET /broadcast/$tourId<\w{8}>/leaderboard controllers.RelayTour.leaderboardView(tourId: RelayTourId) -GET /api/broadcast/round/$roundId<\w{8}>.pgn controllers.RelayRound.apiPgn(roundId: StudyId) +GET /api/broadcast/round/$roundId<\w{8}>.pgn controllers.RelayRound.apiPgn(roundId: RelayRoundId) GET /api/stream/broadcast/round/$roundId<\w{8}>.pgn controllers.RelayRound.stream(roundId: RelayRoundId) GET /api/broadcast controllers.RelayTour.apiIndex GET /api/broadcast/top controllers.RelayTour.apiTop(page: Int ?= 1) diff --git a/modules/relay/src/main/RelayGame.scala b/modules/relay/src/main/RelayGame.scala index dee3cc97ad08f..f0c261ac61b99 100644 --- a/modules/relay/src/main/RelayGame.scala +++ b/modules/relay/src/main/RelayGame.scala @@ -62,7 +62,8 @@ private object RelayGame: variations = false, clocks = true, source = true, - orientation = false + orientation = false, + site = none ) Iso[RelayGames, MultiPgn]( gs => diff --git a/modules/relay/src/main/RelayPgnStream.scala b/modules/relay/src/main/RelayPgnStream.scala index 895fa2a2fef77..84d06cbdd9d40 100644 --- a/modules/relay/src/main/RelayPgnStream.scala +++ b/modules/relay/src/main/RelayPgnStream.scala @@ -27,7 +27,8 @@ final class RelayPgnStream( variations = false, clocks = true, source = false, - orientation = false + orientation = false, + site = none ) private val fileR = """[\s,]""".r private val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("yyyy.MM.dd") diff --git a/modules/study/src/main/PgnDump.scala b/modules/study/src/main/PgnDump.scala index 7cecad8234f5b..f06b214152928 100644 --- a/modules/study/src/main/PgnDump.scala +++ b/modules/study/src/main/PgnDump.scala @@ -71,7 +71,7 @@ final class PgnDump( val opening = chapter.opening val genTags = List( Tag(_.Event, s"${study.name}: ${chapter.name}"), - Tag(_.Site, chapterUrl(study.id, chapter.id)), + Tag(_.Site, flags.site | chapterUrl(study.id, chapter.id)), Tag(_.Variant, chapter.setup.variant.name.capitalize), Tag(_.ECO, opening.fold("?")(_.eco)), Tag(_.Opening, opening.fold("?")(_.name)), @@ -109,9 +109,10 @@ object PgnDump: variations: Boolean, clocks: Boolean, source: Boolean, - orientation: Boolean + orientation: Boolean, + site: Option[String] ) - val fullFlags = WithFlags(true, true, true, true, true) + val fullFlags = WithFlags(true, true, true, true, true, none) def rootToPgn(root: Root, tags: Tags, comments: InitialComments)(using WithFlags): Pgn = rootToPgn(NewRoot(root), tags, comments) diff --git a/modules/study/src/test/Helpers.scala b/modules/study/src/test/Helpers.scala index 254236ec01aa0..c3725b687981a 100644 --- a/modules/study/src/test/Helpers.scala +++ b/modules/study/src/test/Helpers.scala @@ -12,11 +12,11 @@ object Helpers: import lila.tree.NewTree.* def rootToPgn(root: Root): PgnStr = PgnDump - .rootToPgn(root, Tags.empty)(using PgnDump.WithFlags(true, true, true, true, false)) + .rootToPgn(root, Tags.empty)(using PgnDump.WithFlags(true, true, true, true, false, none)) .render def rootToPgn(root: NewRoot): PgnStr = PgnDump - .rootToPgn(root, Tags.empty)(using PgnDump.WithFlags(true, true, true, true, false)) + .rootToPgn(root, Tags.empty)(using PgnDump.WithFlags(true, true, true, true, false, none)) .render extension (root: Root) From e6b20c9546e191ad983a03731499d861691af0ed Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 13:23:15 +0200 Subject: [PATCH 055/260] moderators can relocate forum topics --- app/controllers/ForumPost.scala | 15 ++++++++++++ conf/routes | 15 ++++++------ modules/forum/src/main/ForumForm.scala | 5 +++- modules/forum/src/main/ForumTopicApi.scala | 12 ++++++---- modules/forum/src/main/model.scala | 6 +---- modules/forum/src/main/ui/PostUi.scala | 8 +++++++ modules/forum/src/main/ui/TopicUi.scala | 28 +++++++++++++++++++++- modules/msg/src/main/MsgPreset.scala | 11 ++++++--- ui/bits/src/bits.forum.ts | 14 +++++++++++ 9 files changed, 92 insertions(+), 22 deletions(-) diff --git a/app/controllers/ForumPost.scala b/app/controllers/ForumPost.scala index 24216c5853b28..3661e13cfc796 100644 --- a/app/controllers/ForumPost.scala +++ b/app/controllers/ForumPost.scala @@ -108,6 +108,21 @@ final class ForumPost(env: Env) extends LilaController(env) with ForumController NoContent } + def relocate(id: ForumPostId) = SecureBody(_.ModerateForum) { ctx ?=> me ?=> + Found(postApi.getPost(id).flatMapz(postApi.viewOf)): post => + forms.relocateTo + .bindFromRequest() + .value + .so: to => + env.forum.topicApi + .relocate(post.topic.id, to) + .inject: + post.post.userId.foreach: op => + val newUrl = routes.ForumTopic.show(to, post.topic.slug, 1).url + env.msg.api.systemPost(op, MsgPreset.forumRelocation(post.topic.name, newUrl)) + Redirect(routes.ForumCateg.show(to)).flashSuccess + } + def react(categId: ForumCategId, id: ForumPostId, reaction: String, v: Boolean) = Auth { _ ?=> me ?=> CategGrantWrite(categId): FoundSnip(postApi.react(categId, id, reaction, v)): post => diff --git a/conf/routes b/conf/routes index 5049421ef8e10..a305b5635df89 100644 --- a/conf/routes +++ b/conf/routes @@ -598,15 +598,16 @@ GET /kaladin controllers.Irwin.kaladin # Forum GET /forum controllers.ForumCateg.index GET /forum/search controllers.ForumPost.search(text ?= "", page: Int ?= 1) -GET /forum/:categId controllers.ForumCateg.show(categId: ForumCategId, page: Int ?= 1) -GET /forum/:categId/form controllers.ForumTopic.form(categId: ForumCategId) -POST /forum/:categId/new controllers.ForumTopic.create(categId: ForumCategId) +GET /forum/:categId controllers.ForumCateg.show(categId: ForumCategId, page: Int ?= 1) +GET /forum/:categId/form controllers.ForumTopic.form(categId: ForumCategId) +POST /forum/:categId/new controllers.ForumTopic.create(categId: ForumCategId) GET /forum/participants/:topicId controllers.ForumTopic.participants(topicId: ForumTopicId) -GET /forum/:categId/:slug controllers.ForumTopic.show(categId: ForumCategId, slug, page: Int ?= 1) -POST /forum/:categId/:slug/close controllers.ForumTopic.close(categId: ForumCategId, slug) -POST /forum/:categId/:slug/sticky controllers.ForumTopic.sticky(categId: ForumCategId, slug) -POST /forum/:categId/:slug/new controllers.ForumPost.create(categId: ForumCategId, slug, page: Int ?= 1) +GET /forum/:categId/:slug controllers.ForumTopic.show(categId: ForumCategId, slug, page: Int ?= 1) +POST /forum/:categId/:slug/close controllers.ForumTopic.close(categId: ForumCategId, slug) +POST /forum/:categId/:slug/sticky controllers.ForumTopic.sticky(categId: ForumCategId, slug) +POST /forum/:categId/:slug/new controllers.ForumPost.create(categId: ForumCategId, slug, page: Int ?= 1) POST /forum/delete/:id controllers.ForumPost.delete(id: ForumPostId) +POST /forum/relocate/:id controllers.ForumPost.relocate(id: ForumPostId) POST /forum/:categId/react/:id/:reaction/:v controllers.ForumPost.react(categId: ForumCategId, id: ForumPostId, reaction, v: Boolean) POST /forum/post/:id controllers.ForumPost.edit(id: ForumPostId) GET /forum/redirect/post/:id controllers.ForumPost.redirect(id: ForumPostId) diff --git a/modules/forum/src/main/ForumForm.scala b/modules/forum/src/main/ForumForm.scala index 88ed0281d821d..fb4825c52887d 100644 --- a/modules/forum/src/main/ForumForm.scala +++ b/modules/forum/src/main/ForumForm.scala @@ -3,7 +3,7 @@ package lila.forum import play.api.data.* import play.api.data.Forms.* -import lila.common.Form.cleanText +import lila.common.Form.{ cleanText, into } import lila.common.Form.given final private[forum] class ForumForm( @@ -42,6 +42,9 @@ final private[forum] class ForumForm( val deleteWithReason = Form: single("reason" -> optional(nonEmptyText)) + val relocateTo = Form: + single("categ" -> nonEmptyText.into[ForumCategId]) + private def userTextMapping(inOwnTeam: Boolean, previousText: Option[String] = None)(using me: Me) = cleanText(minLength = 3, 10_000) .verifying( diff --git a/modules/forum/src/main/ForumTopicApi.scala b/modules/forum/src/main/ForumTopicApi.scala index 70721331e6915..9e7b3be2a7191 100644 --- a/modules/forum/src/main/ForumTopicApi.scala +++ b/modules/forum/src/main/ForumTopicApi.scala @@ -39,11 +39,7 @@ final private class ForumTopicApi( .flatMapz: topic => show(categId, slug, topic.lastPage(config.postMaxPerPage)) - def show( - categId: ForumCategId, - slug: String, - page: Int - )(using + def show(categId: ForumCategId, slug: String, page: Int)(using NetDomain )(using me: Option[Me]): Fu[Option[(ForumCateg, ForumTopic, Paginator[ForumPost.WithFrag])]] = for @@ -219,3 +215,9 @@ final private class ForumTopicApi( _ <- categRepo.coll.update .one($id(cat.id), cat.withoutTopic(topic, lastPostId, lastPostIdTroll)) yield () + + def relocate(topic: ForumTopicId, to: ForumCategId)(using Me): Funit = + for + _ <- topicRepo.coll.update.one($id(topic), $set("categId" -> to)) + _ <- postRepo.coll.update.one($doc("topicId" -> topic), $set("categId" -> to)) + yield () diff --git a/modules/forum/src/main/model.scala b/modules/forum/src/main/model.scala index 5f83951c19812..e97097d7cf0b8 100644 --- a/modules/forum/src/main/model.scala +++ b/modules/forum/src/main/model.scala @@ -31,11 +31,7 @@ case class TopicView( def name = topic.name def createdAt = topic.createdAt -case class PostView( - post: ForumPost, - topic: ForumTopic, - categ: ForumCateg -): +case class PostView(post: ForumPost, topic: ForumTopic, categ: ForumCateg): def show = post.showUserIdOrAuthor + " @ " + topic.name + " - " + post.text.take(80) def logFormatted = "%s / %s#%s / %s".format(categ.name, topic.name, post.number, post.text) diff --git a/modules/forum/src/main/ui/PostUi.scala b/modules/forum/src/main/ui/PostUi.scala index 907ba30f38667..2de81c2424e93 100644 --- a/modules/forum/src/main/ui/PostUi.scala +++ b/modules/forum/src/main/ui/PostUi.scala @@ -55,6 +55,14 @@ final class PostUi(helpers: Helpers, bits: ForumBits): ).some else frag( + (canModCateg && post.number == 1).option: + a( + cls := "mod mod-relocate button button-empty", + href := routes.ForumPost.relocate(post.id), + dataIcon := Icon.Forward, + title := "Relocate" + ) + , if canModCateg || topic.isUblogAuthor(me) then a( cls := "mod delete button button-empty", diff --git a/modules/forum/src/main/ui/TopicUi.scala b/modules/forum/src/main/ui/TopicUi.scala index 782832e9be1d5..fac7ecad7833c 100644 --- a/modules/forum/src/main/ui/TopicUi.scala +++ b/modules/forum/src/main/ui/TopicUi.scala @@ -167,7 +167,8 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( ) ) ), - (canModCateg || ctx.me.exists(topic.isAuthor)).option(deleteModal) + (canModCateg || ctx.me.exists(topic.isAuthor)).option(deleteModal), + canModCateg.option(relocateModal(categ)) ) ), formWithCaptcha.map: (form, captcha) => @@ -253,3 +254,28 @@ final class TopicUi(helpers: Helpers, bits: ForumBits, postUi: PostUi)( ) ) ) + + private val relocateTo = List( + "general-chess-discussion" -> "General Chess Discussion", + "lichess-feedback" -> "Lichess Feedback", + "game-analysis" -> "Game Analysis", + "off-topic-discussion" -> "Off-Topic Discussion" + ) + + private def relocateModal(from: lila.forum.ForumCateg) = + div(cls := "forum-relocate-modal none")( + p("Move the entire thread to another forum"), + st.form(method := "post", cls := "form3")( + st.select( + name := "categ", + cls := "form-control" + )( + relocateTo.collect: + case (slug, name) if slug != from.id.value => st.option(value := slug)(name) + ), + form3.actions( + button(cls := "cancel button button-empty", tpe := "button")("Cancel"), + form3.submit(frag("Relocate the thread"))(cls := "button-red") + ) + ) + ) diff --git a/modules/msg/src/main/MsgPreset.scala b/modules/msg/src/main/MsgPreset.scala index bf87020b5595a..7a412df27a9dc 100644 --- a/modules/msg/src/main/MsgPreset.scala +++ b/modules/msg/src/main/MsgPreset.scala @@ -9,15 +9,20 @@ object MsgPreset: import lila.core.msg.{ MsgPreset as Msg } + private val baseUrl = "https://lichess.org" + def maxFollow(username: UserName, max: Int) = Msg( name = "Follow limit reached!", text = s"""Sorry, you can't follow more than $max players on Lichess. -To follow new players, you must first unfollow some on https://lichess.org/@/$username/following. +To follow new players, you must first unfollow some on $baseUrl/@/$username/following. Thank you for your understanding.""" ) + def forumRelocation(title: String, newUrl: String) = + s"""A moderator has moved your post "$title" to a different subforum. You can find it here: $baseUrl$newUrl.""" + object forumDeletion: val presets = List( @@ -30,12 +35,12 @@ Thank you for your understanding.""" def byModerator = compose("A moderator") - def byTeamLeader(forumId: ForumCategId) = compose(s"A team leader of https://lichess.org/forum/$forumId") + def byTeamLeader(forumId: ForumCategId) = compose(s"A team leader of $baseUrl/forum/$forumId") def byBlogAuthor(user: UserName) = compose(by = s"The community blog author $user") private def compose(by: String)(reason: String, forumPost: String) = - s"""$by deleted the following of your posts for this reason: $reason. Please read Lichess' Forum-Etiquette: https://lichess.org/page/forum-etiquette + s"""$by deleted the following of your posts for this reason: $reason. Please read Lichess' Forum-Etiquette: $baseUrl/page/forum-etiquette ---- $forumPost """ diff --git a/ui/bits/src/bits.forum.ts b/ui/bits/src/bits.forum.ts index b8aafc9a492e8..293c850aa5a10 100644 --- a/ui/bits/src/bits.forum.ts +++ b/ui/bits/src/bits.forum.ts @@ -26,6 +26,20 @@ site.load.then(() => { }); return false; }) + .on('click', 'a.mod-relocate', function (this: HTMLAnchorElement) { + const link = this; + site.dialog + .dom({ + cash: $('.forum-relocate-modal'), + attrs: { view: { action: link.href } }, + }) + .then(dlg => { + $(dlg.view).find('form').attr('action', link.href); + $(dlg.view).find('form button.cancel').on('click', dlg.close); + dlg.showModal(); + }); + return false; + }) .on('click', 'form.unsub button', function (this: HTMLButtonElement) { const form = $(this).parent().toggleClass('on off')[0] as HTMLFormElement; xhr.text(`${form.action}?unsub=${this.dataset.unsub}`, { method: 'post' }); From eba535b70c4014fec15fa7d7a4f80c41ba167d08 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 24 Jun 2024 16:25:22 +0200 Subject: [PATCH 056/260] tweak cache size --- modules/study/src/main/StudyChapterPreview.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/study/src/main/StudyChapterPreview.scala b/modules/study/src/main/StudyChapterPreview.scala index f0eecc17605e4..b6af5524481a9 100644 --- a/modules/study/src/main/StudyChapterPreview.scala +++ b/modules/study/src/main/StudyChapterPreview.scala @@ -54,7 +54,7 @@ final class ChapterPreviewApi( object dataList: private[ChapterPreviewApi] val cache = - cacheApi[StudyId, List[ChapterPreview]](256, "study.chapterPreview.data"): + cacheApi[StudyId, List[ChapterPreview]](512, "study.chapterPreview.data"): _.expireAfterWrite(1 minute).buildAsyncFuture(listAll) def apply(studyId: StudyId): Fu[List[ChapterPreview]] = cache.get(studyId) From e019c5dcd231267dd1513a326af443931c22406e Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Mon, 24 Jun 2024 18:21:33 +0000 Subject: [PATCH 057/260] Update play-json to 3.0.4 --- project/Dependencies.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 88f7a4009c0d2..ca195f5f143f3 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -85,7 +85,7 @@ object Dependencies { object play { val playVersion = "2.8.18-lila_3.18" - val json = "org.playframework" %% "play-json" % "3.0.3" + val json = "org.playframework" %% "play-json" % "3.0.4" val api = "com.typesafe.play" %% "play" % playVersion val server = "com.typesafe.play" %% "play-server" % playVersion val netty = "com.typesafe.play" %% "play-netty-server" % playVersion From d4c6d4463d8274fb11ac0027b2f070a6f5e8dcf1 Mon Sep 17 00:00:00 2001 From: Scala Steward Date: Mon, 24 Jun 2024 18:21:48 +0000 Subject: [PATCH 058/260] Update scala3-library to 3.5.0-RC2 --- project/BuildSettings.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index addfdc497f893..65a8bff632d02 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -6,7 +6,7 @@ object BuildSettings { import Dependencies._ val lilaVersion = "4.0" - val globalScalaVersion = "3.5.0-RC1" + val globalScalaVersion = "3.5.0-RC2" def buildSettings = Defaults.coreDefaultSettings ++ Seq( From 073ece8bf1744c67de06e715dec68147f4ada00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Mon, 24 Jun 2024 20:05:29 +0100 Subject: [PATCH 059/260] Only show `Popularity stats` if is official --- ui/analyse/src/study/relay/relayTourView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index d2226c79ee336..1528e64fdf5ba 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -313,7 +313,7 @@ const makeTabs = (ctrl: AnalyseCtrl) => { makeTab('boards', 'Boards'), relay.teams && makeTab('teams', 'Teams'), relay.data.tour.leaderboard ? makeTab('leaderboard', 'Leaderboard') : undefined, - study.members.myMember() + study.members.myMember() && relay.data.tour.official ? h( 'a.text', { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, From fc9140a0c87ade036d5bbfb20e170fb343a2a58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Mon, 24 Jun 2024 21:05:28 +0100 Subject: [PATCH 060/260] use tier tier show but official not --- ui/analyse/src/study/relay/interfaces.ts | 1 + ui/analyse/src/study/relay/relayTourView.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/analyse/src/study/relay/interfaces.ts b/ui/analyse/src/study/relay/interfaces.ts index 4a0d89a0a04e4..4adbb5a17135e 100644 --- a/ui/analyse/src/study/relay/interfaces.ts +++ b/ui/analyse/src/study/relay/interfaces.ts @@ -39,6 +39,7 @@ export interface RelayTour { image?: string; teamTable?: boolean; leaderboard?: boolean; + tier?: number; } export interface RelaySync { diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index 1528e64fdf5ba..61f5b4cc26e08 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -313,7 +313,7 @@ const makeTabs = (ctrl: AnalyseCtrl) => { makeTab('boards', 'Boards'), relay.teams && makeTab('teams', 'Teams'), relay.data.tour.leaderboard ? makeTab('leaderboard', 'Leaderboard') : undefined, - study.members.myMember() && relay.data.tour.official + (study.members.myMember() && relay.data.tour.tier) ? h( 'a.text', { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, From 251ab062cefa45464dba815f683ef1b6aef62612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Mon, 24 Jun 2024 21:09:35 +0100 Subject: [PATCH 061/260] format (not need `()`) --- ui/analyse/src/study/relay/relayTourView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index 61f5b4cc26e08..72f28ecd97d1e 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -313,7 +313,7 @@ const makeTabs = (ctrl: AnalyseCtrl) => { makeTab('boards', 'Boards'), relay.teams && makeTab('teams', 'Teams'), relay.data.tour.leaderboard ? makeTab('leaderboard', 'Leaderboard') : undefined, - (study.members.myMember() && relay.data.tour.tier) + study.members.myMember() && relay.data.tour.tier ? h( 'a.text', { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, From 977daec8f5809b6a4c0b88b1c5018fd8b0b53889 Mon Sep 17 00:00:00 2001 From: Thanh Le Date: Tue, 25 Jun 2024 08:31:26 +0700 Subject: [PATCH 062/260] No need to extend enum --- modules/relay/src/main/RelayFormat.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index b3d903de85b7b..79eb74b25cf60 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -121,11 +121,11 @@ final private class RelayFormatApi( private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url).map(looksLikeJson) private enum RelayFormat: - case SingleFile(url: URL) extends RelayFormat - case LccWithGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat + case SingleFile(url: URL) + case LccWithGames(lcc: RelayRound.Sync.Lcc) // there will be game files with names like "game-1.json" or "game-1.pgn" // but not at the moment. The index is still useful. - case LccWithoutGames(lcc: RelayRound.Sync.Lcc) extends RelayFormat + case LccWithoutGames(lcc: RelayRound.Sync.Lcc) private object RelayFormat: From f0c479b635989c2feddd4633b029fe2f02974ba7 Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Tue, 25 Jun 2024 10:28:06 +0200 Subject: [PATCH 063/260] Include game rules in challenge model --- ui/challenge/src/interfaces.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/challenge/src/interfaces.ts b/ui/challenge/src/interfaces.ts index b4a818fe74c82..e180b98a2db21 100644 --- a/ui/challenge/src/interfaces.ts +++ b/ui/challenge/src/interfaces.ts @@ -9,6 +9,7 @@ export interface ChallengeOpts { } type ChallengeStatus = 'created' | 'offline' | 'canceled' | 'declined' | 'accepted'; +type GameRule = 'noAbort' | 'noRematch' | 'noGiveTime' | 'noClaimWin' | 'noEarlyDraw'; export type ChallengeDirection = 'in' | 'out'; export interface ChallengeUser { @@ -37,6 +38,7 @@ export interface Challenge { status: ChallengeStatus; challenger?: ChallengeUser; destUser?: ChallengeUser; + rules: GameRule[]; variant: Variant; initialFen: FEN; rated: boolean; From 2f8341fe8882181dc997d3e4f9b4141b6ba7cd1b Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Tue, 25 Jun 2024 10:28:21 +0200 Subject: [PATCH 064/260] Conditionally display either accept or view button for challenge --- ui/challenge/src/view.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/ui/challenge/src/view.ts b/ui/challenge/src/view.ts index 6176094602553..92567b43b4707 100644 --- a/ui/challenge/src/view.ts +++ b/ui/challenge/src/view.ts @@ -74,18 +74,24 @@ function challenge(ctrl: ChallengeCtrl, dir: ChallengeDirection) { } function inButtons(ctrl: ChallengeCtrl, c: Challenge): VNode[] { + const viewInsteadOfAccept = c.rules.length > 0; + const acceptElement = h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ + h('button.button.accept', { + attrs: { + type: 'submit', + 'aria-describedby': `challenge-text-${c.id}`, + 'data-icon': licon.Checkmark, + title: ctrl.trans('accept'), + }, + hook: onClick(ctrl.onRedirect), + }), + ]); + const viewElement = h('a.view', { + attrs: { 'data-icon': licon.Eye, href: '/' + c.id, title: ctrl.trans('viewInFullSize') }, + }); + return [ - h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ - h('button.button.accept', { - attrs: { - type: 'submit', - 'aria-describedby': `challenge-text-${c.id}`, - 'data-icon': licon.Checkmark, - title: ctrl.trans('accept'), - }, - hook: onClick(ctrl.onRedirect), - }), - ]), + viewInsteadOfAccept ? viewElement : acceptElement, h('button.button.decline', { attrs: { type: 'submit', 'data-icon': licon.X, title: ctrl.trans('decline') }, hook: onClick(() => ctrl.decline(c.id, 'generic')), From 6115122cb711f00190ea16d75c2ce31bc978d7dd Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Tue, 25 Jun 2024 10:35:06 +0200 Subject: [PATCH 065/260] Handle challenge rules potentially being undefined I would prefer to default it to an empty array, but seems like this is an okay compromise. I do not want to change this on the API level (?). --- ui/challenge/src/interfaces.ts | 2 +- ui/challenge/src/view.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/challenge/src/interfaces.ts b/ui/challenge/src/interfaces.ts index e180b98a2db21..c0dbd7a50d0ce 100644 --- a/ui/challenge/src/interfaces.ts +++ b/ui/challenge/src/interfaces.ts @@ -38,7 +38,7 @@ export interface Challenge { status: ChallengeStatus; challenger?: ChallengeUser; destUser?: ChallengeUser; - rules: GameRule[]; + rules?: GameRule[]; variant: Variant; initialFen: FEN; rated: boolean; diff --git a/ui/challenge/src/view.ts b/ui/challenge/src/view.ts index 92567b43b4707..cf7667b81938d 100644 --- a/ui/challenge/src/view.ts +++ b/ui/challenge/src/view.ts @@ -74,7 +74,7 @@ function challenge(ctrl: ChallengeCtrl, dir: ChallengeDirection) { } function inButtons(ctrl: ChallengeCtrl, c: Challenge): VNode[] { - const viewInsteadOfAccept = c.rules.length > 0; + const viewInsteadOfAccept = !c.rules ? false : c.rules.length > 0; const acceptElement = h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ h('button.button.accept', { attrs: { From 7d71b6b2ba74f93415f40f10e5c2e546c1153b7c Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Tue, 25 Jun 2024 10:40:15 +0200 Subject: [PATCH 066/260] Simplify statement --- ui/challenge/src/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/challenge/src/view.ts b/ui/challenge/src/view.ts index cf7667b81938d..8df6314d4baf0 100644 --- a/ui/challenge/src/view.ts +++ b/ui/challenge/src/view.ts @@ -74,7 +74,7 @@ function challenge(ctrl: ChallengeCtrl, dir: ChallengeDirection) { } function inButtons(ctrl: ChallengeCtrl, c: Challenge): VNode[] { - const viewInsteadOfAccept = !c.rules ? false : c.rules.length > 0; + const viewInsteadOfAccept = (c.rules?.length ?? 0) > 0; const acceptElement = h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ h('button.button.accept', { attrs: { From a034610362f27bdb08d5314cbf56a5f459733c29 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 11:07:14 +0200 Subject: [PATCH 067/260] relay LCC cache WIP --- modules/relay/src/main/RelayFetch.scala | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index de56354f25587..a495a1c3493b6 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -192,6 +192,8 @@ final private class RelayFetch( s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)).mkString(", ")}" .flatMap(multiPgnToGames(_).toFuture) + private val finishedLccGames = scalalib.cache.OnceEvery.hashCode[String](10.minutes) + private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* formatApi @@ -200,8 +202,12 @@ final private class RelayFetch( case RelayFormat.SingleFile(url) => httpGetPgn(url).map { MultiPgn.split(_, max) } case RelayFormat.LccWithGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => + val finishedIndexes = round.finishedGameIndexes + round.finishedGames.foreach: i => + finishedLccGames.put(s"${lcc.id} ${lcc.round} $i") round.pairings .mapWithIndex: (pairing, i) => + val shouldFetch = val game = i + 1 val tags = pairing.tags(lcc.round, game) httpGetJson[GameJson](lcc.gameUrl(game)) @@ -263,7 +269,9 @@ private object RelayFetch: result.map(Tag(_.Result, _)), Tag(_.Round, s"$round.$game").some ).flatten - case class RoundJson(pairings: List[RoundJsonPairing]) + case class RoundJson(pairings: List[RoundJsonPairing]): + def finishedGameIndexes: List[Int] = pairings.zipWithIndex.collect: + case (pairing, i) if pairing.result.forall(_ != "*") => i given Reads[PairingPlayer] = Json.reads given Reads[RoundJsonPairing] = Json.reads given Reads[RoundJson] = Json.reads From 79350082fbe2b853f175a607bee4bd590a5dfdbb Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 11:42:41 +0200 Subject: [PATCH 068/260] cache finished lcc games --- modules/relay/src/main/RelayFetch.scala | 40 +++++++++++++++++-------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index a495a1c3493b6..71ee01b8aeea6 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -27,6 +27,7 @@ final private class RelayFetch( gameRepo: GameRepo, pgnDump: PgnDump, gameProxy: lila.core.game.GameProxy, + cacheApi: CacheApi, onlyIds: Option[List[RelayTourId]] = None )(using Executor, Scheduler, lila.core.i18n.Translator)(using mode: play.api.Mode): @@ -192,7 +193,22 @@ final private class RelayFetch( s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)).mkString(", ")}" .flatMap(multiPgnToGames(_).toFuture) - private val finishedLccGames = scalalib.cache.OnceEvery.hashCode[String](10.minutes) + // cache finished games so they're not requested again for a while + private object lccCache: + import DgtJson.GameJson + type LccGameKey = String + private val finishedGames = + cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.finishedLccGames"): + _.expireAfterWrite(3 minutes).build() + def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags)( + fetch: () => Fu[GameJson] + ): Fu[GameJson] = + val key = s"${lcc.id} ${lcc.round} $index" + finishedGames.getIfPresent(key) match + case Some(game) => fuccess(game) + case None => + fetch().addEffect: game => + if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* @@ -202,24 +218,20 @@ final private class RelayFetch( case RelayFormat.SingleFile(url) => httpGetPgn(url).map { MultiPgn.split(_, max) } case RelayFormat.LccWithGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => - val finishedIndexes = round.finishedGameIndexes - round.finishedGames.foreach: i => - finishedLccGames.put(s"${lcc.id} ${lcc.round} $i") round.pairings .mapWithIndex: (pairing, i) => - val shouldFetch = val game = i + 1 val tags = pairing.tags(lcc.round, game) - httpGetJson[GameJson](lcc.gameUrl(game)) - .recover: + lccCache(lcc, game, tags): () => + httpGetJson[GameJson](lcc.gameUrl(game)).recover: case _: Exception => GameJson(moves = Nil, result = none) - .map { _.toPgn(tags) } + .map { _.toPgn(tags) } .recover: _ => PgnStr(s"${tags}\n\n${pairing.result}") .map(game -> _) .parallel - .map: results => - MultiPgn(results.sortBy(_._1).map(_._2)) + .map: pgns => + MultiPgn(pgns.sortBy(_._1).map(_._2)) case RelayFormat.LccWithoutGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).map: round => MultiPgn: @@ -278,13 +290,15 @@ private object RelayFetch: case class GameJson(moves: List[String], result: Option[String], chess960: Option[Int] = none): def outcome = result.flatMap(Outcome.fromResult) - def toPgn(extraTags: Tags = Tags.empty): PgnStr = + def mergeRoundTags(roundTags: Tags): Tags = val fenTag = chess960 .filter(_ != 518) // LCC sends 518 for standard chess .flatMap(chess.variant.Chess960.positionToFen) .map(pos => Tag(_.FEN, pos.value)) val outcomeTag = outcome.map(o => Tag(_.Result, Outcome.showResult(o.some))) - val tags = extraTags ++ Tags(List(fenTag, outcomeTag).flatten) + roundTags ++ Tags(List(fenTag, outcomeTag).flatten) + def toPgn(roundTags: Tags): PgnStr = + val mergedTags = mergeRoundTags(roundTags) val strMoves = moves .map(_.split(' ')) .mapWithIndex: (move, index) => @@ -296,7 +310,7 @@ private object RelayFetch: ) .render .mkString(" ") - PgnStr(s"$tags\n\n$strMoves") + PgnStr(s"$mergedTags\n\n$strMoves") given Reads[GameJson] = Json.reads object multiPgnToGames: From 242f18f21502a55ba18e8551853796608c922662 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 12:01:03 +0200 Subject: [PATCH 069/260] fix test --- modules/relay/src/test/GameJsonTest.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/test/GameJsonTest.scala b/modules/relay/src/test/GameJsonTest.scala index 2175da1c05393..a4da810901c28 100644 --- a/modules/relay/src/test/GameJsonTest.scala +++ b/modules/relay/src/test/GameJsonTest.scala @@ -1,5 +1,7 @@ package lila.relay +import chess.format.pgn.Tags + class GameJsonTest extends munit.FunSuite: test("toPgn"): @@ -124,4 +126,4 @@ class GameJsonTest extends munit.FunSuite: """d4 { [%clk 0:30:52] } e6 { [%clk 0:30:18] } Nf3 { [%clk 0:31:02] } Nc6 { [%clk 0:30:21] } e4 { [%clk 0:30:51] } Bb4+ { [%clk 0:29:01] } Nc3 { [%clk 0:31:11] } Bd6 { [%clk 0:28:56] } Bc4 { [%clk 0:30:31] } Nf6 { [%clk 0:29:02] } e5 { [%clk 0:30:00] } Be7 { [%clk 0:27:42] } exf6 { [%clk 0:29:43] } Bxf6 { [%clk 0:27:57] } O-O { [%clk 0:29:23] } O-O { [%clk 0:28:22] } Qd3 { [%clk 0:28:50] } Nb4 { [%clk 0:28:25] } Qd2 { [%clk 0:28:47] } d6 { [%clk 0:28:16] } a3 { [%clk 0:28:58] } Nc6 Ng5 Bxg5 { [%clk 0:26:57] } Qxg5 { [%clk 0:29:06] } Qd7 { [%clk 0:26:54] } Bd3 { [%clk 0:29:05] } f6 { [%clk 0:26:47] } Qh4 { [%clk 0:24:44] } g5 { [%clk 0:25:47] } Qxh7+ { [%clk 0:25:09] } Qxh7 { [%clk 0:24:25] } Bxh7+ { [%clk 0:25:32] } Kxh7 { [%clk 0:23:45] } Nb5 { [%clk 0:25:51] } Rf7 { [%clk 0:23:30] } Rd1 { [%clk 0:25:30] } Rg7 { [%clk 0:23:39] } d5 { [%clk 0:24:41] } Ne5 { [%clk 0:23:46] } Nd4 { [%clk 0:24:29] } Rg6 { [%clk 0:23:50] } Nxe6 { [%clk 0:24:48] } Rh6 f4 { [%clk 0:24:55] } Nf3+ gxf3 { [%clk 0:22:47] } Bxe6 { [%clk 0:21:57] } dxe6 { [%clk 0:23:09] } Re8 { [%clk 0:22:07] } fxg5 { [%clk 0:22:45] } fxg5 { [%clk 0:21:57] } Bxg5 { [%clk 0:23:10] } Rg6 { [%clk 0:22:05] } h4 { [%clk 0:22:43] } Rexe6 { [%clk 0:22:27] } Re1 { [%clk 0:22:30] } Re5 { [%clk 0:20:54] } Rxe5 { [%clk 0:22:31] } dxe5 { [%clk 0:21:06] } Rd1 { [%clk 0:22:49] } Rc6 { [%clk 0:21:02] } c3 { [%clk 0:23:14] } Rb6 { [%clk 0:21:11] } Rd7+ { [%clk 0:23:36] } Kg6 { [%clk 0:21:23] } Bc1 { [%clk 0:23:33] } c5 { [%clk 0:21:29] } Rd5 { [%clk 0:23:58] } Kh5 { [%clk 0:21:27] } Rxe5+ { [%clk 0:24:15] } Kxh4 { [%clk 0:21:40] } Rxc5 { [%clk 0:24:34] } Rg6+ { [%clk 0:22:01] } Kf2 { [%clk 0:24:50] } Rg3 { [%clk 0:22:06] } Rh5+ { [%clk 0:24:52] } Kxh5 { [%clk 0:22:13] } Kxg3 { [%clk 0:25:21] } b5 { [%clk 0:22:28] } Kf4 { [%clk 0:25:49] } a5 { [%clk 0:22:26] } Ke5 { [%clk 0:26:11] } Kh4 { [%clk 0:22:34] } Kd5 { [%clk 0:26:34] } Kg3 { [%clk 0:22:34] } Kc5 { [%clk 0:26:56] } Kxf3 { [%clk 0:22:40] } Kxb5 { [%clk 0:27:18] } Ke2 { [%clk 0:22:54] } c4 { [%clk 0:27:47] } Kd1 a4 Kxc1 { [%clk 0:22:31] } c5 { [%clk 0:28:12] } Kxb2 { [%clk 0:22:29] } c6 Kc3 { [%clk 0:22:28] } c7 { [%clk 0:29:00] } Kd4 { [%clk 0:22:36] } c8=Q Kd5 Qe8 { [%clk 0:28:38] } Kd6 { [%clk 0:22:37] } Qe4 { [%clk 0:28:56] } Kc7 { [%clk 0:22:47] } Qe6 { [%clk 0:29:15] } Kb7 { [%clk 0:22:55] } Qd7+ { [%clk 0:29:29] } Kb8 Kb6 Ka8 { [%clk 0:22:45] } Qc8# { [%clk 0:29:56] }""" val game = RelayFetch.DgtJson.GameJson(moves, None) - assertEquals(game.toPgn().value.trim, expected) + assertEquals(game.toPgn(Tags.empty).value.trim, expected) From 03ebd133248f1f08b85617ffd63bc435a6c913c6 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 12:24:34 +0200 Subject: [PATCH 070/260] efficiently sync broadcast from another round, without going through http or pgn --- modules/relay/src/main/RelayFetch.scala | 24 +++++++++++++++------ modules/relay/src/main/RelayFormat.scala | 13 ++++++++++- modules/relay/src/main/RelayGame.scala | 13 +++++++++++ modules/relay/src/main/RelayRoundRepo.scala | 2 ++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 71ee01b8aeea6..4d6fa7adc1455 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -25,6 +25,7 @@ final private class RelayFetch( delayer: RelayDelay, fidePlayers: RelayFidePlayerApi, gameRepo: GameRepo, + studyChapterRepo: lila.study.ChapterRepo, pgnDump: PgnDump, gameProxy: lila.core.game.GameProxy, cacheApi: CacheApi, @@ -191,7 +192,7 @@ final private class RelayFetch( else throw LilaInvalid: s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)).mkString(", ")}" - .flatMap(multiPgnToGames(_).toFuture) + .flatMap(multiPgnToGames.future) // cache finished games so they're not requested again for a while private object lccCache: @@ -215,7 +216,12 @@ final private class RelayFetch( formatApi .get(url) .flatMap { - case RelayFormat.SingleFile(url) => httpGetPgn(url).map { MultiPgn.split(_, max) } + case RelayFormat.Round(id) => + studyChapterRepo + .orderedByStudyLoadingAllInMemory(id.into(StudyId)) + .map(_.view.map(RelayGame.fromChapter).toVector) + case RelayFormat.SingleFile(url) => + httpGetPgn(url).map { MultiPgn.split(_, max) }.flatMap(multiPgnToGames.future) case RelayFormat.LccWithGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => round.pairings @@ -232,13 +238,15 @@ final private class RelayFetch( .parallel .map: pgns => MultiPgn(pgns.sortBy(_._1).map(_._2)) + .flatMap(multiPgnToGames.future) case RelayFormat.LccWithoutGames(lcc) => - httpGetJson[RoundJson](lcc.indexUrl).map: round => - MultiPgn: - round.pairings.mapWithIndex: (pairing, i) => - PgnStr(s"${pairing.tags(lcc.round, i + 1)}\n\n${pairing.result}") + httpGetJson[RoundJson](lcc.indexUrl) + .map: round => + MultiPgn: + round.pairings.mapWithIndex: (pairing, i) => + PgnStr(s"${pairing.tags(lcc.round, i + 1)}\n\n${pairing.result}") + .flatMap(multiPgnToGames.future) } - .flatMap { multiPgnToGames(_).toFuture } private def httpGetPgn(url: URL)(using CanProxy): Fu[PgnStr] = PgnStr.from(formatApi.httpGetAndGuessCharset(url)) @@ -326,6 +334,8 @@ private object RelayFetch: else (acc :+ game, index + 1).asRight[LilaInvalid] .map(_._1) + def future(multiPgn: MultiPgn): Fu[Vector[RelayGame]] = apply(multiPgn).toFuture + private val pgnCache: LoadingCache[PgnStr, Either[LilaInvalid, RelayGame]] = CacheApi .scaffeineNoScheduler(using scala.concurrent.ExecutionContextOpportunistic) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index 79eb74b25cf60..e711760aa709f 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -21,6 +21,7 @@ import lila.memo.{ CacheApi, SettingStore } import lila.study.MultiPgn final private class RelayFormatApi( + roundRepo: RelayRoundRepo, ws: StandaloneWSClient, cacheApi: CacheApi, proxyCredentials: SettingStore[Option[Credentials]] @@ ProxyCredentials, @@ -56,11 +57,20 @@ final private class RelayFormatApi( .map: if _ then LccWithGames(lcc).some else LccWithoutGames(lcc).some - case None => looksLikePgn(url).mapz(SingleFile(url).some) + case None => + guessRelayRound(url).orElse: + looksLikePgn(url).mapz(SingleFile(url).some) .orFailWith(LilaInvalid(s"No games found at $url")) .addEffect: format => logger.info(s"guessed format of $url: $format") + private def guessRelayRound(url: URL): Fu[Option[RelayFormat.Round]] = + url.path.split("/") match + case Array("", "broadcast", _, _, id) if id.size == 8 => + val roundId = RelayRoundId(id) + roundRepo.exists(roundId).map(_.option(RelayFormat.Round(roundId))) + case _ => fuccess(none) + private[relay] def httpGet(url: URL)(using CanProxy): Fu[String] = httpGetResponse(url).map(_.body) @@ -121,6 +131,7 @@ final private class RelayFormatApi( private def looksLikeJson(url: URL)(using CanProxy): Fu[Boolean] = httpGet(url).map(looksLikeJson) private enum RelayFormat: + case Round(id: RelayRoundId) case SingleFile(url: URL) case LccWithGames(lcc: RelayRound.Sync.Lcc) // there will be game files with names like "game-1.json" or "game-1.pgn" diff --git a/modules/relay/src/main/RelayGame.scala b/modules/relay/src/main/RelayGame.scala index f0c261ac61b99..f762701e2a2e9 100644 --- a/modules/relay/src/main/RelayGame.scala +++ b/modules/relay/src/main/RelayGame.scala @@ -53,6 +53,19 @@ private object RelayGame: val whiteTags: TagNames = List(_.White, _.WhiteFideId) val blackTags: TagNames = List(_.Black, _.BlackFideId) + def fromChapter(c: lila.study.Chapter) = RelayGame( + tags = c.tags, + variant = c.setup.variant, + root = c.root, + ending = c.tags.outcome.map: out => + StudyPgnImport.End( + status = chess.Status.UnknownFinish, + outcome = out, + resultText = chess.Outcome.showResult(out.some), + statusText = "" + ) + ) + import scalalib.Iso import chess.format.pgn.{ InitialComments, Pgn } val iso: Iso[RelayGames, MultiPgn] = diff --git a/modules/relay/src/main/RelayRoundRepo.scala b/modules/relay/src/main/RelayRoundRepo.scala index eedde0a66ac91..00e49732913ef 100644 --- a/modules/relay/src/main/RelayRoundRepo.scala +++ b/modules/relay/src/main/RelayRoundRepo.scala @@ -10,6 +10,8 @@ final private class RelayRoundRepo(val coll: Coll)(using Executor): import RelayRoundRepo.* import BSONHandlers.given + def exists(id: RelayRoundId): Fu[Boolean] = coll.exists($id(id)) + def byTourOrderedCursor(tourId: RelayTourId) = coll .find(selectors.tour(tourId)) From bdf095004468c872d8bb5812ee655e4468664df2 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 12:28:32 +0200 Subject: [PATCH 071/260] Revert "Update scala3-library to 3.5.0-RC2" This reverts commit d4c6d4463d8274fb11ac0027b2f070a6f5e8dcf1. Until the warnings are dealt with. --- project/BuildSettings.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 65a8bff632d02..addfdc497f893 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -6,7 +6,7 @@ object BuildSettings { import Dependencies._ val lilaVersion = "4.0" - val globalScalaVersion = "3.5.0-RC2" + val globalScalaVersion = "3.5.0-RC1" def buildSettings = Defaults.coreDefaultSettings ++ Seq( From a665a44b7b73ab2810e01eb4316fbac67e2dd877 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 12:42:21 +0200 Subject: [PATCH 072/260] simplify RelayGame.outcome --- modules/relay/src/main/RelayFetch.scala | 4 ++-- modules/relay/src/main/RelayGame.scala | 17 +++++++---------- modules/relay/src/main/RelayPush.scala | 4 ++-- modules/relay/src/main/RelaySync.scala | 9 +++++---- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 4d6fa7adc1455..7af9cef3a8e47 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -95,7 +95,7 @@ final private class RelayFetch( .map: res => res -> updating: _.withSync(_.addLog(SyncLog.event(res.nbMoves, none))) - .copy(finished = games.nonEmpty && games.forall(_.ending.isDefined)) + .copy(finished = games.nonEmpty && games.forall(_.outcome.isDefined)) .recover: case e: Exception => val result = e.match @@ -358,5 +358,5 @@ private object RelayFetch: comments = Comments.empty, children = res.root.children.updateMainline(_.copy(comments = Comments.empty)) ), - ending = res.end + outcome = res.end.map(_.outcome) ) diff --git a/modules/relay/src/main/RelayGame.scala b/modules/relay/src/main/RelayGame.scala index f762701e2a2e9..0a033b8e44edf 100644 --- a/modules/relay/src/main/RelayGame.scala +++ b/modules/relay/src/main/RelayGame.scala @@ -4,12 +4,13 @@ import chess.format.pgn.{ Tag, TagType, Tags } import lila.study.{ MultiPgn, StudyPgnImport, PgnDump } import lila.tree.Root +import chess.Outcome case class RelayGame( tags: Tags, variant: chess.variant.Variant, root: Root, - ending: Option[StudyPgnImport.End] + outcome: Option[Outcome] ): // We don't use tags.boardNumber. @@ -31,8 +32,8 @@ case class RelayGame( def resetToSetup = copy( root = root.withoutChildren, - ending = None, - tags = tags.copy(value = tags.value.filter(_.name != Tag.Result)) + tags = tags.copy(value = tags.value.filter(_.name != Tag.Result)), + outcome = None ) def fideIdsPair: Option[PairOf[Option[chess.FideId]]] = @@ -42,6 +43,8 @@ case class RelayGame( List(RelayGame.whiteTags, RelayGame.blackTags).exists: _.forall(tag => tags(tag).isEmpty) + def showResult = Outcome.showResult(outcome) + private object RelayGame: val lichessDomains = List("lichess.org", "lichess.dev") @@ -57,13 +60,7 @@ private object RelayGame: tags = c.tags, variant = c.setup.variant, root = c.root, - ending = c.tags.outcome.map: out => - StudyPgnImport.End( - status = chess.Status.UnknownFinish, - outcome = out, - resultText = chess.Outcome.showResult(out.some), - statusText = "" - ) + outcome = c.tags.outcome ) import scalalib.Iso diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 96b45d85c5b75..511a2064ec91c 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -55,7 +55,7 @@ final class RelayPush( .update(rt.round): r1 => val r2 = r1.withSync(_.addLog(event)) val r3 = if event.hasMoves then r2.ensureStarted.resume(rt.tour.official) else r2 - r3.copy(finished = games.nonEmpty && games.forall(_.ending.isDefined)) + r3.copy(finished = games.nonEmpty && games.forall(_.outcome.isDefined)) private def pgnToGames(pgnBody: PgnStr): List[Either[Failure, RelayGame]] = MultiPgn @@ -75,7 +75,7 @@ final class RelayPush( children = game.root.children .updateMainline(_.copy(comments = lila.tree.Node.Comments.empty)) ), - ending = game.end + outcome = game.end.map(_.outcome) ) ) diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index 50b8ee4b7cc9f..0071fdb9c1bae 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -124,10 +124,11 @@ final private class RelaySync( val gameTags = game.tags.value.foldLeft(Tags(Nil)): (newTags, tag) => if !chapter.tags.value.has(tag) then newTags + tag else newTags - val newEndTag = game.ending - .ifFalse(gameTags(_.Result).isDefined) - .filterNot(end => chapter.tags(_.Result).has(end.resultText)) - .map(end => Tag(_.Result, end.resultText)) + val newEndTag = ( + game.outcome.isDefined && + gameTags(_.Result).isEmpty && + !chapter.tags(_.Result).has(game.showResult) + ).option(Tag(_.Result, game.showResult)) val tags = newEndTag.fold(gameTags)(gameTags + _) val chapterNewTags = tags.value.foldLeft(chapter.tags): (chapterTags, tag) => PgnTags(chapterTags + tag) From 0b9065537d6e40b08771fc0901fce9e6b8f57468 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:02:32 +0200 Subject: [PATCH 073/260] push is first choice --- modules/relay/src/main/RelayRoundForm.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index f9182510b8d11..01dff3d7a0bfe 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -80,10 +80,10 @@ final class RelayRoundForm(using mode: Mode): object RelayRoundForm: val sourceTypes = List( + "push" -> "Broadcaster App", "url" -> "Single PGN URL", "urls" -> "Combine several PGN URLs", - "ids" -> "Lichess game IDs", - "push" -> "Push local games" + "ids" -> "Lichess game IDs" ) private val roundNumberRegex = """([^\d]*)(\d{1,2})([^\d]*)""".r From eb533d312f1402bb42606622b10527e7ab4252fe Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:02:53 +0200 Subject: [PATCH 074/260] refactor round sync detection --- modules/relay/src/main/RelayFormat.scala | 10 +++++----- modules/relay/src/main/RelayRound.scala | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/relay/src/main/RelayFormat.scala b/modules/relay/src/main/RelayFormat.scala index e711760aa709f..1cfb173bfa0fd 100644 --- a/modules/relay/src/main/RelayFormat.scala +++ b/modules/relay/src/main/RelayFormat.scala @@ -65,11 +65,11 @@ final private class RelayFormatApi( logger.info(s"guessed format of $url: $format") private def guessRelayRound(url: URL): Fu[Option[RelayFormat.Round]] = - url.path.split("/") match - case Array("", "broadcast", _, _, id) if id.size == 8 => - val roundId = RelayRoundId(id) - roundRepo.exists(roundId).map(_.option(RelayFormat.Round(roundId))) - case _ => fuccess(none) + RelayRound.Sync.Upstream + .Url(url) + .roundId + .so: id => + roundRepo.exists(id).map(_.option(RelayFormat.Round(id))) private[relay] def httpGet(url: URL)(using CanProxy): Fu[String] = httpGetResponse(url).map(_.body) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 781d88444563f..f2495fa5c0ea2 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -126,6 +126,13 @@ object RelayRound: case _ => none case _ => none def isLcc = lcc.isDefined + def roundId: Option[RelayRoundId] = this match + case Url(url) => + url.path.split("/") match + case Array("", "broadcast", _, _, id) if id.size == 8 => + RelayRoundId(id).some + case _ => none + case _ => none case class Lcc(id: String, round: Int): def pageUrl = URL.parse(s"https://view.livechesscloud.com/#$id/$round") From b57d86bd1f57a7bc9972a77217f6d70a83f66aa4 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:03:18 +0200 Subject: [PATCH 075/260] broadcast dynamic period - faster when 5 or more watch the round --- modules/relay/src/main/RelayFetch.scala | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 7af9cef3a8e47..176058e28b552 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -140,10 +140,7 @@ final private class RelayFetch( .filterNot(_.contains("Found an empty PGN")) .foreach { irc.broadcastError(round.id, round.withTour(tour).fullName, _) } Seconds(60) - else - round.sync.period | Seconds: - if upstream.isLcc && !tour.official then 12 - else 5 + else round.sync.period | dynamicPeriod(tour, round, upstream) updating: _.withSync: _.copy( @@ -154,6 +151,17 @@ final private class RelayFetch( } some ) + private def dynamicPeriod(tour: RelayTour, round: RelayRound, upstream: Sync.Upstream) = Seconds: + val base = + if upstream.isLcc then 6 + else if upstream.roundId.isDefined then 5 // uses push so no need to pull often + else 3 + base * { + if tour.official then 1 else 2 + } * { + if round.crowd.exists(_ > 4) then 1 else 2 + } + private val gameIdsUpstreamPgnFlags = PgnDump.WithFlags( clocks = true, moves = true, From 4be8368e70725b6d7447729198cc767f6e1d40ad Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:33:26 +0200 Subject: [PATCH 076/260] relay scala tweaks and queue size --- modules/relay/src/main/RelayFetch.scala | 4 ++-- modules/relay/src/main/RelayPush.scala | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 176058e28b552..7bf41d3343122 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -44,7 +44,7 @@ final private class RelayFetch( LilaScheduler( "RelayFetch.user", - _.Every(if mode.isDev then 2.seconds else 750 millis), + _.Every(if mode.isDev then 2.seconds else 879 millis), _.AtMost(10 seconds), _.Delay(if mode.isDev then 2.second else 33 seconds) ): @@ -154,7 +154,7 @@ final private class RelayFetch( private def dynamicPeriod(tour: RelayTour, round: RelayRound, upstream: Sync.Upstream) = Seconds: val base = if upstream.isLcc then 6 - else if upstream.roundId.isDefined then 5 // uses push so no need to pull often + else if upstream.isRound then 5 // uses push so no need to pull often else 3 base * { if tour.official then 1 else 2 diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 511a2064ec91c..15481cafe8071 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -17,7 +17,7 @@ final class RelayPush( )(using ActorSystem, Executor, Scheduler): private val workQueue = AsyncActorSequencers[RelayRoundId]( - maxSize = Max(8), + maxSize = Max(32), expiration = 1 minute, timeout = 10 seconds, name = "relay.push", From cae053b22477e9188eb31a60f158641358e1f09d Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:33:37 +0200 Subject: [PATCH 077/260] better detect and display round sync --- modules/relay/src/main/RelayRound.scala | 6 ++++-- modules/relay/src/main/ui/FormUi.scala | 10 +++++++++- ui/bits/css/relay/_form.scss | 11 +++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index f2495fa5c0ea2..baa68066a2fb1 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -129,10 +129,12 @@ object RelayRound: def roundId: Option[RelayRoundId] = this match case Url(url) => url.path.split("/") match - case Array("", "broadcast", _, _, id) if id.size == 8 => - RelayRoundId(id).some + case Array("", "broadcast", _, _, id) => + val cleanId = if id.endsWith(".pgn") then id.dropRight(4) else id + (cleanId.size == 8).option(RelayRoundId(cleanId)) case _ => none case _ => none + def isRound = roundId.isDefined case class Lcc(id: String, round: Int): def pageUrl = URL.parse(s"https://view.livechesscloud.com/#$id/$round") diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 21609be40b09b..3b14ebf38d673 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -176,7 +176,15 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): form("syncUrl"), trb.sourceSingleUrl(), help = trb.sourceUrlHelp().some - )(form3.input(_)) + )(form3.input(_)), + round + .flatMap(_.sync.upstream) + .flatMap(_.roundId) + .map: roundId => + flashMessage("round-push")( + "Getting real-time updates from the broadcast ", + a(href := routes.RelayRound.show("-", "-", roundId))("#", roundId) + ) ), form3.group( form("syncUrls"), diff --git a/ui/bits/css/relay/_form.scss b/ui/bits/css/relay/_form.scss index e61f12a8163b4..37b098ec7af2a 100644 --- a/ui/bits/css/relay/_form.scss +++ b/ui/bits/css/relay/_form.scss @@ -39,6 +39,17 @@ } } +.flash-round-push { + border: 5px solid $c-good; + background: $c-bg-box; + + &::before { + color: $c-good; + content: $licon-Checkmark; + font-size: 4em; + } +} + .relay-image { max-width: 100%; height: auto; From 4c256df309d29db46ea4fe08bc834298d9f7722c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 15:51:06 +0200 Subject: [PATCH 078/260] broadcast push visual feedback --- ui/analyse/src/study/relay/relayManagerView.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/analyse/src/study/relay/relayManagerView.ts b/ui/analyse/src/study/relay/relayManagerView.ts index e9de014b60a23..1886b7630cd33 100644 --- a/ui/analyse/src/study/relay/relayManagerView.ts +++ b/ui/analyse/src/study/relay/relayManagerView.ts @@ -17,7 +17,7 @@ export default function (ctrl: RelayCtrl, study: StudyCtrl): MaybeVNode { h('span.text', { attrs: dataIcon(licon.RadioTower) }, 'Broadcast manager'), h('a', { attrs: { href: `/broadcast/round/${ctrl.id}/edit`, 'data-icon': licon.Gear } }), ]), - sync?.url || sync?.ids || sync?.urls ? (sync.ongoing ? stateOn : stateOff)(ctrl) : null, + sync?.url || sync?.ids || sync?.urls ? (sync.ongoing ? stateOn : stateOff)(ctrl) : statePush(), renderLog(ctrl), ]) : undefined, @@ -79,6 +79,9 @@ const stateOff = (ctrl: RelayCtrl) => [h('div.fat', 'Click to connect')], ); +const statePush = () => + h('div.state.push', { attrs: dataIcon(licon.UploadCloud) }, ['Listening to Broadcaster App']); + const dateFormatter = memoize(() => window.Intl && Intl.DateTimeFormat ? new Intl.DateTimeFormat(document.documentElement.lang, { From e2fe02b0bb628101ff3348f9d63c843341fd3c92 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 16:52:13 +0200 Subject: [PATCH 079/260] propagate broadcast updates to sync targets --- modules/relay/src/main/BSONHandlers.scala | 24 +++++----- modules/relay/src/main/RelayApi.scala | 4 ++ modules/relay/src/main/RelayFetch.scala | 12 ++--- modules/relay/src/main/RelayPush.scala | 49 ++++++++++++--------- modules/relay/src/main/RelayRound.scala | 4 ++ modules/relay/src/main/RelayRoundRepo.scala | 8 ++++ modules/relay/src/main/RelaySync.scala | 5 ++- 7 files changed, 65 insertions(+), 41 deletions(-) diff --git a/modules/relay/src/main/BSONHandlers.scala b/modules/relay/src/main/BSONHandlers.scala index e7401f87f95d8..625b4b3a90445 100644 --- a/modules/relay/src/main/BSONHandlers.scala +++ b/modules/relay/src/main/BSONHandlers.scala @@ -2,6 +2,7 @@ package lila.relay import reactivemongo.api.bson.* +import lila.db.BSON import lila.db.dsl.{ *, given } object BSONHandlers: @@ -15,18 +16,17 @@ object BSONHandlers: given upstreamUrlsHandler: BSONDocumentHandler[Upstream.Urls] = Macros.handler given upstreamIdsHandler: BSONDocumentHandler[Upstream.Ids] = Macros.handler - given BSONHandler[Upstream] = tryHandler( - { - case d: BSONDocument if d.contains("url") => upstreamUrlHandler.readTry(d) - case d: BSONDocument if d.contains("urls") => upstreamUrlsHandler.readTry(d) - case d: BSONDocument if d.contains("ids") => upstreamIdsHandler.readTry(d) - }, - { - case url: Upstream.Url => upstreamUrlHandler.writeTry(url).get - case urls: Upstream.Urls => upstreamUrlsHandler.writeTry(urls).get - case ids: Upstream.Ids => upstreamIdsHandler.writeTry(ids).get - } - ) + given BSONHandler[Upstream] = new BSON[Upstream]: + def reads(r: BSON.Reader): Upstream = + if r.contains("url") then upstreamUrlHandler.readTry(r.doc).get + else if r.contains("urls") then upstreamUrlsHandler.readTry(r.doc).get + else upstreamIdsHandler.readTry(r.doc).get + def writes(w: BSON.Writer, up: Upstream) = + val doc = up match + case url: Upstream.Url => upstreamUrlHandler.writeTry(url).get + case urls: Upstream.Urls => upstreamUrlsHandler.writeTry(urls).get + case ids: Upstream.Ids => upstreamIdsHandler.writeTry(ids).get + doc ++ up.roundIds.some.filter(_.nonEmpty).so(ids => $doc("roundIds" -> ids)) import SyncLog.Event given BSONDocumentHandler[Event] = Macros.handler diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 328b99f7737c0..8ca28a4ef4326 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -275,6 +275,10 @@ final class RelayApi( sendToContributors(round.id, "relayLog", Json.toJsObject(event)) round + def syncTargetsOfSource(source: RelayRound): Funit = + (!source.sync.upstream.exists(_.isRound)).so: // prevent chaining (and circular!) round updates + roundRepo.syncTargetsOfSource(source.id) + def reset(old: RelayRound)(using me: Me): Funit = WithRelay(old.id) { relay => for diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 7bf41d3343122..e2622997ded2b 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -120,10 +120,12 @@ final private class RelayFetch( ): Updating[RelayRound] = val round = updating.current result match - case result: SyncResult.Ok if result.nbMoves > 0 => - lila.mon.relay.moves(tour.official, round.slug).increment(result.nbMoves) - if !round.hasStarted && !tour.official then - irc.broadcastStart(round.id, round.withTour(tour).fullName) + case result: SyncResult.Ok if result.hasMovesOrTags => + api.syncTargetsOfSource(round) + if result.nbMoves > 0 then + lila.mon.relay.moves(tour.official, round.slug).increment(result.nbMoves) + if !round.hasStarted && !tour.official then + irc.broadcastStart(round.id, round.withTour(tour).fullName) continueRelay(tour, updating(_.ensureStarted.resume(tour.official))) case _ => continueRelay(tour, updating) @@ -154,7 +156,7 @@ final private class RelayFetch( private def dynamicPeriod(tour: RelayTour, round: RelayRound, upstream: Sync.Upstream) = Seconds: val base = if upstream.isLcc then 6 - else if upstream.isRound then 5 // uses push so no need to pull often + else if upstream.isRound then 10 // uses push so no need to pull often else 3 base * { if tour.official then 1 else 2 diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 15481cafe8071..447ec454d56e9 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -31,31 +31,36 @@ final class RelayPush( if rt.round.sync.hasUpstream then fuccess(List(Left(Failure(Tags.empty, "The relay has an upstream URL, and cannot be pushed to.")))) else - val parsed = pgnToGames(pgn) - val games = parsed.collect { case Right(g) => g }.toVector - val response = parsed.map(_.map(g => Success(g.tags, g.root.mainline.size))) + val parsed = pgnToGames(pgn) + val games = parsed.collect { case Right(g) => g }.toVector + val response: List[Either[Failure, Success]] = + parsed.map(_.map(g => Success(g.tags, g.root.mainline.size))) + val andSyncTargets = response.exists(_.isRight) - rt.round.sync.nonEmptyDelay.fold(push(rt, games).inject(response)): delay => - after(delay.value.seconds)(push(rt, games)) - fuccess(response) + rt.round.sync.nonEmptyDelay match + case None => push(rt, games, andSyncTargets).inject(response) + case Some(delay) => + after(delay.value.seconds)(push(rt, games, andSyncTargets)) + fuccess(response) - private def push(rt: RelayRound.WithTour, games: Vector[RelayGame]) = + private def push(rt: RelayRound.WithTour, games: Vector[RelayGame], andSyncTargets: Boolean) = workQueue(rt.round.id): - sync - .updateStudyChapters(rt, rt.tour.players.fold(games)(_.update(games))) - .map: res => - SyncLog.event(res.nbMoves, none) - .recover: - case e: Exception => SyncLog.event(0, e.some) - .flatMap: event => - if !rt.round.hasStarted && !rt.tour.official && event.hasMoves then - irc.broadcastStart(rt.round.id, rt.fullName) - stats.setActive(rt.round.id) - api - .update(rt.round): r1 => - val r2 = r1.withSync(_.addLog(event)) - val r3 = if event.hasMoves then r2.ensureStarted.resume(rt.tour.official) else r2 - r3.copy(finished = games.nonEmpty && games.forall(_.outcome.isDefined)) + for + event <- sync + .updateStudyChapters(rt, rt.tour.players.fold(games)(_.update(games))) + .map: res => + SyncLog.event(res.nbMoves, none) + .recover: + case e: Exception => SyncLog.event(0, e.some) + _ = if !rt.round.hasStarted && !rt.tour.official && event.hasMoves then + irc.broadcastStart(rt.round.id, rt.fullName) + _ = stats.setActive(rt.round.id) + round <- api.update(rt.round): r1 => + val r2 = r1.withSync(_.addLog(event)) + val r3 = if event.hasMoves then r2.ensureStarted.resume(rt.tour.official) else r2 + r3.copy(finished = games.nonEmpty && games.forall(_.outcome.isDefined)) + _ <- andSyncTargets.so(api.syncTargetsOfSource(round)) + yield () private def pgnToGames(pgnBody: PgnStr): List[Either[Failure, RelayGame]] = MultiPgn diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index baa68066a2fb1..9c4259ec7e96a 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -135,6 +135,10 @@ object RelayRound: case _ => none case _ => none def isRound = roundId.isDefined + def roundIds: List[RelayRoundId] = this match + case url: Url => url.roundId.toList + case Urls(urls) => urls.map(Url.apply).flatMap(_.roundId) + case _ => Nil case class Lcc(id: String, round: Int): def pageUrl = URL.parse(s"https://view.livechesscloud.com/#$id/$round") diff --git a/modules/relay/src/main/RelayRoundRepo.scala b/modules/relay/src/main/RelayRoundRepo.scala index 00e49732913ef..74b267e162c40 100644 --- a/modules/relay/src/main/RelayRoundRepo.scala +++ b/modules/relay/src/main/RelayRoundRepo.scala @@ -51,6 +51,14 @@ final private class RelayRoundRepo(val coll: Coll)(using Executor): def studyIdsOf(tourId: RelayTourId): Fu[List[StudyId]] = coll.distinctEasy[StudyId, List]("_id", selectors.tour(tourId)) + def syncTargetsOfSource(source: RelayRoundId): Funit = + coll.update + .one( + $doc("sync.until".$exists(true), "sync.upstream.roundIds" -> source), + $set("sync.nextAt" -> nowInstant) + ) + .void + private object RelayRoundRepo: object sort: diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index 0071fdb9c1bae..6889ea3f654a5 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -216,8 +216,9 @@ sealed trait SyncResult: val reportKey: String object SyncResult: case class Ok(chapters: List[ChapterResult], games: RelayGames) extends SyncResult: - def nbMoves = chapters.foldLeft(0)(_ + _.newMoves) - val reportKey = "ok" + def nbMoves = chapters.foldLeft(0)(_ + _.newMoves) + def hasMovesOrTags = chapters.exists(c => c.newMoves > 0 || c.tagUpdate) + val reportKey = "ok" case object Timeout extends Exception with SyncResult with util.control.NoStackTrace: val reportKey = "timeout" override def getMessage = "In progress..." From 8125ff09b23027a26e5f22ea62c9dac38377d3ab Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 18:00:59 +0200 Subject: [PATCH 080/260] reset broadcast round with API --- app/controllers/RelayRound.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index 1f8061a742b24..ac81335847ffd 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -94,9 +94,12 @@ final class RelayRound( ) } - def reset(id: RelayRoundId) = Auth { ctx ?=> me ?=> + def reset(id: RelayRoundId) = AuthOrScoped(_.Study.Write) { ctx ?=> me ?=> Found(env.relay.api.byIdAndContributor(id)): rt => - env.relay.api.reset(rt.round).inject(Redirect(rt.path)) + env.relay.api + .reset(rt.round) + .flatMap: _ => + negotiate(Redirect(rt.path), jsonOkResult) } def show(ts: String, rs: String, id: RelayRoundId, embed: Option[UserStr]) = From 789d5cc97aefed0856843e8574696b96ba5547c4 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 18:02:32 +0200 Subject: [PATCH 081/260] code golf --- app/controllers/RelayRound.scala | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index ac81335847ffd..223e9d043e13d 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -96,10 +96,7 @@ final class RelayRound( def reset(id: RelayRoundId) = AuthOrScoped(_.Study.Write) { ctx ?=> me ?=> Found(env.relay.api.byIdAndContributor(id)): rt => - env.relay.api - .reset(rt.round) - .flatMap: _ => - negotiate(Redirect(rt.path), jsonOkResult) + env.relay.api.reset(rt.round) >> negotiate(Redirect(rt.path), jsonOkResult) } def show(ts: String, rs: String, id: RelayRoundId, embed: Option[UserStr]) = From bd627e372f97f1f39ffcc34756c668ab16414539 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 19:53:06 +0200 Subject: [PATCH 082/260] don't sync a round on itself --- modules/relay/src/main/RelayRoundForm.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index 01dff3d7a0bfe..1a3d6ae130710 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -75,7 +75,12 @@ final class RelayRoundForm(using mode: Mode): ) ).fill(fillFromPrevRounds(trs.rounds)) - def edit(r: RelayRound) = Form(roundMapping).fill(Data.make(r)) + def edit(r: RelayRound) = Form( + roundMapping.verifying( + "The round source cannot be itself", + d => d.syncSource.pp.forall(_ != "url") || d.syncUrl.forall(_.roundId.forall(_ != r.id)) + ) + ).fill(Data.make(r)) object RelayRoundForm: From 4d513bfb66f87b117e892e43eab679887793321f Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 20:10:57 +0200 Subject: [PATCH 083/260] broadcast round source details --- modules/relay/src/main/RelayApi.scala | 6 ++-- modules/relay/src/main/ui/FormUi.scala | 45 +++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 8ca28a4ef4326..5f3649b79cd9c 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -53,8 +53,10 @@ final class RelayApi( byIdWithTour(id).flatMapz(rt => formNavigation(rt).dmap(some)) def formNavigation(rt: RelayRound.WithTour): Fu[(RelayRound, ui.FormNavigation)] = - formNavigation(rt.tour).map: nav => - (rt.round, nav.copy(round = rt.round.id.some)) + for + nav <- formNavigation(rt.tour) + sourceRound <- rt.round.sync.upstream.flatMap(_.roundId).so(byIdWithTour) + yield (rt.round, nav.copy(round = rt.round.id.some, sourceRound = sourceRound)) def formNavigation(tour: RelayTour): Fu[ui.FormNavigation] = for group <- withTours.get(tour.id) diff --git a/modules/relay/src/main/ui/FormUi.scala b/modules/relay/src/main/ui/FormUi.scala index 3b14ebf38d673..01062cbd3fcb3 100644 --- a/modules/relay/src/main/ui/FormUi.scala +++ b/modules/relay/src/main/ui/FormUi.scala @@ -13,6 +13,7 @@ case class FormNavigation( tour: RelayTour, rounds: List[RelayRound], round: Option[RelayRoundId], + sourceRound: Option[RelayRound.WithTour] = none, newRound: Boolean = false ): def tourWithGroup = RelayTour.WithGroupTours(tour, group) @@ -102,13 +103,17 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): inner(form, routes.RelayRound.create(nav.tour.id), nav.tour, round = none) ) - def edit(r: RelayRound, form: Form[RelayRoundForm.Data], nav: FormNavigation)(using Context) = + def edit( + r: RelayRound, + form: Form[RelayRoundForm.Data], + nav: FormNavigation + )(using Context) = page(r.name.value, nav): val rt = r.withTour(nav.tour) frag( boxTop(h1(a(href := rt.path)(rt.fullName))), standardFlash, - inner(form, routes.RelayRound.update(r.id), nav.tour, round = r.some), + inner(form, routes.RelayRound.update(r.id), nav.tour, round = r.some, nav.sourceRound), div(cls := "relay-form__actions")( postForm(action := routes.RelayRound.reset(r.id))( submitButton( @@ -130,7 +135,8 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): form: Form[RelayRoundForm.Data], url: play.api.mvc.Call, t: RelayTour, - round: Option[RelayRound] + round: Option[RelayRound], + sourceRound: Option[RelayRound.WithTour] = none )(using ctx: Context) = postForm(cls := "form3", action := url)( (!Granter.opt(_.StudyAdmin)).option: @@ -177,14 +183,31 @@ final class FormUi(helpers: Helpers, ui: RelayUi, tourUi: RelayTourUi): trb.sourceSingleUrl(), help = trb.sourceUrlHelp().some )(form3.input(_)), - round - .flatMap(_.sync.upstream) - .flatMap(_.roundId) - .map: roundId => - flashMessage("round-push")( - "Getting real-time updates from the broadcast ", - a(href := routes.RelayRound.show("-", "-", roundId))("#", roundId) - ) + sourceRound.map: source => + flashMessage("round-push")( + "Getting real-time updates from ", + strong(a(href := source.path)(source.fullName)), + br, + "Owner: ", + userIdLink(source.tour.ownerId.some), + br, + "Delay: ", + source.round.sync.delay.fold("0")(_.toString), + "s", + br, + "Start: ", + source.round.startedAt.orElse(source.round.startsAt).fold(frag("unscheduled"))(momentFromNow), + br, + "Last sync: ", + source.round.sync.log.events.lastOption.map: event => + frag( + momentFromNow(event.at), + br, + event.error match + case Some(err) => s"❌ $err" + case _ => s"✅ ${event.moves} moves" + ) + ) ), form3.group( form("syncUrls"), From f1d34f5a657a2b1d0bfbc4fc6ca045a76c8654c7 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 20:18:08 +0200 Subject: [PATCH 084/260] don't add typing that's not actually used we only care about the presence of rules, not the rules themselves, so let's not hardcode them here --- ui/challenge/src/interfaces.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/challenge/src/interfaces.ts b/ui/challenge/src/interfaces.ts index c0dbd7a50d0ce..2f7d160c3cbdf 100644 --- a/ui/challenge/src/interfaces.ts +++ b/ui/challenge/src/interfaces.ts @@ -9,7 +9,6 @@ export interface ChallengeOpts { } type ChallengeStatus = 'created' | 'offline' | 'canceled' | 'declined' | 'accepted'; -type GameRule = 'noAbort' | 'noRematch' | 'noGiveTime' | 'noClaimWin' | 'noEarlyDraw'; export type ChallengeDirection = 'in' | 'out'; export interface ChallengeUser { @@ -38,7 +37,7 @@ export interface Challenge { status: ChallengeStatus; challenger?: ChallengeUser; destUser?: ChallengeUser; - rules?: GameRule[]; + rules?: unknown[]; variant: Variant; initialFen: FEN; rated: boolean; From 19a4483192ac5d42c2d6eaa6332b82fb646cce24 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 20:19:14 +0200 Subject: [PATCH 085/260] lazily instanciate challenge button vdom since only one of the two will actually be used --- ui/challenge/src/view.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/ui/challenge/src/view.ts b/ui/challenge/src/view.ts index 8df6314d4baf0..558c72d4999a3 100644 --- a/ui/challenge/src/view.ts +++ b/ui/challenge/src/view.ts @@ -75,23 +75,25 @@ function challenge(ctrl: ChallengeCtrl, dir: ChallengeDirection) { function inButtons(ctrl: ChallengeCtrl, c: Challenge): VNode[] { const viewInsteadOfAccept = (c.rules?.length ?? 0) > 0; - const acceptElement = h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ - h('button.button.accept', { - attrs: { - type: 'submit', - 'aria-describedby': `challenge-text-${c.id}`, - 'data-icon': licon.Checkmark, - title: ctrl.trans('accept'), - }, - hook: onClick(ctrl.onRedirect), - }), - ]); - const viewElement = h('a.view', { - attrs: { 'data-icon': licon.Eye, href: '/' + c.id, title: ctrl.trans('viewInFullSize') }, - }); + const acceptElement = () => + h('form', { attrs: { method: 'post', action: `/challenge/${c.id}/accept` } }, [ + h('button.button.accept', { + attrs: { + type: 'submit', + 'aria-describedby': `challenge-text-${c.id}`, + 'data-icon': licon.Checkmark, + title: ctrl.trans('accept'), + }, + hook: onClick(ctrl.onRedirect), + }), + ]); + const viewElement = () => + h('a.view', { + attrs: { 'data-icon': licon.Eye, href: '/' + c.id, title: ctrl.trans('viewInFullSize') }, + }); return [ - viewInsteadOfAccept ? viewElement : acceptElement, + viewInsteadOfAccept ? viewElement() : acceptElement(), h('button.button.decline', { attrs: { type: 'submit', 'data-icon': licon.X, title: ctrl.trans('decline') }, hook: onClick(() => ctrl.decline(c.id, 'generic')), From 638aaedf81708c52c1b1c83e3294a679ae056af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90inh=20Ho=C3=A0ng=20Vi=E1=BB=87t?= <134517889+M-DinhHoangViet@users.noreply.github.com> Date: Wed, 26 Jun 2024 01:21:43 +0700 Subject: [PATCH 086/260] Add missing translation on fide pages (#15599) * Add missing translasion on FIDEs page * move fide translation to `broadcast.xml` file --- modules/coreI18n/src/main/key.scala | 9 ++++++ modules/fide/src/main/ui/FideUi.scala | 42 +++++++++++++-------------- translation/source/broadcast.xml | 9 ++++++ 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index c346242ae85b0..b87b8de10424b 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -1685,6 +1685,15 @@ object I18nKey: val `replacePlayerTags`: I18nKey = "broadcast:replacePlayerTags" val `periodInSeconds`: I18nKey = "broadcast:periodInSeconds" val `periodInSecondsHelp`: I18nKey = "broadcast:periodInSecondsHelp" + val `fideFederations`: I18nKey = "broadcast:fideFederations" + val `top10Rating`: I18nKey = "broadcast:top10Rating" + val `fidePlayers`: I18nKey = "broadcast:fidePlayers" + val `fidePlayerNotFound`: I18nKey = "broadcast:fidePlayerNotFound" + val `fideProfile`: I18nKey = "broadcast:fideProfile" + val `federation`: I18nKey = "broadcast:federation" + val `ageThisYear`: I18nKey = "broadcast:ageThisYear" + val `unrated`: I18nKey = "broadcast:unrated" + val `recentTournaments`: I18nKey = "broadcast:recentTournaments" val `nbBroadcasts`: I18nKey = "broadcast:nbBroadcasts" object streamer: diff --git a/modules/fide/src/main/ui/FideUi.scala b/modules/fide/src/main/ui/FideUi.scala index f56dd7d6c271f..43089bd9c4e55 100644 --- a/modules/fide/src/main/ui/FideUi.scala +++ b/modules/fide/src/main/ui/FideUi.scala @@ -33,15 +33,15 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): td(if stats.top10Rating > 0 then stats.top10Rating else "-") page("FIDE federations", "federations")( cls := "fide-federations", - boxTop(h1("FIDE federations")), + boxTop(h1(trans.broadcast.fideFederations())), table(cls := "slist slist-pad")( thead: tr( - th("Name"), - th("Players"), - th("Classic"), - th("Rapid"), - th("Blitz") + th(trans.site.name()), + th(trans.site.players()), + th(trans.site.classical()), + th(trans.site.rapid()), + th(trans.site.blitz()) ) , tbody(cls := "infinite-scroll")( @@ -72,9 +72,9 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): card( name(), frag( - p("Rank", strong(stats.get.rank)), - p("Top 10 rating", strong(stats.get.top10Rating)), - p("Players", strong(stats.get.nbPlayers.localize)) + p(trans.site.rank(), strong(stats.get.rank)), + p(trans.broadcast.top10Rating(), strong(stats.get.top10Rating)), + p(trans.site.players(), strong(stats.get.nbPlayers.localize)) ) ) ), @@ -104,7 +104,7 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): page("FIDE players", "players")( cls := "fide-players", boxTop( - h1("FIDE players"), + h1(trans.broadcast.fidePlayers()), div(cls := "box__top__actions"): searchForm(query) ), @@ -115,7 +115,7 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): page("FIDE player not found", "players")( cls := "fide-players", boxTop( - h1("FIDE player not found"), + h1(trans.broadcast.fidePlayerNotFound()), div(cls := "box__top__actions"): searchForm("") ), @@ -158,12 +158,12 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): table(cls := "slist slist-pad")( thead: tr( - th(title), + th(trans.site.name()), withFlag.option(th(iconTag(Icon.FlagOutline))), - th("Classic"), - th("Rapid"), - th("Blitz"), - th("Age this year") + th(trans.site.classical()), + th(trans.site.rapid()), + th(trans.site.blitz()), + th(trans.broadcast.ageThisYear()) ) , tbody(cls := "infinite-scroll")( @@ -194,7 +194,7 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): div(cls := "fide-cards fide-player__cards")( player.fed.map: fed => card( - "Federation", + trans.broadcast.federation(), if fed == Federation.idNone then "None" else a(cls := "fide-player__federation", href := routes.Fide.federation(Federation.idToSlug(fed)))( @@ -203,16 +203,16 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): ) ), card( - "FIDE profile", + trans.broadcast.fideProfile(), a(href := s"https://ratings.fide.com/profile/${player.id}")(player.id) ), card( - "Age this year", + trans.broadcast.ageThisYear(), player.age ), tcTrans.map: (tc, name) => - card(name(), player.ratingOf(tc).fold("Unrated")(_.toString)), + card(name(), player.ratingOf(tc).fold(trans.broadcast.unrated())(_.toString)), ), tours.map: tours => - div(cls := "fide-player__tours")(h2("Recent tournaments"), tours) + div(cls := "fide-player__tours")(h2(trans.broadcast.recentTournaments()), tours) ) diff --git a/translation/source/broadcast.xml b/translation/source/broadcast.xml index 95da0bc497da4..f7452e1ec0a07 100644 --- a/translation/source/broadcast.xml +++ b/translation/source/broadcast.xml @@ -43,4 +43,13 @@ Optional: replace player names, ratings and titles Period in seconds Optional, how long to wait between requests. Min 2s, max 60s. Defaults to automatic based on the number of viewers. + FIDE federations + Top 10 rating + FIDE players + FIDE player not found + FIDE profile + Federation + Age this year + Unrated + Recent tournaments From fb53283f83e9613bc69f4f637ce590203ae481b4 Mon Sep 17 00:00:00 2001 From: Bastian Pedersen Date: Tue, 25 Jun 2024 21:10:08 +0200 Subject: [PATCH 087/260] Add a bit of styling to button --- ui/challenge/css/_challenge.scss | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/challenge/css/_challenge.scss b/ui/challenge/css/_challenge.scss index 23a843ef62beb..7ea266394fa7c 100644 --- a/ui/challenge/css/_challenge.scss +++ b/ui/challenge/css/_challenge.scss @@ -47,6 +47,15 @@ width: 33%; } + a.view { + font-size: 1.5rem; + color: $c-primary; + + &:hover { + font-size: 1.85rem; + } + } + button { cursor: pointer; color: $c-good; @@ -126,9 +135,11 @@ display: block; font-weight: bold; margin-bottom: 0.1em; + .user-link { margin-inline-start: -5px; } + signal { margin-inline-start: 5px; } From 870c0be39a9e5f1989b70832cdf13add4b4edef9 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 22:25:06 +0200 Subject: [PATCH 088/260] let all players contact mods unless blocked --- modules/msg/src/main/MsgContact.scala | 17 ++++++++++------- modules/msg/src/main/MsgSecurity.scala | 11 +++++++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/modules/msg/src/main/MsgContact.scala b/modules/msg/src/main/MsgContact.scala index f81d3151d12dd..56dea3465fa51 100644 --- a/modules/msg/src/main/MsgContact.scala +++ b/modules/msg/src/main/MsgContact.scala @@ -5,6 +5,8 @@ import reactivemongo.api.bson.* import lila.core.user.UserMarks import lila.db.dsl.{ *, given } +import lila.core.perm.Permission +import lila.core.perm.Granter private case class Contact( @Key("_id") id: UserId, @@ -13,13 +15,14 @@ private case class Contact( roles: Option[List[String]], createdAt: Instant ): - def isKid = ~kid - def isTroll = marks.exists(_.troll) - def isVerified = roles.exists(_ contains "ROLE_VERIFIED") - def isApiHog = roles.exists(_ contains "ROLE_API_HOG") - def isDaysOld(days: Int) = createdAt.isBefore(nowInstant.minusDays(days)) - def isHoursOld(hours: Int) = createdAt.isBefore(nowInstant.minusHours(hours)) - def isLichess = id.is(UserId.lichess) + def isKid = ~kid + def isTroll = marks.exists(_.troll) + def isVerified = roles.exists(_ contains "ROLE_VERIFIED") + def isApiHog = roles.exists(_ contains "ROLE_API_HOG") + def isDaysOld(days: Int) = createdAt.isBefore(nowInstant.minusDays(days)) + def isHoursOld(hours: Int) = createdAt.isBefore(nowInstant.minusHours(hours)) + def isLichess = id.is(UserId.lichess) + def isGranted(perm: Permission.Selector) = Granter.ofDbKeys(perm, ~roles) private case class Contacts(orig: Contact, dest: Contact): def hasKid = orig.isKid || dest.isKid diff --git a/modules/msg/src/main/MsgSecurity.scala b/modules/msg/src/main/MsgSecurity.scala index 3b166ac368271..e28ee1a2ff6bc 100644 --- a/modules/msg/src/main/MsgSecurity.scala +++ b/modules/msg/src/main/MsgSecurity.scala @@ -78,7 +78,8 @@ final private class MsgSecurity( .getOrElse(fuccess(Ok)) .flatMap: case Troll => - destFollowsOrig(contacts).dmap: + (fuccess(contacts.any(_.isGranted(_.PublicMod))) >>| + destFollowsOrig(contacts)).dmap: if _ then TrollFriend else Troll case mute: Mute => destFollowsOrig(contacts).dmap: @@ -141,15 +142,17 @@ final private class MsgSecurity( contactApi.contacts(orig, dest).flatMapz { post(_, isNew) } def post(contacts: Contacts, isNew: Boolean): Fu[Boolean] = - fuccess(!contacts.dest.isLichess && !contacts.any(_.marks.exists(_.isolate))) >>& { - fuccess(Granter.ofDbKeys(_.PublicMod, ~contacts.orig.roles)) >>| { + ( + !contacts.dest.isLichess && + (!contacts.any(_.marks.exists(_.isolate)) || contacts.any(_.isGranted(_.Shadowban))) + ).so: + fuccess(contacts.orig.isGranted(_.PublicMod)) >>| { relationApi.fetchBlocks(contacts.dest.id, contacts.orig.id).not >>& (create(contacts) >>| reply(contacts)) >>& chatPanicAllowed(contacts.orig.id)(userApi.byId) >>& kidCheck(contacts, isNew) >>& userCache.getBotIds.map { botIds => !contacts.userIds.exists(botIds.contains) } } - } private def create(contacts: Contacts): Fu[Boolean] = prefApi.getMessage(contacts.dest.id).flatMap { From 3de45b1f949cdcfa079844a8420f4f47bbe77827 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 22:40:41 +0200 Subject: [PATCH 089/260] re-order rounds in broadcast stats --- modules/relay/src/main/RelayStatsApi.scala | 2 +- modules/relay/src/main/ui/RelayTourUi.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayStatsApi.scala b/modules/relay/src/main/RelayStatsApi.scala index a413ea1a5b507..3eaac6fa852b2 100644 --- a/modules/relay/src/main/RelayStatsApi.scala +++ b/modules/relay/src/main/RelayStatsApi.scala @@ -24,7 +24,7 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc .aggregateList(RelayTour.maxRelays): framework => import framework.* Match($doc("tourId" -> id)) -> List( - Sort(Ascending("createdAt")), + Sort(Descending("createdAt")), AddFields($doc("sync.log" -> $arr())), PipelineOperator( $lookup.simple(colls.stats, "stats", "_id", "_id") diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 1cbb90bf72df7..5a63f4347545f 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -108,7 +108,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): .css("bits.relay.stats") .js(PageModule("chart.relayStats", Json.obj("rounds" -> stats))): main(cls := "relay-tour page box box-pad")( - boxTop(h1(a(href := routes.RelayTour.show(t.slug, t.id).url)(t.name), " - Stats")), + boxTop(h1("Stats of ", a(href := routes.RelayTour.show(t.slug, t.id).url)(t.name))), div(id := "round-selector"), div(id := "relay-stats-container")(canvas(id := "relay-stats")) ) From bd2708e9ccc423e67ae821c0cb03cce942323936 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 23:36:50 +0200 Subject: [PATCH 090/260] better integrate broadcast stats --- app/controllers/RelayRound.scala | 7 ++++ app/controllers/RelayTour.scala | 7 ---- conf/routes | 2 +- modules/relay/src/main/JsonView.scala | 1 - modules/relay/src/main/RelayStatsApi.scala | 34 ++++++++---------- modules/relay/src/main/ui/RelayTourUi.scala | 11 ------ ui/analyse/css/study/relay/_tour.scss | 10 ++++++ ui/analyse/src/study/relay/relayCtrl.ts | 5 ++- ui/analyse/src/study/relay/relayStats.ts | 39 +++++++++++++++++++++ ui/analyse/src/study/relay/relayTourView.ts | 13 ++++--- ui/bits/css/build/bits.relay.stats.scss | 3 -- ui/bits/css/relay/_stats.scss | 9 ----- ui/chart/src/chart.relayStats.ts | 39 +++++---------------- ui/chart/src/interface.ts | 4 --- 14 files changed, 90 insertions(+), 94 deletions(-) create mode 100644 ui/analyse/src/study/relay/relayStats.ts delete mode 100644 ui/bits/css/build/bits.relay.stats.scss delete mode 100644 ui/bits/css/relay/_stats.scss diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index 223e9d043e13d..d5808ebdcd33a 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -187,6 +187,13 @@ final class RelayRound( env.relay.teamTable.tableJson(rt.relay).map(JsonStrOk) }(Unauthorized, Forbidden) + def stats(id: RelayRoundId) = Open: + env.relay.stats + .get(id) + .map: stats => + import lila.relay.JsonView.given + JsonOk(stats) + private def WithRoundAndTour(@nowarn ts: String, @nowarn rs: String, id: RelayRoundId)( f: RoundModel.WithTour => Fu[Result] )(using ctx: Context): Fu[Result] = diff --git a/app/controllers/RelayTour.scala b/app/controllers/RelayTour.scala index 31d533a2a0e50..c04b581aa592a 100644 --- a/app/controllers/RelayTour.scala +++ b/app/controllers/RelayTour.scala @@ -189,13 +189,6 @@ final class RelayTour(env: Env, apiC: => Api) extends LilaController(env): asAttachmentStream(s"${env.relay.pgnStream.filename(tour)}.pgn"): Ok.chunked(source).as(pgnContentType) - def stats(id: RelayTourId) = Open: - Found(env.relay.api.tourById(id)): tour => - env.relay.stats - .get(tour.id) - .flatMap: stats => - Ok.page(views.relay.tour.stats(tour, stats)) - def apiIndex = Anon: apiC.jsonDownload: env.relay.tourStream diff --git a/conf/routes b/conf/routes index a305b5635df89..f93e01c51d839 100644 --- a/conf/routes +++ b/conf/routes @@ -262,7 +262,6 @@ GET /broadcast/all-private controllers.RelayTour.allPrivate(p GET /broadcast/:ts/$id<\w{8}> controllers.RelayTour.show(ts, id: RelayTourId) GET /api/broadcast/$id<\w{8}> controllers.RelayTour.apiShow(id: RelayTourId) GET /api/broadcast/$tourId<\w{8}>.pgn controllers.RelayTour.pgn(tourId: RelayTourId) -GET /broadcast/$tourId<\w{8}>/stats controllers.RelayTour.stats(tourId: RelayTourId) GET /broadcast/$tourId<\w{8}>/edit controllers.RelayTour.edit(tourId: RelayTourId) POST /broadcast/$tourId<\w{8}>/edit controllers.RelayTour.update(tourId: RelayTourId) POST /broadcast/$tourId<\w{8}>/delete controllers.RelayTour.delete(tourId: RelayTourId) @@ -278,6 +277,7 @@ GET /broadcast/round/$roundId<\w{8}>/edit controllers.RelayRound.edit(roundI POST /broadcast/round/$roundId<\w{8}>/edit controllers.RelayRound.update(roundId: RelayRoundId) POST /broadcast/round/$roundId<\w{8}>/reset controllers.RelayRound.reset(roundId: RelayRoundId) POST /broadcast/round/$roundId<\w{8}>/push controllers.RelayRound.push(roundId: RelayRoundId) +GET /broadcast/round/$roundId<\w{8}>/stats controllers.RelayRound.stats(roundId: RelayRoundId) POST /api/broadcast/round/$roundId<\w{8}>/push controllers.RelayRound.push(roundId: RelayRoundId) GET /broadcast/:ts/:rs/$roundId<\w{8}>.pgn controllers.RelayRound.pgn(ts, rs, roundId: RelayRoundId) GET /broadcast/$roundId<\w{8}>/teams controllers.RelayRound.teamsView(roundId: RelayRoundId) diff --git a/modules/relay/src/main/JsonView.scala b/modules/relay/src/main/JsonView.scala index 2b1ca1bae56b6..54577c231b920 100644 --- a/modules/relay/src/main/JsonView.scala +++ b/modules/relay/src/main/JsonView.scala @@ -165,7 +165,6 @@ object JsonView: given OWrites[RelayStats.RoundStats] = OWrites: r => Json.obj( - "round" -> r.round, "viewers" -> r.viewers.map: (minute, crowd) => Json.arr(minute * 60, crowd) ) diff --git a/modules/relay/src/main/RelayStatsApi.scala b/modules/relay/src/main/RelayStatsApi.scala index 3eaac6fa852b2..22b2a61603808 100644 --- a/modules/relay/src/main/RelayStatsApi.scala +++ b/modules/relay/src/main/RelayStatsApi.scala @@ -8,7 +8,7 @@ object RelayStats: type Minute = Int type Crowd = Int type Graph = List[(Minute, Crowd)] - case class RoundStats(round: RelayRound, viewers: Graph) + case class RoundStats(viewers: Graph) final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using scheduler: Scheduler)(using Executor @@ -19,32 +19,28 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc // on measurement by minute at most; the storage depends on it. scheduler.scheduleWithFixedDelay(1 minute, 1 minute)(() => record()) - def get(id: RelayTourId): Fu[List[RoundStats]] = + def get(id: RelayRoundId): Fu[Option[RoundStats]] = colls.round - .aggregateList(RelayTour.maxRelays): framework => + .aggregateOne(): framework => import framework.* - Match($doc("tourId" -> id)) -> List( - Sort(Descending("createdAt")), - AddFields($doc("sync.log" -> $arr())), + Match($id(id)) -> List( + Project($doc("_id" -> true)), PipelineOperator( $lookup.simple(colls.stats, "stats", "_id", "_id") ), AddFields($doc("stats" -> $doc("$first" -> "$stats"))) ) - .map: docs => + .map: docOpt => for - doc <- docs - round <- doc.asOpt[RelayRound] - data = for - doc <- doc.getAsOpt[Bdoc]("stats") - data <- doc.getAsOpt[List[Int]]("d") - yield data - stats = data.so: - _.grouped(2) - .collect: - case List(minute, crowd) => (minute, crowd) - .toList - yield RoundStats(round, stats) + doc <- docOpt + stats <- doc.getAsOpt[Bdoc]("stats") + data <- stats.getAsOpt[List[Int]]("d") + viewers = data + .grouped(2) + .collect: + case List(minute, crowd) => (minute, crowd) + .toList + yield RoundStats(viewers) def setActive(id: RelayRoundId) = activeRounds.put(id) diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 5a63f4347545f..794970e52d239 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -102,17 +102,6 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): ) ) - def stats(t: RelayTour, stats: List[RelayStats.RoundStats])(using Context) = - import JsonView.given - Page(s"${t.name.value} - Stats") - .css("bits.relay.stats") - .js(PageModule("chart.relayStats", Json.obj("rounds" -> stats))): - main(cls := "relay-tour page box box-pad")( - boxTop(h1("Stats of ", a(href := routes.RelayTour.show(t.slug, t.id).url)(t.name))), - div(id := "round-selector"), - div(id := "relay-stats-container")(canvas(id := "relay-stats")) - ) - def page(title: String, pageBody: Frag, active: String)(using Context): Page = Page(title) .css("bits.page") diff --git a/ui/analyse/css/study/relay/_tour.scss b/ui/analyse/css/study/relay/_tour.scss index 50ad1b4dacc5f..861b8ad0e5218 100644 --- a/ui/analyse/css/study/relay/_tour.scss +++ b/ui/analyse/css/study/relay/_tour.scss @@ -417,6 +417,16 @@ $hover-bg: $m-primary_bg--mix-30; } } +.relay-tour__stats { + .spinner { + margin: 10em auto; + } + canvas { + min-height: 50vh; + margin: 0 2em 0 1em; + } +} + .relay-tour__side { overflow: hidden; &__header { diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 52c56504d66b2..f76331270229b 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -8,8 +8,9 @@ import RelayLeaderboard from './relayLeaderboard'; import { StudyChapters } from '../studyChapters'; import { MultiCloudEval } from '../multiCloudEval'; import { onWindowResize as videoPlayerOnWindowResize } from './videoPlayerView'; +import RelayStats from './relayStats'; -export const relayTabs = ['overview', 'boards', 'teams', 'leaderboard'] as const; +export const relayTabs = ['overview', 'boards', 'teams', 'leaderboard', 'stats'] as const; export type RelayTab = (typeof relayTabs)[number]; export default class RelayCtrl { @@ -21,6 +22,7 @@ export default class RelayCtrl { tab: Prop; teams?: RelayTeams; leaderboard?: RelayLeaderboard; + stats: RelayStats; streams: [string, string][] = []; showStreamerMenu = toggle(false); @@ -49,6 +51,7 @@ export default class RelayCtrl { this.leaderboard = data.tour.leaderboard ? new RelayLeaderboard(data.tour.id, this.federations, redraw) : undefined; + this.stats = new RelayStats(this.currentRound(), redraw); setInterval(() => this.redraw(true), 1000); const pinned = data.pinned; diff --git a/ui/analyse/src/study/relay/relayStats.ts b/ui/analyse/src/study/relay/relayStats.ts new file mode 100644 index 0000000000000..bbaa05e3e0b37 --- /dev/null +++ b/ui/analyse/src/study/relay/relayStats.ts @@ -0,0 +1,39 @@ +import { Redraw } from 'common/snabbdom'; +import { spinnerVdom as spinner } from 'common/spinner'; +import { RelayRound } from './interfaces'; +import * as xhr from 'common/xhr'; +import { h } from 'snabbdom'; + +export default class RelayStats { + data?: any; + + constructor( + readonly round: RelayRound, + private readonly redraw: Redraw, + ) {} + + loadFromXhr = async () => { + this.data = await xhr.json(`/broadcast/round/${this.round.id}/stats`); + this.redraw(); + await site.asset.loadEsm('chart.relayStats', { + init: { + ...this.data, + round: this.round, + }, + }); + }; +} + +export const statsView = (ctrl: RelayStats) => + h( + 'div.relay-tour__stats', + { + class: { loading: !ctrl.data }, + hook: { + insert: _ => { + ctrl.loadFromXhr(); + }, + }, + }, + ctrl.data ? h('canvas') : [spinner()], + ); diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index 72f28ecd97d1e..99a090b4dc968 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -11,6 +11,7 @@ import StudyCtrl from '../studyCtrl'; import { toggle } from 'common/controls'; import * as xhr from 'common/xhr'; import { teamsView } from './relayTeams'; +import { statsView } from './relayStats'; import { makeChat, type RelayViewContext } from '../../view/components'; import { gamesList } from './relayGames'; import { renderStreamerMenu, renderPinnedImage } from './relayView'; @@ -29,6 +30,8 @@ export function renderRelayTour(ctx: RelayViewContext): VNode | undefined { ? games(ctx) : tab == 'teams' ? teams(ctx) + : tab == 'stats' + ? stats(ctx) : leaderboard(ctx); return h('div.box.relay-tour', content); @@ -232,6 +235,8 @@ const teams = (ctx: RelayViewContext) => [ ctx.relay.teams && teamsView(ctx.relay.teams, ctx.study.chapters.list), ]; +const stats = (ctx: RelayViewContext) => [...header(ctx), statsView(ctx.relay.stats)]; + const header = (ctx: RelayViewContext) => { const { ctrl, relay, allowVideo } = ctx; const d = relay.data, @@ -313,13 +318,7 @@ const makeTabs = (ctrl: AnalyseCtrl) => { makeTab('boards', 'Boards'), relay.teams && makeTab('teams', 'Teams'), relay.data.tour.leaderboard ? makeTab('leaderboard', 'Leaderboard') : undefined, - study.members.myMember() && relay.data.tour.tier - ? h( - 'a.text', - { attrs: { ...dataIcon(licon.LineGraph), href: `/broadcast/${relay.data.tour.id}/stats` } }, - 'Popularity stats', - ) - : undefined, + study.members.myMember() && relay.data.tour.tier ? makeTab('stats', 'Stats') : undefined, ]); }; diff --git a/ui/bits/css/build/bits.relay.stats.scss b/ui/bits/css/build/bits.relay.stats.scss deleted file mode 100644 index eadcfdb506213..0000000000000 --- a/ui/bits/css/build/bits.relay.stats.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import '../../../common/css/plugin'; -@import '../../../common/css/component/mselect'; -@import '../relay/stats'; diff --git a/ui/bits/css/relay/_stats.scss b/ui/bits/css/relay/_stats.scss deleted file mode 100644 index e7bb7208db5df..0000000000000 --- a/ui/bits/css/relay/_stats.scss +++ /dev/null @@ -1,9 +0,0 @@ -.mselect { - width: fit-content; - font-size: 1.6em; - padding: 0 0 1.2em 1.4em; -} - -#relay-stats-container { - height: 700px; -} diff --git a/ui/chart/src/chart.relayStats.ts b/ui/chart/src/chart.relayStats.ts index ab59f9f419709..faef705d02be8 100644 --- a/ui/chart/src/chart.relayStats.ts +++ b/ui/chart/src/chart.relayStats.ts @@ -1,15 +1,7 @@ -import { RelayStats, RoundStats } from './interface'; +import { RoundStats } from './interface'; import * as chart from 'chart.js'; import 'chartjs-adapter-dayjs-4'; -import { - hoverBorderColor, - gridColor, - tooltipBgColor, - fontColor, - fontFamily, - maybeChart, - animation, -} from './common'; +import { hoverBorderColor, gridColor, tooltipBgColor, fontColor, fontFamily, animation } from './common'; import { memoize } from 'common'; import ChartDataLabels from 'chartjs-plugin-datalabels'; @@ -47,24 +39,9 @@ const dateFormat = memoize(() => : (d: Date) => d.toLocaleDateString(), ); -export default function initModule(data: RelayStats) { - const $el = $('#relay-stats'); - const last = data.rounds.reverse().find(r => !!r.viewers.length); - const container = $('#round-selector')[0]!; - container.innerHTML = `
`; - const possibleChart = maybeChart($el[0] as HTMLCanvasElement); - const relayChart = (possibleChart as RelayChart) ?? makeChart($el, last); - $('#round-select').on('change', function (this: HTMLSelectElement) { - const selected = data.rounds.find(r => r.round.id == this.value)!; - relayChart.updateData(selected); - }); +export default function initModule(data: RoundStats) { + const $el = $('.relay-tour__stats canvas'); + makeChart($el, data); } const makeDataset = (data: RoundStats, el: HTMLCanvasElement): chart.ChartDataset<'line'>[] => { @@ -117,8 +94,8 @@ const makeDataset = (data: RoundStats, el: HTMLCanvasElement): chart.ChartDatase return plot; }; -const makeChart = ($el: Cash, last?: RoundStats) => { - const ds = last ? makeDataset(last, $el[0] as HTMLCanvasElement) : []; +const makeChart = ($el: Cash, data: RoundStats) => { + const ds = makeDataset(data, $el[0] as HTMLCanvasElement); const config: chart.ChartConfiguration<'line'> = { type: 'line', data: { @@ -198,7 +175,7 @@ const makeChart = ($el: Cash, last?: RoundStats) => { }, title: { display: true, - text: last ? titleText(last) : 'No viewership stats yet', + text: data.viewers[0] ? titleText(data) : 'No viewership stats yet', color: fontColor, }, }, diff --git a/ui/chart/src/interface.ts b/ui/chart/src/interface.ts index 0418aeca7ecd6..01600c06b4cca 100644 --- a/ui/chart/src/interface.ts +++ b/ui/chart/src/interface.ts @@ -87,7 +87,3 @@ export interface RoundStats { round: RelayRound; viewers: [number, number][]; } - -export interface RelayStats { - rounds: RoundStats[]; -} From ccdbfc981e830c86a80627421a42c634f82908a4 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 25 Jun 2024 23:42:52 +0200 Subject: [PATCH 091/260] just to other broadcast round while keeping the tab open --- ui/analyse/src/study/relay/relayCtrl.ts | 2 +- ui/analyse/src/study/relay/relayTourView.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index f76331270229b..8318bcde77d06 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -103,7 +103,7 @@ export default class RelayCtrl { const r = round || this.currentRound(); return `/broadcast/${this.data.tour.slug}/${r.slug}/${r.id}`; }; - + roundUrlWithHash = (round?: RelayRound) => `${this.roundPath(round)}#${this.tab()}`; updateAddressBar = (tourUrl: string, roundUrl: string) => { const url = this.tourShow() ? `${tourUrl}${this.tab() === 'overview' ? '' : `#${this.tab()}`}` : roundUrl; // when jumping from a tour tab to another page, remember which tour tab we were on. diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts index 99a090b4dc968..9d783ec26b518 100644 --- a/ui/analyse/src/study/relay/relayTourView.ts +++ b/ui/analyse/src/study/relay/relayTourView.ts @@ -207,7 +207,7 @@ const roundSelect = (relay: RelayCtrl, study: StudyCtrl) => { }, relay.data.rounds.map(round => h(`tr.mselect__item${round.id == study.data.id ? '.current-round' : ''}`, [ - h('td.name', h('a', { attrs: { href: relay.roundPath(round) } }, round.name)), + h('td.name', h('a', { attrs: { href: relay.roundUrlWithHash(round) } }, round.name)), h('td.time', round.startsAt ? site.dateFormat()(new Date(round.startsAt)) : '-'), h( 'td.status', From ffd52afefbab167f562e551a89d652c82c447fef Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 07:51:10 +0200 Subject: [PATCH 092/260] New Crowdin updates (#15591) * New translations: broadcast.xml (Korean) * New translations: broadcast.xml (Arabic) * New translations: site.xml (Vietnamese) * New translations: broadcast.xml (Romanian) * New translations: broadcast.xml (French) * New translations: broadcast.xml (Spanish) * New translations: broadcast.xml (Afrikaans) * New translations: broadcast.xml (Belarusian) * New translations: broadcast.xml (Bulgarian) * New translations: broadcast.xml (Catalan) * New translations: broadcast.xml (Czech) * New translations: broadcast.xml (Danish) * New translations: broadcast.xml (German) * New translations: broadcast.xml (Greek) * New translations: broadcast.xml (Basque) * New translations: broadcast.xml (Finnish) * New translations: broadcast.xml (Irish) * New translations: broadcast.xml (Hebrew) * New translations: broadcast.xml (Hungarian) * New translations: broadcast.xml (Armenian) * New translations: broadcast.xml (Italian) * New translations: broadcast.xml (Japanese) * New translations: broadcast.xml (Georgian) * New translations: broadcast.xml (Lithuanian) * New translations: broadcast.xml (Dutch) * New translations: broadcast.xml (Polish) * New translations: broadcast.xml (Portuguese) * New translations: broadcast.xml (Russian) * New translations: broadcast.xml (Slovak) * New translations: broadcast.xml (Slovenian) * New translations: broadcast.xml (Albanian) * New translations: broadcast.xml (Swedish) * New translations: broadcast.xml (Turkish) * New translations: broadcast.xml (Ukrainian) * New translations: broadcast.xml (Chinese Simplified) * New translations: broadcast.xml (Chinese Traditional) * New translations: broadcast.xml (Vietnamese) * New translations: broadcast.xml (Galician) * New translations: broadcast.xml (Portuguese, Brazilian) * New translations: broadcast.xml (Indonesian) * New translations: broadcast.xml (Persian) * New translations: broadcast.xml (Marathi) * New translations: broadcast.xml (Thai) * New translations: broadcast.xml (Croatian) * New translations: broadcast.xml (Norwegian Nynorsk) * New translations: broadcast.xml (Kazakh) * New translations: broadcast.xml (Estonian) * New translations: broadcast.xml (Latvian) * New translations: broadcast.xml (Azerbaijani) * New translations: broadcast.xml (Hindi) * New translations: broadcast.xml (English, United States) * New translations: broadcast.xml (Esperanto) * New translations: broadcast.xml (Luxembourgish) * New translations: broadcast.xml (Malayalam) * New translations: broadcast.xml (Rusyn) * New translations: broadcast.xml (Bosnian) * New translations: broadcast.xml (Kannada) * New translations: broadcast.xml (Aragonese) * New translations: broadcast.xml (Norwegian Bokmal) * New translations: broadcast.xml (Kurmanji (Kurdish)) * New translations: broadcast.xml (Sorani (Kurdish)) * New translations: broadcast.xml (Swiss German) * New translations: broadcast.xml (Karakalpak) * New translations: swiss.xml (Vietnamese) * New translations: preferences.xml (Vietnamese) * New translations: site.xml (Persian) * New translations: study.xml (Persian) * New translations: puzzletheme.xml (Persian) * New translations: appeal.xml (Turkish) * New translations: faq.xml (Persian) * New translations: site.xml (Hebrew) * New translations: broadcast.xml (Spanish) * New translations: broadcast.xml (Danish) * New translations: broadcast.xml (German) * New translations: broadcast.xml (Finnish) * New translations: broadcast.xml (Hebrew) * New translations: broadcast.xml (Dutch) * New translations: broadcast.xml (Portuguese) * New translations: broadcast.xml (Galician) * New translations: broadcast.xml (Portuguese, Brazilian) * New translations: broadcast.xml (Swiss German) * New translations: broadcast.xml (French) * New translations: broadcast.xml (Danish) * New translations: site.xml (Persian) * New translations: broadcast.xml (Persian) * New translations: broadcast.xml (Sorani (Kurdish)) * New translations: class.xml (Persian) * New translations: contact.xml (Persian) * New translations: patron.xml (Persian) * New translations: coach.xml (Persian) * New translations: streamer.xml (Persian) * New translations: onboarding.xml (Persian) * New translations: broadcast.xml (Vietnamese) * New translations: team.xml (Persian) * New translations: swiss.xml (Persian) * New translations: broadcast.xml (Japanese) * New translations: broadcast.xml (Norwegian Nynorsk) --- translation/dest/appeal/tr-TR.xml | 2 +- translation/dest/broadcast/af-ZA.xml | 4 +-- translation/dest/broadcast/an-ES.xml | 2 -- translation/dest/broadcast/ar-SA.xml | 4 +-- translation/dest/broadcast/az-AZ.xml | 2 -- translation/dest/broadcast/be-BY.xml | 2 -- translation/dest/broadcast/bg-BG.xml | 2 -- translation/dest/broadcast/bs-BA.xml | 2 -- translation/dest/broadcast/ca-ES.xml | 4 +-- translation/dest/broadcast/ckb-IR.xml | 11 ++++-- translation/dest/broadcast/cs-CZ.xml | 2 -- translation/dest/broadcast/da-DK.xml | 13 ++++++-- translation/dest/broadcast/de-DE.xml | 13 ++++++-- translation/dest/broadcast/el-GR.xml | 2 -- translation/dest/broadcast/en-US.xml | 4 +-- translation/dest/broadcast/eo-UY.xml | 2 -- translation/dest/broadcast/es-ES.xml | 13 ++++++-- translation/dest/broadcast/et-EE.xml | 2 -- translation/dest/broadcast/eu-ES.xml | 2 -- translation/dest/broadcast/fa-IR.xml | 13 ++++++-- translation/dest/broadcast/fi-FI.xml | 12 +++++-- translation/dest/broadcast/fr-FR.xml | 13 ++++++-- translation/dest/broadcast/ga-IE.xml | 2 -- translation/dest/broadcast/gl-ES.xml | 13 ++++++-- translation/dest/broadcast/gsw-CH.xml | 13 ++++++-- translation/dest/broadcast/he-IL.xml | 21 ++++++++---- translation/dest/broadcast/hi-IN.xml | 2 -- translation/dest/broadcast/hr-HR.xml | 2 -- translation/dest/broadcast/hu-HU.xml | 2 -- translation/dest/broadcast/hy-AM.xml | 2 -- translation/dest/broadcast/id-ID.xml | 2 -- translation/dest/broadcast/it-IT.xml | 2 -- translation/dest/broadcast/ja-JP.xml | 13 ++++++-- translation/dest/broadcast/ka-GE.xml | 2 -- translation/dest/broadcast/kaa-UZ.xml | 2 -- translation/dest/broadcast/kk-KZ.xml | 2 -- translation/dest/broadcast/kmr-TR.xml | 2 -- translation/dest/broadcast/kn-IN.xml | 2 -- translation/dest/broadcast/ko-KR.xml | 2 -- translation/dest/broadcast/lb-LU.xml | 2 -- translation/dest/broadcast/lt-LT.xml | 2 -- translation/dest/broadcast/lv-LV.xml | 2 -- translation/dest/broadcast/ml-IN.xml | 2 -- translation/dest/broadcast/mr-IN.xml | 2 -- translation/dest/broadcast/nb-NO.xml | 4 +-- translation/dest/broadcast/nl-NL.xml | 11 ++++-- translation/dest/broadcast/nn-NO.xml | 13 ++++++-- translation/dest/broadcast/pl-PL.xml | 4 +-- translation/dest/broadcast/pt-BR.xml | 13 ++++++-- translation/dest/broadcast/pt-PT.xml | 13 ++++++-- translation/dest/broadcast/ro-RO.xml | 4 +-- translation/dest/broadcast/ru-RU.xml | 2 -- translation/dest/broadcast/ry-UA.xml | 2 -- translation/dest/broadcast/sk-SK.xml | 4 +-- translation/dest/broadcast/sl-SI.xml | 2 -- translation/dest/broadcast/sq-AL.xml | 4 +-- translation/dest/broadcast/sv-SE.xml | 2 -- translation/dest/broadcast/th-TH.xml | 2 -- translation/dest/broadcast/tr-TR.xml | 2 -- translation/dest/broadcast/uk-UA.xml | 4 +-- translation/dest/broadcast/vi-VN.xml | 13 ++++++-- translation/dest/broadcast/zh-CN.xml | 2 -- translation/dest/broadcast/zh-TW.xml | 2 -- translation/dest/class/fa-IR.xml | 2 +- translation/dest/coach/fa-IR.xml | 2 +- translation/dest/contact/fa-IR.xml | 6 ++-- translation/dest/faq/fa-IR.xml | 2 +- translation/dest/onboarding/fa-IR.xml | 2 +- translation/dest/patron/fa-IR.xml | 4 +-- translation/dest/preferences/vi-VN.xml | 2 +- translation/dest/puzzleTheme/fa-IR.xml | 2 +- translation/dest/site/fa-IR.xml | 26 +++++++-------- translation/dest/site/he-IL.xml | 46 +++++++++++++------------- translation/dest/site/vi-VN.xml | 2 +- translation/dest/streamer/fa-IR.xml | 2 +- translation/dest/study/fa-IR.xml | 4 +-- translation/dest/swiss/fa-IR.xml | 9 +++++ translation/dest/swiss/vi-VN.xml | 4 +-- translation/dest/team/fa-IR.xml | 1 + 79 files changed, 235 insertions(+), 206 deletions(-) diff --git a/translation/dest/appeal/tr-TR.xml b/translation/dest/appeal/tr-TR.xml index 3fe8b2d980056..e69a2d85031f2 100644 --- a/translation/dest/appeal/tr-TR.xml +++ b/translation/dest/appeal/tr-TR.xml @@ -14,7 +14,7 @@ Hesabınız moderatörler tarafından kapatılmıştır. Bloglarınız moderatörler tarafından görünmeze alınmıştır. %s bölümümüzü tekrar okuduğunuzdan emin olun. - Oyun zaman aşımınız var. + Uzaklaştırma cezanız var. iletişim kuralları blog kuralları Fair Play diff --git a/translation/dest/broadcast/af-ZA.xml b/translation/dest/broadcast/af-ZA.xml index 425bc4a3a05fc..66640f9569598 100644 --- a/translation/dest/broadcast/af-ZA.xml +++ b/translation/dest/broadcast/af-ZA.xml @@ -14,13 +14,11 @@ Kort beskrywing van die toernooi Volle geleentheid beskrywing Opsionele lang beskrywing van die uitsending. %1$s is beskikbaar. Lengte moet minder as %2$s karakters. - PGN-Bronskakel + PGN-Bronskakel URL wat Lichess sal nagaan vir PGN opdaterings. Dit moet openbaar beskikbaar wees vanaf die Internet. Begin datum in jou eie tydsone Optioneel, indien jy weet wanner die geleentheid begin Gee krediet aan die bron - Uitsaai bronadres - Huidige ronde se bronadres Huidige spel se bronadres Laai al die rondes af Herstel die ronde diff --git a/translation/dest/broadcast/an-ES.xml b/translation/dest/broadcast/an-ES.xml index a0c9494602f13..9b90677dd6922 100644 --- a/translation/dest/broadcast/an-ES.xml +++ b/translation/dest/broadcast/an-ES.xml @@ -19,7 +19,5 @@ Data d\'inicio en a tuya zona horaria Opcional, si sabes quan prencipia l\'evento Cita la fuent - URL d\'a emisión - Vinclo d\'a ronda actual Vinclo d\'a partida actual diff --git a/translation/dest/broadcast/ar-SA.xml b/translation/dest/broadcast/ar-SA.xml index 232fe19818bdc..d81246c170890 100644 --- a/translation/dest/broadcast/ar-SA.xml +++ b/translation/dest/broadcast/ar-SA.xml @@ -27,14 +27,12 @@ وصف موجز للبطولة الوصف الكامل الوصف الاختياري الطويل للبث. %1$s متوفر. يجب أن لا يتجاوز طول النص %2$s حرفاً. - رابط مصدر PGN + رابط مصدر PGN URL الذي سيتحقق منه Lichess للحصول على تحديثات PGN. يجب أن يكون متاحًا للجميع على الإنترنت. حتى 64 معرف لُعْبَة ليتشيس، مفصولة بمسافات. تاريخ البدء في المنطقة الزمنية الخاصة بك اختياري، إذا كنت تعرف متى يبدأ الحدث ائتمن المصدر - رابط البث - رابط الجولة الحالية رابط المباراة الحالية تحميل جميع المباريات إعادة ضبط هذه الجولة diff --git a/translation/dest/broadcast/az-AZ.xml b/translation/dest/broadcast/az-AZ.xml index d02db396eec5b..b591255097c86 100644 --- a/translation/dest/broadcast/az-AZ.xml +++ b/translation/dest/broadcast/az-AZ.xml @@ -17,8 +17,6 @@ Öz saat qurşağınızdakı başlama tarixi İstəyə bağlı, tədbirin başlama vaxtını bilirsinizsə Mənbəyə bax - Yayım URL-i - Hazırkı tur URL-i Hazırkı oyun URL-i Bu turu sıfırla Bu turu sil diff --git a/translation/dest/broadcast/be-BY.xml b/translation/dest/broadcast/be-BY.xml index 75968908b127e..832821c0fb46d 100644 --- a/translation/dest/broadcast/be-BY.xml +++ b/translation/dest/broadcast/be-BY.xml @@ -20,8 +20,6 @@ Дата пачатаку ў вашым часавым поясе Па жаданні, калі вы ведаеце пачатак падзеі Падзякаваць крыніцы - Спасылка на трансляцыю - Спасылка на бягучы тур Спасылка на бягучую гульню Спампаваць усе туры Скасаваць гэты тур diff --git a/translation/dest/broadcast/bg-BG.xml b/translation/dest/broadcast/bg-BG.xml index 87a40894b8942..a615d67c58c03 100644 --- a/translation/dest/broadcast/bg-BG.xml +++ b/translation/dest/broadcast/bg-BG.xml @@ -23,8 +23,6 @@ Дата на започване във вашата часова зона По избор, ако знаете, кога започва събитието Признателност на източника - URL на предаването - URL на настоящия гунд URL на настоящата партия Изтегли всички рундове Нулирай този рунд diff --git a/translation/dest/broadcast/bs-BA.xml b/translation/dest/broadcast/bs-BA.xml index 7d537e804b639..cc667e6c3a81f 100644 --- a/translation/dest/broadcast/bs-BA.xml +++ b/translation/dest/broadcast/bs-BA.xml @@ -23,8 +23,6 @@ Datum početka po Vašoj vremenskoj zoni Neobavezno, ukoliko znate kada počinje događaj Navedite ko je zaslužan - Link za prenos - Link za trenutno kolo Link za trenutnu partiju Skinite sve runde Ponovo postavite ovo kolo diff --git a/translation/dest/broadcast/ca-ES.xml b/translation/dest/broadcast/ca-ES.xml index 023fd03b37e10..a7acd51ca542c 100644 --- a/translation/dest/broadcast/ca-ES.xml +++ b/translation/dest/broadcast/ca-ES.xml @@ -23,14 +23,12 @@ Breu descripció del torneig Descripció total de l\'esdeveniment Opció de llarga descripció de l\'esdeveniment. %1$s és disponible. Ha de tenir menys de %2$s lletres. - URL origen del PGN + URL origen del PGN URL que Lichess comprovarà per a obtenir actualitzacions PGN. Ha de ser públicament accessible des d\'Internet. Fins a 64 identificadors de partides de Lichess, separades per espais. Data d\'inici en la teva zona horària Opcional, si saps quan comença l\'esdeveniment Cita la font - URL d\'emissió - URL actual de ronda URL actual de joc Baixa totes les rondes Restablir aquesta ronda diff --git a/translation/dest/broadcast/ckb-IR.xml b/translation/dest/broadcast/ckb-IR.xml index 5fca9553a022d..13c3baeff38aa 100644 --- a/translation/dest/broadcast/ckb-IR.xml +++ b/translation/dest/broadcast/ckb-IR.xml @@ -25,8 +25,6 @@ بەرواری دەستپێکردن لە چوارچێوەی کاتی خۆتدا بە ھەلبژاردنی خۆتە چ کاتێک دەس پێ بکات لە سەرچاوەکە دڵنیابە - لینکی پالەوانێتیەکە - لینکی خولی یەکەم لینکی یاریەکانی ئێستا دابەزاندنی ھەموو خولەکان دەسکاری کردنی ئەم خولە @@ -37,4 +35,13 @@ ئەم پاڵەوانێتییە بسڕەوە بە دڵنیاییەوە تەواوی پاڵەوانێتییەکە و هەموو خولەکانی و هەموو یارییەکانی بسڕەوە. ئارەزوومەندانە: گۆڕینی ناوی یاریزانان، هەڵسەنگاندن و نازناوەکان + فیدراسیۆنی FIDE + ڕیزبەندی ١٠ باشترینەکان + یاریزانەکانی FIDE + یاریزانی FIDE نەدۆزرایەوە + پرۆفایلی FIDE + فیدراسیۆن + تەمەنی ئەمساڵ + ڕیزبەندی نەکراوە + پاڵەوانێتییەکانی ئەم دواییە diff --git a/translation/dest/broadcast/cs-CZ.xml b/translation/dest/broadcast/cs-CZ.xml index 5827af04c0b2e..b415a32e290af 100644 --- a/translation/dest/broadcast/cs-CZ.xml +++ b/translation/dest/broadcast/cs-CZ.xml @@ -28,8 +28,6 @@ Datum a čas zahájení ve vašem časovém pásmu Nepovinné, pokud víte, kdy událost začíná Uveďte zdroj - URL adresa přenosu - URL aktuálního kola URL adresa právě probíhající partie Stáhnout hry ze všech kol Resetovat toto kolo diff --git a/translation/dest/broadcast/da-DK.xml b/translation/dest/broadcast/da-DK.xml index 9214e24fc7640..0326c4cd4d7af 100644 --- a/translation/dest/broadcast/da-DK.xml +++ b/translation/dest/broadcast/da-DK.xml @@ -23,14 +23,12 @@ Kort beskrivelse af turnering Fuld beskrivelse af begivenheden Valgfri lang beskrivelse af transmissionen. %1$s er tilgængelig. Længde skal være mindre end %2$s tegn. - URL for PGN-kilde + URL for PGN-kilde URL som Lichess vil trække på for at få PGN updates. Den skal være offentlig tilgængelig fra internettet. Op til 64 Lichess parti-ID\'er, adskilt af mellemrum. Startdato i din egen tidszone Valgfri, hvis du ved, hvornår begivenheden starter Krediter kilden - Udsendelse-URL - Nuværende runde URL Nuværende parti URL Download alle runder Nulstil denne runde @@ -45,4 +43,13 @@ Valgfrit: udskift spillernavne, ratings og titler Periode i sekunder Valgfri, hvor lang tid der skal ventes mellem anmodninger. Min 2s, maks. 60s. Er som standard automatisk baseret på antallet af seere. + FIDE-føderationer + Top 10 rating + FIDE-spillere + FIDE-spiller ikke fundet + FIDE-profil + Føderation + Alder i år + Uden rating + Seneste turneringer diff --git a/translation/dest/broadcast/de-DE.xml b/translation/dest/broadcast/de-DE.xml index 54fcd97d89d31..8cf39fe914125 100644 --- a/translation/dest/broadcast/de-DE.xml +++ b/translation/dest/broadcast/de-DE.xml @@ -23,14 +23,12 @@ Kurze Turnierbeschreibung Vollständige Ereignisbeschreibung Optionale, ausführliche Beschreibung der Übertragung. %1$s ist verfügbar. Die Beschreibung muss kürzer als %2$s Zeichen sein. - PGN Quell-URL + PGN Quell-URL URL die Lichess abfragt um PGN Aktualisierungen zu erhalten. Sie muss öffentlich aus dem Internet zugänglich sein. Bis zu 64 Lichess Partie-IDs, getrennt durch Leerzeichen. Startdatum in deiner eigenen Zeitzone Optional, falls du weißt wann das Ereignis beginnt Erwähne die Quelle - URL der Übertragung - URL der aktuellen Runde URL der aktuellen Partie Alle Runden herunterladen Diese Runde zurücksetzen @@ -45,4 +43,13 @@ Optional: Spielernamen, Wertungen und Titel ersetzen Dauer in Sekunden Optional, wie lange zwischen den Anfragen gewartet werden soll. Mindestens 2s, maximal 60s. Standardmäßig auf der Zuschaueranzahl basierend. + FIDE-Verbände + Top 10 Wertung + FIDE-Spieler + FIDE-Spieler nicht gefunden + FIDE-Profil + Verband + Alter in diesem Jahr + Ungewertet + Letzte Turniere diff --git a/translation/dest/broadcast/el-GR.xml b/translation/dest/broadcast/el-GR.xml index 5aeb0c835599f..da7689e53dce7 100644 --- a/translation/dest/broadcast/el-GR.xml +++ b/translation/dest/broadcast/el-GR.xml @@ -26,8 +26,6 @@ Ημερομηνία έναρξης στη δική σας ζώνη ώρας Προαιρετικό, εάν γνωρίζετε πότε αρχίζει η εκδήλωση Αναφέρετε την πηγή - Διεύθυνση URL μετάδοσης - Διεύθυνση URL αυτού του γύρου Διεύθυνση URL αυτού του παιχνιδιού Λήψη όλων των γύρων Επαναφορά αυτού του γύρου diff --git a/translation/dest/broadcast/en-US.xml b/translation/dest/broadcast/en-US.xml index 449b09b6167d1..197568fd59889 100644 --- a/translation/dest/broadcast/en-US.xml +++ b/translation/dest/broadcast/en-US.xml @@ -23,14 +23,12 @@ Short tournament description Full event description Optional long description of the broadcast. %1$s is available. Length must be less than %2$s characters. - PGN Source URL + PGN Source URL URL that Lichess will check to get PGN updates. It must be publicly accessible from the Internet. Up to 64 Lichess game IDs, separated by spaces. Start date in your own timezone Optional, if you know when the event starts Credit the source - Broadcast URL - Current round URL Current game URL Download all rounds Reset this round diff --git a/translation/dest/broadcast/eo-UY.xml b/translation/dest/broadcast/eo-UY.xml index ff7c8046daf57..977be04ab97ab 100644 --- a/translation/dest/broadcast/eo-UY.xml +++ b/translation/dest/broadcast/eo-UY.xml @@ -27,8 +27,6 @@ Komenca dato en via propra horzono Laŭvola, se vi scias, kiam komenciĝas la evento Citu la fonton - Elsenda URL - Nuna raŭnda URL Nuna luda URL Elŝuti ĉiujn raŭndojn Restarigi ĉi tiun raŭndon diff --git a/translation/dest/broadcast/es-ES.xml b/translation/dest/broadcast/es-ES.xml index 715a6e1f9aada..c0b879403d53b 100644 --- a/translation/dest/broadcast/es-ES.xml +++ b/translation/dest/broadcast/es-ES.xml @@ -23,14 +23,12 @@ Breve descripción del torneo Descripción completa del evento Descripción larga opcional de la emisión. %1$s está disponible. La longitud debe ser inferior a %2$s caracteres. - URL origen del archivo PGN + URL origen del archivo PGN URL que Lichess comprobará para obtener actualizaciones PGN. Debe ser públicamente accesible desde Internet. Hasta 64 identificadores de partidas de Lichess, separados por espacios. Fecha de inicio en tu zona horaria Opcional, si sabes cuando comienza el evento Cita la fuente - Enlace de la emisión - Enlace de la ronda actual Enlace de la partida actual Descargar todas las rondas Restablecer esta ronda @@ -45,4 +43,13 @@ Opcional: reemplazar nombres de jugadores, puntuaciones y títulos Período en segundos Opcional, cuánto tiempo esperar entre peticiones. Mín. 2 s., máx. 60 s. Por defecto es automático según el número de espectadores. + Federaciones FIDE + Los 10 mejores + Jugadores FIDE + Jugador FIDE no encontrado + Perfil FIDE + Federación + Edad en este año + Sin puntuación + Torneos recientes diff --git a/translation/dest/broadcast/et-EE.xml b/translation/dest/broadcast/et-EE.xml index 46a035083600f..64e4f4d38d700 100644 --- a/translation/dest/broadcast/et-EE.xml +++ b/translation/dest/broadcast/et-EE.xml @@ -17,8 +17,6 @@ Alguskuupäev sinu ajavööndis Valikuline, kui tead millal sündmus algab Viita allikale - Otseülekande URL - Preaguse vooru URL Praeguse mängu URL Lae alla kõik voorud Lähtesta see voor diff --git a/translation/dest/broadcast/eu-ES.xml b/translation/dest/broadcast/eu-ES.xml index e4e2a834d6743..6c4e49ebd18a0 100644 --- a/translation/dest/broadcast/eu-ES.xml +++ b/translation/dest/broadcast/eu-ES.xml @@ -27,8 +27,6 @@ Zure ordu-zonako hasiera data Hautazkoa, ekitaldia noiz hasten den baldin badakizu Jatorria zein den esaiguzu - Zuzeneko emankizunaren URL helbidea - Uneko txandaren URL helbidea Uneko partidaren URL helbidea Deskargatu txanda guztiak Berrezarri txanda hau diff --git a/translation/dest/broadcast/fa-IR.xml b/translation/dest/broadcast/fa-IR.xml index 075c2c9908079..9cd92915d8239 100644 --- a/translation/dest/broadcast/fa-IR.xml +++ b/translation/dest/broadcast/fa-IR.xml @@ -23,14 +23,12 @@ توضیحات کوتاه مسابقات توضیحات کامل مسابقات توضیحات بلند و اختیاری پخش همگانی. %1$s قابل‌استفاده است. طول متن باید کمتر از %2$s نویسه باشد. - وب‌نشانیِ PGN + وب‌نشانیِ PGN وب‌نشانی‌ای که Lichess برای دریافت به‌روزرسانی‌های PGN می‌بررسد. آن باید از راه اینترنت در دسترس همگان باشد. تا ۶۴ شناسه بازی لیچس٬ جداشده با فاصله. تاریخ شروع، در منطقه زمانی خودتان اختیاری است، اگر می‌دانید چه زمانی رویداد شروع می‌شود به منبع اعتبار دهید - وب‌نشانی پخش همگانی - نشانی دور کنونی نشانی بازی کنونی بارگیری همه دورها ازنوکردن این دور @@ -45,4 +43,13 @@ اختیاری: عوض کردن نام، درجه‌بندی و عنوان بازیکنان مدت در واحد ثانیه اختیاری است، چه مدت باید بین درخواست‌ها صبر کرد. حداقل 2 ثانیه، حداکثر 60 ثانیه. بر اساس تعداد بینندگان، پیشفرض‌ها، به صورت خودکار مقدار می‌گیرند. + کشورگان‌های فیده + ده درجه‌بندی برتر + بازیکنان فیده + بازیکن فیده پیدا نشد + رُخ‌نمای فیده + کشورگان + سنِ امسال + بی‌درجه‌بندی + مسابقاتِ اخیر diff --git a/translation/dest/broadcast/fi-FI.xml b/translation/dest/broadcast/fi-FI.xml index 6e54bd3b0bdf1..0639c6294f341 100644 --- a/translation/dest/broadcast/fi-FI.xml +++ b/translation/dest/broadcast/fi-FI.xml @@ -23,14 +23,12 @@ Turnauksen lyhyt kuvaus Täysimittainen kuvaus tapahtumasta Ei-pakollinen pitkä kuvaus lähetyksestä. %1$s-muotoiluja voi käyttää. Pituus voi olla enintään %2$s merkkiä. - PGN:n lähde-URL + PGN:n lähde-URL URL, josta Lichess hakee PGN-päivitykset. Sen täytyy olla julkisesti saatavilla internetissä. Korkeintaan 64 Lichess-pelin tunnistenumeroa välilyönneillä eroteltuna. Alkamispäivämäärä omalla aikavyöhykkeelläsi Ei-pakollinen, laita jos tiedät milloin tapahtuma alkaa Mainitse lähde - Lähetyksen URL - Tämän kierroksen URL Tämän pelin URL Lataa kaikki kierrokset Nollaa tämä kierros @@ -45,4 +43,12 @@ Valinnainen: korvaa pelaajien nimet, vahvuusluvut ja arvonimet Jakso sekunteina Tarvittaessa pyyntöjen välinen odotusaika: vähintään 2 s, enintään 60 s. Oletuksena on katsojien määrään perustuva automaattinen arvo. + FIDEn liitot + Top 10 -vahvuuslukulista + FIDE-pelaajat + FIDE-pelaajaa ei löytynyt + FIDE-profiili + Kansallinen liitto + Ikä tänä vuonna + Viimeisimmät turnaukset diff --git a/translation/dest/broadcast/fr-FR.xml b/translation/dest/broadcast/fr-FR.xml index 49a3ee11bde87..64dc9a0f4def7 100644 --- a/translation/dest/broadcast/fr-FR.xml +++ b/translation/dest/broadcast/fr-FR.xml @@ -23,14 +23,12 @@ Brève description du tournoi Description complète de l\'événement Description détaillée et optionnelle de la diffusion. %1$s est disponible. La longueur doit être inférieure à %2$s caractères. - URL source de la partie en PGN + URL source de la partie en PGN URL que Lichess interrogera pour obtenir les mises à jour du PGN. Elle doit être accessible publiquement depuis Internet. Jusqu\'à 64 ID de partie Lichess séparés par des espaces. Date de début dans votre fuseau horaire Facultatif, si vous savez quand l\'événement commence Créditer la source - URL de diffusion - Ronde actuelle URL de la partie en cours Télécharger toutes les rondes Réinitialiser cette ronde @@ -45,4 +43,13 @@ Facultatif : remplacer les noms des joueurs, les classements et les titres Période en secondes Facultatif : temps d\'attente entre les requêtes. Min. 2 sec, max. 60 sec. Par défaut automatique selon le nombre de spectateurs. + Fédérations FIDE + 10 plus hauts classements + Joueurs FIDE + Joueur FIDE introuvable + Profil FIDE + Fédération + Âge cette année + Non classé + Tournois récents diff --git a/translation/dest/broadcast/ga-IE.xml b/translation/dest/broadcast/ga-IE.xml index 97bab1791832e..38356993f0fee 100644 --- a/translation/dest/broadcast/ga-IE.xml +++ b/translation/dest/broadcast/ga-IE.xml @@ -17,8 +17,6 @@ Dáta tosaigh i do chrios ama féin Roghnach, má tá a fhios agat cathain a thosóidh an ócáid Creidmheas an fhoinse - Craoladh URL - URL babhta reatha URL cluiche reatha Íoslódáil gach babhta Athshocraigh an babhta seo diff --git a/translation/dest/broadcast/gl-ES.xml b/translation/dest/broadcast/gl-ES.xml index 4852c77cbf46b..5eba39ffce012 100644 --- a/translation/dest/broadcast/gl-ES.xml +++ b/translation/dest/broadcast/gl-ES.xml @@ -23,14 +23,12 @@ Breve descrición do torneo Descrición completa do evento Descrición longa opcional da retransmisión. %1$s está dispoñíbel. A lonxitude debe ser menor de %2$s caracteres. - URL de orixe do arquivo PGN + URL de orixe do arquivo PGN Ligazón que Lichess comprobará para obter actualizacións dos PGN. Debe ser publicamente accesíbel desde a Internet. Até 64 identificadores de partidas de Lichess, separados por espazos. Data de inicio na túa zona horaria Opcional, se sabes cando comeza o evento Cita a fonte - Ligazón da transmisión - Ligazón da rolda actual Ligazón da partida actual Descargar todas as roldas Restablecer esta rolda @@ -45,4 +43,13 @@ Opcional: substituír os nomes dos xogadores, as puntuacións e os títulos Período en segundos Opcional: canto tempo se agarda entre peticións. Min 2s, max 60s. Por defecto é automático baseado no número de espectadores. + Federacións FIDE + As mellores 10 puntuacións + Xogadores FIDE + Xogador FIDE non atopado + Perfil FIDE + Federación + Idade actual + Sen puntuar + Torneos recentes diff --git a/translation/dest/broadcast/gsw-CH.xml b/translation/dest/broadcast/gsw-CH.xml index 7716acc3660c7..2c90135fd1d6f 100644 --- a/translation/dest/broadcast/gsw-CH.xml +++ b/translation/dest/broadcast/gsw-CH.xml @@ -23,14 +23,12 @@ Churzi Turnier Beschribig Vollschtändigi Ereignisbeschribig Optionali, usfüehrlichi Beschribig vu de Überträgig. %1$s isch verfügbar. Die Beschribig muess chürzer als %2$s Zeiche si. - PGN Quälle URL + PGN Quälle URL URL wo Lichess abfrögt, für PGN Aktualisierige z\'erhalte. Sie muess öffentlich im Internet zuegänglich si. Bis zu 64 Lichess Partie - IDs, trännt dur en Leerschlag. Startdatum in dinere eigene Zitzone Optional, falls du weisch, wänn das Ereignis afangt Erwähn die Quälle - Überträgigs-URL - URL vode laufende Rundi URL vode laufende Partie Alli Runde abelade Die Rundi zruggsetze @@ -45,4 +43,13 @@ Optional: Schpillernäme, Wertige und Titel weg lah Periode i Sekunde Optional, wie lang zwüsche Afrage gwartet werden söll. Minimal 2, maximal 60 Sekunde. Die Standardischtellig isch automatisch, basierend uf der Azahl vu de Zueschauer. + FIDE Wältschachverband + Top 10 Ratings + FIDE Schpiller + FIDE Schpiller nöd g\'funde + FIDE Profil + Verband + Alter i dem Jahr + Ungwertet + Aktuellschti Turnier diff --git a/translation/dest/broadcast/he-IL.xml b/translation/dest/broadcast/he-IL.xml index f938cd4c992a4..53807671666cc 100644 --- a/translation/dest/broadcast/he-IL.xml +++ b/translation/dest/broadcast/he-IL.xml @@ -12,27 +12,25 @@ הקרנה ישירה חדשה הקרנות שנרשמת אליהן הסבר על הקרנות - איך להשתמש בהקרנות ב-Lichess. + איך להשתמש בהקרנות ב־Lichess. הסבב החדש יכלול את אותם התורמים והחברים כמו בסבב הקודם. הוספת סבב כרגע בקרוב שהושלמו - ליצ׳ס מאתר מתי הושלם הסבב על פי המשחקים שבקישור למהלכים בשידור חי (המקור). הפעילו את האפשרות הזאת אם אין מקור שממנו נשאבים המשחקים. + Lichess מאתר מתי הושלם הסבב על פי המשחקים שבקישור למהלכים בשידור חי (המקור). הפעילו את האפשרות הזאת אם אין מקור שממנו נשאבים המשחקים. שם סבב מספר סבב שם הטורניר תיאור הטורניר בקצרה תיאור מלא של הטורניר תיאור מפורט של הטורניר (אופציונאלי). %1$s זמין. אורך התיאור לא יעלה על %2$s תווים. - קישור המקור של ה-PGN - הקישור ש־Lichess יבדוק כדי לקלוט עדכונים ב-PGN. הוא חייב להיות פומבי ונגיש דרך האינטרנט. + קישור המקור של ה־PGN + הקישור ש־Lichess יבדוק כדי לקלוט עדכונים ב־PGN. הוא חייב להיות פומבי ונגיש דרך האינטרנט. עד 64 מזהי משחק של Lichess, מופרדים ברווחים. תאריך ההתחלה באזור הזמן שלך אופציונאלי, אם את/ה יודע/ת מתי האירוע צפוי להתחיל תן/י קרדיט למקור - הקישור להקרנה - הקישור לסבב הנוכחי הקישור למשחק הנוכחי הורדת כל הסבבים אפס את הסיבוב הזה @@ -46,5 +44,14 @@ הצגת טבלה פשוטה שמתבססת על תוצאות המשחקים אופציונאלי: החלפה של שמות השחקנים, דירוגיהם ותאריהם תדירות בשניות - אופציונאלי. משפיע על משך הזמן שעובר בין משיכות הנתונים מהמקור. בין 2 ל-60 שניות. כתלות במספר הצופים, הקצב עשוי לחזור לברירת המחדל. + אופציונאלי. משפיע על משך הזמן שעובר בין משיכות הנתונים מהמקור. בין 2 ל־60 שניות. כתלות במספר הצופים, הקצב עשוי לחזור לברירת המחדל. + איגודי FIDE + דירוג עשרת המובילים + שחקני FIDE + לא נמצא שחקן FIDE + פרופיל FIDE + איגוד + גיל השנה + לא מדורג + טורנירים אחרונים diff --git a/translation/dest/broadcast/hi-IN.xml b/translation/dest/broadcast/hi-IN.xml index d27142de5a14f..e87b3901e3a12 100644 --- a/translation/dest/broadcast/hi-IN.xml +++ b/translation/dest/broadcast/hi-IN.xml @@ -22,8 +22,6 @@ अपने स्वयं के समयक्षेत्र में प्रारंभ दिनांक वैकल्पिक, यदि आप जानना चाहते हो की प्रतिस्प्रधा कब शुरू होगी स्रोत को श्रेय दें - प्रसारण की कड़ी - वर्तमान अध्याय URL वर्तमान अध्याय URL सभी राउंड डाउनलोड करें इस फॉर्म को रीसेट करें diff --git a/translation/dest/broadcast/hr-HR.xml b/translation/dest/broadcast/hr-HR.xml index 5a8a5071df827..e41927412f7fb 100644 --- a/translation/dest/broadcast/hr-HR.xml +++ b/translation/dest/broadcast/hr-HR.xml @@ -22,8 +22,6 @@ Datum početka u vlastitoj vremenskoj zoni Neobavezno, ako znaš kada događaj počinje Naglasi izvor - Emitiraj URL - URL trenutne runde URL trenutne igre Preuzmite sve igre Resetiraj ovu rundu diff --git a/translation/dest/broadcast/hu-HU.xml b/translation/dest/broadcast/hu-HU.xml index d0adfb37758bc..20d429283f462 100644 --- a/translation/dest/broadcast/hu-HU.xml +++ b/translation/dest/broadcast/hu-HU.xml @@ -21,8 +21,6 @@ Kezdés időpontja a saját időzónádban Opcionális, ha tudod mikor kezdődik az esemény Forrásmegjelölés - Közvetítés URL - Jelenlegi forduló URL Jelenlegi játszma URL Összes játszma letöltése A forduló újrakezdése diff --git a/translation/dest/broadcast/hy-AM.xml b/translation/dest/broadcast/hy-AM.xml index 16be2356bdea2..71757a855652e 100644 --- a/translation/dest/broadcast/hy-AM.xml +++ b/translation/dest/broadcast/hy-AM.xml @@ -17,8 +17,6 @@ Սկսվելու ամսաթիվը Ձեր ժամագոտում Լրացուցիչ, եթե գիտեք, թե երբ է սկսվելու իրադարձությունը Երախտագիտություն - Հեռարձակման URL-հասցեն - Ընթացիկ խաղափուլի URL-հասցեն Ընթացիկ պարտիայի URL-հասցեն Բեռնել բոլոր խաղափուլերը Հեռացնել այս խաղափուլը diff --git a/translation/dest/broadcast/id-ID.xml b/translation/dest/broadcast/id-ID.xml index 80fb0440c0161..d91125b65a363 100644 --- a/translation/dest/broadcast/id-ID.xml +++ b/translation/dest/broadcast/id-ID.xml @@ -17,8 +17,6 @@ Tanggal mulai di zona waktu Anda sendiri Opsional, jika Anda tahu kapan acara dimulai Sertakan sumber - Tautan siaran - Tautan ronde ini Tautan permainan ini Unduh semua ronde Atur ulang ronde ini diff --git a/translation/dest/broadcast/it-IT.xml b/translation/dest/broadcast/it-IT.xml index adc748be56885..8d54528c4721d 100644 --- a/translation/dest/broadcast/it-IT.xml +++ b/translation/dest/broadcast/it-IT.xml @@ -27,8 +27,6 @@ Data di inizio nel tuo fuso orario Facoltativo, se sai quando inizia l\'evento Cita la fonte - URL della diretta - URL del turno corrente URL della partita corrente Scarica tutti i round Reimposta questo turno diff --git a/translation/dest/broadcast/ja-JP.xml b/translation/dest/broadcast/ja-JP.xml index 86805286f79c1..a28128c2fac84 100644 --- a/translation/dest/broadcast/ja-JP.xml +++ b/translation/dest/broadcast/ja-JP.xml @@ -22,14 +22,12 @@ 大会の短い説明 長い説明 内容の詳しい説明(オプション)。%1$s が利用できます。長さは [欧文換算で] %2$s 字まで。 - PGN のソース URL + PGN のソース URL Lichess が PGN を取得するための URL。インターネット上に公表されているもののみ。 Lichess ゲーム ID、半角スペースで区切って最大 64 個まで。 開始日付(あなたの現地時間) イベント開始時刻(オプション) ソースを表示する - ブロードキャスト URL - 現在のラウンドの URL 現在のゲームの URL 全ラウンドをダウンロード このラウンドをリセット @@ -44,4 +42,13 @@ オプション:プレイヤーの名前、レーティング、タイトルの変更 待機時間(秒) オプション、次のリクエストまでの待機時間を指定。最小 2 秒、最大 60 秒。デフォルト値は視聴者数から自動的に決まります。 + FIDE 加盟協会 + レーティング トップ10 + FIDE 選手 + FIDE 選手が見つかりません + FIDE プロフィール + 所属協会 + 今年時点の年齢 + レーティングなし + 最近のトーナメント diff --git a/translation/dest/broadcast/ka-GE.xml b/translation/dest/broadcast/ka-GE.xml index d5b948037c1d6..09f32253cc559 100644 --- a/translation/dest/broadcast/ka-GE.xml +++ b/translation/dest/broadcast/ka-GE.xml @@ -12,8 +12,6 @@ შეჯიბრის სახელი ტურნირი მცირე აღწერა ტურნირის სრული აღწერა - გადაცემის URL - მიმდინარე ტურის URL მიმდინარე პარტიის URL ჩამოტვირთე ყველა ტური გადატვირთე ეს ტური diff --git a/translation/dest/broadcast/kaa-UZ.xml b/translation/dest/broadcast/kaa-UZ.xml index e1e3345d0d14c..382aecc290122 100644 --- a/translation/dest/broadcast/kaa-UZ.xml +++ b/translation/dest/broadcast/kaa-UZ.xml @@ -12,8 +12,6 @@ Turnir ataması Turnirdiń qısqasha táriypi Turnirdiń tolıq táriypi - Esittiriwdiń URL mánzili - Házirgi tur URL mánzili Házirgi oyın URL mánzili Barlıq turlardı júklep alıw Bul turdı qayta ornatıw diff --git a/translation/dest/broadcast/kk-KZ.xml b/translation/dest/broadcast/kk-KZ.xml index 6406d3b24d3af..4813e2cfc3638 100644 --- a/translation/dest/broadcast/kk-KZ.xml +++ b/translation/dest/broadcast/kk-KZ.xml @@ -22,8 +22,6 @@ Басталу күні (өз уақыт белдеуіңізде) Міндетті емес, егер күнін біліп тұрсаңыз Қайнар көзіне сілтеңіз - Көрсетілім сілтемесі - Қазіргі айналым сілтемесі Қазіргі ойын сілтемесі Барлық айналымдарды жүктеп алу Бұл айналымды жаңарту diff --git a/translation/dest/broadcast/kmr-TR.xml b/translation/dest/broadcast/kmr-TR.xml index 830f1b58748e0..537cdca056584 100644 --- a/translation/dest/broadcast/kmr-TR.xml +++ b/translation/dest/broadcast/kmr-TR.xml @@ -11,8 +11,6 @@ Hejmara roundê Navê pêşbirkê Bi kurtasî di derbqrê pêşbirkî da - Lînka Weşanê - Lînka raundê niha Lînka lîstika niha Vî raundî bîne serî Vî raundî jê bibe diff --git a/translation/dest/broadcast/kn-IN.xml b/translation/dest/broadcast/kn-IN.xml index 132a6c33e8ba4..b661fc44ca339 100644 --- a/translation/dest/broadcast/kn-IN.xml +++ b/translation/dest/broadcast/kn-IN.xml @@ -21,8 +21,6 @@ ನಿಮ್ಮ ಸ್ವಂತ ಸಮಯವಲಯದಲ್ಲಿ ದಿನಾಂಕವನ್ನು ಪ್ರಾರಂಭಿಸಿ ಐಚ್ಛಿಕ, ಈವೆಂಟ್ ಯಾವಾಗ ಪ್ರಾರಂಭವಾಗುತ್ತದೆ ಎಂದು ನಿಮಗೆ ತಿಳಿದಿದ್ದರೆ ಮೂಲವನ್ನು ಕ್ರೆಡಿಟ್ ಮಾಡಿ - URL ಅನ್ನು ಪ್ರಸಾರ ಮಾಡಿ - ಪ್ರಸ್ತುತ ಸುತ್ತಿನ URL ಪ್ರಸ್ತುತ ಆಟದ URL ಎಲ್ಲಾ ಸುತ್ತುಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ ಈ ಸುತ್ತನ್ನು ಮರುಹೊಂದಿಸಿ diff --git a/translation/dest/broadcast/ko-KR.xml b/translation/dest/broadcast/ko-KR.xml index 162ee9fefe881..78dcdf8131b66 100644 --- a/translation/dest/broadcast/ko-KR.xml +++ b/translation/dest/broadcast/ko-KR.xml @@ -24,8 +24,6 @@ 본인 시간대 기준 시작일 선택사항. 언제 이벤트가 시작되는지 알고 있는 경우 출처 - 방송 URL - 현재 라운드 URL 현재 게임 URL 모든 라운드 다운로드 이 라운드 초기화 diff --git a/translation/dest/broadcast/lb-LU.xml b/translation/dest/broadcast/lb-LU.xml index 4ce96f05567d8..ecef25cbd1145 100644 --- a/translation/dest/broadcast/lb-LU.xml +++ b/translation/dest/broadcast/lb-LU.xml @@ -22,8 +22,6 @@ Startdatum an denger eegener Zäitzon Optional, wann du wees wéini den Turnéier ufänkt Quell kreditéieren - Iwwerdroungs-URL - URL vun der aktueller Ronn URL vun der aktueller Partie All Ronnen eroflueden Ronn zerécksetzen diff --git a/translation/dest/broadcast/lt-LT.xml b/translation/dest/broadcast/lt-LT.xml index c4c5fcdf314cb..bc65f2322cfc1 100644 --- a/translation/dest/broadcast/lt-LT.xml +++ b/translation/dest/broadcast/lt-LT.xml @@ -24,8 +24,6 @@ Pradžios laikas jūsų laiko juostoje Neprivaloma; tik jeigu žinote, kada prasideda renginys Paminėkite šaltinį - Transliacijos adresas - Dabartinio raundo adresas Dabartinio žaidimo adresas Atsisiųsti visus raundus Atstatyti raundą diff --git a/translation/dest/broadcast/lv-LV.xml b/translation/dest/broadcast/lv-LV.xml index 92eac6b60cebb..fa09b699e8597 100644 --- a/translation/dest/broadcast/lv-LV.xml +++ b/translation/dest/broadcast/lv-LV.xml @@ -17,8 +17,6 @@ Sākuma datums jūsu laika joslā Neobligāts, ja zināt, kad pasākums sākas Kreditējiet avotu - Tiešraides URL - Pašreizējā raunda URL Pašreizējās spēles URL Lejupielādēt visus raundus Atiestatīt šo raundu diff --git a/translation/dest/broadcast/ml-IN.xml b/translation/dest/broadcast/ml-IN.xml index aee06d3cc0322..42cd329da8aa3 100644 --- a/translation/dest/broadcast/ml-IN.xml +++ b/translation/dest/broadcast/ml-IN.xml @@ -1,6 +1,4 @@ - പ്രക്ഷേപണം ചെയ്യുനതിനുള്ള URL - നിലവിലെ ഘട്ടത്തിന്‍റെ URL നിലവിലെ കളിയുടെ URL diff --git a/translation/dest/broadcast/mr-IN.xml b/translation/dest/broadcast/mr-IN.xml index 3111fb098a28d..562d8ea38e0d7 100644 --- a/translation/dest/broadcast/mr-IN.xml +++ b/translation/dest/broadcast/mr-IN.xml @@ -26,8 +26,6 @@ आपल्या स्वत: च्या टाइमझोनमधील तारीख पर्यायी, इव्हेंट कधी सुरू होतो हे आपल्याला माहिती असल्यास Credit the source - URL चे प्रसारण - चालू फेरीचा URL चालू खेळाचा URL सर्व फेऱ्या डाउनलोड करा ही फेरी रेसेट करा diff --git a/translation/dest/broadcast/nb-NO.xml b/translation/dest/broadcast/nb-NO.xml index 7f960e0cd4471..9aca40b666a0b 100644 --- a/translation/dest/broadcast/nb-NO.xml +++ b/translation/dest/broadcast/nb-NO.xml @@ -23,14 +23,12 @@ Kort beskrivelse av turneringen Full beskrivelse av turneringen Valgfri lang beskrivelse av turneringen. %1$s er tilgjengelig. Beskrivelsen må være kortere enn %2$s tegn. - URL til PGN-kilden + URL til PGN-kilden Lenke som Lichess vil hente PGN-oppdateringer fra. Den må være offentlig tilgjengelig på internett. Opptil 64 ID-er for partier hos Lichess. De må være adskilt med mellomrom. Startdato i din egen tidssone Valgfritt, hvis du vet når arrangementet starter Krediter kilden - URL for denne overføringen - URL for denne runden URL for dette partiet Last ned alle rundene Nullstill denne runden diff --git a/translation/dest/broadcast/nl-NL.xml b/translation/dest/broadcast/nl-NL.xml index 8063d1ddd6a4c..0a9780efdb78f 100644 --- a/translation/dest/broadcast/nl-NL.xml +++ b/translation/dest/broadcast/nl-NL.xml @@ -26,8 +26,6 @@ Aanvangsdatum in je eigen tijdzone Optioneel, als je weet wanneer het evenement start Bronvermelding - Uitzendingslink - Huidige ronde-link Huidige partij-link Alle rondes downloaden Deze ronde opnieuw instellen @@ -42,4 +40,13 @@ Optioneel: vervang spelersnamen, beoordelingen en titels Periode in seconden Optioneel, hoe lang te wachten tussen aanvragen. Minimaal 2 seconden, maximaal 60 seconden. Standaard automatisch gebaseerd op het aantal kijkers. + FIDE-federaties + Top 10-rating + FIDE-spelers + FIDE-speler niet gevonden + FIDE-profiel + Federatie + Leeftijd dit jaar + Zonder rating + Recente toernooien diff --git a/translation/dest/broadcast/nn-NO.xml b/translation/dest/broadcast/nn-NO.xml index f73a33756d29b..5416808ade048 100644 --- a/translation/dest/broadcast/nn-NO.xml +++ b/translation/dest/broadcast/nn-NO.xml @@ -23,14 +23,12 @@ Kortfatta skildring av turneringa Full omtale av arrangementet Valfri lang omtale av overføringa. %1$s er tilgjengeleg. Omtalen må vera kortare enn %2$s teikn. - PGN kjelde-URL + PGN kjelde-URL Lenke som Lichess vil hente PGN-oppdateringar frå. Den må vera offentleg tilgjengeleg på internett. Opp til 64 Lichess spel-ID\'ar, skilde med mellomrom. Startdato i di eiga tidssone Valfritt, om du veit når arrangementet startar Kreditér kjelda - Kunngjerings-URL - URL til noverande runde URL til pågåande parti Last ned alle rundene Tilbakestill denne runden @@ -45,4 +43,13 @@ Valfritt: bytt ut spelarnamn, rangeringar og titlar Periode i sekund Ventetida mellom førespurnadene er valfri frå 2 til 60 sekund. Default tid er basert på sjåartalet. + FIDE-forbund + Topp 10 rating + FIDE-spelarar + Fann ikkje FIDE-spelar + FIDE-profil + Forbund + Alder i år + Urangert + Nylegaste turneringar diff --git a/translation/dest/broadcast/pl-PL.xml b/translation/dest/broadcast/pl-PL.xml index 62130d166a1e2..3b0d1a0582226 100644 --- a/translation/dest/broadcast/pl-PL.xml +++ b/translation/dest/broadcast/pl-PL.xml @@ -25,14 +25,12 @@ Krótki opis turnieju Pełny opis wydarzenia Opcjonalny długi opis transmisji. %1$s jest dostępny. Długość musi być mniejsza niż %2$s znaków. - Adres URL zapisu PGN + Adres URL zapisu PGN Adres URL, który Lichess będzie udostępniał, aby można było uzyskać aktualizacje PGN. Musi być publicznie dostępny z internetu. Do 64 identyfikatorów partii, oddzielonych spacjami. Data rozpoczęcia wydarzenia w Twojej strefie czasowej Opcjonalne, jeśli wiesz kiedy wydarzenie się rozpocznie Potwierdź źródło - Adres URL transmisji - Adres URL bieżącej rundy Adres URL bieżącej partii Pobierz wszystkie rundy Zresetuj tę rundę diff --git a/translation/dest/broadcast/pt-BR.xml b/translation/dest/broadcast/pt-BR.xml index b3ebb7257f5c9..151f8b07b44a7 100644 --- a/translation/dest/broadcast/pt-BR.xml +++ b/translation/dest/broadcast/pt-BR.xml @@ -23,14 +23,12 @@ Descrição curta do torneio Descrição completa do evento Descrição longa e opcional da transmissão. %1$s está disponível. O tamanho deve ser menor que %2$s caracteres. - URL de origem de PGN + URL de origem de PGN URL que Lichess irá verificar para obter atualizações PGN. Deve ser acessível ao público a partir da Internet. Até 64 IDs de partidas do Lichess, separados por espaços. Data de início em seu próprio fuso horário Opcional, se você sabe quando o evento começa Crédito a fonte - URL da transmissão - URL da rodada atual URL da partida atual Baixar todas as rodadas Reiniciar esta rodada @@ -45,4 +43,13 @@ Opcional: substituir nomes de jogador, ratings e títulos Período em segundos Opcional: tempo entre as solicitações. Mín. 2s, máx. 60s. Por padrão, é calculado com base no número de espectadores. + Federações FIDE + Classificação top 10 + Jogadores FIDE + Jogador não encontrando na FIDE + Perfil FIDE + Federação + Idade atual + Sem rating + Torneios recentes diff --git a/translation/dest/broadcast/pt-PT.xml b/translation/dest/broadcast/pt-PT.xml index 098522ff13a8d..411384a0b5982 100644 --- a/translation/dest/broadcast/pt-PT.xml +++ b/translation/dest/broadcast/pt-PT.xml @@ -23,14 +23,12 @@ Breve descrição do torneio Descrição completa do evento Descrição longa do evento opcional da transmissão. %1$s está disponível. Tem de ter menos que %2$s carácteres. - URL da fonte PGN + URL da fonte PGN Link que o Lichess vai verificar para obter atualizações da PGN. Deve ser acessível ao público a partir da internet. Até 64 IDs de jogo Lichess, separados por espaços. Data de início no teu fuso horário Opcional, se souberes quando começa o evento Credita a fonte - Transmitir o link - Link da ronda atual Link da partida atual Transferir todas as rondas Reiniciar esta ronda @@ -45,4 +43,13 @@ Opcional: substituir nomes de jogadores, avaliações e títulos Período em segundos Opcional, quanto tempo de espera entre as requisições. Mínimo 2s, máximo de 60s. O padrão é automático com base no número de espetadores. + Federações FIDE + 10 melhores classificações + Jogadores FIDE + Jogador FIDE não encontrado + Perfil FIDE + Federação + Idade neste ano + Sem classificação + Torneio recentes diff --git a/translation/dest/broadcast/ro-RO.xml b/translation/dest/broadcast/ro-RO.xml index b73890ef9c568..214b2997f36f2 100644 --- a/translation/dest/broadcast/ro-RO.xml +++ b/translation/dest/broadcast/ro-RO.xml @@ -23,14 +23,12 @@ O descriere scurtă a turneului Întreaga descriere a evenimentului Descriere lungă, opțională, a difuzării. %1$s este disponibil. Lungimea trebuie să fie mai mică decât %2$s caractere. - URL sursă PGN + URL sursă PGN URL-ul pe care Lichess îl va verifica pentru a obține actualizări al PGN-ului. Trebuie să fie public accesibil pe Internet. Până la 64 de ID-uri de joc Lichess, separate prin spații. Data de începere conform fusului tău orar Opțional, dacă știi când va începe evenimentul Creditează sursa - URL pentru difuzare - URL runda curenta URL-ul partidei curente Descarcă toate rundele Resetează această rundă diff --git a/translation/dest/broadcast/ru-RU.xml b/translation/dest/broadcast/ru-RU.xml index 4146cf59a043b..804d16ce4c5f1 100644 --- a/translation/dest/broadcast/ru-RU.xml +++ b/translation/dest/broadcast/ru-RU.xml @@ -30,8 +30,6 @@ Дата начала в вашем часовом поясе Дополнительно, если вы знаете, когда событие начнётся Признательность - URL-адрес трансляции - URL-адрес текущего тура URL-адрес текущей партии Скачать все туры Сбросить тур diff --git a/translation/dest/broadcast/ry-UA.xml b/translation/dest/broadcast/ry-UA.xml index 4625abe83fc1f..532fe812fafe3 100644 --- a/translation/dest/broadcast/ry-UA.xml +++ b/translation/dest/broadcast/ry-UA.xml @@ -17,8 +17,6 @@ Дата старта у вашум часовум поясі Опціонално, кідь знаєте коли ся зачинат припад Оддяка - Адрес трансляції - Одкликованя теперішнього тура Одкликованя теперішньої бавкы Стерьхати вшыткі рунды Перепустити сисю рунду diff --git a/translation/dest/broadcast/sk-SK.xml b/translation/dest/broadcast/sk-SK.xml index 92d64b09eb599..f89049ae2e64d 100644 --- a/translation/dest/broadcast/sk-SK.xml +++ b/translation/dest/broadcast/sk-SK.xml @@ -21,13 +21,11 @@ Krátky popis turnaja Úplný popis turnaja Voliteľný dlhý popis vysielania. %1$s je dostupný. Dĺžka musí byť menej ako %2$s znakov. - Zdrojová URL pre PGN súbor + Zdrojová URL pre PGN súbor URL, ktorú bude Lichess kontrolovať, aby získal aktualizácie PGN. Musí byť verejne prístupná z internetu. Dátum a čas začiatku, vo vašej časovej zóne Voliteľné, ak viete kedy sa udalosť začne Uveďte zdroj - Adresa URL pre sledovanie - Adresa URL aktuálneho kola Adresa URL aktuálnej partie Stiahnuť všetky kolá Resetovať toto kolo diff --git a/translation/dest/broadcast/sl-SI.xml b/translation/dest/broadcast/sl-SI.xml index 719c85ee31daa..5e472972abba2 100644 --- a/translation/dest/broadcast/sl-SI.xml +++ b/translation/dest/broadcast/sl-SI.xml @@ -29,8 +29,6 @@ Datum začetka v vaše časovnem pasu Izbirno, če veste, kdaj se dogodek začne Navedi vir - URL oddaje - URL trenutnega kroga URL trenutno igrane igre Prenesite vse kroge Ponastavi ta krog diff --git a/translation/dest/broadcast/sq-AL.xml b/translation/dest/broadcast/sq-AL.xml index d64e22cc2914e..7ed0565bf3142 100644 --- a/translation/dest/broadcast/sq-AL.xml +++ b/translation/dest/broadcast/sq-AL.xml @@ -23,14 +23,12 @@ Përshkrim i shkurtër i turneut Përshkrim i plotë i turneut Përshkrim i gjatë opsional i turneut. %1$s është e disponueshme. Gjatësia duhet të jetë më pak se %2$s shenja. - URL Burimi PNG-je + URL Burimi PNG-je URL-ja që do të kontrollojë Lichess-i për të marrë përditësime PGN-sh. Duhet të jetë e përdorshme publikisht që nga Interneti. Deri në 64 ID lojërash Lichess, ndarë me hapësira. Datë fillimi në zonën tuaj kohore Opsionale, nëse e dini kur fillon veprimtaria Atriboji merita burimit - URL Transmetimi - URL e raundit të tanishëm URL e lojës së tanishme Shkarko krejt raundet Fshije këtë raund diff --git a/translation/dest/broadcast/sv-SE.xml b/translation/dest/broadcast/sv-SE.xml index b7507af95de49..1fc590d49b093 100644 --- a/translation/dest/broadcast/sv-SE.xml +++ b/translation/dest/broadcast/sv-SE.xml @@ -26,8 +26,6 @@ Startdatum i din egen tidszon Valfritt, om du vet när händelsen startar Kreditera källan - Länk till direktsändning (URL) - Länk till aktuellt runda (URL) Länk till aktuellt parti (URL) Ladda ner alla omgångar Återställ den här omgången diff --git a/translation/dest/broadcast/th-TH.xml b/translation/dest/broadcast/th-TH.xml index 76255f55d2b3b..64e1a2df641a8 100644 --- a/translation/dest/broadcast/th-TH.xml +++ b/translation/dest/broadcast/th-TH.xml @@ -21,8 +21,6 @@ วันที่เริ่มในเขตเวลาของคุณ ไม่บังคับ, ถ้าคุณรู้ว่ารายการจะเริ่มเมื่อใด เครดิตแหล่ง - URL การถ่ายทอดสด - URL รอบปัจจุบัน URL เกมปัจจุบัน ตารางผู้นำอัตโนมัติ คำนวณและแสดงกระดานผู้นำอย่างง่ายตามผลลัพธ์ของเกม diff --git a/translation/dest/broadcast/tr-TR.xml b/translation/dest/broadcast/tr-TR.xml index 76e5a6567899d..b1f597d9bd417 100644 --- a/translation/dest/broadcast/tr-TR.xml +++ b/translation/dest/broadcast/tr-TR.xml @@ -27,8 +27,6 @@ Kendi saat diliminizdeki başlangıç zamanı İsteğe bağlı, etkinliğin ne zaman başladığını biliyorsanız ekleyebilirsiniz. Kaynağı görüntüle - Yayın linki - Şu anki turun linki Şu anki oyunun linki Bütün maçları indir Bu turu sıfırla diff --git a/translation/dest/broadcast/uk-UA.xml b/translation/dest/broadcast/uk-UA.xml index 46d8ed6b9d551..d47b7a5ba9139 100644 --- a/translation/dest/broadcast/uk-UA.xml +++ b/translation/dest/broadcast/uk-UA.xml @@ -25,14 +25,12 @@ Короткий опис турніру Повний опис події Необов\'язковий довгий опис трансляції. Наявна розмітка %1$s. Довжина має бути менша ніж %2$s символів. - Адреса джерела PGN + Адреса джерела PGN Посилання, яке Lichess перевірятиме, щоб отримати оновлення PGN. Воно має бути загальнодоступним в Інтернеті. До 64 ігрових ID Lichess, відокремлені пробілами. Дата початку у вашому часовому поясі За бажанням, якщо ви знаєте, коли починається подія Вдячність джерелу - Посилання на трансляцію - Посилання на поточний раунд Посилання на поточну гру Завантажити всі тури Скинути цей раунд diff --git a/translation/dest/broadcast/vi-VN.xml b/translation/dest/broadcast/vi-VN.xml index 4c1b2fafbcbf0..e677d6c0b8464 100644 --- a/translation/dest/broadcast/vi-VN.xml +++ b/translation/dest/broadcast/vi-VN.xml @@ -22,14 +22,12 @@ Mô tả ngắn giải đấu Mô tả đầy đủ giải đấu Tùy chọn mô tả dài về giải đấu. Có thể sử dụng %1$s. Độ dài phải nhỏ hơn %2$s ký tự. - URL Nguồn PGN + URL Nguồn PGN URL mà Lichess sẽ khảo sát để nhận cập nhật PGN. Nó phải được truy cập công khai từ Internet. Tối đa 64 ID ván cờ trên Lichess, phân tách bằng dấu cách. Ngày bắt đầu theo múi giờ của bạn Tùy chọn, nếu bạn biết khi nào sự kiện bắt đầu Công nhận nguồn - URL phát sóng - URL vòng đấu hiện tại URL ván đấu hiện tại Tải về tất cả ván đấu Đặt lại vòng này @@ -44,4 +42,13 @@ Tùy chọn: biệt danh, hệ số Elo và danh hiệu Khoảng thời gian tính bằng giây Tùy chọn, thời gian chờ đợi giữa các yêu cầu. Tối thiểu 2 giây, tối đa 60 giây. Mặc định là tự động dựa trên số lượng người xem. + Các liên đoàn FIDE + Hệ số Elo top 10 + Các kỳ thủ FIDE + Không tìm thấy kỳ thủ FIDE + Hồ sơ FIDE + Liên đoàn + Tuổi năm nay + Chưa xếp hạng + Các giải đấu tham gia gần đây diff --git a/translation/dest/broadcast/zh-CN.xml b/translation/dest/broadcast/zh-CN.xml index 975fa3503897a..6da28119147c7 100644 --- a/translation/dest/broadcast/zh-CN.xml +++ b/translation/dest/broadcast/zh-CN.xml @@ -26,8 +26,6 @@ 开始日期,在你的本地时区 如果你知道比赛开始时间 (可选) 信任来源 - 直播链接 - 当前一轮链接 当前棋局链接 下载所有棋局 重置此轮 diff --git a/translation/dest/broadcast/zh-TW.xml b/translation/dest/broadcast/zh-TW.xml index fbe5362588d1b..3b39eb7f68bc5 100644 --- a/translation/dest/broadcast/zh-TW.xml +++ b/translation/dest/broadcast/zh-TW.xml @@ -17,8 +17,6 @@ 開始日期 (當地時間) 可選,如果知道比賽開始時間 將來源歸因 - 直播連結 - 目前回合連結 目前棋局連結 下載所有棋局 重設此回合 diff --git a/translation/dest/class/fa-IR.xml b/translation/dest/class/fa-IR.xml index 9a2c2455b3ada..a3961045033ce 100644 --- a/translation/dest/class/fa-IR.xml +++ b/translation/dest/class/fa-IR.xml @@ -32,7 +32,7 @@ نام واقعی خصوصی. هرگز در خارج از کلاس نمایش داده نخواهد شد. به یاد آوری دانش آموز کمک می کند. دانش‌آموز اضافه کنید - نمایه %1$s برای %2$s ساخته شد. + رُخ‌نما %1$s برای %2$s ساخته شد. دانش‌جو: %1$s نامِ کاربری: %2$s گذرواژه: %3$s diff --git a/translation/dest/coach/fa-IR.xml b/translation/dest/coach/fa-IR.xml index b8564b76ac806..b115dbf2e943a 100644 --- a/translation/dest/coach/fa-IR.xml +++ b/translation/dest/coach/fa-IR.xml @@ -14,7 +14,7 @@ دانشجوها را قبول می‌کنم در حال حاضر پذیرای دانشجویان نیستم %s مربی شطرنج دانشجویان است - نمایه %s در Lichess را ببینید + رُخ‌نما %s در Lichess را ببینید یک پیام خصوصی ارسال کنید درباره من سابقه بازی diff --git a/translation/dest/contact/fa-IR.xml b/translation/dest/contact/fa-IR.xml index 288a9577c57ed..0f3e915cae714 100644 --- a/translation/dest/contact/fa-IR.xml +++ b/translation/dest/contact/fa-IR.xml @@ -13,7 +13,7 @@ بازیابی گذرواژه را تکمیل کنید تا هویت‌سنجیِ دومِ شما برداشته شود به پشتیبانی حساب کاربری نیاز دارم می خواهم عنوانم در Lichess نشان داده شود - برای آن که عنوان شما روی نمایه Lichessتان نشان داده شود، و در میدان‌های مسابقه عنوان‌داران شرکت کنید، به صفحه تاییدِ عنوان سَر بزنید + برای آن که عنوان شما روی رُخ‌نمای Lichessتان نشان داده شود، و در میدان‌های مسابقه عنوان‌داران شرکت کنید، به صفحه تاییدِ عنوان سَر بزنید می خواهم حساب کاربری ام را ببندم شما می‌توانید در این صفحه حساب کاربری خود را ببندید از طریق رایانامه از ما نخواهید که یک حساب کاربری را ببندیم، ما آن را انجام نخواهیم داد. @@ -28,7 +28,7 @@ امکان پاک کردن پیشینه بازی‌ها، پیشینه معماها یا درجه‌بندی وجود ندارد. می‌خواهم یک کاربر را گزارش کنم برای گزارش کردنِ یک کاربر، از فرم گزارش استفاده کنید - همچنین می‌توانید با زدن روی دکمه گزارش %s در یک صفحه نمایه، به آن صفحه برسید. + همچنین می‌توانید با زدن روی دکمه گزارش %s در یک صفحه رُخ‌نما، به آن صفحه برسید. در تالار گفت و گو کاربرها را گزارش نکنید. از ارسال رایانامه به ما برای گزارش خودداری کنید. لطفا پیام مستقیم به مدیران سایت نفرستید. @@ -59,7 +59,7 @@ یاد بگیرید که چطور پخش زنده خود را در لیچس بسازید شما همینطور میتوانید با تیم پخش زنده در مورد پخش های رسمی تماس بگیرید. درخواست بازنگری برای یک ممنوعیت یا محدودیتِ IP - علامت‌گذاری استفاده از پردازشگر شطرنج یا تقلب + علامت‌گذاری استفاده از رایانه یا تقلب شما می‌توانید یک درخواست بررسی مجدد به %s ارسال کنید. مثبتهای اشتباهی حتماً گاهی اتفاق می‌افتد، و ما درباره آن متاسفیم. اگر درخواست بازنگری شما مشروع و قانونی باشد، ما در نزدیکترین زمان ممکن ممنوعیت را رفع خواهیم کرد. diff --git a/translation/dest/faq/fa-IR.xml b/translation/dest/faq/fa-IR.xml index a300a82cc021b..77a9c5508767a 100644 --- a/translation/dest/faq/fa-IR.xml +++ b/translation/dest/faq/fa-IR.xml @@ -67,7 +67,7 @@ به طور کلی، نام های کاربری نباید: توهین آمیز، جعل هویت شخص دیگری یا تبلیغاتی باشند. می‌توانید درباره %1$s بیشتر بخوانید. دستورالعمل‌ها آیا می‌توانم نام کاربری خود را تغییر دهم؟ - خیر، نام کاربری به دلایل فنی و عملی قابل تغییر نیست. نام‌های کاربری در جاهای مختلف ثبت شده اند: پایگاه‌های داده، گزارش‌ها و در نظر مردم. می توانید یک بار حروف بزرگ را تنظیم کنید. + خیر، نام‌های کاربری به دلیل‌های فنی و عملی تغییرپذیر نیستند. نام‌های کاربری در جاهای زیادی ثبت شده‌اند: دادگان‌ها، برونبُردها، گزارش‌ها و در حافظه مردم. می‌توانید یک بار کوچکی-بزرگی حرف‌ها را تنظیم کنید. غنائم منحصر به فرد برای به دست آوردنش، hiimgosu خود را به چالش کشید تا تمام بازی های %s با استفاده از جنون برنده شود. یک مسابقه گلوله‌ای ساعتی diff --git a/translation/dest/onboarding/fa-IR.xml b/translation/dest/onboarding/fa-IR.xml index 28e40a9bc6d0f..002690c486ab5 100644 --- a/translation/dest/onboarding/fa-IR.xml +++ b/translation/dest/onboarding/fa-IR.xml @@ -2,7 +2,7 @@ خوش آمدید! به لیچس خوش آمدید! - این صفحه نمایه‌تان است. + این صفحه رُخ‌نمای‌تان است. آیا یک کودک قرار است از این حساب استفاده کند؟ شاید بخواهید %s را فعال کنید. حالا چی؟ این‌ها پیشنهاد ماست: قوانین شطرنج را یاد بگیرید diff --git a/translation/dest/patron/fa-IR.xml b/translation/dest/patron/fa-IR.xml index 73ca6bf6362f3..377cdd521f152 100644 --- a/translation/dest/patron/fa-IR.xml +++ b/translation/dest/patron/fa-IR.xml @@ -48,7 +48,7 @@ لطفاً توجه داشته باشید که فقط برگه کمک مالی بالا، جایگاه حامی را به ارمغان می‌آورد. آیا برخی ویژگیها فقط برای پشتیبان‌ها قابل دسترس است؟ نه، زیرا Lichess برای همیشه و برای همه، کاملا رایگان است. قول می‌دهیم. -با این حال، حامیان با بال‌های معرکه‌ای که در نمایه‌شان نشان داده می‌شود، حق پُز دادن دارند. +با این حال، حامیان با بال‌های معرکه‌ای که در رُخ‌نمای‌شان نشان داده می‌شود، حق پُز دادن دارند. مقایسه جزئیاتِ ویژگیها را مشاهده کنید پشتیبانِ Lichess برای یک ماه @@ -75,7 +75,7 @@ مقدار تراکنشِ شما تکمیل شد، و یک رسید برای کمک مالی شما برایتان ایمیل شد. شما هم‌اکنون یک حساب دائمی پشتیبان دارید. - به صفحه نمایه‌تان سَر بزنید! + به صفحه رُخ‌نمای‌تان سَر بزنید! شما هم‌اکنون یک پشتیبانِ مادام‌العمرِ Lichess هستید! شما هم‌اکنون برای یک ماه یک پشتیبانِ Lichess هستید! طی یک ماه، دوباره برای شما بدهکاری ثبت نخواهد شد، و حساب کاربری Lichess شما به یک حساب کاربری معمولی برگردانده خواهد شد. diff --git a/translation/dest/preferences/vi-VN.xml b/translation/dest/preferences/vi-VN.xml index dc800554dd2c6..870514337d715 100644 --- a/translation/dest/preferences/vi-VN.xml +++ b/translation/dest/preferences/vi-VN.xml @@ -1,6 +1,6 @@ - Sửa giao diện + Tuỳ chỉnh Hiển thị Quyền riêng tư Thông báo diff --git a/translation/dest/puzzleTheme/fa-IR.xml b/translation/dest/puzzleTheme/fa-IR.xml index 0500fcff9d51c..a23be5023a9af 100644 --- a/translation/dest/puzzleTheme/fa-IR.xml +++ b/translation/dest/puzzleTheme/fa-IR.xml @@ -123,5 +123,5 @@ یک ذره از همه چیز. شما نمی دانید چه چیزی پیش روی شماست، بنابراین شما باید برای هر چیزی آماده باشید! دقیقا مثل بازی های واقعی. بازی‌های بازیکن دنبال معماهای ایجادشده از بازی‌های خودتان یا بازی‌های سایر بازیکنان، بگردید. - این معماها به صورت عمومی هستند و می توانند از %s بارگیری شوند. + این معماها به صورت همگانی هستند و می‌توانید از %s بارگیریدشان. diff --git a/translation/dest/site/fa-IR.xml b/translation/dest/site/fa-IR.xml index f4d20c9beedd6..e6c9fb7598818 100644 --- a/translation/dest/site/fa-IR.xml +++ b/translation/dest/site/fa-IR.xml @@ -44,7 +44,7 @@ ممکن است حریف شما بازی را ترک کرده باشد. شما می توانید ادعای پیروزی کنید, اعلام تساوی کنید یا منتظر او بمانید. ادعای پیروزی اعلام تساوی - لطفا در گپ زدن مودب باشید! + لطفا در گپ‌زنی بااَدب باشید! نخستین کسی که به این وب‌نشانی آید با شما بازی خواهد کرد. سفید تسلیم شد سیاه تسلیم شد @@ -70,8 +70,8 @@ افزایش عمق شاخه اصلی خط کنونی را به خط اصلی تبدیل کنید از اینجا به بعد را پاک کنید - بستن شاخه‌ها - باز کردن شاخه‌ها + بستن شاخه‌ها + باز کردن شاخه‌ها نتیجه تحلیل را به عنوان یکی از تنوعهای بازی انتخاب نمایید کپی PGN این شاخه انتقال بدهید @@ -112,7 +112,7 @@ همه چیز آماده است! PGN را وارد کنید حذف - آیا این بازیِ وارد شده حذف گردد؟ + آیا این بازیِ درونبُرده پاک شود؟ حالت پخش مشابه بازی درنگ حین اشتباهات @@ -142,7 +142,7 @@ %s غیردقیق %s غیردقیق - مدت زمان حركت + مدت حركت‌ها چرخاندن صفحه تکرار سه گانه ادعای تساوی @@ -325,7 +325,7 @@ %s بازی در حال انجام %s بازی در حال انجام - استخراج بازی ها + برون‏بُرد بازی‌ها محدوده درجه‌بندی %s ثانیه اضافه کن @@ -398,7 +398,7 @@ ذخیره جدول رده‌بندی از وضعیت فعلی نماگرفت بگیرید - دانلود گیف بازی + بارگیری GIF بازی پوزیشن دلخواه(FEN) را در این قسمت وارد کنید متن PGN را در این قسمت وارد کنید یا یک فایل PGN بارگذاری کنید @@ -504,11 +504,11 @@ بارگذاری موقعیت خصوصی گزارش %s به مدیران سایت - میزان تکمیل نمایه: %s + میزان تکمیل رُخ‌نما: %s درجه‌‏بندی %s اگر ندارید، خالی گذارید - نمایه - ویرایش نمایه + رُخ‌نما + ویرایش رُخ‌نما نام نام خانوادگی تعیین کردن شکلک @@ -740,8 +740,8 @@ تاخیر شبکه بین شما و Lichess زمان سپری شده برای پردازش یک حرکت بارگیری حرکت‌نویسی - دانلود خام - دانلود جایگذاری شده + بارگیری خام + بارگیری درونبُرد رودررو شما می توانید برای حرکت در بازی از صفحه استفاده کنید برای مشاهده آن ها اسکرول کنید. @@ -800,7 +800,7 @@ فام بازنشاندن به رنگ‌های پیش‌فرض نوع مهره - قرار دادن در سایت خود + قرار دادن در وبگاه خود این نام کاربری در حال حاضر انتخاب شده است.لطفا نام دیگری انتخاب کنید. نام کاربری باید با حرف شروع شود. نام کاربری باید با حرف یا شماره خاتمه یابد. diff --git a/translation/dest/site/he-IL.xml b/translation/dest/site/he-IL.xml index 07c1667f44639..d908411238a2e 100644 --- a/translation/dest/site/he-IL.xml +++ b/translation/dest/site/he-IL.xml @@ -75,7 +75,7 @@ הסתרת מהלכים חלופיים הצגת מהלכים חלופיים וריאנט יחיד - העתקת ה-PGN של הוריאנט + העתקת ה־PGN של הוריאנט מסע הפסד וריאנט ניצחון וריאנט @@ -92,7 +92,7 @@ דירוג ממוצע: %s משחקים אחרונים המשחקים המובילים - משחקים על־גבי לוח של שחקנים עם דירוג פיד״ה של %1$s+ מ-%2$s עד %3$s + משחקים על־גבי לוח של שחקנים עם דירוג פיד״ה של %1$s+ מ־%2$s עד %3$s מט בעוד חצי מהלך %s מט בעוד %s חצאי מהלכים @@ -233,7 +233,7 @@ שם משתמש שם משתמש או דוא״ל שינוי שם המשתמש - באותיות לועזיות ניתן להחליף אותיות קטנות בגדולות, למשל מ-\"johndoe\" ל-\"JohnDoe\". + באותיות לועזיות ניתן להחליף אותיות קטנות בגדולות, למשל מ־\"johndoe\" ל־\"JohnDoe\". שינוי שם המשתמש. ניתן לעשותו פעם אחת בלבד, ורק על ידי החלפת אותיות גדולות בקטנות ולהיפך. ודאו ששם המשתמש שלכם מתאים גם לילדים. לא תוכלו לשנות אותו מאוחר יותר וחשבונות עם שמות משתמש לא הולמים יסגרו! רק לצורך איפוס הסיסמה. @@ -245,10 +245,10 @@ שכחת סיסמה? הסיסמה הזו נפוצה ביותר וקלה מדי לניחוש. נא לא להשתמש בשם המשתמש בתור הסיסמה. - השתמשת בסיסמה שלך באתר אחר, ויתכן שהיא מועדת לפריצה. כדי להגן על חשבונך בליצ׳ס, עליך להגדיר סיסמה חדשה. תודה על ההבנה. + השתמשת בסיסמה שלך באתר אחר, ויתכן שהיא מועדת לפריצה. כדי להגן על חשבונך ב־Lichess, עליך להגדיר סיסמה חדשה. תודה על ההבנה. את/ה עוזב/ת את Lichess - לעולם אל תקלידו את סיסמתכם בליצ׳ס באף אתר אחר! - מעבר ל-%s + לעולם אל תקלידו את סיסמתכם ב־Lichessבאף אתר אחר! + מעבר ל־%s אל תשתמשו בסיסמה שהציע לכם אדם אחר. הוא ישתמש בה כדי לגנוב את חשבונכם! אל תשמשו בכתובת מייל שהציע אדם אחר. הוא ישתמש בה כדי לגנוב את חשבונכם. עזרה עם אימייל האישור @@ -473,10 +473,10 @@ זה CAPTCHA של שחמט. לחץ/י על הלוח כדי לעשות מהלך, כדי להוכיח שאת/ה בן אנוש. - אנא בצע/י את המהלך הנכון בלוח ה-Captcha. + אנא בצע/י את המהלך הנכון בלוח ה־Captcha. לא מט - מט ב-1 ללבן - מט ב-1 לשחור + מט ב־1 ללבן + מט ב־1 לשחור נסו שוב מתחבר מחדש לא מחובר @@ -517,7 +517,7 @@ גרף פחות מדקה %s - פחות מ- %s דקות + פחות מ־ %s דקות פחות מ%s דקות פחות מ%s דקות @@ -665,7 +665,7 @@ במשחקים איטיים תמיד אף פעם - %1$s מתחרה ב-%2$s + %1$s מתחרה ב־%2$s ניצחון הפסד %1$s נגד %2$s ב%3$s @@ -749,7 +749,7 @@ המשחק הסימולטני אינו קיים. חזרה לדף הבית של המשחקים הסימולטניים משחק סימולטני מערב שחקן יחיד אשר משחק נגד שחקנים רבים בו זמנית. - מתוך 50 משחקים בו־זמנית, פישר ניצח ב-47 משחקים, השיג 2 תוצאות תיקו והפסיד במשחק אחד. + מתוך 50 משחקים בו־זמנית, פישר ניצח ב־47 משחקים, השיג 2 תוצאות תיקו והפסיד במשחק אחד. המושג נלקח מאירועים בעולם האמיתי. בחיים האמיתיים, המארח/ת עובר/ת משולחן לשולחן ומשחק/ת מהלך אחד בכל פעם. כאשר המשחק הסימולטני מתחיל, כל שחקן מתחיל את המשחק עם המארח, אשר משחק בתור הלבן. המשחק הסימולטני מסתיים כאשר כל המשחקים מסתיימים. המשחקים הסימולטניים הם תמיד לא מדורגים. האפשרויות של: \"נסה שוב\", ״החזר מהלך״ ו\"הוסף זמן\" מבוטלות. @@ -800,9 +800,9 @@ %1$s שחקני %2$s השבוע. דירוגך במשחקי %1$s הוא %2$s. - דירוגך גבוה יותר מ-%1$s משחקני %2$s. - %1$s טוב יותר מ-%2$s משחקני ה%3$s. - יותר טוב מ-%1$s משחקני ה־%2$s + דירוגך גבוה יותר מ־%1$s משחקני %2$s. + %1$s טוב יותר מ־%2$s משחקני ה%3$s. + יותר טוב מ־%1$s משחקני ה־%2$s טרם נקבע לך דירוג במשחקי %s. הדירוג שלך מצטבר @@ -858,7 +858,7 @@ ניתוח המשחק %1$s מארח/ת %2$s - %1$s מצטרף/ת ל-%2$s + %1$s מצטרף/ת ל־%2$s %1$s אוהב/ת את %2$s הצטרפות מהירה לובי @@ -954,7 +954,7 @@ ושמרו %s המשכים מוגדרים מראש ושמרו %s המשכים מוגדרים מראש - קיבלתם הודעה פרטית מ-Lichess. + קיבלתם הודעה פרטית מ־Lichess. לחצו כאן כדי לקרוא אותה מצטערים :( נאלצנו להשעות אותך לזמן מה. @@ -981,7 +981,7 @@ Blitz Rapid Classical - משחקים מהירים בטירוף: פחות מ-30 שניות על השעון + משחקים מהירים בטירוף: פחות מ־30 שניות על השעון משחקים מהירים מאוד: פחות מ3 דקות על השעון משחקים מהירים: בין 3 ל8 דקות משחקים זריזים: בין 8 ל25 דקות @@ -1003,9 +1003,9 @@ אתה עדיין לא יכול לפרסם בפורום זה. שחק כמה משחקים! עקוב בטל את המעקב - תייג/ה אותך ב- %1$s. - %1$s הזכיר/ה אותך בהודעה ב-\"%2$s\". - הזמין אותך ל-\"%1$s\". + תייג/ה אותך ב־ %1$s. + %1$s הזכיר/ה אותך בהודעה ב־\"%2$s\". + הזמין אותך ל־\"%1$s\". %1$s הזמין/ה אותך ללוח הלמידה \"%2$s\". את/ה כעת חבר/ה בקבוצה. הצטרפת אל \"%1$s\". @@ -1039,7 +1039,7 @@ צבע מנחה המשחק זמן התחלה משוער הצג ב%s - גרום למשחק להיות פומבי ב-%s. בטל בשביל משחק סימולטני פרטי. + גרום למשחק להיות פומבי ב־%s. בטל בשביל משחק סימולטני פרטי. תיאור המשחק הסימולטני משהו שאת/ה רוצה להגיד למשתתפים? %s זמין לתחביר מתקדם יותר. @@ -1071,7 +1071,7 @@ לא תוכל/י להתחיל משחק חדש עד גמר הנוכחי. מאז עד - משחקים מדורגים אשר נדגמו מכלל שחקני ליצ׳ס + משחקים מדורגים אשר נדגמו מכלל שחקני Lichess הפוך צד סגירת החשבון תבטל את פנייתך הטיפים שלנו לארגון אירועים diff --git a/translation/dest/site/vi-VN.xml b/translation/dest/site/vi-VN.xml index d74461afe3a23..d7dd5ee8df616 100644 --- a/translation/dest/site/vi-VN.xml +++ b/translation/dest/site/vi-VN.xml @@ -447,7 +447,7 @@ trò chuyện trong ván đấu và có một URL có thể chia sẻ công khai Tạo bởi Giải đấu đang diễn ra Đã đóng việc sắp xếp cặp đấu. - Cặp đấu đang được xếp, %s hãy sẵn sàng! + %s chờ nhé, đang xếp cặp đấu, chuẩn bị sẵn sàng! Tạm rút Tiếp tục Bạn đã vào ván! diff --git a/translation/dest/streamer/fa-IR.xml b/translation/dest/streamer/fa-IR.xml index f23040f050ec2..22e1f28a71152 100644 --- a/translation/dest/streamer/fa-IR.xml +++ b/translation/dest/streamer/fa-IR.xml @@ -21,7 +21,7 @@ %s ما را بخوانید تا در مدت استریم شما از بازی جوانمردانه برای همه اطمینان حاصل شود. پرسشها و پاسخهای متداول درباره استریم نمودن به صورت منصفانه مزایای جریان با کلمه کلیدی - یک نقشک بَرخَط-محتواساز شعله‌ور در نمایه Lichessتان دریافت کنید. + یک نقشک بَرخَط-محتواساز شعله‌ور در رُخ‌نمای Lichessتان دریافت کنید. در بالای لیست پخش کننده ها پرش کنید. به فالوور های خود در لیچس اطلاع دهید جریان خود را در بازی ها مسابقات و مطالعات خود نشان دهید diff --git a/translation/dest/study/fa-IR.xml b/translation/dest/study/fa-IR.xml index 6ea7f494e9887..e9b1372cd043f 100644 --- a/translation/dest/study/fa-IR.xml +++ b/translation/dest/study/fa-IR.xml @@ -58,7 +58,7 @@ پیشین بعدی آخرین - اشتراک & صدور + همرسانی و برون‏بُرد نمونه سازی PGN درس بارگیری تمام بازی ها @@ -70,7 +70,7 @@ برای جاسازی این نوشته، این کد را در تالار گفت و گو قرار دهید در موقعیت آغازین شروع نمایید شروع از %s - در وبسایت یا وبلاگ خود قرار دهید + در وبگاهتان قرار دهید درباره قرار دادن (در سایت) بیشتر بخوانید فقط مطالعاتِ عمومی می‌توانند جایگذاری شوند! بگشایید diff --git a/translation/dest/swiss/fa-IR.xml b/translation/dest/swiss/fa-IR.xml index 713cdf9f5474a..87df9c4f5efcd 100644 --- a/translation/dest/swiss/fa-IR.xml +++ b/translation/dest/swiss/fa-IR.xml @@ -39,6 +39,15 @@ فاصله بین دورها رویارویی‌های ممنوع نام کاربری بازیکنانی که نباید با هم بازی کنند (مثلا خواهر و برادرها). دو نام کاربری در هر خط، با فاصله از هم جدا شوند. + رویارویی دستی در دور پسین + همه رویارویی‌های دور پسین را دستی مشخص کنید. یک جفت بازیکن در هر خط. نمونه: +بازیکن‌آ بازیکن‌ب +بازیکن‌پ بازیکن‌ت +برای استراحت دادن (یک امتیاز) به یک بازیکن به جای رویارویی، یک خط مانند این بیفزایید: +بازیکن‌ث ۱ +بازیکنان نبوده، غایب در نظر گرفته می‌شوند و صفر امتیاز می‌گیرند. +اگر این خانه را خالی گذارید، Lichess خودکار رویارویی‌ها را می‌چیند. + باید آخرین بازی سوییسی‌شان را کرده باشند مسابقات سوئیسی جدید چه زمانی از مسابقات با ساختار سوئیسی به جای آرنا استفاده کنیم؟ در مسابقه با فرم سوئیسی، تمام شرکت کننده ها به تعداد برابر بازی انجام می دهند و هر دو بازیکن فقط یک بار با یکدیگر بازی می کنند. diff --git a/translation/dest/swiss/vi-VN.xml b/translation/dest/swiss/vi-VN.xml index 1db024a122804..6719e4bdf2d5a 100644 --- a/translation/dest/swiss/vi-VN.xml +++ b/translation/dest/swiss/vi-VN.xml @@ -24,7 +24,7 @@ Bắt đầu trong Vòng đấu tiếp theo - Ván cờ hiện đang chơi + Ván đấu đang diễn ra Thời gian bắt đầu giải đấu Số vòng đấu @@ -117,7 +117,7 @@ Thứ gần nhất bạn có thể có với một giải đấu vòng tròn là Chỉ cho phép những người dùng được chỉ định trước tham gia Ngoại trừ những người trong danh sách này, những người khác đều bị cấm tham gia. Mỗi dòng một tên người dùng. Chơi các ván đấu của bạn - Miễn + Được miễn Vắng mặt Điểm số phụ diff --git a/translation/dest/team/fa-IR.xml b/translation/dest/team/fa-IR.xml index faf93c170d6c4..fb5bf2b3f6578 100644 --- a/translation/dest/team/fa-IR.xml +++ b/translation/dest/team/fa-IR.xml @@ -55,6 +55,7 @@ درخواست‌های رد شده برای دسترسی به اخبار و رویداد ها در تیم رسمی %s عضو شوید صفحه تیم ها + این مسابقات به پایان رسیده‌است و تیم‌ها دیگر نمی‌توانند به‌روز شوند. تیم‌هایی که در این نبرد با یکدیگر رقابت خواهند کرد را لیست کنید. هر تیم در یک خط. از تکمیل خودکار استفاده کنید. می‌توانید این لیست را از یک مسابقه به مسابقات دیگر کپی کنید! From 07aa9d268bab4bef2b073f243a2c880788fe247e Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 08:18:38 +0200 Subject: [PATCH 093/260] remove debug --- modules/relay/src/main/RelayRoundForm.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index 1a3d6ae130710..dc3ef46b7e56e 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -78,7 +78,7 @@ final class RelayRoundForm(using mode: Mode): def edit(r: RelayRound) = Form( roundMapping.verifying( "The round source cannot be itself", - d => d.syncSource.pp.forall(_ != "url") || d.syncUrl.forall(_.roundId.forall(_ != r.id)) + d => d.syncSource.forall(_ != "url") || d.syncUrl.forall(_.roundId.forall(_ != r.id)) ) ).fill(Data.make(r)) From 349093184e0877942392e27cb731abef7da40fc0 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 08:25:49 +0200 Subject: [PATCH 094/260] tweak broadcast dynamicPeriod --- modules/relay/src/main/RelayFetch.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index e2622997ded2b..78e02a8a0add4 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -157,11 +157,13 @@ final private class RelayFetch( val base = if upstream.isLcc then 6 else if upstream.isRound then 10 // uses push so no need to pull often - else 3 + else 2 base * { if tour.official then 1 else 2 } * { if round.crowd.exists(_ > 4) then 1 else 2 + } * { + if round.hasStarted then 1 else 2 } private val gameIdsUpstreamPgnFlags = PgnDump.WithFlags( From aa6034daab452f6d60372c0cf7f61fcff4d74301 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 08:39:09 +0200 Subject: [PATCH 095/260] official broadcasters can use lichess as a broadcast source --- modules/relay/src/main/RelayRoundForm.scala | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/modules/relay/src/main/RelayRoundForm.scala b/modules/relay/src/main/RelayRoundForm.scala index dc3ef46b7e56e..6315c25f74a00 100644 --- a/modules/relay/src/main/RelayRoundForm.scala +++ b/modules/relay/src/main/RelayRoundForm.scala @@ -44,9 +44,9 @@ final class RelayRoundForm(using mode: Mode): ) private def lccIsComplete(url: Upstream.Url) = - url.isLcc || !url.url.host.toString.contains("livechesscloud.com") + url.isLcc || !url.url.host.toString.endsWith("livechesscloud.com") - val roundMapping = + def roundMapping(using Me) = mapping( "name" -> cleanText(minLength = 3, maxLength = 80).into[RelayRound.Name], "caption" -> optional(cleanText(minLength = 3, maxLength = 80).into[RelayRound.Caption]), @@ -54,6 +54,10 @@ final class RelayRoundForm(using mode: Mode): "syncUrl" -> optional( of[Upstream.Url] .verifying("LCC URLs must end with /{round-number}, e.g. /5 for round 5", lccIsComplete) + .verifying( + "Invalid source URL", + u => !u.url.host.toString.endsWith("lichess.org") || Granter(_.Relay) + ) ), "syncUrls" -> optional(of[Upstream.Urls]), "syncIds" -> optional(of[Upstream.Ids]), @@ -67,7 +71,7 @@ final class RelayRoundForm(using mode: Mode): .transform[List[RelayGame.Slice]](RelayGame.Slices.parse, RelayGame.Slices.show) )(Data.apply)(unapply) - def create(trs: RelayTour.WithRounds) = Form( + def create(trs: RelayTour.WithRounds)(using Me) = Form( roundMapping .verifying( s"Maximum rounds per tournament: ${RelayTour.maxRelays}", @@ -75,11 +79,12 @@ final class RelayRoundForm(using mode: Mode): ) ).fill(fillFromPrevRounds(trs.rounds)) - def edit(r: RelayRound) = Form( - roundMapping.verifying( - "The round source cannot be itself", - d => d.syncSource.forall(_ != "url") || d.syncUrl.forall(_.roundId.forall(_ != r.id)) - ) + def edit(r: RelayRound)(using Me) = Form( + roundMapping + .verifying( + "The round source cannot be itself", + d => d.syncSource.forall(_ != "url") || d.syncUrl.forall(_.roundId.forall(_ != r.id)) + ) ).fill(Data.make(r)) object RelayRoundForm: @@ -176,7 +181,6 @@ object RelayRoundForm: "twitch.com", "youtube.com", "youtu.be", - "lichess.org", "google.com", "vk.com", "chess-results.com", From cf3f3758ef1735fddf52415c47b33b260ccceb7c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 09:20:36 +0200 Subject: [PATCH 096/260] don't expose initial empty chapter in broadcast round API --- app/controllers/RelayRound.scala | 2 +- modules/study/src/main/StudyChapterPreview.scala | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/controllers/RelayRound.scala b/app/controllers/RelayRound.scala index d5808ebdcd33a..f59aab2d91df2 100644 --- a/app/controllers/RelayRound.scala +++ b/app/controllers/RelayRound.scala @@ -123,7 +123,7 @@ final class RelayRound( studyC.CanView(study)( for group <- env.relay.api.withTours.get(rt.tour.id) - previews <- env.study.preview.jsonList(study.id) + previews <- env.study.preview.jsonList.withoutInitialEmpty(study.id) yield JsonOk(env.relay.jsonView.withUrlAndPreviews(rt.withStudy(study), previews, group)) )(studyC.privateUnauthorizedJson, studyC.privateForbiddenJson) diff --git a/modules/study/src/main/StudyChapterPreview.scala b/modules/study/src/main/StudyChapterPreview.scala index b6af5524481a9..3661354a19a5a 100644 --- a/modules/study/src/main/StudyChapterPreview.scala +++ b/modules/study/src/main/StudyChapterPreview.scala @@ -52,6 +52,18 @@ final class ChapterPreviewApi( def apply(studyId: StudyId): Fu[AsJsons] = cache.get(studyId) + def withoutInitialEmpty(studyId: StudyId): Fu[AsJsons] = + apply(studyId).map: json => + val singleInitial = json + .asOpt[JsArray] + .map(_.value) + .filter(_.sizeIs == 1) + .flatMap(_.headOption) + .exists: + case single: JsObject => single.str("name").contains("Chapter 1") + case _ => false + if singleInitial then JsArray.empty else json + object dataList: private[ChapterPreviewApi] val cache = cacheApi[StudyId, List[ChapterPreview]](512, "study.chapterPreview.data"): From c2579e534fc1cd66daaa0e8309808b4b6ddab3ec Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 09:40:25 +0200 Subject: [PATCH 097/260] remove css transition on mini-game gauge for potential perf savings --- ui/analyse/css/study/panel/_multiboard.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/analyse/css/study/panel/_multiboard.scss b/ui/analyse/css/study/panel/_multiboard.scss index 062c9455d6ad1..f7f4800d144bb 100644 --- a/ui/analyse/css/study/panel/_multiboard.scss +++ b/ui/analyse/css/study/panel/_multiboard.scss @@ -111,7 +111,6 @@ width: 100%; height: 50%; background: $black; - transition: height 1s; } opacity: 0.4; From 7c7bfebd76faea65c2dd9453f4d2987d3080457e Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 00:48:38 -0700 Subject: [PATCH 098/260] Add password show/hide button in Typescript --- ui/bits/css/_auth.scss | 14 ++++++++++++++ ui/bits/src/bits.login.ts | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/ui/bits/css/_auth.scss b/ui/bits/css/_auth.scss index bce6ed4c6ab76..947c36920e1c2 100644 --- a/ui/bits/css/_auth.scss +++ b/ui/bits/css/_auth.scss @@ -64,3 +64,17 @@ margin-top: 1rem; } } + +.password-wrapper { + position: relative; +} + +.show-hide-password { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; +} diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index 901119e530ffa..f72323e481f81 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -25,7 +25,7 @@ function loginStart() { const toggleSubmit = ($submit: Cash, v: boolean) => $submit.prop('disabled', !v).toggleClass('disabled', !v); - + passwordShowHide(); (function load() { const form = document.querySelector(selector) as HTMLFormElement, $f = $(form); @@ -110,3 +110,20 @@ function signupStart() { site.asset.loadEsm('bits.passwordComplexity', { init: 'form3-password' }); } + +function passwordShowHide() { + $('#form3-password').each(function (this: HTMLElement) { + const $input = $(this); + $input.wrap('
'); + const $wrapper = $input.parent(); + const $button = $('').appendTo( + $wrapper, + ); + $button.on('click', function (e: Event) { + e.preventDefault(); + const type = $input.attr('type') === 'password' ? 'text' : 'password'; + $input.attr('type', type); + $button.toggleClass('show', type === 'text'); + }); + }); +} From 91b7295d0959a88d402dd3e2eb5f756b888cbe36 Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 01:11:16 -0700 Subject: [PATCH 099/260] Move UI element definitions to scala --- modules/web/src/main/ui/AuthUi.scala | 11 ++++++----- ui/bits/css/_auth.scss | 1 - ui/bits/src/bits.login.ts | 12 +++++------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index 587d40472d729..3e3b5ea4befe0 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -337,9 +337,7 @@ body { margin-top: 45px; } "policy" -> trans.site.agreementPolicy() ) - private def formFields(username: Field, password: Field, email: Option[Field], register: Boolean)(using - Context - ) = + private def formFields(username: Field, password: Field, email: Option[Field], register: Boolean)(using Context) = frag( form3.group( username, @@ -350,8 +348,11 @@ body { margin-top: 45px; } form3.input(f)(autofocus, required, autocomplete := "username"), register.option(p(cls := "error username-exists none")(trans.site.usernameAlreadyUsed())) ), - form3.passwordModified(password, trans.site.password())( - autocomplete := (if register then "new-password" else "current-password") + div(cls := "password-wrapper")( + form3.passwordModified(password, trans.site.password())( + autocomplete := (if register then "new-password" else "current-password") + ), + button(cls := "show-hide-password", title := "Show/hide password")("Joey") ), register.option(form3.passwordComplexityMeter(trans.site.newPasswordStrength())), email.map: email => diff --git a/ui/bits/css/_auth.scss b/ui/bits/css/_auth.scss index 947c36920e1c2..21f72c379fd4c 100644 --- a/ui/bits/css/_auth.scss +++ b/ui/bits/css/_auth.scss @@ -73,7 +73,6 @@ position: absolute; right: 10px; top: 50%; - transform: translateY(-50%); background: none; border: none; cursor: pointer; diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index f72323e481f81..129e38ee5fb8c 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -82,6 +82,7 @@ function loginStart() { } function signupStart() { + passwordShowHide(); const $form = $('#signup-form'), $exists = $form.find('.username-exists'), $username = $form.find('input[name="username"]').on('change keyup paste', () => { @@ -112,13 +113,10 @@ function signupStart() { } function passwordShowHide() { - $('#form3-password').each(function (this: HTMLElement) { - const $input = $(this); - $input.wrap('
'); - const $wrapper = $input.parent(); - const $button = $('').appendTo( - $wrapper, - ); + $('.password-wrapper').each(function (this: HTMLElement) { + const $wrapper = $(this); + const $input = $wrapper.find('input[type="password"], input[type="text"]'); + const $button = $wrapper.find('.show-hide-password'); $button.on('click', function (e: Event) { e.preventDefault(); const type = $input.attr('type') === 'password' ? 'text' : 'password'; From cf4f1e0bcb77e574e7349d9fe4bb7f41f9de9a88 Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 01:39:57 -0700 Subject: [PATCH 100/260] Use eye icon instead of button --- modules/web/src/main/ui/AuthUi.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index 3e3b5ea4befe0..d76567e44f6da 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -352,7 +352,9 @@ body { margin-top: 45px; } form3.passwordModified(password, trans.site.password())( autocomplete := (if register then "new-password" else "current-password") ), - button(cls := "show-hide-password", title := "Show/hide password")("Joey") + button(cls := "show-hide-password")( + i(dataIcon := Icon.Eye) + ) ), register.option(form3.passwordComplexityMeter(trans.site.newPasswordStrength())), email.map: email => From 4fd9ce6375deeece5bc13c2b4ffbc17c49447a6f Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 01:47:07 -0700 Subject: [PATCH 101/260] Add/remove strikethrough on password toggle --- ui/bits/css/_auth.scss | 11 +++++++++++ ui/bits/src/bits.login.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/ui/bits/css/_auth.scss b/ui/bits/css/_auth.scss index 21f72c379fd4c..bf8ad9bab9212 100644 --- a/ui/bits/css/_auth.scss +++ b/ui/bits/css/_auth.scss @@ -77,3 +77,14 @@ border: none; cursor: pointer; } + +.show-hide-password.strikethrough::before { + content: ''; + position: absolute; + width: 100%; + height: 2px; + background-color: currentColor; + transform: rotate(45deg); + top: 50%; + left: 0; +} diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index 129e38ee5fb8c..3d5a59fb4513f 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -122,6 +122,7 @@ function passwordShowHide() { const type = $input.attr('type') === 'password' ? 'text' : 'password'; $input.attr('type', type); $button.toggleClass('show', type === 'text'); + $button.toggleClass('strikethrough'); }); }); } From 1a4e881ffdeb54240a73d0707ab616522918ceaa Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 01:56:56 -0700 Subject: [PATCH 102/260] Cleanup --- ui/bits/src/bits.login.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index 3d5a59fb4513f..2e902ef59a3fa 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -25,7 +25,6 @@ function loginStart() { const toggleSubmit = ($submit: Cash, v: boolean) => $submit.prop('disabled', !v).toggleClass('disabled', !v); - passwordShowHide(); (function load() { const form = document.querySelector(selector) as HTMLFormElement, $f = $(form); @@ -79,10 +78,11 @@ function loginStart() { }); }); })(); + + addPasswordVisibilityToggleListener(); } function signupStart() { - passwordShowHide(); const $form = $('#signup-form'), $exists = $form.find('.username-exists'), $username = $form.find('input[name="username"]').on('change keyup paste', () => { @@ -110,9 +110,11 @@ function signupStart() { }); site.asset.loadEsm('bits.passwordComplexity', { init: 'form3-password' }); + + addPasswordVisibilityToggleListener(); } -function passwordShowHide() { +function addPasswordVisibilityToggleListener() { $('.password-wrapper').each(function (this: HTMLElement) { const $wrapper = $(this); const $input = $wrapper.find('input[type="password"], input[type="text"]'); From 8ed4e83258204befcb429d0d11743b4b61eeda87 Mon Sep 17 00:00:00 2001 From: joeyksclark Date: Wed, 26 Jun 2024 02:12:54 -0700 Subject: [PATCH 103/260] Run scala linter --- modules/web/src/main/ui/AuthUi.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index d76567e44f6da..7579d1116c0c9 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -337,7 +337,9 @@ body { margin-top: 45px; } "policy" -> trans.site.agreementPolicy() ) - private def formFields(username: Field, password: Field, email: Option[Field], register: Boolean)(using Context) = + private def formFields(username: Field, password: Field, email: Option[Field], register: Boolean)(using + Context + ) = frag( form3.group( username, From 73008f9602f828914509849ac58b2b57d9fd071c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 12:34:03 +0200 Subject: [PATCH 104/260] /api/user?challenge=true --- app/controllers/Account.scala | 1 + app/controllers/Api.scala | 3 +- app/controllers/Challenge.scala | 6 +- app/controllers/Setup.scala | 2 +- modules/api/src/main/UserApi.scala | 10 ++- .../challenge/src/main/ChallengeGranter.scala | 64 ++++++++++--------- modules/setup/src/main/ApiConfig.scala | 1 + modules/setup/src/main/Config.scala | 1 + 8 files changed, 51 insertions(+), 37 deletions(-) diff --git a/app/controllers/Account.scala b/app/controllers/Account.scala index 938b0c03b1371..3802e73ba49f3 100644 --- a/app/controllers/Account.scala +++ b/app/controllers/Account.scala @@ -102,6 +102,7 @@ final class Account( me.value, withFollows = apiC.userWithFollows, withTrophies = false, + withCanChallenge = false, forWiki = wikiGranted ) .dmap { JsonOk(_) } diff --git a/app/controllers/Api.scala b/app/controllers/Api.scala index 17947ad1ffa0c..f6c3ad921adb0 100644 --- a/app/controllers/Api.scala +++ b/app/controllers/Api.scala @@ -47,7 +47,8 @@ final class Api( .extended( name, withFollows = userWithFollows, - withTrophies = getBool("trophies") + withTrophies = getBool("trophies"), + withCanChallenge = getBool("challenge") ) .map(toApiResult) .map(toHttp) diff --git a/app/controllers/Challenge.scala b/app/controllers/Challenge.scala index 4c4e8fffbcee3..070426baceb29 100644 --- a/app/controllers/Challenge.scala +++ b/app/controllers/Challenge.scala @@ -274,7 +274,7 @@ final class Challenge( case None => redir case Some(dest) if ctx.is(dest) => redir case Some(dest) => - env.challenge.granter.isDenied(dest, c.perfType).flatMap { + env.challenge.granter.isDenied(dest, c.perfType.key.some).flatMap { case Some(denied) => showChallenge(c, lila.challenge.ChallengeDenied.translated(denied).some) case None => api.setDestUser(c, dest).inject(redir) @@ -303,7 +303,7 @@ final class Challenge( limit.challengeUser(me, rateLimited, cost = cost): for challenge <- makeOauthChallenge(config, me, destUser) - grant <- env.challenge.granter.isDenied(destUser, config.perfType) + grant <- env.challenge.granter.isDenied(destUser, config.perfKey.some) res <- grant match case Some(denied) => fuccess: @@ -376,7 +376,7 @@ final class Challenge( NoBot: Found(env.game.gameRepo.game(gameId)): g => g.opponentOf(me).flatMap(_.userId).so(env.user.repo.byId).orNotFound { opponent => - env.challenge.granter.isDenied(opponent, g.perfKey).flatMap { + env.challenge.granter.isDenied(opponent, g.perfKey.some).flatMap { case Some(d) => BadRequest(jsonError(lila.challenge.ChallengeDenied.translated(d))) case _ => api.offerRematchForGame(g, me).map { diff --git a/app/controllers/Setup.scala b/app/controllers/Setup.scala index 23125ee8e9074..62c5d85c372c1 100644 --- a/app/controllers/Setup.scala +++ b/app/controllers/Setup.scala @@ -48,7 +48,7 @@ final class Setup( for origUser <- ctx.user.soFu(env.user.perfsRepo.withPerf(_, config.perfType)) destUser <- userId.so(env.user.api.enabledWithPerf(_, config.perfType)) - denied <- destUser.so(u => env.challenge.granter.isDenied(u.user, config.perfType)) + denied <- destUser.so(u => env.challenge.granter.isDenied(u.user, config.perfKey.some)) result <- denied match case Some(denied) => val message = lila.challenge.ChallengeDenied.translated(denied) diff --git a/modules/api/src/main/UserApi.scala b/modules/api/src/main/UserApi.scala index 25114a9fce2b1..ad4d4eea7f058 100644 --- a/modules/api/src/main/UserApi.scala +++ b/modules/api/src/main/UserApi.scala @@ -27,6 +27,7 @@ final class UserApi( trophyApi: lila.user.TrophyApi, shieldApi: lila.tournament.TournamentShieldApi, revolutionApi: lila.tournament.RevolutionApi, + challengeGranter: lila.challenge.ChallengeGranter, net: NetConfig )(using Executor, lila.core.i18n.Translator): @@ -38,16 +39,18 @@ final class UserApi( def extended( username: UserStr, withFollows: Boolean, - withTrophies: Boolean + withTrophies: Boolean, + withCanChallenge: Boolean )(using Option[Me], Lang): Fu[Option[JsObject]] = userApi.withPerfs(username).flatMapz { - extended(_, withFollows, withTrophies).dmap(some) + extended(_, withFollows, withTrophies, withCanChallenge).dmap(some) } def extended( u: User | UserWithPerfs, withFollows: Boolean, withTrophies: Boolean, + withCanChallenge: Boolean, forWiki: Boolean = false )(using as: Option[Me], lang: Lang): Fu[JsObject] = u.match @@ -69,6 +72,7 @@ final class UserApi( gameCache.nbImportedBy(u.id), (withTrophies && !u.lame).soFu(getTrophiesAndAwards(u.user)), streamerApi.listed(u.user), + withCanChallenge.so(challengeGranter.mayChallenge(u.user).dmap(some)), forWiki.soFu(userRepo.email(u.id)) ).mapN: ( @@ -83,6 +87,7 @@ final class UserApi( nbImported, trophiesAndAwards, streamer, + canChallenge, email ) => jsonView.full(u.user, u.perfs.some, withProfile = true) ++ { @@ -112,6 +117,7 @@ final class UserApi( .add("nbFollowing", following) .add("nbFollowers", withFollows.option(0)) .add("trophies", trophiesAndAwards.map(trophiesJson)) + .add("canChallenge", canChallenge) .add( "streamer", streamer.map: s => diff --git a/modules/challenge/src/main/ChallengeGranter.scala b/modules/challenge/src/main/ChallengeGranter.scala index cae67882d0350..cce3747dbc1c1 100644 --- a/modules/challenge/src/main/ChallengeGranter.scala +++ b/modules/challenge/src/main/ChallengeGranter.scala @@ -42,38 +42,42 @@ final class ChallengeGranter( val ratingThreshold = 300 - def isDenied(dest: User, perfKey: PerfKey)(using + def mayChallenge(dest: User)(using Executor)(using me: Option[Me]): Fu[Boolean] = + isDenied(dest, None).map(_.isEmpty) + + // perfkey is None when we're not yet trying to challenge + def isDenied(dest: User, perfKey: Option[PerfKey])(using Executor )(using me: Option[Me]): Fu[Option[ChallengeDenied]] = me - .fold[Fu[Option[ChallengeDenied.Reason]]] { - prefApi.getChallenge(dest.id).map { - case lila.core.pref.Challenge.ALWAYS => none - case _ => YouAreAnon.some - } - } { from => - type Res = Option[ChallengeDenied.Reason] - given Conversion[Res, Fu[Res]] = fuccess - relationApi.fetchRelation(dest.id, from.userId).zip(prefApi.getChallenge(dest.id)).flatMap { - case (Some(Block), _) => YouAreBlocked.some - case (_, lila.core.pref.Challenge.NEVER) => TheyDontAcceptChallenges.some - case (Some(Follow), _) => none // always accept from followed - case (_, _) if from.marks.engine && !dest.marks.engine => YouAreBlocked.some - case (_, lila.core.pref.Challenge.FRIEND) => FriendsOnly.some - case (_, lila.core.pref.Challenge.RATING) => - userApi - .perfsOf(from.value -> dest, primary = false) - .map: (fromPerfs, destPerfs) => - if fromPerfs(perfKey).provisional || destPerfs(perfKey).provisional - then RatingIsProvisional(perfKey).some - else - val diff = - math.abs(fromPerfs(perfKey).intRating.value - destPerfs(perfKey).intRating.value) - (diff > ratingThreshold).option(RatingOutsideRange(perfKey)) - case (_, lila.core.pref.Challenge.REGISTERED) => none - case _ if from == dest => SelfChallenge.some - case _ => none - } - } + .match + case None => + prefApi.getChallenge(dest.id).map { + case lila.core.pref.Challenge.ALWAYS => none + case _ => YouAreAnon.some + } + case Some(from) => + type Res = Option[ChallengeDenied.Reason] + given Conversion[Res, Fu[Res]] = fuccess + relationApi.fetchRelation(dest.id, from.userId).zip(prefApi.getChallenge(dest.id)).flatMap { + case (Some(Block), _) => YouAreBlocked.some + case (_, lila.core.pref.Challenge.NEVER) => TheyDontAcceptChallenges.some + case (Some(Follow), _) => none // always accept from followed + case (_, _) if from.marks.engine && !dest.marks.engine => YouAreBlocked.some + case (_, lila.core.pref.Challenge.FRIEND) => FriendsOnly.some + case (_, lila.core.pref.Challenge.RATING) => + perfKey.so: pk => + userApi + .perfsOf(from.value -> dest, primary = false) + .map: (fromPerfs, destPerfs) => + if fromPerfs(pk).provisional || destPerfs(pk).provisional + then RatingIsProvisional(pk).some + else + val diff = math.abs(fromPerfs(pk).intRating.value - destPerfs(pk).intRating.value) + (diff > ratingThreshold).option(RatingOutsideRange(pk)) + case (_, lila.core.pref.Challenge.REGISTERED) => none + case _ if from == dest => SelfChallenge.some + case _ => none + } .map: case None if dest.isBot && perfKey == PerfKey.ultraBullet => BotUltraBullet.some case res => res diff --git a/modules/setup/src/main/ApiConfig.scala b/modules/setup/src/main/ApiConfig.scala index 9ae05303a8525..688f4dfac46e7 100644 --- a/modules/setup/src/main/ApiConfig.scala +++ b/modules/setup/src/main/ApiConfig.scala @@ -23,6 +23,7 @@ final case class ApiConfig( ): def perfType: PerfType = lila.rating.PerfType(variant, chess.Speed(days.isEmpty.so(clock))) + def perfKey = perfType.key def validFen = Variant.isValidInitialFen(variant, position) diff --git a/modules/setup/src/main/Config.scala b/modules/setup/src/main/Config.scala index 583e2be167797..e53f2dffb40b9 100644 --- a/modules/setup/src/main/Config.scala +++ b/modules/setup/src/main/Config.scala @@ -58,6 +58,7 @@ private[setup] trait Config: def makeSpeed: Speed = chess.Speed(makeClock) def perfType: PerfType = lila.rating.PerfType(variant, makeSpeed) + def perfKey = perfType.key trait Positional: self: Config => From 223488aeea09da620077295dc75ac6ed219fa1e1 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 13:12:46 +0200 Subject: [PATCH 105/260] fix regression where broadcast round is marked as started without moves played --- modules/relay/src/main/RelayFetch.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 78e02a8a0add4..efd60356b6fc5 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -126,7 +126,8 @@ final private class RelayFetch( lila.mon.relay.moves(tour.official, round.slug).increment(result.nbMoves) if !round.hasStarted && !tour.official then irc.broadcastStart(round.id, round.withTour(tour).fullName) - continueRelay(tour, updating(_.ensureStarted.resume(tour.official))) + continueRelay(tour, updating(_.ensureStarted.resume(tour.official))) + else continueRelay(tour, updating) case _ => continueRelay(tour, updating) private def continueRelay(tour: RelayTour, updating: Updating[RelayRound]): Updating[RelayRound] = @@ -212,7 +213,7 @@ final private class RelayFetch( type LccGameKey = String private val finishedGames = cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.finishedLccGames"): - _.expireAfterWrite(3 minutes).build() + _.expireAfterWrite(5 minutes).build() def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags)( fetch: () => Fu[GameJson] ): Fu[GameJson] = From 71b2b35a17367ed4baad4a6524d8aceeb36c703a Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 14:16:33 +0200 Subject: [PATCH 106/260] password show/hide simplify scala,html,scss,ts --- modules/ui/src/main/helper/Form3.scala | 6 +++++- modules/web/src/main/ui/AuthUi.scala | 9 ++------- ui/bits/css/_auth.scss | 26 ++++++-------------------- ui/bits/src/bits.login.ts | 5 ++--- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 1430d89156abc..86e66e0a0a926 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -179,7 +179,11 @@ final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi): def hiddenFalse(field: Field): Tag = hidden(field, "false".some) def passwordModified(field: Field, content: Frag)(modifiers: Modifier*)(using Translate): Frag = - group(field, content)(input(_, typ = "password")(required)(modifiers)) + group(field, content): f => + div(cls := "password-wrapper")( + input(f, typ = "password")(required)(modifiers), + button(cls := "show-hide-password", dataIcon := Icon.Eye) + ) def passwordComplexityMeter(labelContent: Frag): Frag = div(cls := "password-complexity")( diff --git a/modules/web/src/main/ui/AuthUi.scala b/modules/web/src/main/ui/AuthUi.scala index 7579d1116c0c9..587d40472d729 100644 --- a/modules/web/src/main/ui/AuthUi.scala +++ b/modules/web/src/main/ui/AuthUi.scala @@ -350,13 +350,8 @@ body { margin-top: 45px; } form3.input(f)(autofocus, required, autocomplete := "username"), register.option(p(cls := "error username-exists none")(trans.site.usernameAlreadyUsed())) ), - div(cls := "password-wrapper")( - form3.passwordModified(password, trans.site.password())( - autocomplete := (if register then "new-password" else "current-password") - ), - button(cls := "show-hide-password")( - i(dataIcon := Icon.Eye) - ) + form3.passwordModified(password, trans.site.password())( + autocomplete := (if register then "new-password" else "current-password") ), register.option(form3.passwordComplexityMeter(trans.site.newPasswordStrength())), email.map: email => diff --git a/ui/bits/css/_auth.scss b/ui/bits/css/_auth.scss index bf8ad9bab9212..f7e1bfa5070f8 100644 --- a/ui/bits/css/_auth.scss +++ b/ui/bits/css/_auth.scss @@ -65,26 +65,12 @@ } } -.password-wrapper { - position: relative; -} - .show-hide-password { - position: absolute; - right: 10px; - top: 50%; - background: none; - border: none; - cursor: pointer; + @extend %button-none; + float: right; + margin-right: 1em; + margin-top: -2.2em; } - -.show-hide-password.strikethrough::before { - content: ''; - position: absolute; - width: 100%; - height: 2px; - background-color: currentColor; - transform: rotate(45deg); - top: 50%; - left: 0; +.show-hide-password.revealed { + color: $c-bad; } diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index 2e902ef59a3fa..5725f3bed5b31 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -117,14 +117,13 @@ function signupStart() { function addPasswordVisibilityToggleListener() { $('.password-wrapper').each(function (this: HTMLElement) { const $wrapper = $(this); - const $input = $wrapper.find('input[type="password"], input[type="text"]'); const $button = $wrapper.find('.show-hide-password'); $button.on('click', function (e: Event) { e.preventDefault(); + const $input = $wrapper.find('input'); const type = $input.attr('type') === 'password' ? 'text' : 'password'; $input.attr('type', type); - $button.toggleClass('show', type === 'text'); - $button.toggleClass('strikethrough'); + $button.toggleClass('revealed', type == 'text'); }); }); } From 2de5624af5e528bb13cd5619a9d05614dedaa4a8 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 14:36:42 +0200 Subject: [PATCH 107/260] add password reveal to many other pages such as /account/twofactor, /account/close, /account/passwd and more --- modules/team/src/main/ui/RequestUi.scala | 2 +- modules/ui/src/main/helper/Form3.scala | 6 ++++-- ui/bits/css/_auth.scss | 10 ---------- ui/bits/css/build/bits.account.scss | 1 + ui/bits/css/build/bits.auth.scss | 1 + ui/bits/src/bits.account.ts | 3 +++ ui/bits/src/bits.login.ts | 21 +++----------------- ui/common/css/form/_form3.scss | 17 ---------------- ui/common/css/form/_password.scss | 25 ++++++++++++++++++++++++ ui/common/src/password.ts | 13 ++++++++++++ 10 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 ui/common/css/form/_password.scss create mode 100644 ui/common/src/password.ts diff --git a/modules/team/src/main/ui/RequestUi.scala b/modules/team/src/main/ui/RequestUi.scala index 1b511ca2c8dd5..28f00b11f4a62 100644 --- a/modules/team/src/main/ui/RequestUi.scala +++ b/modules/team/src/main/ui/RequestUi.scala @@ -27,7 +27,7 @@ final class RequestUi(helpers: Helpers, bits: TeamUi): ) ), t.password.nonEmpty.so( - form3.passwordModified(form("password"), trt.entryCode())( + form3.passwordModified(form("password"), trt.entryCode(), reveal = false)( autocomplete := "new-password" ) ), diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 86e66e0a0a926..cfbfa45ad70b7 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -178,11 +178,13 @@ final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi): // allows disabling of a field that defaults to true def hiddenFalse(field: Field): Tag = hidden(field, "false".some) - def passwordModified(field: Field, content: Frag)(modifiers: Modifier*)(using Translate): Frag = + def passwordModified(field: Field, content: Frag, reveal: Boolean = true)( + modifiers: Modifier* + )(using Translate): Frag = group(field, content): f => div(cls := "password-wrapper")( input(f, typ = "password")(required)(modifiers), - button(cls := "show-hide-password", dataIcon := Icon.Eye) + reveal.option(button(cls := "password-reveal", dataIcon := Icon.Eye)) ) def passwordComplexityMeter(labelContent: Frag): Frag = diff --git a/ui/bits/css/_auth.scss b/ui/bits/css/_auth.scss index f7e1bfa5070f8..bce6ed4c6ab76 100644 --- a/ui/bits/css/_auth.scss +++ b/ui/bits/css/_auth.scss @@ -64,13 +64,3 @@ margin-top: 1rem; } } - -.show-hide-password { - @extend %button-none; - float: right; - margin-right: 1em; - margin-top: -2.2em; -} -.show-hide-password.revealed { - color: $c-bad; -} diff --git a/ui/bits/css/build/bits.account.scss b/ui/bits/css/build/bits.account.scss index bede02bec5ebd..fc6680e347e51 100644 --- a/ui/bits/css/build/bits.account.scss +++ b/ui/bits/css/build/bits.account.scss @@ -3,4 +3,5 @@ @import '../../../common/css/form/form3'; @import '../../../common/css/form/radio'; @import '../../../common/css/form/emoji-picker'; +@import '../../../common/css/form/password'; @import '../account'; diff --git a/ui/bits/css/build/bits.auth.scss b/ui/bits/css/build/bits.auth.scss index 6aae96927a3d9..487fae78396be 100644 --- a/ui/bits/css/build/bits.auth.scss +++ b/ui/bits/css/build/bits.auth.scss @@ -1,4 +1,5 @@ @import '../../../common/css/plugin'; @import '../../../common/css/form/form3'; @import '../../../common/css/form/captcha'; +@import '../../../common/css/form/password'; @import '../auth'; diff --git a/ui/bits/src/bits.account.ts b/ui/bits/src/bits.account.ts index a187550c826b8..9dab290051bb2 100644 --- a/ui/bits/src/bits.account.ts +++ b/ui/bits/src/bits.account.ts @@ -1,5 +1,6 @@ import * as licon from 'common/licon'; import * as xhr from 'common/xhr'; +import { addPasswordVisibilityToggleListener } from 'common/password'; import flairPicker from './load/flairPicker'; site.load.then(() => { @@ -7,6 +8,8 @@ site.load.then(() => { flairPicker(this); }); + addPasswordVisibilityToggleListener(); + const localPrefs: [string, string, string, boolean][] = [ ['behavior', 'arrowSnap', 'arrow.snap', true], ['behavior', 'courtesy', 'courtesy', false], diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index 5725f3bed5b31..3fd5d4a7f319d 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -1,9 +1,12 @@ import * as xhr from 'common/xhr'; import debounce from 'common/debounce'; +import { addPasswordVisibilityToggleListener } from 'common/password'; import { storedJsonProp } from 'common/storage'; export function initModule(mode: 'login' | 'signup') { mode === 'login' ? loginStart() : signupStart(); + + addPasswordVisibilityToggleListener(); } class LoginHistory { historyStorage = storedJsonProp('login.history', () => []); @@ -78,8 +81,6 @@ function loginStart() { }); }); })(); - - addPasswordVisibilityToggleListener(); } function signupStart() { @@ -110,20 +111,4 @@ function signupStart() { }); site.asset.loadEsm('bits.passwordComplexity', { init: 'form3-password' }); - - addPasswordVisibilityToggleListener(); -} - -function addPasswordVisibilityToggleListener() { - $('.password-wrapper').each(function (this: HTMLElement) { - const $wrapper = $(this); - const $button = $wrapper.find('.show-hide-password'); - $button.on('click', function (e: Event) { - e.preventDefault(); - const $input = $wrapper.find('input'); - const type = $input.attr('type') === 'password' ? 'text' : 'password'; - $input.attr('type', type); - $button.toggleClass('revealed', type == 'text'); - }); - }); } diff --git a/ui/common/css/form/_form3.scss b/ui/common/css/form/_form3.scss index d0c92b3062ccb..1cab3475eb0f3 100644 --- a/ui/common/css/form/_form3.scss +++ b/ui/common/css/form/_form3.scss @@ -114,23 +114,6 @@ textarea.form-control { border-top: $border; } -.password-complexity { - margin-top: -2rem; - margin-bottom: 3rem; -} - -.password-complexity-meter { - display: flex; - grid-gap: 0.25rem; - height: 0.4rem; - margin-top: 1rem; - - > * { - background-color: gray; - width: 25%; - } -} - .form-fieldset { @extend %box-radius; margin: 1rem 0 3rem 0; diff --git a/ui/common/css/form/_password.scss b/ui/common/css/form/_password.scss new file mode 100644 index 0000000000000..f7c2cb3385eba --- /dev/null +++ b/ui/common/css/form/_password.scss @@ -0,0 +1,25 @@ +.password-complexity { + margin-top: -2rem; + margin-bottom: 3rem; +} +.password-complexity-meter { + display: flex; + grid-gap: 0.25rem; + height: 0.4rem; + margin-top: 1rem; + + > * { + background-color: gray; + width: 25%; + } +} + +.password-reveal { + @extend %button-none; + float: right; + margin-right: 1em; + margin-top: -2.2em; +} +.password-reveal.revealed { + color: $c-bad; +} diff --git a/ui/common/src/password.ts b/ui/common/src/password.ts new file mode 100644 index 0000000000000..5c6eb7135a5a3 --- /dev/null +++ b/ui/common/src/password.ts @@ -0,0 +1,13 @@ +export const addPasswordVisibilityToggleListener = () => { + $('.password-wrapper').each(function (this: HTMLElement) { + const $wrapper = $(this); + const $button = $wrapper.find('.password-reveal'); + $button.on('click', function (e: Event) { + e.preventDefault(); + const $input = $wrapper.find('input'); + const type = $input.attr('type') === 'password' ? 'text' : 'password'; + $input.attr('type', type); + $button.toggleClass('revealed', type == 'text'); + }); + }); +}; From 2b56c14919f0fafd91122a421f419cf249134e4f Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 15:09:48 +0200 Subject: [PATCH 108/260] during broadcast push, lookup all db games before saying the round is complete closes #14462 --- modules/relay/src/main/RelayPush.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 447ec454d56e9..b1bd90cc0c59b 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -7,12 +7,13 @@ import chess.{ ErrorStr, Game, Replay, Square } import scala.concurrent.duration.* import scalalib.actor.AsyncActorSequencers -import lila.study.{ MultiPgn, StudyPgnImport } +import lila.study.{ MultiPgn, StudyPgnImport, ChapterPreviewApi } final class RelayPush( sync: RelaySync, api: RelayApi, stats: RelayStatsApi, + chapterPreview: ChapterPreviewApi, irc: lila.core.irc.IrcApi )(using ActorSystem, Executor, Scheduler): @@ -55,10 +56,12 @@ final class RelayPush( _ = if !rt.round.hasStarted && !rt.tour.official && event.hasMoves then irc.broadcastStart(rt.round.id, rt.fullName) _ = stats.setActive(rt.round.id) + allGamesFinished <- (games.nonEmpty && games.forall(_.outcome.isDefined)).so: + chapterPreview.dataList(rt.round.studyId).map(_.forall(_.finished)) round <- api.update(rt.round): r1 => val r2 = r1.withSync(_.addLog(event)) val r3 = if event.hasMoves then r2.ensureStarted.resume(rt.tour.official) else r2 - r3.copy(finished = games.nonEmpty && games.forall(_.outcome.isDefined)) + r3.copy(finished = allGamesFinished) _ <- andSyncTargets.so(api.syncTargetsOfSource(round)) yield () From ef67e663e67c072e6f43e867aa1f6863da713587 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 16:16:31 +0200 Subject: [PATCH 109/260] stream team members as light or full, with different speed and limit --- app/controllers/TeamApi.scala | 3 ++- modules/api/src/main/UserApi.scala | 10 +++++++--- modules/relay/src/main/RelayApi.scala | 3 ++- modules/team/src/main/TeamMemberStream.scala | 17 +++++++++++------ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/controllers/TeamApi.scala b/app/controllers/TeamApi.scala index ca55d42d0f963..32f961d5bc1c4 100644 --- a/app/controllers/TeamApi.scala +++ b/app/controllers/TeamApi.scala @@ -50,9 +50,10 @@ final class TeamApi(env: Env, apiC: => Api) extends LilaController(env): else ctx.me.so(api.belongsTo(team.id, _)) canView.map: if _ then + val full = getBool("full") apiC.jsonDownload( env.team - .memberStream(team, MaxPerSecond(20)) + .memberStream(team, full) .map: (user, joinedAt) => env.api.userApi.one(user, joinedAt.some) ) diff --git a/modules/api/src/main/UserApi.scala b/modules/api/src/main/UserApi.scala index ad4d4eea7f058..0b1099aac33e2 100644 --- a/modules/api/src/main/UserApi.scala +++ b/modules/api/src/main/UserApi.scala @@ -10,6 +10,7 @@ import lila.core.perm.Granter import lila.user.Trophy import lila.rating.PerfType import lila.core.perf.UserWithPerfs +import lila.core.LightUser final class UserApi( jsonView: lila.user.JsonView, @@ -31,9 +32,12 @@ final class UserApi( net: NetConfig )(using Executor, lila.core.i18n.Translator): - def one(u: UserWithPerfs, joinedAt: Option[Instant] = None): JsObject = { - addStreaming(jsonView.full(u.user, u.perfs.some, withProfile = true), u.id) ++ - Json.obj("url" -> makeUrl(s"@/${u.username}")) // for app BC + def one(u: UserWithPerfs | LightUser, joinedAt: Option[Instant] = None): JsObject = { + val (light, userJson) = u match + case u: UserWithPerfs => (u.user.light, jsonView.full(u.user, u.perfs.some, withProfile = false)) + case u: LightUser => (u, Json.toJsObject(u)) + addStreaming(userJson, light.id) ++ + Json.obj("url" -> makeUrl(s"@/${light.name}")) // for app BC }.add("joinedTeamAt", joinedAt) def extended( diff --git a/modules/relay/src/main/RelayApi.scala b/modules/relay/src/main/RelayApi.scala index 5f3649b79cd9c..175a3fff7ee13 100644 --- a/modules/relay/src/main/RelayApi.scala +++ b/modules/relay/src/main/RelayApi.scala @@ -285,8 +285,9 @@ final class RelayApi( WithRelay(old.id) { relay => for _ <- studyApi.deleteAllChapters(relay.studyId, me) + _ <- roundRepo.coll.updateField($id(relay.id), "finished", false) _ <- old.hasStartedEarly.so: - roundRepo.coll.update.one($id(relay.id), $set("finished" -> false) ++ $unset("startedAt")).void + roundRepo.coll.unsetField($id(relay.id), "startedAt").void _ <- roundRepo.coll.update.one($id(relay.id), $set("sync.log" -> $arr())) yield leaderboard.invalidate(relay.tourId) } >> requestPlay(old.id, v = true) diff --git a/modules/team/src/main/TeamMemberStream.scala b/modules/team/src/main/TeamMemberStream.scala index 6e465e95a510f..e573656a6427e 100644 --- a/modules/team/src/main/TeamMemberStream.scala +++ b/modules/team/src/main/TeamMemberStream.scala @@ -5,18 +5,23 @@ import reactivemongo.akkastream.cursorProducer import lila.db.dsl.{ *, given } import lila.core.perf.UserWithPerfs +import lila.core.LightUser final class TeamMemberStream( memberRepo: TeamMemberRepo, - userApi: lila.core.user.UserApi + userApi: lila.core.user.UserApi, + lightApi: lila.core.user.LightUserApi )(using Executor, akka.stream.Materializer): - def apply(team: Team, perSecond: MaxPerSecond): Source[(UserWithPerfs, Instant), ?] = - idsBatches(team, perSecond) + def apply(team: Team, fullUser: Boolean): Source[(UserWithPerfs | LightUser, Instant), ?] = + idsBatches(team, MaxPerSecond(if fullUser then 20 else 50)) + .limit(if fullUser then 1000 else 5000) .mapAsync(1): members => - userApi - .listWithPerfs(members.view.map(_._1).toList) - .map(_.zip(members.map(_._2))) + val users = + if fullUser + then userApi.listWithPerfs(members.view.map(_._1).toList) + else lightApi.asyncManyFallback(members.view.map(_._1).toList) + users.map(_.zip(members.map(_._2))) .mapConcat(identity) def subscribedIds(team: Team, perSecond: MaxPerSecond): Source[UserId, ?] = From 576b427c8080393bc81566afef42cdb816ff8ca0 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 17:01:28 +0200 Subject: [PATCH 110/260] enrich broadcast pushed games using fide ids --- modules/relay/src/main/RelayPush.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index b1bd90cc0c59b..ff226320c6cc1 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -14,6 +14,7 @@ final class RelayPush( api: RelayApi, stats: RelayStatsApi, chapterPreview: ChapterPreviewApi, + fidePlayers: RelayFidePlayerApi, irc: lila.core.irc.IrcApi )(using ActorSystem, Executor, Scheduler): @@ -44,9 +45,10 @@ final class RelayPush( after(delay.value.seconds)(push(rt, games, andSyncTargets)) fuccess(response) - private def push(rt: RelayRound.WithTour, games: Vector[RelayGame], andSyncTargets: Boolean) = + private def push(rt: RelayRound.WithTour, rawGames: Vector[RelayGame], andSyncTargets: Boolean) = workQueue(rt.round.id): for + games <- fidePlayers.enrichGames(rt.tour)(rawGames) event <- sync .updateStudyChapters(rt, rt.tour.players.fold(games)(_.update(games))) .map: res => From 8b0eaac33501aba9cfb85e02e809efad0c196d1a Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 17:08:22 +0200 Subject: [PATCH 111/260] keep the previous tournament tier when modified by a normal user contributor --- modules/relay/src/main/RelayTourForm.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayTourForm.scala b/modules/relay/src/main/RelayTourForm.scala index b5bbc133ed8b3..bc1cf38775a50 100644 --- a/modules/relay/src/main/RelayTourForm.scala +++ b/modules/relay/src/main/RelayTourForm.scala @@ -63,7 +63,7 @@ object RelayTourForm: name = name, description = description, markup = markup, - tier = tier.ifTrue(Granter(_.Relay)), + tier = if Granter(_.Relay) then tier else tour.tier, autoLeaderboard = autoLeaderboard, teamTable = teamTable, players = players, From 2e998da7143126b7a60b5024a341c2e2458377a4 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 17:15:46 +0200 Subject: [PATCH 112/260] remove topnav dropdown bottom padding --- ui/common/css/header/_topnav-visible.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/common/css/header/_topnav-visible.scss b/ui/common/css/header/_topnav-visible.scss index 72dfad61ca3ae..c74b6b31d888f 100644 --- a/ui/common/css/header/_topnav-visible.scss +++ b/ui/common/css/header/_topnav-visible.scss @@ -95,7 +95,6 @@ div { visibility: visible; max-height: none; - padding-bottom: 8px; } } } From fac407f05512e0fae5a98187aabd394b4437efd2 Mon Sep 17 00:00:00 2001 From: Trevor Bayless <3620552+trevorbayless@users.noreply.github.com> Date: Wed, 26 Jun 2024 11:08:41 -0500 Subject: [PATCH 113/260] Submit form on enter instead of toggling password --- modules/ui/src/main/helper/Form3.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index cfbfa45ad70b7..0d98250c9c769 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -184,7 +184,7 @@ final class Form3(formHelper: FormHelper & I18nHelper, flairApi: FlairApi): group(field, content): f => div(cls := "password-wrapper")( input(f, typ = "password")(required)(modifiers), - reveal.option(button(cls := "password-reveal", dataIcon := Icon.Eye)) + reveal.option(button(cls := "password-reveal", tpe := "button", dataIcon := Icon.Eye)) ) def passwordComplexityMeter(labelContent: Frag): Frag = From 21b7b8620e6ab3e256bc1710bfb6ff15b9aa92fc Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 18:18:47 +0200 Subject: [PATCH 114/260] also cache unstarted lcc games some actually never start --- modules/relay/src/main/RelayFetch.scala | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index efd60356b6fc5..75da6add05d3a 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -208,12 +208,16 @@ final private class RelayFetch( .flatMap(multiPgnToGames.future) // cache finished games so they're not requested again for a while + // TODO also cache non-started games. Some actually never start. private object lccCache: import DgtJson.GameJson type LccGameKey = String private val finishedGames = cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.finishedLccGames"): _.expireAfterWrite(5 minutes).build() + private val createdGames = // not started yet (possibly never) + cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.createdLccGames"): + _.expireAfterWrite(1 minute).build() def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags)( fetch: () => Fu[GameJson] ): Fu[GameJson] = @@ -221,8 +225,12 @@ final private class RelayFetch( finishedGames.getIfPresent(key) match case Some(game) => fuccess(game) case None => - fetch().addEffect: game => - if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) + createdGames.getIfPresent(key) match + case Some(game) => fuccess(game) + case None => + fetch().addEffect: game => + if game.moves.isEmpty then createdGames.put(key, game) + else if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* From a5b5ca021d371f44339d10e64272e78d93fcc1de Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 18:41:02 +0200 Subject: [PATCH 115/260] cache created game for longer after the tournament has been going for 5 minutes --- modules/relay/src/main/RelayFetch.scala | 23 ++++++++++++++--------- modules/relay/src/main/RelayRound.scala | 8 ++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 75da6add05d3a..d512454d22e51 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -182,11 +182,11 @@ final private class RelayFetch( given CanProxy = CanProxy(rt.tour.official) rt.round.sync.upstream.so: case Sync.Upstream.Ids(ids) => fetchFromGameIds(rt.tour, ids) - case Sync.Upstream.Url(url) => delayer(url, rt.round, fetchFromUpstream) + case Sync.Upstream.Url(url) => delayer(url, rt.round, fetchFromUpstream(rt)) case Sync.Upstream.Urls(urls) => urls.toVector .parallel: url => - delayer(url, rt.round, fetchFromUpstream) + delayer(url, rt.round, fetchFromUpstream(rt)) .map(_.flatten) private def fetchFromGameIds(tour: RelayTour, ids: List[GameId]): Fu[RelayGames] = @@ -208,20 +208,24 @@ final private class RelayFetch( .flatMap(multiPgnToGames.future) // cache finished games so they're not requested again for a while - // TODO also cache non-started games. Some actually never start. private object lccCache: import DgtJson.GameJson type LccGameKey = String private val finishedGames = cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.finishedLccGames"): _.expireAfterWrite(5 minutes).build() - private val createdGames = // not started yet (possibly never) + private val createdGames = cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.createdLccGames"): - _.expireAfterWrite(1 minute).build() - def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags)( + _.expireAfter[LccGameKey, GameJson]( + create = (key, _) => (if key.startsWith("start ") then 1 minutes else 5 minutes), + update = (_, _, current) => current, + read = (_, _, current) => current + ).build() + + def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags, nearStart: Boolean)( fetch: () => Fu[GameJson] ): Fu[GameJson] = - val key = s"${lcc.id} ${lcc.round} $index" + val key = s"${nearStart.so("start ")}${lcc.id} ${lcc.round} $index" finishedGames.getIfPresent(key) match case Some(game) => fuccess(game) case None => @@ -232,7 +236,7 @@ final private class RelayFetch( if game.moves.isEmpty then createdGames.put(key, game) else if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) - private def fetchFromUpstream(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = + private def fetchFromUpstream(rt: RelayRound.WithTour)(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* formatApi .get(url) @@ -245,11 +249,12 @@ final private class RelayFetch( httpGetPgn(url).map { MultiPgn.split(_, max) }.flatMap(multiPgnToGames.future) case RelayFormat.LccWithGames(lcc) => httpGetJson[RoundJson](lcc.indexUrl).flatMap: round => + val nearStart = rt.round.secondsAfterStart.exists(_ < 300) round.pairings .mapWithIndex: (pairing, i) => val game = i + 1 val tags = pairing.tags(lcc.round, game) - lccCache(lcc, game, tags): () => + lccCache(lcc, game, tags, nearStart): () => httpGetJson[GameJson](lcc.gameUrl(game)).recover: case _: Exception => GameJson(moves = Nil, result = none) .map { _.toPgn(tags) } diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 9c4259ec7e96a..6e6bd03a87f76 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -58,6 +58,14 @@ case class RelayRound( def withTour(tour: RelayTour) = RelayRound.WithTour(this, tour) + def secondsAfterStart: Option[Seconds] = + startedAt + .orElse(startsAt) + .map: start => + (nowSeconds - start.toSeconds).toInt + .filter(0 < _) + .map(Seconds.apply) + override def toString = s"""relay #$id "$name" $sync""" object RelayRound: From a1398f1c7b77c6c402274ad3f8db9e5797fc3fb1 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 18:44:43 +0200 Subject: [PATCH 116/260] tweak broadcast dynamic pull period --- modules/relay/src/main/RelayFetch.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index d512454d22e51..6c73efccc764b 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -160,7 +160,9 @@ final private class RelayFetch( else if upstream.isRound then 10 // uses push so no need to pull often else 2 base * { - if tour.official then 1 else 2 + if tour.tier.exists(_ > RelayTour.Tier.NORMAL) then 1 + else if tour.official then 2 + else 3 } * { if round.crowd.exists(_ > 4) then 1 else 2 } * { From 99b8a24fb8e864983ebe4f42c18382d5ac653215 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 22:47:09 +0200 Subject: [PATCH 117/260] New Crowdin updates (#15604) * New translations: broadcast.xml (Afrikaans) * New translations: broadcast.xml (English, United States) * New translations: broadcast.xml (Afrikaans) * New translations: broadcast.xml (Albanian) * New translations: broadcast.xml (Catalan) * New translations: broadcast.xml (Polish) * New translations: broadcast.xml (Chinese Simplified) * New translations: broadcast.xml (Ukrainian) --- translation/dest/broadcast/af-ZA.xml | 9 +++++++++ translation/dest/broadcast/ca-ES.xml | 9 +++++++++ translation/dest/broadcast/en-US.xml | 9 +++++++++ translation/dest/broadcast/pl-PL.xml | 9 +++++++++ translation/dest/broadcast/sq-AL.xml | 9 +++++++++ translation/dest/broadcast/uk-UA.xml | 7 +++++++ translation/dest/broadcast/zh-CN.xml | 1 + 7 files changed, 53 insertions(+) diff --git a/translation/dest/broadcast/af-ZA.xml b/translation/dest/broadcast/af-ZA.xml index 66640f9569598..36d4db91881e3 100644 --- a/translation/dest/broadcast/af-ZA.xml +++ b/translation/dest/broadcast/af-ZA.xml @@ -29,4 +29,13 @@ Vee beslis die hele toernooi uit, met al sy rondtes en spelle. Opsioneel: vervang spelername, graderings en titels Periode in sekondes + FIDE-federasies + Top 10 gradering + FIDE-deelnemers + FIDE-deelnemer nie gevind nie + FIDE-profiel + Federasie + Ouderdom vanjaar + Ongegradeerd + Onlangse toernooie diff --git a/translation/dest/broadcast/ca-ES.xml b/translation/dest/broadcast/ca-ES.xml index a7acd51ca542c..dc8cffaee098c 100644 --- a/translation/dest/broadcast/ca-ES.xml +++ b/translation/dest/broadcast/ca-ES.xml @@ -43,4 +43,13 @@ Opcional: Reemplaça noms dels jugadors, puntuacions i títols Període en segons Opcional, quant de temps esperar entre sol·licituds. Mínim 2 segons, màxim 60 segons. Per defecte es gestiona automàticament en funció del nombre de visualitzadors. + Federacions FIDE + Top 10 Ràting + Jugadors FIDE + No s\'ha trobat el jugador FIDE + Perfil FIDE + Federació + Edat aquest any + Sense avaluació + Tornejos recents diff --git a/translation/dest/broadcast/en-US.xml b/translation/dest/broadcast/en-US.xml index 197568fd59889..fa2e0b5d34339 100644 --- a/translation/dest/broadcast/en-US.xml +++ b/translation/dest/broadcast/en-US.xml @@ -43,4 +43,13 @@ Optional: replace player names, ratings and titles Period in seconds Optional, how long to wait between requests. Min 2s, max 60s. Defaults to automatic based on the number of viewers. + FIDE federations + Top 10 rating + FIDE players + FIDE player not found + FIDE profile + Federation + Age this year + Unrated + Recent tournaments diff --git a/translation/dest/broadcast/pl-PL.xml b/translation/dest/broadcast/pl-PL.xml index 3b0d1a0582226..d6894b30044c1 100644 --- a/translation/dest/broadcast/pl-PL.xml +++ b/translation/dest/broadcast/pl-PL.xml @@ -45,4 +45,13 @@ Opcjonalnie: zmień nazwy, rankingi oraz tytuły gracza Przedział czasu w sekundach Opcjonalnie, jak długo czekać pomiędzy odpytaniami. Min 2s, max 60s. Domyślnie jest to ustawiane automatycznie na podstawie liczby widzów. + Federacje FIDE + 10 najlepszych rankingów + Zawodnicy FIDE + Nie znaleziono zawodnika FIDE + Profil FIDE + Federacja + Wiek w tym roku + Bez rankingu + Najnowsze turnieje diff --git a/translation/dest/broadcast/sq-AL.xml b/translation/dest/broadcast/sq-AL.xml index 7ed0565bf3142..7d17e0d6cb638 100644 --- a/translation/dest/broadcast/sq-AL.xml +++ b/translation/dest/broadcast/sq-AL.xml @@ -42,4 +42,13 @@ Opsionale: zëvendësoni emra lojëtarësh, vlerësime dhe tituj Periudhë, në sekonda Opsionale, sa gjatë të pritet mes kërkesash. Min. 2s, maks. 60s. Si parazgjedhje, përdoret vlera automatike bazuar në numrin e parësve. + Federata FIDE + 10 vlerësimet kryesuese + Lojtarë FIDE + S’u gjet lojtar FIDE + Profil FIDE + Federim + Moshë këtë vit + Pa pikë + Turne së fundi diff --git a/translation/dest/broadcast/uk-UA.xml b/translation/dest/broadcast/uk-UA.xml index d47b7a5ba9139..79342ddcf13c1 100644 --- a/translation/dest/broadcast/uk-UA.xml +++ b/translation/dest/broadcast/uk-UA.xml @@ -45,4 +45,11 @@ За бажанням: замінити імена, рейтинги та титули гравців Період у секундах Опціонально: час очікування між запитами. Мінімально 2 с, максимально 60 с. За замовчуванням - автоматично, виходячи з кількості запитів глядачів. + Федерації FIDE + Гравці FIDE + Гравця FIDE не знайдено + Профіль FIDE + Федерація + Без рейтингу + Нещодавні турніри diff --git a/translation/dest/broadcast/zh-CN.xml b/translation/dest/broadcast/zh-CN.xml index 6da28119147c7..027c600aa2274 100644 --- a/translation/dest/broadcast/zh-CN.xml +++ b/translation/dest/broadcast/zh-CN.xml @@ -40,4 +40,5 @@ 可选项:替换选手的名字、等级分和头衔 时长(秒) 可选项,请求之间等待多长时间。最小2秒,最大60秒。默认值为基于观众数量的自动值。 + 今年的年龄 From c03a9e8524a43fcf45a658db78c7d1dddc6c2bfd Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 23:26:10 +0200 Subject: [PATCH 118/260] relay stats higher interval and duration --- modules/relay/src/main/RelayStatsApi.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/RelayStatsApi.scala b/modules/relay/src/main/RelayStatsApi.scala index 22b2a61603808..46e61d52dafc1 100644 --- a/modules/relay/src/main/RelayStatsApi.scala +++ b/modules/relay/src/main/RelayStatsApi.scala @@ -17,7 +17,7 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc import BSONHandlers.given // on measurement by minute at most; the storage depends on it. - scheduler.scheduleWithFixedDelay(1 minute, 1 minute)(() => record()) + scheduler.scheduleWithFixedDelay(2 minutes, 2 minutes)(() => record()) def get(id: RelayRoundId): Fu[Option[RoundStats]] = colls.round @@ -45,7 +45,7 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc def setActive(id: RelayRoundId) = activeRounds.put(id) // keep monitoring rounds for some time after they stopped syncing - private val activeRounds = ExpireSetMemo[RelayRoundId](1 hour) + private val activeRounds = ExpireSetMemo[RelayRoundId](2 hours) private def record(): Funit = for crowds <- fetchRoundCrowds From 22fd0fedd6e2904880d37dfe28519be22d5f0d38 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 26 Jun 2024 23:31:18 +0200 Subject: [PATCH 119/260] simplify round stats fetch now that we don't need the round with it --- modules/relay/src/main/RelayStatsApi.scala | 31 +++++++--------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/modules/relay/src/main/RelayStatsApi.scala b/modules/relay/src/main/RelayStatsApi.scala index 46e61d52dafc1..ea963452496c8 100644 --- a/modules/relay/src/main/RelayStatsApi.scala +++ b/modules/relay/src/main/RelayStatsApi.scala @@ -19,28 +19,15 @@ final class RelayStatsApi(roundRepo: RelayRoundRepo, colls: RelayColls)(using sc // on measurement by minute at most; the storage depends on it. scheduler.scheduleWithFixedDelay(2 minutes, 2 minutes)(() => record()) - def get(id: RelayRoundId): Fu[Option[RoundStats]] = - colls.round - .aggregateOne(): framework => - import framework.* - Match($id(id)) -> List( - Project($doc("_id" -> true)), - PipelineOperator( - $lookup.simple(colls.stats, "stats", "_id", "_id") - ), - AddFields($doc("stats" -> $doc("$first" -> "$stats"))) - ) - .map: docOpt => - for - doc <- docOpt - stats <- doc.getAsOpt[Bdoc]("stats") - data <- stats.getAsOpt[List[Int]]("d") - viewers = data - .grouped(2) - .collect: - case List(minute, crowd) => (minute, crowd) - .toList - yield RoundStats(viewers) + def get(id: RelayRoundId): Fu[RoundStats] = + colls.stats + .primitiveOne[List[Int]]($id(id), "d") + .mapz: + _.grouped(2) + .collect: + case List(minute, crowd) => (minute, crowd) + .toList + .map(RoundStats.apply) def setActive(id: RelayRoundId) = activeRounds.put(id) From e6aae67c6bc71bb99d344f5297cd9eb1d9464947 Mon Sep 17 00:00:00 2001 From: Henrique Barros Date: Thu, 27 Jun 2024 00:39:28 -0300 Subject: [PATCH 120/260] update coordinates preference to add new type for all squares and use it on puzzles --- modules/coreI18n/src/main/key.scala | 1 + modules/pref/src/main/Pref.scala | 6 +++++- modules/pref/src/main/ui/PrefHelper.scala | 3 ++- translation/source/site.xml | 1 + ui/common/src/prefs.ts | 1 + ui/learn/src/chessground.ts | 5 +++-- ui/puzzle/src/view/chessground.ts | 1 + 7 files changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index b87b8de10424b..43063e794c3f4 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -478,6 +478,7 @@ object I18nKey: val `slow`: I18nKey = "slow" val `insideTheBoard`: I18nKey = "insideTheBoard" val `outsideTheBoard`: I18nKey = "outsideTheBoard" + val `allSquaresOfTheBoard`: I18nKey = "allSquaresOfTheBoard" val `onSlowGames`: I18nKey = "onSlowGames" val `always`: I18nKey = "always" val `never`: I18nKey = "never" diff --git a/modules/pref/src/main/Pref.scala b/modules/pref/src/main/Pref.scala index 534c960e01ff4..dd5bd2eda6fb6 100644 --- a/modules/pref/src/main/Pref.scala +++ b/modules/pref/src/main/Pref.scala @@ -329,17 +329,21 @@ object Pref: val NONE = 0 val INSIDE = 1 val OUTSIDE = 2 + val ALL = 3 val choices = Seq( NONE -> "No", INSIDE -> "Inside the board", - OUTSIDE -> "Outside the board" + OUTSIDE -> "Outside the board", + ALL -> "Inside all squares of the board" + ) def classOf(v: Int) = v match case INSIDE => "in" case OUTSIDE => "out" + case ALL => "all" case _ => "no" object Replay: diff --git a/modules/pref/src/main/ui/PrefHelper.scala b/modules/pref/src/main/ui/PrefHelper.scala index cec233b50b8b4..ed98f7a37e5b2 100644 --- a/modules/pref/src/main/ui/PrefHelper.scala +++ b/modules/pref/src/main/ui/PrefHelper.scala @@ -24,7 +24,8 @@ trait PrefHelper: List( (Pref.Coords.NONE, trans.site.no.txt()), (Pref.Coords.INSIDE, trans.site.insideTheBoard.txt()), - (Pref.Coords.OUTSIDE, trans.site.outsideTheBoard.txt()) + (Pref.Coords.OUTSIDE, trans.site.outsideTheBoard.txt()), + (Pref.Coords.ALL, trans.site.allSquaresOfTheBoard.txt()) ) def translatedMoveListWhilePlayingChoices(using Translate) = diff --git a/translation/source/site.xml b/translation/source/site.xml index d3bfc03e4e9e6..842f8ee9b058b 100644 --- a/translation/source/site.xml +++ b/translation/source/site.xml @@ -594,6 +594,7 @@ Slow Inside the board Outside the board + All squares of the board On slow games Always Never diff --git a/ui/common/src/prefs.ts b/ui/common/src/prefs.ts index 202a3ddeec458..3bc29762d0158 100644 --- a/ui/common/src/prefs.ts +++ b/ui/common/src/prefs.ts @@ -5,6 +5,7 @@ export const Coords = { Hidden: 0, Inside: 1, Outside: 2, + All: 3, }; export type Coords = (typeof Coords)[keyof typeof Coords]; diff --git a/ui/learn/src/chessground.ts b/ui/learn/src/chessground.ts index 9fb4d030fdf0b..44ec2d9963f48 100644 --- a/ui/learn/src/chessground.ts +++ b/ui/learn/src/chessground.ts @@ -19,17 +19,18 @@ export default function (ctrl: RunCtrl): VNode { insert: vnode => { const el = vnode.elm as HTMLElement; el.addEventListener('contextmenu', e => e.preventDefault()); - ctrl.setChessground(site.makeChessground(el, makeConfig())); + ctrl.setChessground(site.makeChessground(el, makeConfig(ctrl))); }, destroy: () => ctrl.chessground!.destroy(), }, }); } -const makeConfig = (): CgConfig => ({ +const makeConfig = (ctrl: RunCtrl): CgConfig => ({ fen: '8/8/8/8/8/8/8/8', blockTouchScroll: true, coordinates: true, + coordinatesOnSquares: ctrl.pref.coords === Prefs.Coords.All, movable: { free: false, color: undefined }, drawable: { enabled: false }, draggable: { enabled: true }, diff --git a/ui/puzzle/src/view/chessground.ts b/ui/puzzle/src/view/chessground.ts index 5776f2f3986e9..8011bf1f44a3b 100644 --- a/ui/puzzle/src/view/chessground.ts +++ b/ui/puzzle/src/view/chessground.ts @@ -22,6 +22,7 @@ export function makeConfig(ctrl: PuzzleCtrl): CgConfig { check: opts.check, lastMove: opts.lastMove, coordinates: ctrl.pref.coords !== Prefs.Coords.Hidden, + coordinatesOnSquares: ctrl.pref.coords === Prefs.Coords.All, addPieceZIndex: ctrl.pref.is3d, addDimensionsCssVarsTo: document.body, movable: { From 59fdaaf0b43fb9e42b84fcd3d229374d7385ae21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Gl=C3=B3rias?= <9739913+SergioGlorias@users.noreply.github.com> Date: Thu, 27 Jun 2024 06:44:41 +0100 Subject: [PATCH 121/260] translate broadcast menu (missing fide) (#15610) * translate broadcast menu (missing fide) * scalafmt --- modules/relay/src/main/ui/RelayTourUi.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 794970e52d239..5236215dc2056 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -142,8 +142,10 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): a(href := routes.RelayTour.calendar, cls := menu.activeO("calendar"))(trans.site.tournamentCalendar()), a(href := routes.RelayTour.help, cls := menu.activeO("help"))(trans.broadcast.aboutBroadcasts()), div(cls := "sep"), - a(cls := menu.active("players"), href := routes.Fide.index(1))("FIDE players"), - a(cls := menu.active("federations"), href := routes.Fide.federations(1))("FIDE federations") + a(cls := menu.active("players"), href := routes.Fide.index(1))(trans.broadcast.fidePlayers()), + a(cls := menu.active("federations"), href := routes.Fide.federations(1))( + trans.broadcast.fideFederations() + ) ) private object card: From 3a9296661c36903e1e88ea77f4cb366ef9deec03 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 08:30:14 +0200 Subject: [PATCH 122/260] challenge view link style --- ui/challenge/css/_challenge.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/challenge/css/_challenge.scss b/ui/challenge/css/_challenge.scss index 7ea266394fa7c..6badfd4bc5c80 100644 --- a/ui/challenge/css/_challenge.scss +++ b/ui/challenge/css/_challenge.scss @@ -32,7 +32,7 @@ .buttons { @extend %flex-between-nowrap; - + align-items: stretch; @media (hover: hover) { display: none; } @@ -50,13 +50,14 @@ a.view { font-size: 1.5rem; color: $c-primary; - &:hover { - font-size: 1.85rem; + background: $c-primary; + color: $c-over; } } button { + border-radius: 0; cursor: pointer; color: $c-good; width: 100%; @@ -97,7 +98,8 @@ } } - button::before { + button::before, + a.view::before { line-height: 3rem; } From 1f2dc21da8a1563a6efaa215c8181f978a2d606d Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 08:48:20 +0200 Subject: [PATCH 123/260] wider challenges dropdown --- ui/challenge/css/_challenge.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/challenge/css/_challenge.scss b/ui/challenge/css/_challenge.scss index 6badfd4bc5c80..4a8ab2b12cccf 100644 --- a/ui/challenge/css/_challenge.scss +++ b/ui/challenge/css/_challenge.scss @@ -1,7 +1,7 @@ #challenge-app { @extend %box-radius-left, %dropdown-shadow; overflow: hidden; - width: 270px; + width: 300px; text-align: center; .empty { From 240a9f8393f7c5ab14d349ef051f2d572477fad5 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 09:19:23 +0200 Subject: [PATCH 124/260] increase forum post max length - #15575 --- modules/forum/src/main/ForumForm.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/forum/src/main/ForumForm.scala b/modules/forum/src/main/ForumForm.scala index fb4825c52887d..70ce1ad91fea1 100644 --- a/modules/forum/src/main/ForumForm.scala +++ b/modules/forum/src/main/ForumForm.scala @@ -46,7 +46,7 @@ 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, 10_000) + cleanText(minLength = 3, 15_000) .verifying( "You have reached the daily maximum for links in forum posts.", t => inOwnTeam || promotion.test(me, t, previousText) From 7aa1f4612499aaf7d1e53b9e040695ee92595a16 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 10:43:46 +0200 Subject: [PATCH 125/260] cache tail games --- modules/relay/src/main/RelayFetch.scala | 29 +++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 6c73efccc764b..a84992a499e99 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -209,34 +209,41 @@ final private class RelayFetch( s"Invalid game IDs: ${ids.filter(id => !games.exists(_._1.id == id)).mkString(", ")}" .flatMap(multiPgnToGames.future) - // cache finished games so they're not requested again for a while private object lccCache: import DgtJson.GameJson type LccGameKey = String + // cache finished games so they're not requested again for a while private val finishedGames = cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.finishedLccGames"): _.expireAfterWrite(5 minutes).build() + // cache created (non-started) games until they start private val createdGames = - cacheApi.notLoadingSync[LccGameKey, GameJson](512, "relay.fetch.createdLccGames"): + cacheApi.notLoadingSync[LccGameKey, GameJson](256, "relay.fetch.createdLccGames"): _.expireAfter[LccGameKey, GameJson]( create = (key, _) => (if key.startsWith("start ") then 1 minutes else 5 minutes), update = (_, _, current) => current, read = (_, _, current) => current ).build() + // cache games with number > 12 to reduce load on big tournaments + private val tailGames = + cacheApi.notLoadingSync[LccGameKey, GameJson](256, "relay.fetch.tailLccGames"): + _.expireAfterWrite(1 minutes).build() def apply(lcc: RelayRound.Sync.Lcc, index: Int, roundTags: Tags, nearStart: Boolean)( fetch: () => Fu[GameJson] ): Fu[GameJson] = val key = s"${nearStart.so("start ")}${lcc.id} ${lcc.round} $index" - finishedGames.getIfPresent(key) match - case Some(game) => fuccess(game) - case None => - createdGames.getIfPresent(key) match - case Some(game) => fuccess(game) - case None => - fetch().addEffect: game => - if game.moves.isEmpty then createdGames.put(key, game) - else if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) + finishedGames + .getIfPresent(key) + .orElse(createdGames.getIfPresent(key)) + .orElse(tailGames.getIfPresent(key)) + .match + case Some(game) => fuccess(game) + case None => + fetch().addEffect: game => + if game.moves.isEmpty then createdGames.put(key, game) + else if game.mergeRoundTags(roundTags).outcome.isDefined then finishedGames.put(key, game) + else if index >= 12 then tailGames.put(key, game) private def fetchFromUpstream(rt: RelayRound.WithTour)(url: URL, max: Max)(using CanProxy): Fu[RelayGames] = import DgtJson.* From 7a1af7ef7e45969eb0714a97de6bac7976cc60e2 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 10:49:54 +0200 Subject: [PATCH 126/260] play error sound after 3 errors --- ui/analyse/src/study/relay/relayCtrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 8318bcde77d06..2527f9227ef52 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -152,7 +152,7 @@ export default class RelayCtrl { }, 4500); this.redraw(); if (event.error) { - if (this.data.sync.log.slice(-2).every(e => e.error)) site.sound.play('error'); + if (this.data.sync.log.slice(-3).every(e => e.error)) site.sound.play('error'); console.warn(`relay synchronisation error: ${event.error}`); } }, From 8faedcf854a0a753fde7f9c186f09baec0aeb151 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 11:53:21 +0200 Subject: [PATCH 127/260] mod view of failing broadcasts --- modules/relay/src/main/SyncLog.scala | 2 ++ modules/relay/src/main/ui/RelayTourUi.scala | 19 +++++++++++++++++-- ui/bits/css/relay/_card.scss | 5 +++++ ui/bits/css/relay/_relay.scss | 9 +++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/modules/relay/src/main/SyncLog.scala b/modules/relay/src/main/SyncLog.scala index 177098d87b2eb..e3e059884100b 100644 --- a/modules/relay/src/main/SyncLog.scala +++ b/modules/relay/src/main/SyncLog.scala @@ -10,6 +10,8 @@ case class SyncLog(events: Vector[SyncLog.Event]) extends AnyVal: def updatedAt = events.lastOption.map(_.at) + def lastErrors: List[String] = events.reverse.takeWhile(_.isKo).flatMap(_.error).toList + def add(event: SyncLog.Event) = copy( events = { diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 5236215dc2056..9f872e30f4888 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -33,6 +33,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): pageMenu("index"), div(cls := "page-menu__content box box-pad")( boxTop(h1(trc.liveBroadcasts()), searchForm("")), + Granter.opt(_.StudyAdmin).option(adminIndex(active)), nonEmptyTier(_.BEST, "best"), nonEmptyTier(_.HIGH, "high"), nonEmptyTier(_.NORMAL, "normal"), @@ -49,6 +50,16 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): ) ) + private def adminIndex(active: List[RelayTour.ActiveWithSomeRounds])(using Context) = + val errored = active.flatMap(a => a.link.sync.log.lastErrors.some.filter(_.nonEmpty).map(a -> _)) + errored.nonEmpty.option: + div(cls := "relay-index__admin")( + h2("Ongoing broadcasts with errors"), + st.section(cls := "relay-cards"): + errored.map: (tr, errors) => + card.render(tr, live = _.display.hasStarted, errors = errors.take(5)) + ) + private def listLayout(title: String, menu: Tag)(body: Modifier*)(using Context) = Page(trc.liveBroadcasts.txt()) .css("bits.relay.index") @@ -160,7 +171,9 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): private def image(t: RelayTour) = t.image.fold(ui.thumbnail.fallback(cls := "relay-card__image")): id => img(cls := "relay-card__image", src := ui.thumbnail.url(id, _.Size.Small)) - def render[A <: RelayRound.AndTourAndGroup](tr: A, live: A => Boolean)(using Context) = + def render[A <: RelayRound.AndTourAndGroup](tr: A, live: A => Boolean, errors: List[String] = Nil)(using + Context + ) = link(tr.tour, tr.path, live(tr))( image(tr.tour), span(cls := "relay-card__body")( @@ -178,7 +191,9 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): else tr.display.startedAt.orElse(tr.display.startsAt).map(momentFromNow(_)) ), h3(cls := "relay-card__title")(tr.group.fold(tr.tour.name.value)(_.value)), - span(cls := "relay-card__desc")(tr.tour.description) + if errors.nonEmpty + then ul(cls := "relay-card__errors")(errors.map(li(_))) + else span(cls := "relay-card__desc")(tr.tour.description) ) ) diff --git a/ui/bits/css/relay/_card.scss b/ui/bits/css/relay/_card.scss index 7f046141c6538..f2dd67fae36d1 100644 --- a/ui/bits/css/relay/_card.scss +++ b/ui/bits/css/relay/_card.scss @@ -84,4 +84,9 @@ @extend %roboto; color: $c-font-dim; } + &__errors { + @extend %break-word, %nowrap-ellipsis; + font-family: monospace; + color: $c-bad; + } } diff --git a/ui/bits/css/relay/_relay.scss b/ui/bits/css/relay/_relay.scss index 3b89ee2287f73..0147b28f292d9 100644 --- a/ui/bits/css/relay/_relay.scss +++ b/ui/bits/css/relay/_relay.scss @@ -9,6 +9,15 @@ margin: 2em 0 1em 0; } } +.relay-index__admin { + h2 { + margin: 0 0 1em 0; + color: $c-bad; + } + padding-bottom: 1em; + border-bottom: $border; + margin-bottom: 2em; +} .relay-image { &--fallback { From d5e1934946679ed877414ccc869e29469f62ea34 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 12:07:17 +0200 Subject: [PATCH 128/260] improve broadcast owner UI --- modules/relay/src/main/ui/RelayTourUi.scala | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 9f872e30f4888..7a3f1f61e4ed3 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -75,9 +75,20 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): renderPager(asRelayPager(pager), query)(cls := "relay-cards--search") ) - def byOwner(pager: Paginator[RelayTour | WithLastRound], owner: LightUser)(using Context) = + def byOwner(pager: Paginator[RelayTour | WithLastRound], owner: LightUser)(using ctx: Context) = listLayout(trc.liveBroadcasts.txt(), pageMenu("by", owner.some))( - boxTop(h1(lightUserLink(owner), " ", trc.liveBroadcasts())), + boxTop( + h1( + if ctx.is(owner) + then trc.myBroadcasts() + else frag(lightUserLink(owner), " ", trc.liveBroadcasts()) + ), + div(cls := "box__top__actions")( + a(href := routes.RelayTour.form, cls := "button button-green text", dataIcon := Icon.PlusButton)( + trc.newBroadcast() + ) + ) + ), standardFlash, renderPager(pager, owner = owner.some) ) @@ -129,7 +140,10 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): lila.ui.bits.pageMenuSubnav( a(href := routes.RelayTour.index(), cls := menu.activeO("index"))(trans.broadcast.broadcasts()), ctx.me.map: me => - a(href := routes.RelayTour.by(me.username, 1), cls := by.exists(_.is(me)).option("active")): + a( + href := routes.RelayTour.by(me.username, 1), + cls := (menu == "new" || by.exists(_.is(me))).option("active") + ): trans.broadcast.myBroadcasts() , by.filterNot(ctx.is) @@ -149,7 +163,6 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): "Private Broadcasts" ) ), - a(href := routes.RelayTour.form, cls := menu.activeO("new"))(trans.broadcast.newBroadcast()), a(href := routes.RelayTour.calendar, cls := menu.activeO("calendar"))(trans.site.tournamentCalendar()), a(href := routes.RelayTour.help, cls := menu.activeO("help"))(trans.broadcast.aboutBroadcasts()), div(cls := "sep"), From a58219e6426035feaa293c985fa3c8c5933b7a31 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 12:07:33 +0200 Subject: [PATCH 129/260] slightly smaller h1s --- ui/common/css/base/_typography.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/common/css/base/_typography.scss b/ui/common/css/base/_typography.scss index f01eaa3e8e248..1d73d4e035054 100644 --- a/ui/common/css/base/_typography.scss +++ b/ui/common/css/base/_typography.scss @@ -18,7 +18,7 @@ h4 { } h1 { - @include fluid-size('font-size', 20px, 40px); + @include fluid-size('font-size', 20px, 39px); a { color: $c-link-dim; From 72ec4703affb075fcaded0868caefad689374402 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 12:27:08 +0200 Subject: [PATCH 130/260] better broadcast error reporting --- modules/relay/src/main/RelayListing.scala | 7 +++---- modules/relay/src/main/RelayTour.scala | 8 +++++++- modules/relay/src/main/ui/RelayTourUi.scala | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/modules/relay/src/main/RelayListing.scala b/modules/relay/src/main/RelayListing.scala index 49191e9c3f330..dfceff7f8ec77 100644 --- a/modules/relay/src/main/RelayListing.scala +++ b/modules/relay/src/main/RelayListing.scala @@ -26,10 +26,9 @@ final class RelayListing( local = "_id", foreign = "tourId", pipe = List( - $doc("$match" -> $doc("finished" -> false)), - $doc("$addFields" -> $doc("sync.log" -> $arr())), - $doc("$sort" -> RelayRoundRepo.sort.chrono), - $doc("$limit" -> 1) + $doc("$match" -> $doc("finished" -> false)), + $doc("$sort" -> RelayRoundRepo.sort.chrono), + $doc("$limit" -> 1) ) ) for diff --git a/modules/relay/src/main/RelayTour.scala b/modules/relay/src/main/RelayTour.scala index f0c106eed16e5..ac9fc74ee51de 100644 --- a/modules/relay/src/main/RelayTour.scala +++ b/modules/relay/src/main/RelayTour.scala @@ -85,7 +85,13 @@ object RelayTour: display: RelayRound, // which round to show on the tour link link: RelayRound, // which round to actually link to group: Option[RelayGroup.Name] - ) extends RelayRound.AndTourAndGroup + ) extends RelayRound.AndTourAndGroup: + def errors: List[String] = + val round = display + ~round.sync.log.lastErrors.some + .filter(_.nonEmpty) + .orElse: + (round.hasStarted && !round.sync.ongoing).option(List("Not syncing!")) case class WithLastRound(tour: RelayTour, round: RelayRound, group: Option[RelayGroup.Name]) extends RelayRound.AndTourAndGroup: diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index 7a3f1f61e4ed3..e702512d75568 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -51,7 +51,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): ) private def adminIndex(active: List[RelayTour.ActiveWithSomeRounds])(using Context) = - val errored = active.flatMap(a => a.link.sync.log.lastErrors.some.filter(_.nonEmpty).map(a -> _)) + val errored = active.flatMap(a => a.errors.some.filter(_.nonEmpty).map(a -> _)) errored.nonEmpty.option: div(cls := "relay-index__admin")( h2("Ongoing broadcasts with errors"), From c14e96be995661719ab52c268f513a9b38ef8162 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 12:48:05 +0200 Subject: [PATCH 131/260] monitor broadcast fetch dedup --- modules/common/src/main/mon.scala | 1 + modules/relay/src/main/RelayDelay.scala | 1 + 2 files changed, 2 insertions(+) diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala index fbabcd2bbb8d6..7015bf8c55928 100644 --- a/modules/common/src/main/mon.scala +++ b/modules/common/src/main/mon.scala @@ -287,6 +287,7 @@ object mon: def tourCrowd(tourId: RelayTourId) = gauge("relay.tour.crowd").withTag("tour", tourId.value) def httpGet(host: String, proxy: Option[String]) = future("relay.http.get", tags("host" -> host, "proxy" -> proxy.getOrElse("none"))) + val dedup = counter("relay.fetch.dedup").withoutTags() object bot: def moves(username: String) = counter("bot.moves").withTag("name", username) diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index b03a49cb55cc6..b37c90e33dbcd 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -40,6 +40,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): (_, v) => Option(v) match case Some(GamesSeenBy(games, seenBy)) if !seenBy(round.id) => + lila.mon.relay.dedup.increment() GamesSeenBy(games, seenBy + round.id) case _ => val futureGames = doFetch().addEffect: games => From 1185499a9134cd7176c4bf4e6e1ed1a3d8bf813a Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 27 Jun 2024 15:18:51 +0200 Subject: [PATCH 132/260] let study members see broadcast stats --- app/controllers/Study.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index 23401b4da5e76..16225b0b3f674 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -243,7 +243,7 @@ final class Study( division = division ) ) - withMembers = !study.isRelay || isGrantedOpt(_.StudyAdmin) + withMembers = !study.isRelay || isGrantedOpt(_.StudyAdmin) || ctx.me.exists(study.isMember) studyJson <- env.study.jsonView(study, previews, chapter, fedNames.some, withMembers = withMembers) yield WithChapter(study, chapter) -> JsData( study = studyJson, From 0ce44f323fd8ddbc65310ba62730c89b491ac146 Mon Sep 17 00:00:00 2001 From: tom-anders <13141438+tom-anders@users.noreply.github.com> Date: Wed, 26 Jun 2024 23:33:31 +0200 Subject: [PATCH 133/260] feat: allow changing clock.sound via PrefSingleChange --- modules/pref/src/main/PrefForm.scala | 1 + modules/pref/src/main/PrefSingleChange.scala | 2 ++ 2 files changed, 3 insertions(+) diff --git a/modules/pref/src/main/PrefForm.scala b/modules/pref/src/main/PrefForm.scala index 0981760c569f6..d655c80980e35 100644 --- a/modules/pref/src/main/PrefForm.scala +++ b/modules/pref/src/main/PrefForm.scala @@ -47,6 +47,7 @@ object PrefForm: val submitMove = "submitMove" -> bitCheckedNumber(Pref.SubmitMove.choices) val confirmResign = "confirmResign" -> checkedNumber(Pref.ConfirmResign.choices) val moretime = "moretime" -> checkedNumber(Pref.Moretime.choices) + val clockSound = "clockSound" -> booleanNumber val ratings = "ratings" -> booleanNumber val flairs = "flairs" -> boolean val follow = "follow" -> booleanNumber diff --git a/modules/pref/src/main/PrefSingleChange.scala b/modules/pref/src/main/PrefSingleChange.scala index b35e163e0a073..e4c9a270b5b30 100644 --- a/modules/pref/src/main/PrefSingleChange.scala +++ b/modules/pref/src/main/PrefSingleChange.scala @@ -51,6 +51,8 @@ object PrefSingleChange: _.copy(confirmResign = v), changing(_.moretime): v => _.copy(moretime = v), + changing(_.clockSound): v => + _.copy(clockSound = v == 1), changing(_.ratings): v => _.copy(ratings = v), changing(_.follow): v => From f06eccfba0c8f50259a69f284c014922cedabc59 Mon Sep 17 00:00:00 2001 From: Trevor Bayless <3620552+trevorbayless@users.noreply.github.com> Date: Thu, 27 Jun 2024 10:48:19 -0500 Subject: [PATCH 134/260] Add focus indicator when tabbed on password reveal button --- ui/common/css/form/_password.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/common/css/form/_password.scss b/ui/common/css/form/_password.scss index f7c2cb3385eba..59e64ebb777d8 100644 --- a/ui/common/css/form/_password.scss +++ b/ui/common/css/form/_password.scss @@ -19,6 +19,10 @@ float: right; margin-right: 1em; margin-top: -2.2em; + + &:focus-visible { + outline: 2px solid $c-primary; + } } .password-reveal.revealed { color: $c-bad; From 560f5a19a355ad63702b263bd4bfaf5cacf2c82e Mon Sep 17 00:00:00 2001 From: Jonathan Gamble Date: Thu, 27 Jun 2024 14:08:12 -0500 Subject: [PATCH 135/260] remove site globals --- pnpm-lock.yaml | 3 + ui/@types/lichess/dialog.d.ts | 60 -------- ui/@types/lichess/index.d.ts | 20 +-- ui/analyse/src/ctrl.ts | 4 +- ui/analyse/src/explorer/explorerConfig.ts | 3 +- ui/analyse/src/keyboard.ts | 3 +- ui/analyse/src/practice/practiceCtrl.ts | 4 +- ui/analyse/src/serverSideUnderboard.ts | 6 +- ui/analyse/src/study/chapterEditForm.ts | 3 +- ui/analyse/src/study/chapterNewForm.ts | 3 +- ui/analyse/src/study/gamebook/gamebookEdit.ts | 3 +- ui/analyse/src/study/inviteForm.ts | 3 +- ui/analyse/src/study/serverEval.ts | 3 +- ui/analyse/src/study/studyForm.ts | 3 +- ui/analyse/src/study/studySearch.ts | 3 +- ui/analyse/src/study/topics.ts | 3 +- ui/analyse/src/view/actionMenu.ts | 5 +- ui/bits/src/bits.cropDialog.ts | 13 +- ui/bits/src/bits.diagnosticDialog.ts | 8 +- ui/bits/src/bits.forum.ts | 53 ++++--- ui/bits/src/bits.publicChats.ts | 3 +- ui/ceval/src/util.ts | 41 +++--- ui/ceval/src/view/main.ts | 4 +- ui/cli/src/cli.ts | 8 +- ui/common/css/component/_dialog.scss | 14 ++ ui/common/src/common.ts | 45 ++++++ ui/{site => common}/src/dialog.ts | 129 +++++++++++++++--- ui/common/src/linkPopup.ts | 22 +-- ui/common/src/richText.ts | 5 +- ui/editor/src/view.ts | 3 +- ui/insight/src/multipleSelect.ts | 3 +- ui/keyboardMove/src/ctrl.ts | 3 +- ui/lobby/src/ctrl.ts | 3 +- ui/lobby/src/main.ts | 3 +- ui/lobby/src/view/setup/modal.ts | 5 +- ui/msg/src/view/enhance.ts | 7 +- ui/nvui/package.json | 1 + ui/nvui/src/notify.ts | 3 +- ui/nvui/tsconfig.json | 2 +- ui/opening/src/opening.ts | 3 +- ui/puzzle/src/ctrl.ts | 5 +- ui/puzzle/src/keyboard.ts | 3 +- ui/round/src/ctrl.ts | 3 +- ui/round/src/keyboard.ts | 3 +- ui/round/src/socket.ts | 3 +- ui/simul/src/view/created.ts | 19 ++- ui/site/src/announce.ts | 2 +- ui/site/src/asset.ts | 2 + ui/site/src/boot.ts | 2 +- ui/site/src/functions.ts | 14 -- ui/site/src/log.ts | 3 + ui/site/src/powertip.ts | 2 +- ui/site/src/site.ts | 9 -- ui/tournament/src/view/battle.ts | 3 +- ui/voice/src/view.ts | 5 +- 55 files changed, 342 insertions(+), 249 deletions(-) delete mode 100644 ui/@types/lichess/dialog.d.ts rename ui/{site => common}/src/dialog.ts (58%) delete mode 100644 ui/site/src/functions.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47e38a009a786..1bba8567486ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: chessops: specifier: ^0.14.1 version: 0.14.1 + common: + specifier: workspace:* + version: link:../common snabbdom: specifier: 3.5.1 version: 3.5.1 diff --git a/ui/@types/lichess/dialog.d.ts b/ui/@types/lichess/dialog.d.ts deleted file mode 100644 index 440e5ee879d5b..0000000000000 --- a/ui/@types/lichess/dialog.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -// implementation: file://./../../site/src/component/dialog.ts - -interface Dialog { - readonly open: boolean; // is visible? - readonly view: HTMLElement; // your content div - readonly returnValue?: 'ok' | 'cancel' | string; // how did we close? - - showModal(): Promise; // resolves on close - show(): Promise; // resolves on close - close(): void; -} - -interface DialogOpts { - class?: string; // zero or more classes for your view div - css?: ({ url: string } | { themed: string })[]; // fetches themed or full url css - htmlText?: string; // content, text will be used as-is - cash?: Cash; // content, overrides htmlText, will be cloned and any 'none' class removed - htmlUrl?: string; // content, overrides htmlText and cash, url will be xhr'd - append?: { node: HTMLElement; selector?: string }[]; // appended to view or selected parents - attrs?: { dialog?: _Snabbdom.Attrs; view?: _Snabbdom.Attrs }; // optional attrs for dialog and view div - action?: Action | Action[]; // if present, add handlers to action buttons - onClose?: (dialog: Dialog) => void; // called when dialog closes - noCloseButton?: boolean; // if true, no upper right corner close button - noClickAway?: boolean; // if true, no click-away-to-close - noScrollable?: boolean; // if true, no scrollable div container. Fixes dialogs containing an auto-completer -} - -interface DomDialogOpts extends DialogOpts { - parent?: Element; // for centering and dom placement, otherwise fixed on document.body - show?: 'modal' | boolean; // if not falsy, auto-show, and if 'modal' remove from dom on close -} - -//snabDialog automatically shows as 'modal' on redraw unless onInsert callback is supplied -interface SnabDialogOpts extends DialogOpts { - vnodes?: _Snabbdom.LooseVNodes; // content, overrides other content properties - onInsert?: (dialog: Dialog) => void; // if supplied, call show() or showModal() manually -} - -// Action can be any "clickable" client button, usually to dismiss the dialog -interface Action { - selector: string; // selector, click handler will be installed - action?: string | ((dialog: Dialog, action: Action) => void); - // if action not provided, just close - // if string, given value will set dialog.returnValue and dialog is closed on click - // if function, it will be called on click and YOU must close the dialog -} - -declare namespace _Snabbdom { - type Attrs = Record; - type Key = string | number | symbol; - type VNode = { - sel: string | undefined; - data: { [key: string]: any } | undefined; - children: Array | undefined; - elm: Node | undefined; - text: string | undefined; - key: Key | undefined; - }; - type LooseVNodes = (VNode | string | undefined | null | boolean)[]; -} diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts index d9c566a98c71b..de14a44412dfe 100644 --- a/ui/@types/lichess/index.d.ts +++ b/ui/@types/lichess/index.d.ts @@ -1,11 +1,8 @@ // eslint-disable-next-line /// - // eslint-disable-next-line /// // eslint-disable-next-line -/// -// eslint-disable-next-line /// // eslint-disable-next-line /// @@ -25,7 +22,6 @@ interface Site { defaultParams: Record; }; mousetrap: LichessMousetrap; // file://./../../site/src/mousetrap.ts - requestIdleCallback(f: () => void, timeout?: number): void; sri: string; storage: LichessStorageHelper; tempStorage: LichessStorageHelper; @@ -38,12 +34,13 @@ interface Site { baseUrl(): string; url(url: string, opts?: AssetUrlOpts): string; flairSrc(flair: Flair): string; - loadCss(path: string): Promise; - loadCssPath(path: string): Promise; - removeCssPath(path: string): void; + loadCss(href: string): Promise; + loadCssPath(key: string): Promise; + removeCss(href: string): void; + removeCssPath(key: string): void; jsModule(name: string): string; loadIife(path: string, opts?: AssetUrlOpts): Promise; - loadEsm(name: string, opts?: EsmModuleOpts): Promise; + loadEsm(key: string, opts?: EsmModuleOpts): Promise; userComplete(opts: UserCompleteOpts): Promise; }; idleTimer(delay: number, onIdle: () => void, onWakeUp: () => void): void; @@ -52,7 +49,6 @@ interface Site { redirect(o: RedirectTo, beep?: boolean): void; reload(): void; watchers(el: HTMLElement): void; - escapeHtml(str: string): string; announce(d: LichessAnnouncement): void; trans(i18n: I18nDict): Trans; sound: SoundI; // file://./../../site/src/sound.ts @@ -76,12 +72,6 @@ interface Site { makeChat(data: any): any; makeChessground(el: HTMLElement, config: CgConfig): CgApi; log: LichessLog; // file://./../../site/src/log.ts - dialog: { - // file://./../../site/src/dialog.ts - ready: Promise; - dom(opts: DomDialogOpts): Promise; - snab(opts: SnabDialogOpts): _Snabbdom.VNode; - }; // the remaining are not set in site.lichess.globals.ts load: Promise; // DOMContentLoaded promise diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index 07a7a20b17477..63ae5b59cb025 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -25,7 +25,7 @@ import { compute as computeAutoShapes } from './autoShape'; import { Config as ChessgroundConfig } from 'chessground/config'; import { CevalCtrl, isEvalBetter, sanIrreversible, EvalMeta } from 'ceval'; import { TreeView } from './treeView/treeView'; -import { defined, prop, Prop, toggle, Toggle } from 'common'; +import { defined, prop, Prop, toggle, Toggle, requestIdleCallback } from 'common'; import { DrawShape } from 'chessground/draw'; import { lichessRules } from 'chessops/compat'; import EvalCache from './evalCache'; @@ -173,7 +173,7 @@ export default class AnalyseCtrl { if (location.hash === '#practice' || (this.study && this.study.data.chapter.practice)) this.togglePractice(); - else if (location.hash === '#menu') site.requestIdleCallback(this.actionMenu.toggle, 500); + else if (location.hash === '#menu') requestIdleCallback(this.actionMenu.toggle, 500); this.startCeval(); keyboard.bind(this); diff --git a/ui/analyse/src/explorer/explorerConfig.ts b/ui/analyse/src/explorer/explorerConfig.ts index 08ac9388327d3..323631d7b1dd2 100644 --- a/ui/analyse/src/explorer/explorerConfig.ts +++ b/ui/analyse/src/explorer/explorerConfig.ts @@ -1,6 +1,7 @@ import { h, VNode } from 'snabbdom'; import { Prop, prop } from 'common'; import * as licon from 'common/licon'; +import { snabDialog } from 'common/dialog'; import { bind, dataIcon, iconTag, onInsert } from 'common/snabbdom'; import { storedProp, storedJsonProp, StoredJsonProp, StoredProp, storedStringProp } from 'common/storage'; import { ExplorerDb, ExplorerSpeed, ExplorerMode } from './interfaces'; @@ -333,7 +334,7 @@ const playerModal = (ctrl: ExplorerConfigCtrl) => { } return '.button-metal'; }; - return site.dialog.snab({ + return snabDialog({ class: 'explorer__config__player__choice', onClose() { ctrl.data.playerName.open(false); diff --git a/ui/analyse/src/keyboard.ts b/ui/analyse/src/keyboard.ts index fb3a1c2e38d4d..f242493612354 100644 --- a/ui/analyse/src/keyboard.ts +++ b/ui/analyse/src/keyboard.ts @@ -1,6 +1,7 @@ import * as control from './control'; import AnalyseCtrl from './ctrl'; import * as xhr from 'common/xhr'; +import { snabDialog } from 'common/dialog'; import { VNode } from 'snabbdom'; export const bind = (ctrl: AnalyseCtrl) => { @@ -135,7 +136,7 @@ export const bind = (ctrl: AnalyseCtrl) => { }; export function view(ctrl: AnalyseCtrl): VNode { - return site.dialog.snab({ + return snabDialog({ class: 'help.keyboard-help', htmlUrl: xhr.url('/analysis/help', { study: !!ctrl.study }), onClose() { diff --git a/ui/analyse/src/practice/practiceCtrl.ts b/ui/analyse/src/practice/practiceCtrl.ts index 4f5de45835de6..2cbf9d4c868c2 100644 --- a/ui/analyse/src/practice/practiceCtrl.ts +++ b/ui/analyse/src/practice/practiceCtrl.ts @@ -4,7 +4,7 @@ import { detectThreefold } from '../nodeFinder'; import { tablebaseGuaranteed } from '../explorer/explorerCtrl'; import AnalyseCtrl from '../ctrl'; import { Redraw } from '../interfaces'; -import { defined, prop, Prop } from 'common'; +import { defined, prop, Prop, requestIdleCallback } from 'common'; import { altCastles } from 'chess'; import { parseUci } from 'chessops/util'; import { makeSan } from 'chessops/san'; @@ -195,7 +195,7 @@ export function make(root: AnalyseCtrl, playableDepth: () => number): PracticeCt checkCevalOrTablebase(); } - site.requestIdleCallback(checkCevalOrTablebase, 800); + requestIdleCallback(checkCevalOrTablebase, 800); return { onCeval: checkCeval, diff --git a/ui/analyse/src/serverSideUnderboard.ts b/ui/analyse/src/serverSideUnderboard.ts index e056205db2a74..383b26f371678 100644 --- a/ui/analyse/src/serverSideUnderboard.ts +++ b/ui/analyse/src/serverSideUnderboard.ts @@ -5,7 +5,9 @@ import { url as xhrUrl, textRaw as xhrTextRaw } from 'common/xhr'; import { AnalyseData } from './interfaces'; import { ChartGame, AcplChart } from 'chart'; import { stockfishName } from 'common/spinner'; +import { domDialog } from 'common/dialog'; import { FEN } from 'chessground/types'; +import { escapeHtml } from 'common'; export default function (element: HTMLElement, ctrl: AnalyseCtrl) { $(element).replaceWith(ctrl.opts.$underboard); @@ -147,14 +149,14 @@ export default function (element: HTMLElement, ctrl: AnalyseCtrl) { // uglier in the process. const url = `${baseUrl()}/embed/game/${data.game.id}?theme=auto&bg=auto${location.hash}`; const iframe = ``; - site.dialog.dom({ + domDialog({ show: 'modal', htmlText: '
' + $(this).html() + '

' + '
' +
-        site.escapeHtml(iframe) +
+        escapeHtml(iframe) +
         '

' + iframe + '

' + diff --git a/ui/analyse/src/study/chapterEditForm.ts b/ui/analyse/src/study/chapterEditForm.ts index 55fb53b54793e..f739bc8bd26fc 100644 --- a/ui/analyse/src/study/chapterEditForm.ts +++ b/ui/analyse/src/study/chapterEditForm.ts @@ -4,6 +4,7 @@ import { spinnerVdom as spinner } from 'common/spinner'; import { option, emptyRedButton } from '../view/util'; import { ChapterMode, EditChapterData, Orientation, StudyChapterConfig, ChapterPreview } from './interfaces'; import { defined, prop } from 'common'; +import { snabDialog } from 'common/dialog'; import { h, VNode } from 'snabbdom'; import { Redraw } from '../interfaces'; import { StudySocketSend } from '../socket'; @@ -57,7 +58,7 @@ export function view(ctrl: StudyChapterEditForm): VNode | undefined { const data = ctrl.current(), noarg = ctrl.trans.noarg; return data - ? site.dialog.snab({ + ? snabDialog({ class: 'edit-' + data.id, // full redraw when changing chapter onClose() { ctrl.current(null); diff --git a/ui/analyse/src/study/chapterNewForm.ts b/ui/analyse/src/study/chapterNewForm.ts index 90241fdd2aa8c..85c669ac264e1 100644 --- a/ui/analyse/src/study/chapterNewForm.ts +++ b/ui/analyse/src/study/chapterNewForm.ts @@ -1,5 +1,6 @@ import { parseFen } from 'chessops/fen'; import { defined, prop, Prop, toggle } from 'common'; +import { snabDialog } from 'common/dialog'; import * as licon from 'common/licon'; import { bind, bindSubmit, onInsert, looseH as h, dataIcon } from 'common/snabbdom'; import { storedProp } from 'common/storage'; @@ -126,7 +127,7 @@ export function view(ctrl: StudyChapterNewForm): VNode { : 'normal'; const noarg = trans.noarg; - return site.dialog.snab({ + return snabDialog({ class: 'chapter-new', onClose() { ctrl.isOpen(false); diff --git a/ui/analyse/src/study/gamebook/gamebookEdit.ts b/ui/analyse/src/study/gamebook/gamebookEdit.ts index 94a670ce84c10..19fc064b7d0b2 100644 --- a/ui/analyse/src/study/gamebook/gamebookEdit.ts +++ b/ui/analyse/src/study/gamebook/gamebookEdit.ts @@ -1,5 +1,6 @@ import * as control from '../../control'; import AnalyseCtrl from '../../ctrl'; +import { requestIdleCallback } from 'common'; import * as licon from 'common/licon'; import throttle from 'common/throttle'; import { iconTag, bind, MaybeVNodes } from 'common/snabbdom'; @@ -27,7 +28,7 @@ export function render(ctrl: AnalyseCtrl): VNode { () => { study.commentForm.start(study.vm.chapterId, ctrl.path, ctrl.node); study.vm.toolTab('comments'); - site.requestIdleCallback( + requestIdleCallback( () => $('#comment-text').each(function (this: HTMLTextAreaElement) { this.focus(); diff --git a/ui/analyse/src/study/inviteForm.ts b/ui/analyse/src/study/inviteForm.ts index 4e6acb992792c..fcaadd6e87007 100644 --- a/ui/analyse/src/study/inviteForm.ts +++ b/ui/analyse/src/study/inviteForm.ts @@ -6,6 +6,7 @@ import { prop, Prop } from 'common'; import { StudyMemberMap } from './interfaces'; import { AnalyseSocketSend } from '../socket'; import { storedSet, StoredSet } from 'common/storage'; +import { snabDialog } from 'common/dialog'; export interface StudyInviteFormCtrl { open: Prop; @@ -58,7 +59,7 @@ export function view(ctrl: ReturnType): VNode { const candidates = [...new Set([...ctrl.spectators(), ...ctrl.previouslyInvited()])] .filter(s => !ctrl.members()[titleNameToId(s)]) // remove existing members .sort(); - return site.dialog.snab({ + return snabDialog({ class: 'study__invite', onClose() { ctrl.open(false); diff --git a/ui/analyse/src/study/serverEval.ts b/ui/analyse/src/study/serverEval.ts index 3cbfdfa25f664..08ae909af4af4 100644 --- a/ui/analyse/src/study/serverEval.ts +++ b/ui/analyse/src/study/serverEval.ts @@ -1,6 +1,7 @@ import * as licon from 'common/licon'; import { bind, onInsert } from 'common/snabbdom'; import { spinnerVdom, chartSpinner } from 'common/spinner'; +import { requestIdleCallback } from 'common'; import { h, VNode } from 'snabbdom'; import AnalyseCtrl from '../ctrl'; import { ChartGame, AcplChart } from 'chart'; @@ -40,7 +41,7 @@ export function view(ctrl: ServerEval): VNode { const mainline = ctrl.requested ? ctrl.root.data.treeParts : ctrl.analysedMainline(); const chart = h('canvas.study__server-eval.ready.' + analysis.id, { hook: onInsert(el => { - site.requestIdleCallback(async () => { + requestIdleCallback(async () => { (await site.asset.loadEsm('chart.game')) .acpl(el as HTMLCanvasElement, ctrl.root.data, mainline, ctrl.root.trans) .then(chart => (ctrl.chart = chart)); diff --git a/ui/analyse/src/study/studyForm.ts b/ui/analyse/src/study/studyForm.ts index 5be70dac7909e..bf9ccf3600186 100644 --- a/ui/analyse/src/study/studyForm.ts +++ b/ui/analyse/src/study/studyForm.ts @@ -1,6 +1,7 @@ import { VNode } from 'snabbdom'; import * as licon from 'common/licon'; import { prop } from 'common'; +import { snabDialog } from 'common/dialog'; import { bindSubmit, bindNonPassive, onInsert, looseH as h } from 'common/snabbdom'; import { emptyRedButton } from '../view/util'; import { StudyData } from './interfaces'; @@ -81,7 +82,7 @@ export function view(ctrl: StudyForm): VNode { ['member', ctrl.trans.noarg('members')], ['everyone', ctrl.trans.noarg('everyone')], ]; - return site.dialog.snab({ + return snabDialog({ class: 'study-edit', onClose() { ctrl.open(false); diff --git a/ui/analyse/src/study/studySearch.ts b/ui/analyse/src/study/studySearch.ts index bafaac6712070..546d139339504 100644 --- a/ui/analyse/src/study/studySearch.ts +++ b/ui/analyse/src/study/studySearch.ts @@ -1,6 +1,7 @@ import { Prop, Toggle, propWithEffect, toggle } from 'common'; import * as licon from 'common/licon'; import { bind, dataIcon, onInsert } from 'common/snabbdom'; +import { snabDialog } from 'common/dialog'; import { h, VNode } from 'snabbdom'; import { Redraw } from '../interfaces'; import { ChapterPreview } from './interfaces'; @@ -49,7 +50,7 @@ const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // export function view(ctrl: SearchCtrl) { const cleanQuery = ctrl.cleanQuery(); const highlightRegex = cleanQuery && new RegExp(escapeRegExp(cleanQuery), 'gi'); - return site.dialog.snab({ + return snabDialog({ class: 'study-search', onClose() { ctrl.open(false); diff --git a/ui/analyse/src/study/topics.ts b/ui/analyse/src/study/topics.ts index 57137124dac62..c723939182667 100644 --- a/ui/analyse/src/study/topics.ts +++ b/ui/analyse/src/study/topics.ts @@ -1,6 +1,7 @@ import { prop } from 'common'; import { bind, bindSubmit, onInsert } from 'common/snabbdom'; import * as xhr from 'common/xhr'; +import { snabDialog } from 'common/dialog'; import { h, VNode } from 'snabbdom'; import { Redraw } from '../interfaces'; import { Topic } from './interfaces'; @@ -36,7 +37,7 @@ export const view = (ctrl: StudyCtrl): VNode => let tagify: Tagify | undefined; export const formView = (ctrl: TopicsCtrl, userId?: string): VNode => - site.dialog.snab({ + snabDialog({ class: 'study-topics', onClose() { ctrl.open(false); diff --git a/ui/analyse/src/view/actionMenu.ts b/ui/analyse/src/view/actionMenu.ts index ba41ec680f93e..c54571c3336e0 100644 --- a/ui/analyse/src/view/actionMenu.ts +++ b/ui/analyse/src/view/actionMenu.ts @@ -1,6 +1,7 @@ import { isEmpty } from 'common'; import * as licon from 'common/licon'; import { isTouchDevice } from 'common/device'; +import { domDialog } from 'common/dialog'; import { bind, dataIcon, MaybeVNodes, looseH as h } from 'common/snabbdom'; import { VNode } from 'snabbdom'; import { AutoplayDelay } from '../autoplay'; @@ -130,9 +131,7 @@ export function view(ctrl: AnalyseCtrl): VNode { h( 'a', { - hook: bind('click', () => - site.dialog.dom({ cash: $('.continue-with.g_' + d.game.id), show: 'modal' }), - ), + hook: bind('click', () => domDialog({ cash: $('.continue-with.g_' + d.game.id), show: 'modal' })), attrs: dataIcon(licon.Swords), }, noarg('continueFromHere'), diff --git a/ui/bits/src/bits.cropDialog.ts b/ui/bits/src/bits.cropDialog.ts index 05c51a7385979..7f89eca2a5788 100644 --- a/ui/bits/src/bits.cropDialog.ts +++ b/ui/bits/src/bits.cropDialog.ts @@ -1,4 +1,5 @@ import { defined } from 'common'; +import { domDialog } from 'common/dialog'; import Cropper from 'cropperjs'; export interface CropOpts { @@ -61,17 +62,17 @@ export async function initModule(o?: CropOpts) { minContainerHeight: viewBounds.height, }); - const dlg = await site.dialog.dom({ + const dlg = await domDialog({ class: 'crop-viewer', - css: [{ themed: 'bits.cropDialog' }, { url: 'npm/cropper.min.css' }], + css: [{ hashed: 'bits.cropDialog' }, { url: 'npm/cropper.min.css' }], htmlText: `

Crop image to desired shape

`, - append: [{ selector: '.crop-view', node: container }], - action: [ - { selector: '.dialog-actions > .cancel', action: d => d.close() }, - { selector: '.dialog-actions > .submit', action: crop }, + append: [{ where: '.crop-view', node: container }], + actions: [ + { selector: '.dialog-actions > .cancel', listener: d => d.close() }, + { selector: '.dialog-actions > .submit', listener: crop }, ], onClose: () => { URL.revokeObjectURL(url); diff --git a/ui/bits/src/bits.diagnosticDialog.ts b/ui/bits/src/bits.diagnosticDialog.ts index c0935b0300809..d377fd0a31d4f 100644 --- a/ui/bits/src/bits.diagnosticDialog.ts +++ b/ui/bits/src/bits.diagnosticDialog.ts @@ -1,5 +1,7 @@ import { isTouchDevice } from 'common/device'; +import { domDialog } from 'common/dialog'; import * as licon from 'common/licon'; +import { escapeHtml } from 'common'; export async function initModule() { const ops = processQueryParams(); @@ -13,7 +15,7 @@ export async function initModule() { `Engine: ${site.storage.get('ceval.engine')}, ` + `Threads: ${site.storage.get('ceval.threads')}` + (logs ? `\n\n${logs}` : ''); - const escaped = site.escapeHtml(text); + const escaped = escapeHtml(text); const flash = ops > 0 ? `

Changes applied

` : ''; const submit = document.body.dataset.user ? `
@@ -22,9 +24,9 @@ export async function initModule() { : ''; const clear = logs ? `` : ''; const copy = ``; - const dlg = await site.dialog.dom({ + const dlg = await domDialog({ class: 'diagnostic', - css: [{ themed: 'bits.diagnosticDialog' }], + css: [{ hashed: 'bits.diagnosticDialog' }], htmlText: `

Diagnostics

${flash}
${escaped}
diff --git a/ui/bits/src/bits.forum.ts b/ui/bits/src/bits.forum.ts index 293c850aa5a10..2a9a6fbb8cf25 100644 --- a/ui/bits/src/bits.forum.ts +++ b/ui/bits/src/bits.forum.ts @@ -1,4 +1,5 @@ import * as xhr from 'common/xhr'; +import { domDialog } from 'common/dialog'; import { Textcomplete } from '@textcomplete/core'; import { TextareaEditor } from '@textcomplete/textarea'; @@ -6,38 +7,34 @@ site.load.then(() => { $('.forum') .on('click', 'a.delete', function (this: HTMLAnchorElement) { const link = this; - site.dialog - .dom({ - cash: $('.forum-delete-modal'), - attrs: { view: { action: link.href } }, - }) - .then(dlg => { - $(dlg.view) - .find('form') - .attr('action', link.href) - .on('submit', function (this: HTMLFormElement, e: Event) { - e.preventDefault(); - xhr.formToXhr(this); - $(link).closest('.forum-post').hide(); - dlg.close(); - }); - $(dlg.view).find('form button.cancel').on('click', dlg.close); - dlg.showModal(); - }); + domDialog({ + cash: $('.forum-delete-modal'), + attrs: { view: { action: link.href } }, + }).then(dlg => { + $(dlg.view) + .find('form') + .attr('action', link.href) + .on('submit', function (this: HTMLFormElement, e: Event) { + e.preventDefault(); + xhr.formToXhr(this); + $(link).closest('.forum-post').hide(); + dlg.close(); + }); + $(dlg.view).find('form button.cancel').on('click', dlg.close); + dlg.showModal(); + }); return false; }) .on('click', 'a.mod-relocate', function (this: HTMLAnchorElement) { const link = this; - site.dialog - .dom({ - cash: $('.forum-relocate-modal'), - attrs: { view: { action: link.href } }, - }) - .then(dlg => { - $(dlg.view).find('form').attr('action', link.href); - $(dlg.view).find('form button.cancel').on('click', dlg.close); - dlg.showModal(); - }); + domDialog({ + cash: $('.forum-relocate-modal'), + attrs: { view: { action: link.href } }, + }).then(dlg => { + $(dlg.view).find('form').attr('action', link.href); + $(dlg.view).find('form button.cancel').on('click', dlg.close); + dlg.showModal(); + }); return false; }) .on('click', 'form.unsub button', function (this: HTMLButtonElement) { diff --git a/ui/bits/src/bits.publicChats.ts b/ui/bits/src/bits.publicChats.ts index da0d520eefea9..0d9819cd2962b 100644 --- a/ui/bits/src/bits.publicChats.ts +++ b/ui/bits/src/bits.publicChats.ts @@ -1,4 +1,5 @@ import { text, form } from 'common/xhr'; +import { domDialog } from 'common/dialog'; site.load.then(() => { let autoRefreshEnabled = true; @@ -39,7 +40,7 @@ site.load.then(() => { $('#communication').on('click', '.line:not(.lichess)', function (this: HTMLDivElement) { const $l = $(this); - site.dialog.dom({ cash: $('.timeout-modal') }).then(dlg => { + domDialog({ cash: $('.timeout-modal') }).then(dlg => { $('.username', dlg.view).text($l.find('.user-link').text()); $('.text', dlg.view).text($l.text().split(' ').slice(1).join(' ')); $('.button', dlg.view).on('click', function (this: HTMLButtonElement) { diff --git a/ui/ceval/src/util.ts b/ui/ceval/src/util.ts index e07387d7389e4..884bfc5848f78 100644 --- a/ui/ceval/src/util.ts +++ b/ui/ceval/src/util.ts @@ -1,5 +1,6 @@ import { isMobile } from 'common/device'; -import { memoize } from 'common/common'; +import { memoize, escapeHtml } from 'common'; +import { domDialog } from 'common/dialog'; export function isEvalBetter(a: Tree.ClientEval, b: Tree.ClientEval): boolean { return a.depth > b.depth || (a.depth === b.depth && a.nodes > b.nodes); @@ -35,28 +36,26 @@ export const sharedWasmMemory = (lo: number, hi = 32767): WebAssembly.Memory => export function showEngineError(engine: string, error: string) { console.log(error); - site.dialog - .dom({ - class: 'engine-error', - htmlText: - `

${site.escapeHtml(engine)} error

` + - (error.includes('Status 503') - ? `

Your external engine does not appear to be connected.

+ domDialog({ + class: 'engine-error', + htmlText: + `

${escapeHtml(engine)} error

` + + (error.includes('Status 503') + ? `

Your external engine does not appear to be connected.

Please check the network and restart your provider if possible.

` - : `${site.escapeHtml(error)}

Things to try

    + : `${escapeHtml(error)}

    Things to try

    • Decrease memory slider in engine settings
    • Clear site settings for lichess.org
    • Select another engine
    • Update your browser
    `), - }) - .then((dlg: Dialog) => { - const select = () => - setTimeout(() => { - const range = document.createRange(); - range.selectNodeContents(dlg.view.querySelector('.err')!); - window.getSelection()?.removeAllRanges(); - window.getSelection()?.addRange(range); - }, 0); - dlg.view.querySelector('.err')?.addEventListener('focus', select); - dlg.showModal(); - }); + }).then(dlg => { + const select = () => + setTimeout(() => { + const range = document.createRange(); + range.selectNodeContents(dlg.view.querySelector('.err')!); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }, 0); + dlg.view.querySelector('.err')?.addEventListener('focus', select); + dlg.showModal(); + }); } diff --git a/ui/ceval/src/view/main.ts b/ui/ceval/src/view/main.ts index 637571be7cacf..75f115d27eb8a 100644 --- a/ui/ceval/src/view/main.ts +++ b/ui/ceval/src/view/main.ts @@ -2,7 +2,7 @@ import * as winningChances from '../winningChances'; import * as licon from 'common/licon'; import { stepwiseScroll } from 'common/scroll'; import { onInsert, bind, LooseVNodes, looseH as h } from 'common/snabbdom'; -import { defined, notNull } from 'common'; +import { defined, notNull, requestIdleCallback } from 'common'; import { ParentCtrl, NodeEvals, CevalState } from '../types'; import { VNode } from 'snabbdom'; import { Position } from 'chessops/chess'; @@ -309,7 +309,7 @@ function getElPvMoves(e: TouchEvent | MouseEvent): (string | null)[] { } function checkHover(el: HTMLElement, ceval: CevalCtrl): void { - site.requestIdleCallback( + requestIdleCallback( () => ceval.setHovering(getElFen(el), $(el).find('div.pv:hover').attr('data-uci') || undefined), 500, ); diff --git a/ui/cli/src/cli.ts b/ui/cli/src/cli.ts index 03983a42d0efc..0a40a37e65191 100644 --- a/ui/cli/src/cli.ts +++ b/ui/cli/src/cli.ts @@ -1,4 +1,6 @@ import { load as loadDasher } from 'dasher'; +import { domDialog } from 'common/dialog'; +import { escapeHtml } from 'common'; export function initModule({ input }: { input: HTMLInputElement }) { site.asset.userComplete({ @@ -46,15 +48,15 @@ function commandHelp(aliases: string, args: string, desc: string) { '
    ' + aliases .split(' ') - .map(a => `

    ${a} ${site.escapeHtml(args)}

    `) + .map(a => `

    ${a} ${escapeHtml(args)}

    `) .join('') + `
    ${desc}
    ` ); } function help() { - site.dialog.dom({ - css: [{ themed: 'cli.help' }], + domDialog({ + css: [{ hashed: 'cli.help' }], class: 'clinput-help', show: 'modal', htmlText: diff --git a/ui/common/css/component/_dialog.scss b/ui/common/css/component/_dialog.scss index a78efd82223ec..b7389d4df8fe7 100644 --- a/ui/common/css/component/_dialog.scss +++ b/ui/common/css/component/_dialog.scss @@ -81,4 +81,18 @@ dialog { text-align: center; padding: 2em; color: $c-font; + + &.alert { + @extend %flex-column; + gap: 2em; + padding: 2em; + width: unset; + height: unset; + + span { + display: flex; + justify-content: end; + gap: 2em; + } + } } diff --git a/ui/common/src/common.ts b/ui/common/src/common.ts index df6b55c6f697a..b7a1252f053d5 100644 --- a/ui/common/src/common.ts +++ b/ui/common/src/common.ts @@ -16,6 +16,14 @@ export function as(v: T, f: () => void): () => T { }; } +export function deepFreeze(obj: any): void { + if (obj === null || typeof obj !== 'object') return; + for (const prop of Object.values(obj)) { + if (prop && typeof prop === 'object') deepFreeze(prop); + } + Object.freeze(obj); +} + export interface Prop { (): T; (v: T): T; @@ -132,3 +140,40 @@ export function pushMap(m: SparseMap, key: string, val: T) { export function hyphenToCamel(str: string) { return str.replace(/-([a-z])/g, g => g[1].toUpperCase()); } + +export const requestIdleCallback = (f: () => void, timeout?: number) => { + if (window.requestIdleCallback) window.requestIdleCallback(f, timeout ? { timeout } : undefined); + else requestAnimationFrame(f); +}; + +export const escapeHtml = (str: string) => + /[&<>"']/.test(str) + ? str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + : str; + +// does not compare complex objects or non-enumerable properties +export function enumerableEquivalence(a: any, b: any, enforceArrayOrder = true): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (Array.isArray(a)) { + return ( + Array.isArray(b) && + a.length === b.length && + (enforceArrayOrder + ? a.every((x, i) => enumerableEquivalence(x, b[i])) + : a.every(x => b.find((y: any) => enumerableEquivalence(x, y)))) + ); + } + if (typeof a !== 'object') return false; + const [aKeys, bKeys] = [Object.keys(a), Object.keys(b)]; + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!bKeys.includes(key) || !enumerableEquivalence(a[key], b[key])) return false; + } + return true; +} diff --git a/ui/site/src/dialog.ts b/ui/common/src/dialog.ts similarity index 58% rename from ui/site/src/dialog.ts rename to ui/common/src/dialog.ts index ab4815dc4fa5a..6e5c38a783766 100644 --- a/ui/site/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -1,11 +1,54 @@ -import { onInsert, looseH as h, VNode } from 'common/snabbdom'; -import { isTouchDevice } from 'common/device'; -import * as xhr from 'common/xhr'; -import * as licon from 'common/licon'; +import { onInsert, looseH as h, VNode, Attrs, LooseVNodes } from './snabbdom'; +import { isTouchDevice } from './device'; +import * as xhr from './xhr'; +import * as licon from './licon'; let dialogPolyfill: { registerDialog: (dialog: HTMLDialogElement) => void }; -// for usage see file://./../../../@types/lichess/dialog.d.ts +export interface Dialog { + readonly open: boolean; // is visible? + readonly view: HTMLElement; // your content div + readonly returnValue?: 'ok' | 'cancel' | string; // how did we close? + + showModal(): Promise; // resolves on close + show(): Promise; // resolves on close + actions(actions?: Action | Action[]): void; // set new or reattach existing actions + close(): void; +} + +export interface DialogOpts { + class?: string; // zero or more classes for your view div + css?: ({ url: string } | { hashed: string })[]; // fetches hashed or full url css + htmlText?: string; // content, text will be used as-is + cash?: Cash; // content, overrides htmlText, will be cloned and any 'none' class removed + htmlUrl?: string; // content, overrides htmlText and cash, url will be xhr'd + append?: { node: HTMLElement; where?: string; how?: 'after' | 'before' | 'child' }[]; // default 'child' + attrs?: { dialog?: Attrs; view?: Attrs }; // optional attrs for dialog and view div + actions?: Action | Action[]; // if present, add listeners to action buttons + onClose?: (dialog: Dialog) => void; // called when dialog closes + noCloseButton?: boolean; // if true, no upper right corner close button + noClickAway?: boolean; // if true, no click-away-to-close + noScrollable?: boolean; // if true, no scrollable div container. Fixes dialogs containing an auto-completer +} + +export interface DomDialogOpts extends DialogOpts { + parent?: Element; // for centering and dom placement, otherwise fixed on document.body + show?: 'modal' | boolean; // if not falsy, auto-show, and if 'modal' remove from dom on close +} + +//snabDialog automatically shows as 'modal' on redraw unless onInsert callback is supplied +export interface SnabDialogOpts extends DialogOpts { + vnodes?: LooseVNodes; // content, overrides other content properties + onInsert?: (dialog: Dialog) => void; // if supplied, call show() or showModal() manually +} + +export type ActionListener = (dialog: Dialog, action: Action, e: Event) => void; + +// Actions are managed listeners / results that are easily refreshed on DOM changes +// if no event is specified, then 'click' is assumed +export type Action = + | { selector: string; event?: string | string[]; listener: ActionListener } + | { selector: string; event?: string | string[]; result: string }; export const ready = site.load.then(async () => { window.addEventListener('resize', onResize); @@ -93,9 +136,37 @@ export function snabDialog(o: SnabDialogOpts): VNode { ); } +export async function alert(msg: string): Promise { + await domDialog({ + htmlText: msg, + class: 'alert', + show: 'modal', + }); +} + +export async function confirm(msg: string): Promise { + return ( + ( + await domDialog({ + htmlText: `
    ${msg}
    + `, + class: 'alert', + noCloseButton: true, + noClickAway: true, + show: 'modal', + actions: [ + { selector: '.yes', result: 'yes' }, + { selector: '.no', result: 'no' }, + ], + }) + ).returnValue === 'yes' + ); +} + class DialogWrapper implements Dialog { - private restore?: { focus: HTMLElement; overflow: string }; + private restore?: { focus?: HTMLElement; overflow: string }; private resolve?: (dialog: Dialog) => void; + private eventCleanup: { el: Element; type: string; listener: EventListener }[] = []; private observer: MutationObserver = new MutationObserver(list => { for (const m of list) if (m.type === 'childList') @@ -126,16 +197,13 @@ class DialogWrapper implements Dialog { dialog.querySelector('.close-button-anchor > .close-button')?.addEventListener('click', cancelOnInterval); if (!o.noClickAway) setTimeout(() => dialog.addEventListener('click', cancelOnInterval)); - for (const node of o.append ?? []) { - (node.selector ? view.querySelector(node.selector) : view)!.appendChild(node.node); + for (const app of o.append ?? []) { + const where = (app.where ? view.querySelector(app.where) : view)!; + if (app.how === 'before') where.before(app.node); + else if (app.how === 'after') where.after(app.node); + else where.appendChild(app.node); } - if (o.action) - for (const a of Array.isArray(o.action) ? o.action : [o.action]) { - view.querySelector(a.selector)?.addEventListener('click', () => { - if (!a.action || typeof a.action === 'string') this.close(a.action); - else a.action(this, a); - }); - } + this.actions(); } get open() { @@ -150,7 +218,32 @@ class DialogWrapper implements Dialog { this.dialog.returnValue = v; } + // attach/reattach existing listeners or provide a set of new ones + actions = (actions = this.o.actions) => { + for (const { el, type, listener } of this.eventCleanup) { + el.removeEventListener(type, listener); + } + this.eventCleanup = []; + if (!actions) return; + for (const a of Array.isArray(actions) ? actions : [actions]) { + for (const event of Array.isArray(a.event) ? a.event : a.event ? [a.event] : ['click']) { + for (const el of this.view.querySelectorAll(a.selector)) { + const listener = (e: Event) => { + if ('listener' in a) a.listener(this, a, e); + else this.close(a.result); + }; + this.eventCleanup.push({ el, type: event, listener }); + el.addEventListener(event, listener); + } + } + } + }; + show = (): Promise => { + this.restore = { + overflow: document.body.style.overflow, + }; + document.body.style.overflow = 'hidden'; this.returnValue = ''; this.dialog.show(); return new Promise(resolve => (this.resolve = resolve)); @@ -179,18 +272,20 @@ class DialogWrapper implements Dialog { private onRemove = () => { this.observer.disconnect(); if (!this.dialog.returnValue) this.dialog.returnValue = 'cancel'; - this.restore?.focus.focus(); // one modal at a time please + this.restore?.focus?.focus(); // one modal at a time please if (this.restore?.overflow !== undefined) document.body.style.overflow = this.restore.overflow; this.restore = undefined; this.resolve?.(this); this.o.onClose?.(this); this.dialog.remove(); + for (const css of this.o.css ?? []) + 'hashed' in css && site.asset.removeCssPath(css.hashed), 'url' in css && site.asset.removeCss(css.url); }; } function assets(o: DialogOpts) { const cssPromises = (o.css ?? []).map(css => { - if ('themed' in css) return site.asset.loadCssPath(css.themed); + if ('hashed' in css) return site.asset.loadCssPath(css.hashed); else if ('url' in css) return site.asset.loadCss(css.url); else return Promise.resolve(); }); diff --git a/ui/common/src/linkPopup.ts b/ui/common/src/linkPopup.ts index 49dd3eb287520..e9e74df2b53d2 100644 --- a/ui/common/src/linkPopup.ts +++ b/ui/common/src/linkPopup.ts @@ -1,3 +1,5 @@ +import { domDialog } from './dialog'; + export const makeLinkPopups = (dom: HTMLElement | Cash, trans: Trans, selector = 'a[href^="http"]') => { const $el = $(dom); if (!$el.hasClass('link-popup-ready')) @@ -10,11 +12,10 @@ export const onClick = (a: HTMLLinkElement, trans: Trans): boolean => { const url = new URL(a.href); if (isPassList(url)) return true; - site.dialog - .dom({ - class: 'link-popup', - css: [{ themed: 'bits.linkPopup' }], - htmlText: ` + domDialog({ + class: 'link-popup', + css: [{ hashed: 'bits.linkPopup' }], + htmlText: `