From c1b6d4b53d28a12e28bcb568b9b67a2fc56177e0 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sat, 3 Oct 2026 20:59:47 +0800 Subject: [PATCH 001/137] fix: reuse navbar styler and clean up resize listeners --- XMOJ.user.js | 43 +++++++++++++------ package.json | 2 +- tests/navbar-styler.test.cjs | 83 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 tests/navbar-styler.test.cjs diff --git a/XMOJ.user.js b/XMOJ.user.js index bddb25ba..9d3aa023 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2736,7 +2736,7 @@ async function InitializeContestWebApp() { if (!revealed && root.querySelector(".navbar")) { // As on the legacy pages: top bar first, then show the page. revealed = true; - if (UtilityEnabled("NewTopBar")) new NavbarStyler(); + UpdateNavbarStyler(); RevealPage(); } if (!route) return; @@ -2763,7 +2763,7 @@ async function InitializeContestWebApp() { window.addEventListener("popstate", ScheduleEnhance); setInterval(() => { UpdateCountdowns(); - if (UtilityEnabled("NewTopBar")) new NavbarStyler(); + UpdateNavbarStyler(); }, 1000); window.addEventListener("focus", () => { if (!UtilityEnabled("AutoRefresh") || !["list", "contest", "rank"].includes(route?.page)) return; @@ -2789,13 +2789,30 @@ async function InitializeContestWebApp() { } catch (error) { console.error("[XMOJ-Script] Navigation API:", error); } } +let navbarStyler = null; + +function UpdateNavbarStyler() { + const navbar = document.querySelector('.navbar.navbar-expand-lg.bg-body-tertiary'); + // Both UI refresh timers share one listener. Release the old navbar when Vue + // replaces it, or when the top bar is disabled or removed. + if (navbarStyler && (navbarStyler.navbar !== navbar || !UtilityEnabled("NewTopBar"))) { + navbarStyler.destroy(); + navbarStyler = null; + } + if (!navbar || !UtilityEnabled("NewTopBar")) return; + if (!navbarStyler) navbarStyler = new NavbarStyler(navbar); + else navbarStyler.init(); +} + class NavbarStyler { - constructor() { + constructor(navbar) { try { - this.navbar = document.querySelector('.navbar.navbar-expand-lg.bg-body-tertiary'); - if (this.navbar && UtilityEnabled("NewTopBar")) { - this.init(); - } + this.navbar = navbar; + this.resizeHandler = () => { + if (this.navbar?.isConnected) this.updateBlurOverlay(); + }; + window.addEventListener('resize', this.resizeHandler); + this.init(); } catch (e) { console.error(e); if (UtilityEnabled("DebugMode")) { @@ -2804,12 +2821,16 @@ class NavbarStyler { } } + destroy() { + window.removeEventListener('resize', this.resizeHandler); + this.navbar = null; + } + init() { try { this.applyStyles(); this.createOverlay(); this.createSpacer(); - window.addEventListener('resize', () => this.updateBlurOverlay()); this.updateBlurOverlay(); } catch (e) { console.error(e); @@ -3153,7 +3174,7 @@ async function main() { if (document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > a") != null) document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > a").removeAttribute("data-toggle"); // The navbar is in its final state: apply the top bar now rather than on // the first interval tick, then show the page. - if (UtilityEnabled("NewTopBar")) new NavbarStyler(); + UpdateNavbarStyler(); RevealPage(); } if (UtilityEnabled("RemoveUseless") && document.getElementsByTagName("marquee")[0] != undefined) { @@ -3219,9 +3240,7 @@ async function main() { document.getElementById("nowdate").innerHTML = Year + "-" + (Month < 10 ? "0" : "") + Month + "-" + (_Date < 10 ? "0" : "") + _Date + " " + (Hours < 10 ? "0" : "") + Hours + ":" + (Minutes < 10 ? "0" : "") + Minutes + ":" + (Seconds < 10 ? "0" : "") + Seconds; } catch (Error) { } - if (UtilityEnabled("NewTopBar")) { - new NavbarStyler(); - } + UpdateNavbarStyler(); if (UtilityEnabled("ResetType")) { if (document.querySelector("#profile") != undefined && document.querySelector("#profile").innerHTML == "登录") { let PopupUL = document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > ul"); diff --git a/package.json b/package.json index bb93213b..92bde47f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { - "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs" + "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs" }, "devDependencies": { "playwright": "1.59.1" diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs new file mode 100644 index 00000000..bf78ef78 --- /dev/null +++ b/tests/navbar-styler.test.cjs @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); + +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); + +test('navbar refreshes keep one resize listener and release replaced navbars', {timeout: 60000}, async () => { + const start = 'let navbarStyler = null;'; + const end = '// Wrapped in an async IIFE'; + for (const marker of [start, end]) { + assert.equal(source.split(marker).length - 1, 1, 'expected one navbar extraction marker: ' + marker); + } + const navbarSource = source.slice(source.indexOf(start), source.indexOf(end)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + try { + await page.setContent(''); + await page.evaluate(() => { + window.topBarEnabled = true; + window.UtilityEnabled = name => name === 'NewTopBar' && window.topBarEnabled; + window.resizeListeners = new Set(); + const add = window.addEventListener; + const remove = window.removeEventListener; + window.addEventListener = function(type, listener, options) { + if (type === 'resize') window.resizeListeners.add(listener); + return add.call(this, type, listener, options); + }; + window.removeEventListener = function(type, listener, options) { + if (type === 'resize') window.resizeListeners.delete(listener); + return remove.call(this, type, listener, options); + }; + }); + await page.addScriptTag({content: navbarSource}); + const result = await page.evaluate(() => { + const counts = []; + // Simulate sustained polling without waiting for either UI's timer. + for (let i = 0; i < 2000; i++) UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const originalListener = [...window.resizeListeners][0]; + const firstNavbar = document.querySelector('nav'); + firstNavbar.outerHTML = ''; + window.dispatchEvent(new Event('resize')); + for (let i = 0; i < 2000; i++) UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const removedOldListener = !window.resizeListeners.has(originalListener); + const navbar = document.querySelector('nav'); + navbar.style.height = '96px'; + window.dispatchEvent(new Event('resize')); + const overlayHeightAfterResize = document.getElementById('blur-overlay').style.height; + UpdateNavbarStyler(); + const spacerHeight = document.getElementById('navbar-spacer').style.height; + const overlayCount = document.querySelectorAll('#blur-overlay').length; + const spacerCount = document.querySelectorAll('#navbar-spacer').length; + window.topBarEnabled = false; + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + window.topBarEnabled = true; + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + navbar.remove(); + window.dispatchEvent(new Event('resize')); + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const retainedNavbar = navbarStyler !== null; + return {counts, removedOldListener, overlayHeightAfterResize, spacerHeight, overlayCount, spacerCount, retainedNavbar}; + }); + assert.deepEqual(result.counts, [1, 1, 0, 1, 0]); + assert.equal(result.removedOldListener, true); + assert.equal(result.overlayHeightAfterResize, '96px'); + assert.equal(result.spacerHeight, '120px'); + assert.equal(result.overlayCount, 1); + assert.equal(result.spacerCount, 1); + assert.equal(result.retainedNavbar, false); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +}); From d1754141bb5722c9cfdd2e3c2d4e034870105d7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:00:44 +0000 Subject: [PATCH 002/137] 3.8.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92bde47f..cddb8886 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.0", + "version": "3.8.1", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { From 0e3cbb596156a73c808b6dcb9f90f3fbd01cf44a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:00:50 +0000 Subject: [PATCH 003/137] Update version info to 3.8.1 --- Update.json | 11 +++++++++++ XMOJ.user.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 66797755..9aa97fe0 100644 --- a/Update.json +++ b/Update.json @@ -3817,6 +3817,17 @@ } ], "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" + }, + "3.8.1": { + "UpdateDate": 1791032445214, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1036, + "Description": "fix: reuse navbar styler and clean up resize listeners" + } + ], + "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 9d3aa023..22a39fdd 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.0 +// @version 3.8.1 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen From 519b281676515414ce87350a6f982f288ae9751b Mon Sep 17 00:00:00 2001 From: boomzero Date: Sat, 3 Oct 2026 21:07:45 +0800 Subject: [PATCH 004/137] fix: restore navbar styling and remove owned artifacts on teardown --- XMOJ.user.js | 50 +++++++++++++++++++++++++------- tests/navbar-styler.test.cjs | 56 ++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 22a39fdd..0f5f09cb 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2808,6 +2808,13 @@ class NavbarStyler { constructor(navbar) { try { this.navbar = navbar; + this.addedClasses = ['fixed-top', 'container', 'ml-auto'].filter(name => !navbar.classList.contains(name)); + this.restoreNavbarStyles = this.preserveStyles(navbar, [ + 'position', 'border-top-left-radius', 'border-top-right-radius', + 'border-bottom-left-radius', 'border-bottom-right-radius', 'box-shadow', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'max-width', 'background-color', 'opacity', 'z-index' + ]); this.resizeHandler = () => { if (this.navbar?.isConnected) this.updateBlurOverlay(); }; @@ -2821,8 +2828,26 @@ class NavbarStyler { } } + preserveStyles(element, properties) { + const original = properties.map(property => [property, element.style.getPropertyValue(property), element.style.getPropertyPriority(property)]); + return () => { + for (const [property, value, priority] of original) { + if (value) element.style.setProperty(property, value, priority); + else element.style.removeProperty(property); + } + }; + } + destroy() { window.removeEventListener('resize', this.resizeHandler); + // Restore only properties/classes we changed, and remove only our own DOM. + this.navbar.classList.remove(...this.addedClasses); + this.restoreNavbarStyles(); + this.restoreOverlayStyles?.(); + this.restoreSpacerStyles?.(); + this.overlay?.remove(); + this.overlayStyle?.remove(); + this.spacer?.remove(); this.navbar = null; } @@ -2880,8 +2905,11 @@ class NavbarStyler { let overlay = document.createElement('div'); overlay.id = 'blur-overlay'; document.body.appendChild(overlay); + this.overlay = overlay; let style = document.createElement('style'); + this.overlayStyle?.remove(); + this.overlayStyle = style; style.textContent = UtilityEnabled("MonochromeUI") ? ` #blur-overlay { display: none !important; @@ -2897,6 +2925,11 @@ class NavbarStyler { `; document.head.appendChild(style); } + const overlay = document.getElementById('blur-overlay'); + if (overlay !== this.overlay && overlay !== this.borrowedOverlay) { + this.borrowedOverlay = overlay; + this.restoreOverlayStyles = this.preserveStyles(overlay, ['top', 'left', 'width', 'height']); + } } catch (e) { console.error(e); if (UtilityEnabled("DebugMode")) { @@ -2930,20 +2963,15 @@ class NavbarStyler { if (!spacer) { spacer = document.createElement('div'); spacer.id = 'navbar-spacer'; - spacer.style.height = `${newHeight}px`; spacer.style.width = '100%'; document.body.insertBefore(spacer, document.body.firstChild); - } else { - let currentHeight = parseInt(spacer.style.height, 10); - if (currentHeight !== newHeight) { - document.body.removeChild(spacer); - spacer = document.createElement('div'); - spacer.id = 'navbar-spacer'; - spacer.style.height = `${newHeight}px`; - spacer.style.width = '100%'; - document.body.insertBefore(spacer, document.body.firstChild); - } + this.spacer = spacer; + } else if (spacer !== this.spacer && spacer !== this.borrowedSpacer) { + this.borrowedSpacer = spacer; + this.restoreSpacerStyles = this.preserveStyles(spacer, ['height']); } + // Update in place so an existing page-owned spacer is never replaced. + if (spacer.style.height !== `${newHeight}px`) spacer.style.height = `${newHeight}px`; } catch (e) { console.error(e); if (UtilityEnabled("DebugMode")) { diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs index bf78ef78..e35ec5c6 100644 --- a/tests/navbar-styler.test.cjs +++ b/tests/navbar-styler.test.cjs @@ -43,13 +43,17 @@ test('navbar refreshes keep one resize listener and release replaced navbars', { counts.push(window.resizeListeners.size); const originalListener = [...window.resizeListeners][0]; const firstNavbar = document.querySelector('nav'); - firstNavbar.outerHTML = ''; + const overlayBeforeDetachedResize = document.getElementById('blur-overlay').style.cssText; + firstNavbar.outerHTML = ''; window.dispatchEvent(new Event('resize')); + const detachedResizePreservedOverlay = document.getElementById('blur-overlay').style.cssText === overlayBeforeDetachedResize; for (let i = 0; i < 2000; i++) UpdateNavbarStyler(); counts.push(window.resizeListeners.size); const removedOldListener = !window.resizeListeners.has(originalListener); const navbar = document.querySelector('nav'); navbar.style.height = '96px'; + navbar.style.color = 'red'; + navbar.classList.add('page-added'); window.dispatchEvent(new Event('resize')); const overlayHeightAfterResize = document.getElementById('blur-overlay').style.height; UpdateNavbarStyler(); @@ -59,6 +63,20 @@ test('navbar refreshes keep one resize listener and release replaced navbars', { window.topBarEnabled = false; UpdateNavbarStyler(); counts.push(window.resizeListeners.size); + const disabled = { + artifacts: document.querySelectorAll('#blur-overlay, #navbar-spacer').length, + styles: document.head.querySelectorAll('style').length, + fixedTop: navbar.classList.contains('fixed-top'), + container: navbar.classList.contains('container'), + pageClass: navbar.classList.contains('page-added'), + position: navbar.style.position, + positionPriority: navbar.style.getPropertyPriority('position'), + margin: navbar.style.margin, + borderRadius: navbar.style.borderRadius, + opacity: navbar.style.opacity, + color: navbar.style.color, + height: navbar.style.height + }; window.topBarEnabled = true; UpdateNavbarStyler(); counts.push(window.resizeListeners.size); @@ -67,15 +85,49 @@ test('navbar refreshes keep one resize listener and release replaced navbars', { UpdateNavbarStyler(); counts.push(window.resizeListeners.size); const retainedNavbar = navbarStyler !== null; - return {counts, removedOldListener, overlayHeightAfterResize, spacerHeight, overlayCount, spacerCount, retainedNavbar}; + const artifactsAfterRemoval = document.querySelectorAll('#blur-overlay, #navbar-spacer').length; + document.body.innerHTML = '
'; + const pageOverlay = document.getElementById('blur-overlay'); + const pageSpacer = document.getElementById('navbar-spacer'); + UpdateNavbarStyler(); + const pageSpacerReused = document.getElementById('navbar-spacer') === pageSpacer; + window.topBarEnabled = false; + UpdateNavbarStyler(); + const borrowed = { + overlayPreserved: document.getElementById('blur-overlay') === pageOverlay, + spacerPreserved: document.getElementById('navbar-spacer') === pageSpacer, + top: pageOverlay.style.top, + topPriority: pageOverlay.style.getPropertyPriority('top'), + left: pageOverlay.style.left, + width: pageOverlay.style.width, + height: pageOverlay.style.height, + spacerHeight: pageSpacer.style.height, + spacerPriority: pageSpacer.style.getPropertyPriority('height'), + spacerWidth: pageSpacer.style.width, + listeners: window.resizeListeners.size + }; + return {counts, removedOldListener, detachedResizePreservedOverlay, overlayHeightAfterResize, spacerHeight, overlayCount, spacerCount, retainedNavbar, disabled, artifactsAfterRemoval, pageSpacerReused, borrowed}; }); assert.deepEqual(result.counts, [1, 1, 0, 1, 0]); assert.equal(result.removedOldListener, true); + assert.equal(result.detachedResizePreservedOverlay, true); assert.equal(result.overlayHeightAfterResize, '96px'); assert.equal(result.spacerHeight, '120px'); assert.equal(result.overlayCount, 1); assert.equal(result.spacerCount, 1); assert.equal(result.retainedNavbar, false); + assert.equal(result.artifactsAfterRemoval, 0); + assert.deepEqual(result.disabled, { + artifacts: 0, styles: 0, fixedTop: false, container: true, pageClass: true, + position: 'relative', positionPriority: 'important', margin: '1px 2px 3px 4px', + borderRadius: '1px 2px 3px 4px', opacity: '0.9', color: 'red', height: '96px' + }); + assert.equal(result.pageSpacerReused, true); + assert.deepEqual(result.borrowed, { + overlayPreserved: true, spacerPreserved: true, + top: '2px', topPriority: 'important', left: '3px', width: '11px', height: '12px', + spacerHeight: '9px', spacerPriority: 'important', spacerWidth: '17px', listeners: 0 + }); assert.deepEqual(errors, []); } finally { await browser.close(); From 965f08a70d5258c6f69883108fe48fd8f6f871c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:08:13 +0000 Subject: [PATCH 005/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 9aa97fe0..3a6342e6 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791032445214, + "UpdateDate": 1791032888707, "Prerelease": true, "UpdateContents": [ { From 51dc11e8f7a494af4f455b2fa494388e91671fb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:08:51 +0000 Subject: [PATCH 006/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 3a6342e6..86428a99 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791032888707, + "UpdateDate": 1791032926617, "Prerelease": true, "UpdateContents": [ { From 56c4989b7b74504d78895a0d7f0fe4e895b9dcb3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:11:44 +0000 Subject: [PATCH 007/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 86428a99..fe55bef2 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791032926617, + "UpdateDate": 1791033098883, "Prerelease": true, "UpdateContents": [ { From f21d58ce6e8ef664d4e4dd8bc7f36ad994732833 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:16:40 +0000 Subject: [PATCH 008/137] Update time and description of 3.8.1 --- Update.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Update.json b/Update.json index fe55bef2..b759fb9a 100644 --- a/Update.json +++ b/Update.json @@ -3819,15 +3819,15 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791033098883, + "UpdateDate": 1791033395430, "Prerelease": true, "UpdateContents": [ { "PR": 1036, - "Description": "fix: reuse navbar styler and clean up resize listeners" + "Description": "fix: prevent navbar listener leaks and download overlap" } ], - "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。

" + "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。

" } } } \ No newline at end of file From ac477fe8dcc656192670bcf089c577554865647d Mon Sep 17 00:00:00 2001 From: boomzero Date: Sat, 3 Oct 2026 21:16:13 +0800 Subject: [PATCH 009/137] fix: keep downloads out of the navbar spacer --- XMOJ.user.js | 6 +++- tests/navbar-styler.test.cjs | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 0f5f09cb..982f4374 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2961,8 +2961,12 @@ class NavbarStyler { let spacer = document.getElementById('navbar-spacer'); let newHeight = this.navbar.offsetHeight + 24; if (!spacer) { - spacer = document.createElement('div'); + // Legacy page handlers use body > div for the content container. + // A block span reserves space without becoming their first match. + spacer = document.createElement('span'); spacer.id = 'navbar-spacer'; + spacer.setAttribute('aria-hidden', 'true'); + spacer.style.display = 'block'; spacer.style.width = '100%'; document.body.insertBefore(spacer, document.body.firstChild); this.spacer = spacer; diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs index e35ec5c6..371f2a48 100644 --- a/tests/navbar-styler.test.cjs +++ b/tests/navbar-styler.test.cjs @@ -133,3 +133,56 @@ test('navbar refreshes keep one resize listener and release replaced navbars', { await browser.close(); } }); + +test('downloads stay in the page container below the fixed navbar', {timeout: 60000}, async () => { + const navbarStart = 'let navbarStyler = null;'; + const navbarEnd = '// Wrapped in an async IIFE'; + const downloadsStart = '} else if (location.pathname == "/downloads.php") {'; + const downloadsEnd = '} else if (location.pathname == "/problemstatus.php") {'; + for (const marker of [navbarStart, navbarEnd, downloadsStart, downloadsEnd]) { + assert.equal(source.split(marker).length - 1, 1, 'expected one extraction marker: ' + marker); + } + const navbarSource = source.slice(source.indexOf(navbarStart), source.indexOf(navbarEnd)); + const downloadsSource = source.slice(source.indexOf(downloadsStart) + downloadsStart.length, source.indexOf(downloadsEnd)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + try { + // Run the real downloads handler without external image requests or login. + await page.route('**/*', route => route.abort()); + await page.setContent(`
  • Original download
`); + await page.evaluate(() => { window.UtilityEnabled = name => ['NewTopBar', 'NewDownload', 'MonochromeUI'].includes(name); }); + await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler();\n' + downloadsSource}); + const before = await page.evaluate(() => { + const list = document.querySelector('.software_list'); + return { + inPageContainer: document.getElementById('page-content').contains(list), + inSpacer: document.getElementById('navbar-spacer').contains(list), + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + firstCardTop: list.firstElementChild.getBoundingClientRect().top, + cards: list.children.length + }; + }); + assert.equal(before.inPageContainer, true, 'downloads must not be appended to the spacer'); + assert.equal(before.inSpacer, false); + assert.equal(before.cards, 15); + assert.ok(before.firstCardTop >= before.navbarBottom, 'the fixed navbar must not cover the first download row'); + const after = await page.evaluate(() => { + document.querySelector('nav').style.height = '96px'; + UpdateNavbarStyler(); + return { + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + firstCardTop: document.querySelector('.software_item').getBoundingClientRect().top + }; + }); + assert.ok(after.firstCardTop >= after.navbarBottom, 'the spacing must follow navbar height changes'); + } finally { + await browser.close(); + } +}); From 46542379dc159cd03a752816e1228f39e9722ba9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:17:15 +0000 Subject: [PATCH 010/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index b759fb9a..d0805cb3 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791033395430, + "UpdateDate": 1791033430780, "Prerelease": true, "UpdateContents": [ { From 3fcc576687d3dce0079713acf9d9890311dcd6a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:17:57 +0000 Subject: [PATCH 011/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index d0805cb3..d0f2916e 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791033430780, + "UpdateDate": 1791033472195, "Prerelease": true, "UpdateContents": [ { From a64b24af0af6dce174b9b8fb172d668c949c437a Mon Sep 17 00:00:00 2001 From: boomzero Date: Sat, 3 Oct 2026 21:23:33 +0800 Subject: [PATCH 012/137] fix: load the same monochrome fonts on web and classic pages --- XMOJ.user.js | 20 ++++++++++++------ tests/contest-web.test.cjs | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 982f4374..745489f9 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -572,6 +572,17 @@ const ThemeCanvasCSS = ` html[data-bs-theme='light'] { background: var(--mono-white, var(--bs-body-bg, #fff)); color-scheme: light; } `; +// Both UI initializers use this link; loading only in legacy main() leaves /web +// on the fallback fonts even though it applies the same monochrome font stack. +function LoadMonochromeFonts() { + if (document.getElementById("xmoj-monochrome-fonts")) return; + const link = document.createElement("link"); + link.id = "xmoj-monochrome-fonts"; + link.rel = "stylesheet"; + link.href = "https://fonts.loli.net/css2?family=Playfair+Display:wght@400;700&family=Source+Serif+4:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap"; + (document.head || document.documentElement).appendChild(link); +} + // Set to true by the early block if Bootstrap CSS was injected from the @resource // cache. Checked in the IIFE to decide whether a CDN fallback is needed. let _earlyBootstrapInjected = false; @@ -616,6 +627,7 @@ let _earlyObs = null; document.documentElement.setAttribute("data-bs-theme", dark ? "dark" : "light"); let head = document.head || document.documentElement; + if (get("MonochromeUI")) LoadMonochromeFonts(); let bootstrapCSS = GM_getResourceText("BootstrapCSS"); if (!bootstrapCSS) { @@ -2325,6 +2337,7 @@ async function InitializeContestWebApp() { bootstrap.href = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"; document.head.appendChild(bootstrap); const mono = UtilityEnabled("MonochromeUI"); + if (mono) LoadMonochromeFonts(); const skin = document.createElement("style"); skin.textContent = mono ? MonochromeSkinCSS : NewBootstrapSkinCSS; if (UtilityEnabled("AddAnimation")) skin.textContent += `.status, .test-case { transition: ${mono ? "100ms ease" : "0.5s"} !important; }`; @@ -3130,12 +3143,7 @@ async function main() { Temp[i].remove(); } } - if (UtilityEnabled("MonochromeUI")) { - let fontLink = document.createElement("link"); - fontLink.rel = "stylesheet"; - fontLink.href = "https://fonts.loli.net/css2?family=Playfair+Display:wght@400;700&family=Source+Serif+4:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap"; - document.head.appendChild(fontLink); - } + if (UtilityEnabled("MonochromeUI")) LoadMonochromeFonts(); var resources = [{ type: 'link', href: 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/codemirror.min.css', diff --git a/tests/contest-web.test.cjs b/tests/contest-web.test.cjs index b59f044c..9528d2bb 100644 --- a/tests/contest-web.test.cjs +++ b/tests/contest-web.test.cjs @@ -176,3 +176,46 @@ test('the load-time hide keeps innerText readable for the page handlers', () => assert.match(hide, /body \{ opacity: 0 !important; \}/); assert.doesNotMatch(hide, /visibility/); }); + +test('web and classic pages load the same monochrome fonts before either Bootstrap path', () => { + const early = between('// Set to true by the early block', 'const CaptchaSiteKey'); + const fontURLs = new Set(); + for (const pathname of ['/web/contest', '/problem.php?id=1000']) { + for (const cachedBootstrap of [false, true]) { + for (const headReady of [false, true]) { + const errors = []; + const {scope, styles} = context({ + location: new URL('https://www.xmoj.tech' + pathname), + GM_getResourceText: () => cachedBootstrap ? 'body { color: black; }' : '', + MutationObserver: class { observe() {} }, + console: {error: (...args) => errors.push(args)} + }); + if (headReady) scope.document.head = {appendChild: node => styles.push(node)}; + scope.document.querySelectorAll = () => []; + vm.runInContext(early, scope); + const fonts = styles.filter(node => node.id === 'xmoj-monochrome-fonts'); + assert.equal(fonts.length, 1, pathname + ': font loading must not depend on the Bootstrap cache or head readiness'); + assert.equal(fonts[0].rel, 'stylesheet'); + fontURLs.add(fonts[0].href); + scope.LoadMonochromeFonts(); + scope.LoadMonochromeFonts(); + assert.equal(styles.filter(node => node.id === 'xmoj-monochrome-fonts').length, 1, 'late initializers must reuse the early font link'); + assert.deepEqual(errors, []); + } + } + } + assert.equal(fontURLs.size, 1); + const url = [...fontURLs][0]; + assert.match(url, /Source\+Serif\+4/); + assert.match(url, /Playfair\+Display/); + assert.match(url, /JetBrains\+Mono/); + for (const disabled of ['NewBootstrap', 'MonochromeUI']) { + const {scope, storage, styles} = context({ + GM_getResourceText: () => '', + location: new URL('https://www.xmoj.tech/web/contest') + }); + storage.set('UserScript-Setting-' + disabled, 'false'); + vm.runInContext(early, scope); + assert.equal(styles.filter(node => node.id === 'xmoj-monochrome-fonts').length, 0, 'do not load fonts for a disabled skin'); + } +}); From 83813255a52329c9b9e7e777f19a4d5d72d13859 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:24:00 +0000 Subject: [PATCH 013/137] Update time and description of 3.8.1 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index d0f2916e..7b0dc058 100644 --- a/Update.json +++ b/Update.json @@ -3819,7 +3819,7 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791033472195, + "UpdateDate": 1791033835614, "Prerelease": true, "UpdateContents": [ { From 875813345fd1c5f21a039e132af4824a6a173ba6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 13:24:27 +0000 Subject: [PATCH 014/137] Update time and description of 3.8.1 --- Update.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Update.json b/Update.json index 7b0dc058..a456d0f0 100644 --- a/Update.json +++ b/Update.json @@ -3819,15 +3819,15 @@ "Notes": "

本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。

\n

题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。

" }, "3.8.1": { - "UpdateDate": 1791033835614, + "UpdateDate": 1791033862020, "Prerelease": true, "UpdateContents": [ { "PR": 1036, - "Description": "fix: prevent navbar listener leaks and download overlap" + "Description": "fix: navbar listener leaks, download overlap, and web fonts" } ], - "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。

" + "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" } } } \ No newline at end of file From 1aeb9072d470e6d33f3de41bc455eaff7cfd8803 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sat, 3 Oct 2026 23:27:06 +0800 Subject: [PATCH 015/137] fix: use consistent Chinese navbar fallback fonts --- XMOJ.user.js | 6 +- tests/fixtures/fonts/OFL.txt | 93 ++++++++++++++++++ tests/fixtures/fonts/README.md | 10 ++ .../fixtures/fonts/source-serif-4-latin.woff2 | Bin 0 -> 20112 bytes tests/navbar-styler.test.cjs | 37 +++++++ 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/fonts/OFL.txt create mode 100644 tests/fixtures/fonts/README.md create mode 100644 tests/fixtures/fonts/source-serif-4-latin.woff2 diff --git a/XMOJ.user.js b/XMOJ.user.js index 745489f9..3a5d84df 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -143,6 +143,9 @@ const MonochromeSkinCSS = ` --mono-border-thin: 1px solid var(--mono-gray-300); --mono-font-heading: 'Playfair Display', Georgia, serif; --mono-font-body: 'Source Serif 4', 'Source Serif Pro', Georgia, serif; + /* Source Serif has no Chinese glyphs. An explicit navbar fallback + avoids different fonts under legacy lang=en and /web lang=zh-CN. */ + --mono-font-navbar: 'Source Serif 4', 'Source Serif Pro', Georgia, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', serif; --mono-font-mono: 'JetBrains Mono', 'Consolas', monospace; --mono-transition: 100ms ease; } @@ -197,6 +200,7 @@ const MonochromeSkinCSS = ` /* Navbar */ .navbar, nav.navbar { + font-family: var(--mono-font-navbar) !important; border-bottom: 4px solid var(--mono-black) !important; background-color: var(--mono-white) !important; opacity: 1 !important; @@ -204,7 +208,7 @@ const MonochromeSkinCSS = ` .navbar .nav-link { color: var(--mono-black) !important; text-decoration: none !important; - font-family: var(--mono-font-body) !important; + font-family: var(--mono-font-navbar) !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; font-size: 0.85rem !important; diff --git a/tests/fixtures/fonts/OFL.txt b/tests/fixtures/fonts/OFL.txt new file mode 100644 index 00000000..318074ec --- /dev/null +++ b/tests/fixtures/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/fixtures/fonts/README.md b/tests/fixtures/fonts/README.md new file mode 100644 index 00000000..b4de304b --- /dev/null +++ b/tests/fixtures/fonts/README.md @@ -0,0 +1,10 @@ +This is the unmodified Latin subset of Source Serif 4 (regular, v15) served by +Google Fonts, the same font used by the monochrome theme. It is distributed under +the SIL Open Font License in OFL.txt. + +Source: https://fonts.gstatic.com/s/sourceserif4/v15/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqnhtdDs4DrjTMXw.woff2 +License: https://github.com/google/fonts/blob/main/ofl/sourceserif4/OFL.txt + +The navbar rendering regression embeds this 20 KB font offline. A local font or +an unloaded font does not reproduce Chromium's language-dependent Chinese +fallback for this webfont; checking computed font-family alone also misses it. diff --git a/tests/fixtures/fonts/source-serif-4-latin.woff2 b/tests/fixtures/fonts/source-serif-4-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..12f5b101b9d3f0b18ecdc0e26b61c8d4875fbffb GIT binary patch literal 20112 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 { + const skin = source.match(/const MonochromeSkinCSS = `([\s\S]*?)`;/)[1]; + const font = fs.readFileSync(path.join(__dirname, "fixtures/fonts/source-serif-4-latin.woff2")).toString("base64"); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + const rendered = []; + for (const language of ['en', 'zh-CN']) { + const page = await browser.newPage(); + // The public legacy page declares en, while /web declares zh-CN. Source + // Serif 4 has no Chinese glyphs: compare the fonts Chromium actually uses, + // not just getComputedStyle().fontFamily, which matched before the fix. + const html = `

`; + await page.route("https://navbar-font.test/**", route => route.fulfill({contentType: "text/html; charset=utf-8", body: html})); + await page.goto("https://navbar-font.test/" + language); + await page.evaluate(() => document.fonts.ready); + const session = await page.context().newCDPSession(page); + await session.send('DOM.enable'); + await session.send('CSS.enable'); + const {root} = await session.send('DOM.getDocument'); + const fonts = {}; + for (const selector of ['.navbar-brand', '.navbar-nav .nav-link', '.dropdown-toggle span', '.dropdown-item']) { + const {nodeId} = await session.send('DOM.querySelector', {nodeId: root.nodeId, selector}); + const result = await session.send('CSS.getPlatformFontsForNode', {nodeId}); + fonts[selector] = result.fonts.map(font => ({name: font.familyName, glyphs: font.glyphCount})).sort((a, b) => a.name.localeCompare(b.name)); + assert.ok(fonts[selector].length, 'must inspect rendered glyphs: ' + selector); + } + assert.ok(fonts[".navbar-brand"].some(font => font.name === "Source Serif 4"), "the Latin webfont must be loaded to exercise Chinese fallback"); + rendered.push(fonts); + await session.detach(); + await page.close(); + } + assert.deepEqual(rendered[1], rendered[0], 'document language must not change the navbar Chinese fallback font'); + } finally { + await browser.close(); + } +}); From 66d0fbb6a29171b4a275b8ddeb270f2522e425dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 15:27:46 +0000 Subject: [PATCH 016/137] 3.8.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cddb8886..834ca508 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.1", + "version": "3.8.2", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { From ac3405d2980d69457f0c1c312b42eac1fe3fd5e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 15:27:53 +0000 Subject: [PATCH 017/137] Update version info to 3.8.2 --- Update.json | 11 +++++++++++ XMOJ.user.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Update.json b/Update.json index a456d0f0..3de67639 100644 --- a/Update.json +++ b/Update.json @@ -3828,6 +3828,17 @@ } ], "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" + }, + "3.8.2": { + "UpdateDate": 1791041267195, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1039, + "Description": "fix: match Chinese navbar fonts between web and classic UI" + } + ], + "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 3a5d84df..eaf0b0b1 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.1 +// @version 3.8.2 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen From 67dccb0e83e66cfbe5d2044d67e9ed19211295a4 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 07:46:09 +0800 Subject: [PATCH 018/137] fix: support migrated account settings page --- XMOJ.user.js | 181 +++++++++++++++++++++----------- package.json | 2 +- tests/account-settings.test.cjs | 151 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 64 deletions(-) create mode 100644 tests/account-settings.test.cjs diff --git a/XMOJ.user.js b/XMOJ.user.js index 745489f9..6df2995f 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2144,15 +2144,68 @@ function CreateProblemSwitcher(ProblemList, IsCurrent) { return problemSwitcher; } +function IsAccountSettingsPage(pathname) { + return pathname == "/modifypage.php" || pathname == "/modify_user_info.php"; +} + +// Keep the migrated site's form, including its submit handler and CSRF fields. +// Script badges are saved separately so they cannot interfere with account edits. +function InitializeAccountBadgeEditor(container) { + RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { + if (!response.Success) return; + let editor = document.createElement("div"); + editor.className = "border p-2 my-3"; + editor.innerHTML = `
标签
+
+
+
+
+
+
+ +
`; + let content = editor.querySelector("#UserScriptBadgeContent"); + let background = editor.querySelector("#UserScriptBadgeBackground"); + let color = editor.querySelector("#UserScriptBadgeColor"); + let button = editor.querySelector("button"); + let status = editor.querySelector("[role='status']"); + content.value = response.Data.Content; + background.value = response.Data.BackgroundColor; + color.value = response.Data.Color; + button.addEventListener("click", () => { + button.disabled = true; + status.innerText = ""; + RequestAPI("EditBadge", { + "UserID": String(CurrentUsername), + "Content": content.value, + "BackgroundColor": background.value, + "Color": color.value + }, (result) => { + button.disabled = false; + status.innerText = result.Success ? "修改成功" : result.Message; + if (result.Success) { + let keys = []; + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i); + if (key.startsWith("UserScript-User-" + CurrentUsername + "-Badge-")) keys.push(key); + } + keys.forEach(key => localStorage.removeItem(key)); + } + }); + }); + container.appendChild(editor); + }); +} + // The ResetType user menu. Shared by the legacy navbar and the /web app so both // menus offer the same entries and animate the same way. function CreateUserMenuItems() { let Entries = [ - ["修改帐号", () => { location.href = "https://www.xmoj.tech/modifypage.php"; }], + ["修改帐号", () => { location.href = "https://www.xmoj.tech/modify_user_info.php"; }], ["个人中心", () => { location.href = "https://www.xmoj.tech/userinfo.php?user=" + CurrentUsername; }], ["短消息", () => { location.href = "https://www.xmoj.tech/mail.php"; }], ["插件设置", () => { location.href = "https://www.xmoj.tech/index.php?ByUserScript=1"; }], - ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modifypage.php?ByUserScript=1"; }], + ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }], ["注销", () => { clearCredential(); GM.cookie.set({ @@ -3496,7 +3549,7 @@ async function main() { Alert.classList.add("alert-primary"); Alert.role = "alert"; Alert.innerHTML = `欢迎您使用XMOJ增强脚本!点击 - 此处 + 此处 查看更新日志。`; Container.appendChild(Alert); let UtilitiesCard = document.createElement("div"); @@ -5082,9 +5135,9 @@ async function main() { PassCheck.click(); } }); - } else if (location.pathname == "/modifypage.php" && document.querySelector("body > div > div") == null) { - //页面结构异常(如403/404页面),跳过处理 - } else if (location.pathname == "/modifypage.php") { + } else if (IsAccountSettingsPage(location.pathname) && (document.querySelector("body > div > div") == null || (SearchParams.get("ByUserScript") == null && document.querySelector("body > div > div form") == null))) { + //页面结构异常或未登录,保留站点提示 + } else if (IsAccountSettingsPage(location.pathname)) { if (SearchParams.get("ByUserScript") != null) { document.title = "XMOJ-Script 更新日志"; if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = ""; @@ -5138,6 +5191,8 @@ async function main() { UpdateDataCardLink.innerText = "查看该版本"; } }); + } else if (location.pathname == "/modify_user_info.php") { + InitializeAccountBadgeEditor(document.querySelector("body > div > div")); } else { document.title = "修改账号"; let Nickname = document.getElementsByName("nick")[0].value; @@ -5303,67 +5358,67 @@ async function main() { if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; SuccessElement.style.display = "block"; }); - if (UtilityEnabled("ExportACCode")) { - let ExportACCode = document.createElement("button"); - if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode); - ExportACCode.innerText = "导出AC代码"; - ExportACCode.className = "btn btn-outline-secondary"; - ExportACCode.addEventListener("click", () => { - ExportACCode.disabled = true; - ExportACCode.innerText = "正在导出..."; - let Request = new XMLHttpRequest(); - Request.addEventListener("readystatechange", () => { - if (Request.readyState == 4) { - if (Request.status == 200) { - let Response = Request.responseText; - let ACCode = Response.split("------------------------------------------------------\r\n"); - let ScriptElement = document.createElement("script"); - ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; - document.head.appendChild(ScriptElement); - ScriptElement.onload = () => { - var Zip = new JSZip(); - for (let i = 0; i < ACCode.length; i++) { - let CurrentCode = ACCode[i]; - if (CurrentCode != "") { - let lineBreakPos = CurrentCode.search(/[\r\n]/); - if (lineBreakPos === -1) continue; - let headerLine = CurrentCode.slice(0, lineBreakPos); - let digitMatch = headerLine.match(/\d+/); - if (!digitMatch) continue; - let CurrentQuestionID = digitMatch[0]; - let bodyStart = lineBreakPos + 1; - if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') { - bodyStart = lineBreakPos + 2; - } - CurrentCode = CurrentCode.slice(bodyStart); - CurrentCode = CurrentCode.replaceAll("\r", ""); - Zip.file(CurrentQuestionID + ".cpp", CurrentCode); + } + if (SearchParams.get("ByUserScript") == null && UtilityEnabled("ExportACCode")) { + let ExportACCode = document.createElement("button"); + if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode); + ExportACCode.innerText = "导出AC代码"; + ExportACCode.className = "btn btn-outline-secondary"; + ExportACCode.addEventListener("click", () => { + ExportACCode.disabled = true; + ExportACCode.innerText = "正在导出..."; + let Request = new XMLHttpRequest(); + Request.addEventListener("readystatechange", () => { + if (Request.readyState == 4) { + if (Request.status == 200) { + let Response = Request.responseText; + let ACCode = Response.split("------------------------------------------------------\r\n"); + let ScriptElement = document.createElement("script"); + ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; + document.head.appendChild(ScriptElement); + ScriptElement.onload = () => { + var Zip = new JSZip(); + for (let i = 0; i < ACCode.length; i++) { + let CurrentCode = ACCode[i]; + if (CurrentCode != "") { + let lineBreakPos = CurrentCode.search(/[\r\n]/); + if (lineBreakPos === -1) continue; + let headerLine = CurrentCode.slice(0, lineBreakPos); + let digitMatch = headerLine.match(/\d+/); + if (!digitMatch) continue; + let CurrentQuestionID = digitMatch[0]; + let bodyStart = lineBreakPos + 1; + if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') { + bodyStart = lineBreakPos + 2; } + CurrentCode = CurrentCode.slice(bodyStart); + CurrentCode = CurrentCode.replaceAll("\r", ""); + Zip.file(CurrentQuestionID + ".cpp", CurrentCode); } - ExportACCode.innerText = "正在生成压缩包……"; - Zip.generateAsync({type: "blob"}) - .then(function (Content) { - saveAs(Content, "ACCodes.zip"); - ExportACCode.innerText = "AC代码导出成功"; - ExportACCode.disabled = false; - setTimeout(() => { - ExportACCode.innerText = "导出AC代码"; - }, 1000); - }); - }; - } else { - ExportACCode.disabled = false; - ExportACCode.innerText = "AC代码导出失败"; - setTimeout(() => { - ExportACCode.innerText = "导出AC代码"; - }, 1000); - } + } + ExportACCode.innerText = "正在生成压缩包……"; + Zip.generateAsync({type: "blob"}) + .then(function (Content) { + saveAs(Content, "ACCodes.zip"); + ExportACCode.innerText = "AC代码导出成功"; + ExportACCode.disabled = false; + setTimeout(() => { + ExportACCode.innerText = "导出AC代码"; + }, 1000); + }); + }; + } else { + ExportACCode.disabled = false; + ExportACCode.innerText = "AC代码导出失败"; + setTimeout(() => { + ExportACCode.innerText = "导出AC代码"; + }, 1000); } - }); - Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true); - Request.send(); + } }); - } + Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true); + Request.send(); + }); } } else if (location.pathname == "/userinfo.php" && document.querySelector("body > div > div") == null) { //页面结构异常(如403/404页面),跳过处理 diff --git a/package.json b/package.json index cddb8886..e32f85ce 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { - "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs" + "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs" }, "devDependencies": { "playwright": "1.59.1" diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs new file mode 100644 index 00000000..dece96bb --- /dev/null +++ b/tests/account-settings.test.cjs @@ -0,0 +1,151 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); + +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) { + assert.equal(source.split(marker).length - 1, 1, 'expected a unique extraction marker: ' + marker); + } + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const helpers = Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); +const handler = 'window.RunAccountPage = async () => { if (false) {' + + Between('} else if (IsAccountSettingsPage(location.pathname) &&', '} else if (location.pathname == "/userinfo.php" &&') + '}};'; +const nativeForm = `
+ + +
`; + +test('account-page migration browser regressions', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + async function Page(route, content = nativeForm, options = {}) { + const page = await browser.newPage(); + await page.route('**/*', request => request.fulfill({contentType: 'text/html', body: + `
${content}
`})); + await page.goto('https://account-settings.test' + route); + await page.evaluate(options => { + window.CurrentUsername = 'Tester'; + window.SearchParams = new URL(location.href).searchParams; + window.UtilityEnabled = name => name === 'ExportACCode' && options.export; + window.ServerURL = 'https://updates.test'; + window.GetRelativeTime = () => 'today'; + window.escapeHTML = value => value; + window.GetUserInfo = async () => ({EmailHash: 'hash'}); + window.apiCalls = []; + window.RequestAPI = (action, data, callback) => { + apiCalls.push({action, data}); + callback(action === 'GetBadge' ? { + Success: !options.noBadge, Data: {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'} + } : {Success: !options.badgeFailure, Message: 'Save failed'}); + }; + window.requests = []; + window.fetch = async url => { + requests.push(url); + return {json: async () => ({UpdateHistory: {'1.0.0': { + UpdateDate: '2026-01-01', UpdateContents: [{PR: 920, Description: 'Account migration'}] + }}})}; + }; + window.nativeAccountForm = document.querySelector('form'); + nativeAccountForm?.addEventListener('submit', event => { + event.preventDefault(); + window.nativeSubmitCount = (window.nativeSubmitCount || 0) + 1; + }); + }, options); + await page.addScriptTag({content: helpers + handler}); + await page.evaluate(() => RunAccountPage()); + return page; + } + try { + await t.test('preserves migrated fields, CSRF, action and native submit listener', async () => { + const page = await Page('/modify_user_info.php', nativeForm, {export: true}); + assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true); + assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php'); + assert.equal(await page.locator('[name="csrf"]').inputValue(), 'token'); + assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + assert.equal(await page.locator('input[type="password"]').count(), 0); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + assert.equal(await page.getByRole('button', {name: '导出AC代码', exact: true}).count(), 1); + assert.deepEqual(await page.evaluate(() => requests), []); + await page.close(); + }); + for (const failure of [false, true]) { + await t.test('badge save ' + (failure ? 'reports failure and retains cache' : 'clears only this user’s badge cache'), async () => { + const page = await Page('/modify_user_info.php', nativeForm, {badgeFailure: failure}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale'); + localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep'); + }); + await page.locator('#UserScriptBadgeContent').fill(''); + await page.getByRole('button', {name: '修改标签', exact: true}).click(); + assert.deepEqual(await page.evaluate(() => apiCalls[1]), {action: 'EditBadge', data: { + UserID: 'Tester', Content: '', BackgroundColor: '#112233', Color: '#ffffff' + }}); + assert.equal(await page.getByRole('status').innerText(), failure ? 'Save failed' : '修改成功'); + assert.equal(await page.getByRole('button', {name: '修改标签', exact: true}).isEnabled(), true); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), failure ? 'stale' : null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep'); + assert.equal(await page.evaluate(() => window.nativeSubmitCount || 0), 0); + await page.close(); + }); + } + await t.test('badge access denial leaves the native form usable', async () => { + const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); + assert.equal(await page.locator('#UserScriptBadgeContent').count(), 0); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + await page.close(); + }); + for (const route of ['/modify_user_info.php', '/modifypage.php']) { + await t.test('renders changelog on ' + route + ' even without an account form', async () => { + const page = await Page(route + '?ByUserScript=1', 'Please log in', {export: true}); + assert.equal(await page.title(), 'XMOJ-Script 更新日志'); + assert.equal(await page.locator('.card-title').innerText(), '1.0.0'); + assert.match(await page.locator('.list-group-item').innerText(), /Account migration/); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + assert.equal(await page.locator('button').count(), 0); + await page.close(); + }); + await t.test('leaves login and error messages intact on ' + route, async () => { + const page = await Page(route, 'Please log in', {export: true}); + assert.equal(await page.locator('.mt-3').innerText(), 'Please log in'); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + await page.evaluate(async () => { + document.body.innerHTML = '

403

'; + await RunAccountPage(); + }); + assert.equal(await page.locator('h1').innerText(), '403'); + await page.close(); + }); + } + await t.test('retains the legacy account enhancement', async () => { + const legacy = nativeForm.replace('', ''); + const page = await Page('/modifypage.php', legacy, {noBadge: true}); + assert.equal(await page.locator('#Nickname').inputValue(), 'Nickname'); + assert.equal(await page.locator('#ModifyInfo').count(), 1); + await page.close(); + }); + await t.test('leaves the separate password form intact', async () => { + const page = await Page('/modify_password.php', '
', {export: true}); + assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + assert.equal(await page.locator('button').count(), 0); + await page.close(); + }); + for (const [label, route] of [['修改帐号', '/modify_user_info.php'], ['插件更新日志', '/modify_user_info.php?ByUserScript=1']]) { + await t.test('user menu sends ' + label + ' to the migrated route', async () => { + const page = await Page('/index.php'); + await page.evaluate(() => CreateUserMenuItems().forEach(item => document.body.appendChild(item))); + await Promise.all([page.waitForURL('https://www.xmoj.tech' + route), page.getByText(label, {exact: true}).click()]); + assert.equal(page.url(), 'https://www.xmoj.tech' + route); + await page.close(); + }); + } + } finally { + await browser.close(); + } +}); From c9399b82ea89c45e11b78e9bd2f1f8a782a5251e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 23:46:44 +0000 Subject: [PATCH 019/137] 3.8.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e32f85ce..f6421c2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.1", + "version": "3.8.2", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { From 8a8a5113678b5d3fc7d08e294e57e0461665e8d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 23:46:50 +0000 Subject: [PATCH 020/137] Update version info to 3.8.2 --- Update.json | 11 +++++++++++ XMOJ.user.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Update.json b/Update.json index a456d0f0..a0ee32ba 100644 --- a/Update.json +++ b/Update.json @@ -3828,6 +3828,17 @@ } ], "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" + }, + "3.8.2": { + "UpdateDate": 1791071205446, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1040, + "Description": "fix: support migrated account settings page" + } + ], + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单及标签编辑、AC 代码导出功能。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 6df2995f..b5e59c7d 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.1 +// @version 3.8.2 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen From bc914f43d6edd7d56deb0b831c6f1c394aef127f Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 08:30:34 +0800 Subject: [PATCH 021/137] fix: preserve native account listeners and recover badge saves --- XMOJ.user.js | 191 ++++++++++++++++++-------------- tests/account-settings.test.cjs | 102 +++++++++++++++-- 2 files changed, 200 insertions(+), 93 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index b5e59c7d..ba96dc39 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1691,7 +1691,13 @@ let clearCredential = async () => { } } }; -let RequestAPI = (Action, Data, CallBack) => { +let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { + let completed = false; + let Fail = (message) => { + if (completed) return; + completed = true; + if (ErrorCallBack) ErrorCallBack(message); + }; try { let Session = ""; let Temp = document.cookie.split(";"); @@ -1734,18 +1740,38 @@ let RequestAPI = (Action, Data, CallBack) => { "DebugMode": UtilityEnabled("DebugMode") }, data: DataString, + // Existing callers keep their timeout behavior; badge saves opt in. + timeout: ErrorCallBack ? 15000 : 0, + onerror: () => Fail("网络错误,请重试"), + ontimeout: () => Fail("请求超时,请重试"), + onabort: () => Fail("请求已取消,请重试"), onload: (Response) => { + if (completed) return; if (UtilityEnabled("DebugMode")) { console.log("Received for", Action + ":", Response.responseText); } + if (ErrorCallBack && (Response.status < 200 || Response.status >= 300)) { + Fail("请求失败(HTTP " + Response.status + "),请重试"); + return; + } + let result; try { - CallBack(JSON.parse(Response.responseText)); + result = JSON.parse(Response.responseText); } catch (Error) { console.log(Response.responseText); + Fail("服务器响应异常,请重试"); + return; + } + completed = true; + try { + if (CallBack) CallBack(result); + } catch (Error) { + console.error(Error); } } }); } catch (e) { + Fail("请求失败,请重试"); console.error(e); if (UtilityEnabled("DebugMode")) { SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode."); @@ -2148,53 +2174,73 @@ function IsAccountSettingsPage(pathname) { return pathname == "/modifypage.php" || pathname == "/modify_user_info.php"; } +function LoadAccountBadge(content, background, color, onLoad) { + RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { + if (!response?.Success) return; + let badge = response.Data ?? {}; + content.value = String(badge.Content ?? ""); + // Color inputs cannot represent an empty value. Use explicit defaults + // for missing/invalid colors rather than the browser's silent fallback. + background.value = /^#[0-9a-f]{6}$/i.test(badge.BackgroundColor) ? badge.BackgroundColor : "#000000"; + color.value = /^#[0-9a-f]{6}$/i.test(badge.Color) ? badge.Color : "#ffffff"; + onLoad(); + }); +} + +function SaveAccountBadge(content, background, color) { + let userID = String(CurrentUsername); + return new Promise(resolve => { + RequestAPI("EditBadge", { + "UserID": userID, + "Content": String(content), + "BackgroundColor": String(background), + "Color": String(color) + }, (response) => { + if (!response?.Success) { + resolve({Success: false, Message: response?.Message || "服务器响应异常,请重试"}); + return; + } + let keys = []; + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i); + if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key); + } + keys.forEach(key => localStorage.removeItem(key)); + resolve(response); + }, message => resolve({Success: false, Message: message})); + }); +} + // Keep the migrated site's form, including its submit handler and CSRF fields. // Script badges are saved separately so they cannot interfere with account edits. function InitializeAccountBadgeEditor(container) { - RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { - if (!response.Success) return; - let editor = document.createElement("div"); - editor.className = "border p-2 my-3"; - editor.innerHTML = `
标签
-
-
-
-
-
-
- -
`; - let content = editor.querySelector("#UserScriptBadgeContent"); - let background = editor.querySelector("#UserScriptBadgeBackground"); - let color = editor.querySelector("#UserScriptBadgeColor"); - let button = editor.querySelector("button"); - let status = editor.querySelector("[role='status']"); - content.value = response.Data.Content; - background.value = response.Data.BackgroundColor; - color.value = response.Data.Color; - button.addEventListener("click", () => { - button.disabled = true; - status.innerText = ""; - RequestAPI("EditBadge", { - "UserID": String(CurrentUsername), - "Content": content.value, - "BackgroundColor": background.value, - "Color": color.value - }, (result) => { - button.disabled = false; - status.innerText = result.Success ? "修改成功" : result.Message; - if (result.Success) { - let keys = []; - for (let i = 0; i < localStorage.length; i++) { - let key = localStorage.key(i); - if (key.startsWith("UserScript-User-" + CurrentUsername + "-Badge-")) keys.push(key); - } - keys.forEach(key => localStorage.removeItem(key)); - } - }); - }); - container.appendChild(editor); + let editor = document.createElement("div"); + editor.className = "border p-2 my-3"; + editor.innerHTML = `
标签
+
+
+
+
+
+
+ +
`; + let content = editor.querySelector("#UserScriptBadgeContent"); + let background = editor.querySelector("#UserScriptBadgeBackground"); + let color = editor.querySelector("#UserScriptBadgeColor"); + let button = editor.querySelector("button"); + let status = editor.querySelector("[role='status']"); + button.addEventListener("click", async () => { + button.disabled = true; + status.innerText = ""; + try { + let result = await SaveAccountBadge(content.value, background.value, color.value); + status.innerText = result.Success ? "修改成功" : result.Message; + } finally { + button.disabled = false; + } }); + LoadAccountBadge(content, background, color, () => container.appendChild(editor)); } // The ResetType user menu. Shared by the legacy navbar and the /web app so both @@ -3159,12 +3205,15 @@ async function main() { if (UtilityEnabled("Translate")) { if (document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a") != null) document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a").innerText = "题库"; } - //send analytics + // Preserve native account listeners during page-wide customization. RequestAPI("SendData", {}); - if (UtilityEnabled("ReplaceLinks")) { + // These native forms rely on site-installed listeners. Whole-body + // replacements discard them even when the account handler keeps the form. + let preserveAccountForm = location.pathname == "/modify_user_info.php" || location.pathname == "/modify_password.php"; + if (UtilityEnabled("ReplaceLinks") && !preserveAccountForm) { document.body.innerHTML = String(document.body.innerHTML).replaceAll(/\[([^<]*)<\/a>\]/g, ""); } - if (UtilityEnabled("ReplaceXM")) { + if (UtilityEnabled("ReplaceXM") && !preserveAccountForm) { document.body.innerHTML = String(document.body.innerHTML).replaceAll("我", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("小明", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("下海", "上海"); @@ -3177,6 +3226,7 @@ async function main() { document.title = String(document.title).replaceAll("小明", "高老师"); } + // Bootstrap stylesheet and markup migration. if (UtilityEnabled("NewBootstrap")) { // Remove any old Bootstrap/theme stylesheets that the browser's preload // scanner may have fetched and applied before the document-start @@ -5284,24 +5334,8 @@ async function main() { document.getElementById("AtcoderAccount").value = AtcoderAccount; document.getElementById("USACOAccount").value = USACOAccount; document.getElementById("LuoguAccount").value = LuoguAccount; - RequestAPI("GetBadge", { - "UserID": String(CurrentUsername) - }, (Response) => { - if (Response.Success) { - BadgeRow.style.display = ""; - BadgeContent.value = Response.Data.Content; - BadgeBackgroundColor.value = Response.Data.BackgroundColor; - BadgeColor.value = Response.Data.Color; - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + CurrentUsername + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - } + LoadAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor, () => { + BadgeRow.style.display = ""; }); ModifyInfo.addEventListener("click", async () => { ModifyInfo.disabled = true; @@ -5319,23 +5353,14 @@ async function main() { let BadgeContent = document.querySelector("#BadgeContent").value; let BadgeBackgroundColor = document.querySelector("#BadgeBackgroundColor").value; let BadgeColor = document.querySelector("#BadgeColor").value; - await new Promise((Resolve) => { - RequestAPI("EditBadge", { - "UserID": String(CurrentUsername), - "Content": String(BadgeContent), - "BackgroundColor": String(BadgeBackgroundColor), - "Color": String(BadgeColor) - }, (Response) => { - if (Response.Success) { - Resolve(); - } else { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = Response.Message; - } - }); - }); + let badgeResult = await SaveAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor); + if (!badgeResult.Success) { + ModifyInfo.disabled = false; + if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; + ErrorElement.style.display = "block"; + ErrorElement.innerText = badgeResult.Message; + return; + } let Nickname = document.querySelector("#Nickname").value; let OldPassword = document.querySelector("#OldPassword").value; let NewPassword = document.querySelector("#NewPassword").value; diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index dece96bb..58a1c5d7 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -13,12 +13,17 @@ function Between(start, end) { return source.slice(source.indexOf(start), source.indexOf(end)); } const helpers = Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); +const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); +const preprocessing = 'window.RunAccountPreprocessing = () => {' + Between( + '// Preserve native account listeners during page-wide customization.', + '// Bootstrap stylesheet and markup migration.') + '};'; const handler = 'window.RunAccountPage = async () => { if (false) {' + Between('} else if (IsAccountSettingsPage(location.pathname) &&', '} else if (location.pathname == "/userinfo.php" &&') + '}};'; const nativeForm = `
`; +const legacyForm = nativeForm.replace('', ''); test('account-page migration browser regressions', {timeout: 60000}, async t => { const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); @@ -30,17 +35,38 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.evaluate(options => { window.CurrentUsername = 'Tester'; window.SearchParams = new URL(location.href).searchParams; - window.UtilityEnabled = name => name === 'ExportACCode' && options.export; + window.UtilityEnabled = name => ['ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'ExportACCode' && options.export); window.ServerURL = 'https://updates.test'; window.GetRelativeTime = () => 'today'; window.escapeHTML = value => value; window.GetUserInfo = async () => ({EmailHash: 'hash'}); window.apiCalls = []; - window.RequestAPI = (action, data, callback) => { - apiCalls.push({action, data}); - callback(action === 'GetBadge' ? { - Success: !options.noBadge, Data: {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'} - } : {Success: !options.badgeFailure, Message: 'Save failed'}); + document.cookie = 'PHPSESSID=fixture; path=/'; + window.GM_info = {script: {version: 'test'}}; + window.badgeFailureMode = options.badgeFailureMode; + window.GM_xmlhttpRequest = request => { + const action = new URL(request.url).pathname.slice(1); + if (action !== 'SendData') apiCalls.push({action, data: JSON.parse(request.data).Data}); + if (action === 'EditBadge') { + const mode = window.badgeFailureMode; + if (['network', 'timeout', 'abort'].includes(mode)) { + window.requestTimeout = request.timeout; + request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); + // A late callback must not turn a failed save into success. + request.onload({status: 200, responseText: '{"Success":true}'}); + return; + } + if (mode === 'throw') throw new Error('Transport initialization failed'); + if (['invalid-json', 'invalid-response', 'http'].includes(mode)) { + request.onload({status: mode === 'http' ? 503 : 200, + responseText: mode === 'invalid-json' ? 'Error' : 'null'}); + return; + } + } + request.onload({status: 200, responseText: JSON.stringify(action === 'GetBadge' ? { + Success: !options.noBadge, Data: Object.hasOwn(options, 'badgeData') ? options.badgeData : + {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'} + } : {Success: !options.badgeFailure, Message: 'Save failed'})}); }; window.requests = []; window.fetch = async url => { @@ -55,8 +81,11 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => window.nativeSubmitCount = (window.nativeSubmitCount || 0) + 1; }); }, options); - await page.addScriptTag({content: helpers + handler}); - await page.evaluate(() => RunAccountPage()); + await page.addScriptTag({content: api + helpers + preprocessing + handler}); + await page.evaluate(async () => { + RunAccountPreprocessing(); + await RunAccountPage(); + }); return page; } try { @@ -66,6 +95,8 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php'); assert.equal(await page.locator('[name="csrf"]').inputValue(), 'token'); assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + assert.equal(await page.locator('[name="school"]').inputValue(), 'School'); + assert.equal(await page.locator('[name="email"]').inputValue(), 'user@example.test'); assert.equal(await page.locator('input[type="password"]').count(), 0); await page.getByRole('button', {name: 'Save account'}).click(); assert.equal(await page.evaluate(() => nativeSubmitCount), 1); @@ -93,6 +124,58 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.close(); }); } + for (const route of ['/modify_user_info.php', '/modifypage.php']) { + const legacy = route === '/modifypage.php'; + const button = legacy ? '#ModifyInfo' : 'button:has-text("修改标签")'; + const content = legacy ? '#BadgeContent' : '#UserScriptBadgeContent'; + const background = legacy ? '#BadgeBackgroundColor' : '#UserScriptBadgeBackground'; + const color = legacy ? '#BadgeColor' : '#UserScriptBadgeColor'; + for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { + await t.test('normalizes incomplete badge data on ' + route + ': ' + JSON.stringify(badgeData), async () => { + const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeData}); + assert.equal(await page.locator(content).inputValue(), ''); + assert.equal(await page.locator(background).inputValue(), '#000000'); + assert.equal(await page.locator(color).inputValue(), '#ffffff'); + await page.locator(button).click(); + assert.deepEqual(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data), { + UserID: 'Tester', Content: '', BackgroundColor: '#000000', Color: '#ffffff' + }); + assert.equal(await page.locator(button).isEnabled(), true); + await page.close(); + }); + } + for (const [mode, message] of [ + ['network', '网络错误,请重试'], ['timeout', '请求超时,请重试'], + ['abort', '请求已取消,请重试'], ['throw', '请求失败,请重试'], + ['invalid-json', '服务器响应异常,请重试'], ['invalid-response', '服务器响应异常,请重试'], + ['http', '请求失败(HTTP 503),请重试'] + ]) { + await t.test('recovers and retries a ' + mode + ' badge failure on ' + route, async () => { + const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeFailureMode: mode}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale'); + localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep'); + }); + await page.locator(content).fill('New badge'); + await page.locator(button).click(); + assert.equal(await page.locator(button).isEnabled(), true); + assert.equal(await page.locator(legacy ? '#ErrorElement' : '[role="status"]').innerText(), message); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'stale'); + assert.deepEqual(await page.evaluate(() => requests), []); + if (['network', 'timeout', 'abort'].includes(mode)) { + assert.equal(await page.evaluate(() => requestTimeout), 15000); + } + await page.evaluate(() => { window.badgeFailureMode = null; }); + await page.locator(button).click(); + assert.equal(await page.locator(button).isEnabled(), true); + assert.equal(await page.locator(legacy ? '#SuccessElement' : '[role="status"]').innerText(), '修改成功'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep'); + assert.deepEqual(await page.evaluate(() => requests), legacy ? ['https://www.xmoj.tech/modify.php'] : []); + await page.close(); + }); + } + } await t.test('badge access denial leaves the native form usable', async () => { const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); assert.equal(await page.locator('#UserScriptBadgeContent').count(), 0); @@ -123,8 +206,7 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => }); } await t.test('retains the legacy account enhancement', async () => { - const legacy = nativeForm.replace('', ''); - const page = await Page('/modifypage.php', legacy, {noBadge: true}); + const page = await Page('/modifypage.php', legacyForm, {noBadge: true}); assert.equal(await page.locator('#Nickname').inputValue(), 'Nickname'); assert.equal(await page.locator('#ModifyInfo').count(), 1); await page.close(); From 1a9c48085dfa0abcbb4368d00fa0d2bc5d6ab048 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 00:30:59 +0000 Subject: [PATCH 022/137] Update time and description of 3.8.2 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index a0ee32ba..3e9cb571 100644 --- a/Update.json +++ b/Update.json @@ -3830,7 +3830,7 @@ "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" }, "3.8.2": { - "UpdateDate": 1791071205446, + "UpdateDate": 1791073854781, "Prerelease": true, "UpdateContents": [ { From 7350dfa1691f5ef718ccd1820ca06c2fb743f355 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 00:31:50 +0000 Subject: [PATCH 023/137] Update time and description of 3.8.2 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 3e9cb571..a6a6ad4f 100644 --- a/Update.json +++ b/Update.json @@ -3830,7 +3830,7 @@ "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" }, "3.8.2": { - "UpdateDate": 1791073854781, + "UpdateDate": 1791073903614, "Prerelease": true, "UpdateContents": [ { From a7efe58ebf086bf80578c8dcbb41786517b97c49 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 08:58:45 +0800 Subject: [PATCH 024/137] fix: anchor navbar before Bootstrap CSS loads --- XMOJ.user.js | 5 ++++- tests/navbar-styler.test.cjs | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index eaf0b0b1..ea1c87d8 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2827,7 +2827,7 @@ class NavbarStyler { this.navbar = navbar; this.addedClasses = ['fixed-top', 'container', 'ml-auto'].filter(name => !navbar.classList.contains(name)); this.restoreNavbarStyles = this.preserveStyles(navbar, [ - 'position', 'border-top-left-radius', 'border-top-right-radius', + 'position', 'top', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius', 'box-shadow', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'max-width', 'background-color', 'opacity', 'z-index' @@ -2886,6 +2886,9 @@ class NavbarStyler { try { let n = this.navbar; n.classList.add('fixed-top', 'container', 'ml-auto'); + // The CDN fallback may still be loading when the top bar starts. Without + // Bootstrap's .fixed-top rule, top:auto moves it down with the spacer. + n.style.top = '0'; if (UtilityEnabled("MonochromeUI")) { Object.assign(n.style, { position: 'fixed', diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs index cea21d77..2cdb28fe 100644 --- a/tests/navbar-styler.test.cjs +++ b/tests/navbar-styler.test.cjs @@ -223,3 +223,44 @@ test('Chinese navbar glyphs use the same fonts under legacy and web document lan await browser.close(); } }); + +test('navbar reserves content space before Bootstrap CSS loads and restores its original top', {timeout: 60000}, async () => { + const start = 'let navbarStyler = null;'; + const end = '// Wrapped in an async IIFE'; + const navbarSource = source.slice(source.indexOf(start), source.indexOf(end)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + for (const width of [375, 1440]) { + const page = await browser.newPage({viewport: {width, height: 800}}); + for (const mono of [false, true]) { + // The resource cache can be empty and the CDN fallback can be slow + // or fail. No .fixed-top rule is available yet in this fixture. + await page.setContent('
Page content
'); + await page.evaluate(mono => { window.UtilityEnabled = name => name === 'MonochromeUI' ? mono : name === 'NewTopBar'; }, mono); + await page.addScriptTag({content: '{\n' + navbarSource + '\nUpdateNavbarStyler(); window.testStyler = navbarStyler;\n}'}); + const Measure = () => page.evaluate(() => ({ + navbarTop: document.querySelector('nav').getBoundingClientRect().top, + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + contentTop: document.getElementById('content').getBoundingClientRect().top + })); + const before = await Measure(); + assert.equal(before.navbarTop, mono ? 0 : 16, 'the navbar must anchor to the viewport without Bootstrap'); + assert.ok(before.contentTop >= before.navbarBottom, 'the navbar must not cover content while CSS is unavailable'); + // When Bootstrap finally arrives, the reserved space still works. + await page.addStyleTag({content: '.fixed-top { position: fixed; top: 0; right: 0; left: 0; }'}); + const after = await Measure(); + assert.ok(after.contentTop >= after.navbarBottom); + await page.evaluate(() => window.testStyler.destroy()); + assert.equal(await page.locator('nav').evaluate(node => node.style.top), '', 'remove the owned offset on teardown'); + } + await page.close(); + } + const page = await browser.newPage(); + await page.setContent(''); + await page.evaluate(() => { window.UtilityEnabled = () => true; }); + await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler(); navbarStyler.destroy();'}); + assert.deepEqual(await page.locator('nav').evaluate(node => [node.style.top, node.style.getPropertyPriority('top')]), ['7px', 'important']); + } finally { + await browser.close(); + } +}); From c730563a2a1d48fa424df3853d2641023185d625 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 00:59:16 +0000 Subject: [PATCH 025/137] Update time and description of 3.8.2 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 3de67639..8dc5b4f8 100644 --- a/Update.json +++ b/Update.json @@ -3830,7 +3830,7 @@ "Notes": "

修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" }, "3.8.2": { - "UpdateDate": 1791041267195, + "UpdateDate": 1791075550636, "Prerelease": true, "UpdateContents": [ { From 4da8acd8082a06709564fcbc3199a365f34c3c9d Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 13:10:44 +0800 Subject: [PATCH 026/137] 3.8.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f6421c2a..c682dca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.2", + "version": "3.8.3", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { From cc8aa97a150e76df0690e612ba4659e3fec73665 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:11:32 +0000 Subject: [PATCH 027/137] Update version info to 3.8.3 --- Update.json | 11 +++++++++++ XMOJ.user.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 8dc5b4f8..821ede8b 100644 --- a/Update.json +++ b/Update.json @@ -3839,6 +3839,17 @@ } ], "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" + }, + "3.8.3": { + "UpdateDate": 1791090686677, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1040, + "Description": "fix: support migrated account settings page" + } + ], + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单及标签编辑、AC 代码导出功能。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index 670e88c1..8a5059d1 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.2 +// @version 3.8.3 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen From 149a6e60c0c8508c846083823624364db1c7a165 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:11:53 +0000 Subject: [PATCH 028/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 821ede8b..93e9faab 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791090686677, + "UpdateDate": 1791090712283, "Prerelease": true, "UpdateContents": [ { From 4b4e2621a5bb2ee217152458d884e46d65a1c8d4 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 13:17:04 +0800 Subject: [PATCH 029/137] fix: recover badge loads without overwriting unloaded values --- XMOJ.user.js | 74 ++++++++++++++++++++++++++------- tests/account-settings.test.cjs | 72 ++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 18 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 8a5059d1..99258e58 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2178,9 +2178,12 @@ function IsAccountSettingsPage(pathname) { return pathname == "/modifypage.php" || pathname == "/modify_user_info.php"; } -function LoadAccountBadge(content, background, color, onLoad) { +function LoadAccountBadge(content, background, color, onLoad, onError) { RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { - if (!response?.Success) return; + if (!response?.Success) { + onError(response?.Message || "服务器响应异常,请重试"); + return; + } let badge = response.Data ?? {}; content.value = String(badge.Content ?? ""); // Color inputs cannot represent an empty value. Use explicit defaults @@ -2188,7 +2191,39 @@ function LoadAccountBadge(content, background, color, onLoad) { background.value = /^#[0-9a-f]{6}$/i.test(badge.BackgroundColor) ? badge.BackgroundColor : "#000000"; color.value = /^#[0-9a-f]{6}$/i.test(badge.Color) ? badge.Color : "#ffffff"; onLoad(); - }); + }, onError); +} + +// Unloaded fields must never be saved as a blank replacement for an existing badge. +function InitializeAccountBadgeLoading(content, background, color, status, onReady = () => {}) { + let state = {loaded: false}; + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary mt-2"; + retry.innerText = "重试加载标签"; + status.insertAdjacentElement("afterend", retry); + let Load = () => { + state.loaded = false; + [content, background, color].forEach(input => { input.disabled = true; }); + onReady(false); + status.innerText = "正在加载标签..."; + retry.hidden = true; + retry.disabled = true; + LoadAccountBadge(content, background, color, () => { + state.loaded = true; + [content, background, color].forEach(input => { input.disabled = false; }); + status.innerText = ""; + retry.disabled = false; + onReady(true); + }, message => { + status.innerText = "标签加载失败:" + message; + retry.hidden = false; + retry.disabled = false; + }); + }; + retry.addEventListener("click", Load); + Load(); + return state; } function SaveAccountBadge(content, background, color) { @@ -2234,7 +2269,12 @@ function InitializeAccountBadgeEditor(container) { let color = editor.querySelector("#UserScriptBadgeColor"); let button = editor.querySelector("button"); let status = editor.querySelector("[role='status']"); + container.appendChild(editor); + let badgeState = InitializeAccountBadgeLoading(content, background, color, status, loaded => { + button.disabled = !loaded; + }); button.addEventListener("click", async () => { + if (!badgeState.loaded) return; button.disabled = true; status.innerText = ""; try { @@ -2244,7 +2284,6 @@ function InitializeAccountBadgeEditor(container) { button.disabled = false; } }); - LoadAccountBadge(content, background, color, () => container.appendChild(editor)); } // The ResetType user menu. Shared by the legacy navbar and the /web app so both @@ -5341,9 +5380,12 @@ async function main() { document.getElementById("AtcoderAccount").value = AtcoderAccount; document.getElementById("USACOAccount").value = USACOAccount; document.getElementById("LuoguAccount").value = LuoguAccount; - LoadAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor, () => { - BadgeRow.style.display = ""; - }); + BadgeRow.style.display = ""; + let badgeLoadStatus = document.createElement("div"); + badgeLoadStatus.id = "BadgeLoadStatus"; + badgeLoadStatus.role = "status"; + BadgeRow.insertAdjacentElement("afterend", badgeLoadStatus); + let badgeState = InitializeAccountBadgeLoading(BadgeContent, BadgeBackgroundColor, BadgeColor, badgeLoadStatus); ModifyInfo.addEventListener("click", async () => { ModifyInfo.disabled = true; if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = ""; @@ -5360,13 +5402,17 @@ async function main() { let BadgeContent = document.querySelector("#BadgeContent").value; let BadgeBackgroundColor = document.querySelector("#BadgeBackgroundColor").value; let BadgeColor = document.querySelector("#BadgeColor").value; - let badgeResult = await SaveAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor); - if (!badgeResult.Success) { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = badgeResult.Message; - return; + // Account-only edits remain available when the badge API is + // unavailable; never send the unloaded badge inputs to EditBadge. + if (badgeState.loaded) { + let badgeResult = await SaveAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor); + if (!badgeResult.Success) { + ModifyInfo.disabled = false; + if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; + ErrorElement.style.display = "block"; + ErrorElement.innerText = badgeResult.Message; + return; + } } let Nickname = document.querySelector("#Nickname").value; let OldPassword = document.querySelector("#OldPassword").value; diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index 58a1c5d7..b943dd8b 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -44,11 +44,17 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => document.cookie = 'PHPSESSID=fixture; path=/'; window.GM_info = {script: {version: 'test'}}; window.badgeFailureMode = options.badgeFailureMode; + window.badgeLoadFailureMode = options.badgeLoadFailureMode; + window.badgeAccessDenied = options.noBadge; window.GM_xmlhttpRequest = request => { const action = new URL(request.url).pathname.slice(1); if (action !== 'SendData') apiCalls.push({action, data: JSON.parse(request.data).Data}); - if (action === 'EditBadge') { - const mode = window.badgeFailureMode; + if (action === 'EditBadge' || action === 'GetBadge') { + const mode = action === 'GetBadge' ? window.badgeLoadFailureMode : window.badgeFailureMode; + if (mode === 'pending') { + window.pendingBadgeRequest = request; + return; + } if (['network', 'timeout', 'abort'].includes(mode)) { window.requestTimeout = request.timeout; request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); @@ -64,7 +70,7 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => } } request.onload({status: 200, responseText: JSON.stringify(action === 'GetBadge' ? { - Success: !options.noBadge, Data: Object.hasOwn(options, 'badgeData') ? options.badgeData : + Success: !window.badgeAccessDenied, Message: 'Load denied', Data: Object.hasOwn(options, 'badgeData') ? options.badgeData : {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'} } : {Success: !options.badgeFailure, Message: 'Save failed'})}); }; @@ -178,11 +184,69 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => } await t.test('badge access denial leaves the native form usable', async () => { const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); - assert.equal(await page.locator('#UserScriptBadgeContent').count(), 0); + assert.equal(await page.locator('#UserScriptBadgeContent').isDisabled(), true); + assert.match(await page.getByRole('status').innerText(), /Load denied/); await page.getByRole('button', {name: 'Save account'}).click(); assert.equal(await page.evaluate(() => nativeSubmitCount), 1); await page.close(); }); + for (const route of ['/modify_user_info.php', '/modifypage.php']) { + const legacy = route === '/modifypage.php'; + const content = legacy ? '#BadgeContent' : '#UserScriptBadgeContent'; + const save = legacy ? '#ModifyInfo' : 'button:has-text("修改标签")'; + const status = legacy ? '#BadgeLoadStatus' : '[role="status"]'; + for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'invalid-response', 'http', 'denied']) { + await t.test('protects existing badges and retries a ' + mode + ' load failure on ' + route, async () => { + const page = await Page(route, legacy ? legacyForm : nativeForm, + {badgeLoadFailureMode: mode, noBadge: mode === 'denied'}); + assert.match(await page.locator(status).innerText(), /标签加载失败:/); + assert.equal(await page.locator(content).isDisabled(), true); + assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isVisible(), true); + await page.evaluate(() => localStorage.setItem('UserScript-User-Tester-Badge-Content', 'existing badge')); + if (legacy) { + // An unrelated account edit must not send unloaded badge values. + await page.locator('#Nickname').fill('Edited account'); + await page.locator(save).click(); + assert.deepEqual(await page.evaluate(() => requests), ['https://www.xmoj.tech/modify.php']); + } else { + assert.equal(await page.locator(save).isDisabled(), true); + await page.locator(save).dispatchEvent('click'); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + } + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'existing badge'); + await page.evaluate(() => { + window.badgeLoadFailureMode = null; + window.badgeAccessDenied = false; + }); + await page.getByRole('button', {name: '重试加载标签', exact: true}).click(); + assert.equal(await page.locator(content).isEnabled(), true); + assert.equal(await page.locator(content).inputValue(), 'Badge'); + assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isHidden(), true); + if (legacy) assert.equal(await page.locator('#Nickname').inputValue(), 'Edited account'); + await page.locator(content).fill('Recovered badge'); + await page.locator(save).click(); + assert.equal(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data.Content), 'Recovered badge'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null); + await page.close(); + }); + } + await t.test('blocks badge writes while the initial load is pending on ' + route, async () => { + const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeLoadFailureMode: 'pending'}); + assert.match(await page.locator(status).innerText(), /正在加载标签/); + assert.equal(await page.locator(content).isDisabled(), true); + if (legacy) await page.locator(save).click(); + else await page.locator(save).dispatchEvent('click'); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); + await page.evaluate(() => pendingBadgeRequest.onload({status: 200, responseText: JSON.stringify({ + Success: true, Data: {Content: 'Existing badge', BackgroundColor: '#112233', Color: '#ffffff'} + })})); + assert.equal(await page.locator(content).isEnabled(), true); + assert.equal(await page.locator(content).inputValue(), 'Existing badge'); + await page.close(); + }); + } for (const route of ['/modify_user_info.php', '/modifypage.php']) { await t.test('renders changelog on ' + route + ' even without an account form', async () => { const page = await Page(route + '?ByUserScript=1', 'Please log in', {export: true}); From f93813ff0e0d007535e9dfe3930611d3f4e4f759 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:17:30 +0000 Subject: [PATCH 030/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 93e9faab..41c08ffa 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791090712283, + "UpdateDate": 1791091044974, "Prerelease": true, "UpdateContents": [ { From b88db843596f767f9cf2df879547767fec24029a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:17:47 +0000 Subject: [PATCH 031/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 41c08ffa..5f206403 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791091044974, + "UpdateDate": 1791091065885, "Prerelease": true, "UpdateContents": [ { From 719ed4bed95a60bb9bef9e810ade1e8bc7a4c399 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 13:32:08 +0800 Subject: [PATCH 032/137] fix: enforce Chinese across classic and web pages --- XMOJ.user.js | 80 +++++++++++++++++- package.json | 2 +- tests/chinese-language.test.cjs | 138 ++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 tests/chinese-language.test.cjs diff --git a/XMOJ.user.js b/XMOJ.user.js index 99258e58..9eb7c294 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -44,6 +44,82 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +// Set the app preference at document-start, before Vue requests /api/nav. +function InitializeChineseLanguage() { + let wasEnglish = document.cookie.split(";").some(cookie => cookie.trim() == "XMOJ_LANG=en"); + document.cookie = "XMOJ_LANG=zh; path=/; max-age=31536000; SameSite=Lax" + (location.protocol == "https:" ? "; Secure" : ""); + return wasEnglish; +} + +function EnforceChineseView(root = document) { + document.documentElement.lang = "zh-CN"; + if (!document.getElementById("UserScript-ChineseLanguage")) { + let style = document.createElement("style"); + style.id = "UserScript-ChineseLanguage"; + style.textContent = `#lang_cn_to_en, #lang_en_to_cn, .xmoj-lang-switch, + [data-xmoj-script-language-selector] { display: none !important; } + .lang_en { display: none !important; } + .lang_cn { display: revert !important; }`; + document.head.appendChild(style); + } + // Keep Vue's nodes and handlers intact; it can replace the navbar on navigation. + for (let link of root.querySelectorAll('#xmoj-navbar > ul.navbar-right > li > a[href="#"]:not(.dropdown-toggle)')) { + if (/^(English|中文)$/.test(link.textContent.trim())) link.setAttribute("data-xmoj-script-language-selector", ""); + } + // The problem/solution selector has its own reactive language, separate from nav. + for (let button of root.querySelectorAll(".xmoj-lang-switch button")) { + if (button.textContent.trim() == "中文" && !button.classList.contains("hidden") && !button.hidden) button.click(); + } +} + +async function EnsureChinesePage() { + EnforceChineseView(); + const retryKey = "UserScript-ChineseLanguageReload"; + const pageKey = location.pathname + location.search + location.hash; + const classicEnglish = document.querySelector('a#lang_en_to_cn[href*="change_lang.php"]') != null; + if (!classicEnglish && !(IsContestWebApp() && initiallyEnglishLanguage)) { + sessionStorage.removeItem(retryKey); + return false; + } + try { + // A server that refuses the preference must not trap the user in a reload loop. + if (sessionStorage.getItem(retryKey) == pageKey) throw new Error("中文设置未生效"); + if (classicEnglish) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch("/change_lang.php?lang=cn", {credentials: "same-origin", cache: "no-store", signal: controller.signal}); + if (!response.ok) throw new Error("HTTP " + response.status); + } finally { + clearTimeout(timeout); + } + } + sessionStorage.setItem(retryKey, pageKey); + location.reload(); + } catch (error) { + console.error("[XMOJ-Script] Chinese language:", error); + let alert = document.createElement("div"); + alert.className = "alert alert-warning"; + alert.role = "alert"; + alert.textContent = "切换中文失败,请检查网络后重试。 "; + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary"; + retry.textContent = "重试"; + retry.addEventListener("click", () => { + sessionStorage.removeItem(retryKey); + location.reload(); + }); + alert.appendChild(retry); + document.body.prepend(alert); + RevealPage(); + return false; + } + return true; +} + +const initiallyEnglishLanguage = InitializeChineseLanguage(); + // The /web application owns its DOM. Never run the legacy body.innerHTML rewrites // inside it; InitializeContestWebApp maps its Bootstrap 3 markup to Bootstrap 5. function IsContestWebApp(pathname = location.pathname) { @@ -2887,6 +2963,7 @@ async function InitializeContestWebApp() { return false; }); if (UtilityEnabled("NewBootstrap")) ApplyBootstrap5Markup(); + EnforceChineseView(root); EnhanceNav(); if (!revealed && root.querySelector(".navbar")) { // As on the legacy pages: top bar first, then show the page. @@ -3164,6 +3241,7 @@ if (_foucStyle) { } setTimeout(RevealPage, 4000); } +if (await EnsureChinesePage()) return; if (IsContestWebApp()) { await InitializeContestWebApp(); return; @@ -3764,7 +3842,7 @@ async function main() { }, {"ID": "AddAnimation", "Type": "A", "Name": "增加动画"}, { "ID": "ReplaceYN", "Type": "F", "Name": "题目前状态提示替换为好看的图标" }, {"ID": "RemoveAlerts", "Type": "D", "Name": "去除多余反复的提示"}, { - "ID": "Translate", "Type": "F", "Name": "统一使用中文,翻译了部分英文*" + "ID": "Translate", "Type": "F", "Name": "翻译部分英文和统一用语*" }, { "ID": "ReplaceLinks", "Type": "F", "Name": "将网站中所有以方括号包装的链接替换为按钮" }, {"ID": "RemoveUseless", "Type": "D", "Name": "删去无法使用的功能*"}, { diff --git a/package.json b/package.json index c682dca8..f9d97bdc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { - "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs" + "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs tests/chinese-language.test.cjs" }, "devDependencies": { "playwright": "1.59.1" diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs new file mode 100644 index 00000000..dddb5a93 --- /dev/null +++ b/tests/chinese-language.test.cjs @@ -0,0 +1,138 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const {chromium} = require('playwright'); +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'expected unique marker: ' + marker); + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const language = Between('function InitializeChineseLanguage(', '// The /web application owns its DOM.'); +const route = Between('function IsContestWebApp(', 'function GetContestRoute('); + +// Check reload behavior without navigating a real page away from its assertions. +function Context({cookie = '', pathname = '/problem.php', english = false, fail = false} = {}) { + const storage = new Map(); + const requests = []; + const nodes = []; + let reloads = 0; + const location = new URL('https://www.xmoj.tech' + pathname + '?id=1000#samples'); + location.reload = () => { reloads++; }; + const scope = vm.createContext({ + location, console: {error() {}}, RevealPage() {}, AbortController, setTimeout, clearTimeout, + sessionStorage: {getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key)}, + fetch: async (url, options) => { + requests.push({url, options}); + if (fail) throw new Error('Network failed'); + return {ok: true}; + }, + document: { + cookie, documentElement: {}, head: {appendChild: node => nodes.push(node)}, body: {prepend: node => nodes.push(node)}, + getElementById: id => nodes.find(node => node.id === id), + querySelectorAll: () => [], querySelector: () => english ? {} : null, + createElement: () => ({appendChild() {}, addEventListener() {}}) + } + }); + vm.runInContext(route + language, scope); + return {scope, requests, storage, nodes, reloads: () => reloads}; +} + +test('forces the web cookie before requests and reloads an existing English app once', async () => { + const {scope, requests, reloads} = Context({cookie: 'PHPSESSID=fixture; XMOJ_LANG=en', pathname: '/web/contest/123/A'}); + assert.match(scope.document.cookie, /^XMOJ_LANG=zh; path=/); + assert.equal(await scope.EnsureChinesePage(), true); + assert.equal(reloads(), 1); + assert.equal(scope.location.href, 'https://www.xmoj.tech/web/contest/123/A?id=1000#samples'); + assert.equal(requests.length, 0); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(reloads(), 1, 'refusing the preference must not create a reload loop'); +}); + +test('switches the classic English session before processing the page', async () => { + const {scope, requests, reloads} = Context({english: true}); + assert.equal(await scope.EnsureChinesePage(), true); + assert.equal(requests[0].url, '/change_lang.php?lang=cn'); + assert.equal(requests[0].options.credentials, 'same-origin'); + assert.equal(requests[0].options.cache, 'no-store'); + assert.equal(reloads(), 1); +}); + +test('Chinese classic pages do not reload merely because html declares lang=en', async () => { + const {scope, requests, reloads, storage} = Context(); + scope.document.documentElement.lang = 'en'; + storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(scope.document.documentElement.lang, 'zh-CN'); + assert.equal(requests.length, 0); + assert.equal(reloads(), 0); + assert.equal(storage.size, 0); +}); + +test('failed classic language switches show a retry message without reloading', async () => { + const {scope, reloads, nodes} = Context({english: true, fail: true}); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(reloads(), 0); + assert.match(nodes.find(node => node.role === 'alert').textContent, /切换中文失败/); +}); + +test('hides classic and Vue language selectors while preserving forms and Chinese content', {timeout: 60000}, async () => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + try { + await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body: ` +
English + +
+ English statement +
+
English content
+ `})); + await page.goto('https://chinese-language.test/problem.php'); + await page.evaluate(() => { + window.accountForm = document.querySelector('form'); + window.submits = 0; + accountForm.addEventListener('submit', event => { event.preventDefault(); submits++; }); + document.querySelector('.xmoj-lang-switch button:last-child').addEventListener('click', event => { + event.target.classList.add('hidden'); + document.getElementById('statement').textContent = '中文内容'; + window.chineseClicks = (window.chineseClicks || 0) + 1; + }); + }); + await page.addScriptTag({content: route + language}); + await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); }); + assert.equal(await page.locator('#lang_cn_to_en').isHidden(), true); + assert.equal(await page.locator('#lang_en_to_cn').isHidden(), true); + assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true); + assert.equal(await page.locator('#xmoj-navbar .dropdown-toggle').isVisible(), true, 'a username named English must remain visible'); + assert.equal(await page.locator('.xmoj-lang-switch').isHidden(), true); + assert.equal(await page.locator('.lang_cn').isVisible(), true); + assert.equal(await page.locator('.lang_en').isHidden(), true); + assert.equal(await page.locator('#statement').innerText(), '中文内容'); + assert.equal(await page.evaluate(() => chineseClicks), 1); + assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => submits), 1); + assert.equal(await page.locator('#UserScript-ChineseLanguage').count(), 1); + await page.evaluate(() => { + const item = document.createElement('li'); + item.innerHTML = '中文'; + document.querySelector('#xmoj-navbar ul').appendChild(item); + EnforceChineseView(document.getElementById('app')); + }); + assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true); + } finally { + await browser.close(); + } +}); + +test('startup enforces Chinese before either UI initializer and Vue refreshes reapply it', () => { + const startup = Between('(async () => {\nif (GetContestWebRedirect()) return;', '//otherwise CurrentUsername might be undefined'); + assert.ok(startup.indexOf('await EnsureChinesePage()') < startup.indexOf('await InitializeContestWebApp()')); + const enhance = Between('function Enhance() {', 'function ScheduleEnhance() {'); + assert.match(enhance, /EnforceChineseView\(root\)/); +}); From 7b2f16716c3c550e128cb15eb8e1ed299ddff8d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:32:41 +0000 Subject: [PATCH 033/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 5f206403..26e2f626 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791091065885, + "UpdateDate": 1791091956558, "Prerelease": true, "UpdateContents": [ { From c5b593d75c9a95a1fc4228b8de6fa82978901a5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:32:50 +0000 Subject: [PATCH 034/137] Update time and description of 3.8.3 --- Update.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Update.json b/Update.json index 26e2f626..4fba733c 100644 --- a/Update.json +++ b/Update.json @@ -3841,15 +3841,15 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791091956558, + "UpdateDate": 1791091969345, "Prerelease": true, "UpdateContents": [ { "PR": 1040, - "Description": "fix: support migrated account settings page" + "Description": "fix: restore account settings and enforce Chinese UI" } ], - "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单及标签编辑、AC 代码导出功能。

" + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单及标签编辑、AC 代码导出功能。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" } } } \ No newline at end of file From 7796bdc1047a0746b6d080c9e75ebcf2fb0a2ee3 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 13:44:57 +0800 Subject: [PATCH 035/137] fix: initialize badge editor on the current account page --- XMOJ.user.js | 175 +++++--------------------------- tests/account-settings.test.cjs | 125 +++++++++++++++-------- tests/chinese-language.test.cjs | 6 +- tests/contest-web.test.cjs | 3 +- 4 files changed, 110 insertions(+), 199 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 9eb7c294..f607de5b 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -681,6 +681,11 @@ let _earlyObs = null; // the saved theme and inject Bootstrap CSS + the skin CSS before the first paint, // and we block the page's own old stylesheets from loading at all. (() => { + const AccountRedirect = GetAccountSettingsRedirect(); + if (AccountRedirect) { + location.replace(AccountRedirect); + return; + } // Old contest pages: move to the /web app before anything is drawn. const ContestWebRedirect = GetContestWebRedirect(); if (ContestWebRedirect) { @@ -2251,7 +2256,16 @@ function CreateProblemSwitcher(ProblemList, IsCurrent) { } function IsAccountSettingsPage(pathname) { - return pathname == "/modifypage.php" || pathname == "/modify_user_info.php"; + return pathname == "/modify_user_info.php"; +} + +function GetAccountSettingsRedirect() { + return location.pathname == "/modifypage.php" ? "/modify_user_info.php" + location.search + location.hash : null; +} + +function InitializeAccountFeatures(authenticated) { + if (!authenticated || !CurrentUsername || !IsAccountSettingsPage(location.pathname) || new URLSearchParams(location.search).has("ByUserScript")) return; + InitializeAccountBadgeEditor(); } function LoadAccountBadge(content, background, color, onLoad, onError) { @@ -2328,10 +2342,13 @@ function SaveAccountBadge(content, background, color) { // Keep the migrated site's form, including its submit handler and CSRF fields. // Script badges are saved separately so they cannot interfere with account edits. -function InitializeAccountBadgeEditor(container) { +function InitializeAccountBadgeEditor() { + if (document.getElementById("UserScriptBadgeEditor")) return; + let container = document.querySelector("main") || document.querySelector("body > .container") || document.body; let editor = document.createElement("div"); + editor.id = "UserScriptBadgeEditor"; editor.className = "border p-2 my-3"; - editor.innerHTML = `
标签
+ editor.innerHTML = `
标签编辑
@@ -3224,6 +3241,7 @@ class NavbarStyler { // which executes userscripts as classic scripts (not ES modules). (async () => { if (GetContestWebRedirect()) return; +if (GetAccountSettingsRedirect()) return; if (document.readyState === "loading") { await new Promise(r => document.addEventListener("DOMContentLoaded", r, { once: true })); } @@ -3270,6 +3288,8 @@ if (profileElement === null) { } CurrentUsername = profileElement.innerText; CurrentUsername = CurrentUsername.replaceAll(/[^a-zA-Z0-9]/g, ""); +// Initialize migrated account tools independently of the legacy navbar/layout handler. +InitializeAccountFeatures(logined || (CurrentUsername && !/^(Login|Guest)$/i.test(CurrentUsername))); let IsAdmin = AdminUserList.indexOf(CurrentUsername) !== -1; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); @@ -5365,155 +5385,6 @@ async function main() { UpdateDataCardLink.innerText = "查看该版本"; } }); - } else if (location.pathname == "/modify_user_info.php") { - InitializeAccountBadgeEditor(document.querySelector("body > div > div")); - } else { - document.title = "修改账号"; - let Nickname = document.getElementsByName("nick")[0].value; - let School = document.getElementsByName("school")[0].value; - let EmailAddress = document.getElementsByName("email")[0].value; - let CodeforcesAccount = document.getElementsByName("acc_cf")[0].value; - let AtcoderAccount = document.getElementsByName("acc_atc")[0].value; - let USACOAccount = document.getElementsByName("acc_usaco")[0].value; - let LuoguAccount = document.getElementsByName("acc_luogu")[0].value; - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = `
-
-
-
-
-
-
- - 修改头像 -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - -
`; - document.getElementById("Nickname").value = Nickname; - document.getElementById("School").value = School; - document.getElementById("EmailAddress").value = EmailAddress; - document.getElementById("CodeforcesAccount").value = CodeforcesAccount; - document.getElementById("AtcoderAccount").value = AtcoderAccount; - document.getElementById("USACOAccount").value = USACOAccount; - document.getElementById("LuoguAccount").value = LuoguAccount; - BadgeRow.style.display = ""; - let badgeLoadStatus = document.createElement("div"); - badgeLoadStatus.id = "BadgeLoadStatus"; - badgeLoadStatus.role = "status"; - BadgeRow.insertAdjacentElement("afterend", badgeLoadStatus); - let badgeState = InitializeAccountBadgeLoading(BadgeContent, BadgeBackgroundColor, BadgeColor, badgeLoadStatus); - ModifyInfo.addEventListener("click", async () => { - ModifyInfo.disabled = true; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = ""; - ErrorElement.style.display = "none"; - SuccessElement.style.display = "none"; - //表单元素缺失时不提交,避免用空值覆盖用户信息 - if (document.querySelector("#BadgeContent") == null || document.querySelector("#BadgeBackgroundColor") == null || document.querySelector("#BadgeColor") == null || document.querySelector("#Nickname") == null || document.querySelector("#OldPassword") == null || document.querySelector("#NewPassword") == null || document.querySelector("#NewPasswordAgain") == null || document.querySelector("#School") == null || document.querySelector("#EmailAddress") == null || document.querySelector("#CodeforcesAccount") == null || document.querySelector("#AtcoderAccount") == null || document.querySelector("#USACOAccount") == null || document.querySelector("#LuoguAccount") == null) { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = "页面加载异常,请刷新后重试"; - return; - } - let BadgeContent = document.querySelector("#BadgeContent").value; - let BadgeBackgroundColor = document.querySelector("#BadgeBackgroundColor").value; - let BadgeColor = document.querySelector("#BadgeColor").value; - // Account-only edits remain available when the badge API is - // unavailable; never send the unloaded badge inputs to EditBadge. - if (badgeState.loaded) { - let badgeResult = await SaveAccountBadge(BadgeContent, BadgeBackgroundColor, BadgeColor); - if (!badgeResult.Success) { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = badgeResult.Message; - return; - } - } - let Nickname = document.querySelector("#Nickname").value; - let OldPassword = document.querySelector("#OldPassword").value; - let NewPassword = document.querySelector("#NewPassword").value; - let NewPasswordAgain = document.querySelector("#NewPasswordAgain").value; - let School = document.querySelector("#School").value; - let EmailAddress = document.querySelector("#EmailAddress").value; - let CodeforcesAccount = document.querySelector("#CodeforcesAccount").value; - let AtcoderAccount = document.querySelector("#AtcoderAccount").value; - let USACOAccount = document.querySelector("#USACOAccount").value; - let LuoguAccount = document.querySelector("#LuoguAccount").value; - await fetch("https://www.xmoj.tech/modify.php", { - "headers": { - "content-type": "application/x-www-form-urlencoded" - }, - "referrer": location.href, - "method": "POST", - "body": "nick=" + encodeURIComponent(Nickname) + "&" + "opassword=" + encodeURIComponent(OldPassword) + "&" + "npassword=" + encodeURIComponent(NewPassword) + "&" + "rptpassword=" + encodeURIComponent(NewPasswordAgain) + "&" + "school=" + encodeURIComponent(School) + "&" + "email=" + encodeURIComponent(EmailAddress) + "&" + "acc_cf=" + encodeURIComponent(CodeforcesAccount) + "&" + "acc_atc=" + encodeURIComponent(AtcoderAccount) + "&" + "acc_usaco=" + encodeURIComponent(USACOAccount) + "&" + "acc_luogu=" + encodeURIComponent(LuoguAccount) - }); - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - SuccessElement.style.display = "block"; - }); } if (SearchParams.get("ByUserScript") == null && UtilityEnabled("ExportACCode")) { let ExportACCode = document.createElement("button"); diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index b943dd8b..fc81ffee 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -23,17 +23,19 @@ const nativeForm = `
`; -const legacyForm = nativeForm.replace('', ''); +const startup = 'window.RunAccountStartup = authenticated => { const logined = authenticated; ' + Between( + '// Initialize migrated account tools independently of the legacy navbar/layout handler.', + 'let IsAdmin = AdminUserList.indexOf(CurrentUsername)') + '};'; test('account-page migration browser regressions', {timeout: 60000}, async t => { const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); async function Page(route, content = nativeForm, options = {}) { const page = await browser.newPage(); - await page.route('**/*', request => request.fulfill({contentType: 'text/html', body: - `
${content}
`})); + await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body: + options.markup || `
${content}
`})); await page.goto('https://account-settings.test' + route); await page.evaluate(options => { - window.CurrentUsername = 'Tester'; + window.CurrentUsername = options.signedOut ? 'Login' : 'Tester'; window.SearchParams = new URL(location.href).searchParams; window.UtilityEnabled = name => ['ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'ExportACCode' && options.export); window.ServerURL = 'https://updates.test'; @@ -86,12 +88,13 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => event.preventDefault(); window.nativeSubmitCount = (window.nativeSubmitCount || 0) + 1; }); - }, options); - await page.addScriptTag({content: api + helpers + preprocessing + handler}); - await page.evaluate(async () => { + }, {...options, signedOut: options.signedOut || content === 'Please log in'}); + await page.addScriptTag({content: api + helpers + preprocessing + handler + startup}); + await page.evaluate(async authenticated => { + RunAccountStartup(authenticated); RunAccountPreprocessing(); await RunAccountPage(); - }); + }, !options.signedOut && content !== 'Please log in'); return page; } try { @@ -110,6 +113,37 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => assert.deepEqual(await page.evaluate(() => requests), []); await page.close(); }); + for (const [layout, markup] of [ + ['main without a form', '
'], + ['direct body form', nativeForm], + ['changed container and navbar', '
' + nativeForm + '
'] + ]) { + await t.test('shows and saves the badge editor with ' + layout, async () => { + const page = await Page('/modify_user_info.php', nativeForm, {markup}); + assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true); + assert.equal(await page.getByRole('heading', {name: '标签编辑'}).isVisible(), true); + assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge'); + assert.equal(await page.locator('form #UserScriptBadgeEditor').count(), 0); + await page.evaluate(() => RunAccountStartup(true)); + assert.equal(await page.locator('#UserScriptBadgeEditor').count(), 1); + await page.locator('#UserScriptBadgeContent').fill('Restored badge'); + await page.getByRole('button', {name: '修改标签', exact: true}).click(); + assert.equal(await page.getByRole('status').innerText(), '修改成功'); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge').map(call => call.data.Content)), ['Restored badge']); + assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + await page.close(); + }); + } + await t.test('uses the signed-in profile if the old login check no longer matches', async () => { + const page = await Page('/modify_user_info.php'); + await page.evaluate(() => { + document.getElementById('UserScriptBadgeEditor').remove(); + RunAccountStartup(false); + }); + assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true); + assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge'); + await page.close(); + }); for (const failure of [false, true]) { await t.test('badge save ' + (failure ? 'reports failure and retains cache' : 'clears only this user’s badge cache'), async () => { const page = await Page('/modify_user_info.php', nativeForm, {badgeFailure: failure}); @@ -130,15 +164,14 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.close(); }); } - for (const route of ['/modify_user_info.php', '/modifypage.php']) { - const legacy = route === '/modifypage.php'; - const button = legacy ? '#ModifyInfo' : 'button:has-text("修改标签")'; - const content = legacy ? '#BadgeContent' : '#UserScriptBadgeContent'; - const background = legacy ? '#BadgeBackgroundColor' : '#UserScriptBadgeBackground'; - const color = legacy ? '#BadgeColor' : '#UserScriptBadgeColor'; + for (const route of ['/modify_user_info.php']) { + const button = 'button:has-text("修改标签")'; + const content = '#UserScriptBadgeContent'; + const background = '#UserScriptBadgeBackground'; + const color = '#UserScriptBadgeColor'; for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { await t.test('normalizes incomplete badge data on ' + route + ': ' + JSON.stringify(badgeData), async () => { - const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeData}); + const page = await Page(route, nativeForm, {badgeData}); assert.equal(await page.locator(content).inputValue(), ''); assert.equal(await page.locator(background).inputValue(), '#000000'); assert.equal(await page.locator(color).inputValue(), '#ffffff'); @@ -157,7 +190,7 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => ['http', '请求失败(HTTP 503),请重试'] ]) { await t.test('recovers and retries a ' + mode + ' badge failure on ' + route, async () => { - const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeFailureMode: mode}); + const page = await Page(route, nativeForm, {badgeFailureMode: mode}); await page.evaluate(() => { localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale'); localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep'); @@ -165,7 +198,7 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.locator(content).fill('New badge'); await page.locator(button).click(); assert.equal(await page.locator(button).isEnabled(), true); - assert.equal(await page.locator(legacy ? '#ErrorElement' : '[role="status"]').innerText(), message); + assert.equal(await page.locator('[role="status"]').innerText(), message); assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'stale'); assert.deepEqual(await page.evaluate(() => requests), []); if (['network', 'timeout', 'abort'].includes(mode)) { @@ -174,10 +207,10 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.evaluate(() => { window.badgeFailureMode = null; }); await page.locator(button).click(); assert.equal(await page.locator(button).isEnabled(), true); - assert.equal(await page.locator(legacy ? '#SuccessElement' : '[role="status"]').innerText(), '修改成功'); + assert.equal(await page.locator('[role="status"]').innerText(), '修改成功'); assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null); assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep'); - assert.deepEqual(await page.evaluate(() => requests), legacy ? ['https://www.xmoj.tech/modify.php'] : []); + assert.deepEqual(await page.evaluate(() => requests), []); await page.close(); }); } @@ -190,30 +223,22 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => assert.equal(await page.evaluate(() => nativeSubmitCount), 1); await page.close(); }); - for (const route of ['/modify_user_info.php', '/modifypage.php']) { - const legacy = route === '/modifypage.php'; - const content = legacy ? '#BadgeContent' : '#UserScriptBadgeContent'; - const save = legacy ? '#ModifyInfo' : 'button:has-text("修改标签")'; - const status = legacy ? '#BadgeLoadStatus' : '[role="status"]'; + for (const route of ['/modify_user_info.php']) { + const content = '#UserScriptBadgeContent'; + const save = 'button:has-text("修改标签")'; + const status = '[role="status"]'; for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'invalid-response', 'http', 'denied']) { await t.test('protects existing badges and retries a ' + mode + ' load failure on ' + route, async () => { - const page = await Page(route, legacy ? legacyForm : nativeForm, + const page = await Page(route, nativeForm, {badgeLoadFailureMode: mode, noBadge: mode === 'denied'}); assert.match(await page.locator(status).innerText(), /标签加载失败:/); assert.equal(await page.locator(content).isDisabled(), true); assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isVisible(), true); await page.evaluate(() => localStorage.setItem('UserScript-User-Tester-Badge-Content', 'existing badge')); - if (legacy) { - // An unrelated account edit must not send unloaded badge values. - await page.locator('#Nickname').fill('Edited account'); - await page.locator(save).click(); - assert.deepEqual(await page.evaluate(() => requests), ['https://www.xmoj.tech/modify.php']); - } else { - assert.equal(await page.locator(save).isDisabled(), true); - await page.locator(save).dispatchEvent('click'); - await page.getByRole('button', {name: 'Save account'}).click(); - assert.equal(await page.evaluate(() => nativeSubmitCount), 1); - } + assert.equal(await page.locator(save).isDisabled(), true); + await page.locator(save).dispatchEvent('click'); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'existing badge'); await page.evaluate(() => { @@ -224,7 +249,6 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => assert.equal(await page.locator(content).isEnabled(), true); assert.equal(await page.locator(content).inputValue(), 'Badge'); assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isHidden(), true); - if (legacy) assert.equal(await page.locator('#Nickname').inputValue(), 'Edited account'); await page.locator(content).fill('Recovered badge'); await page.locator(save).click(); assert.equal(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data.Content), 'Recovered badge'); @@ -233,11 +257,10 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => }); } await t.test('blocks badge writes while the initial load is pending on ' + route, async () => { - const page = await Page(route, legacy ? legacyForm : nativeForm, {badgeLoadFailureMode: 'pending'}); + const page = await Page(route, nativeForm, {badgeLoadFailureMode: 'pending'}); assert.match(await page.locator(status).innerText(), /正在加载标签/); assert.equal(await page.locator(content).isDisabled(), true); - if (legacy) await page.locator(save).click(); - else await page.locator(save).dispatchEvent('click'); + await page.locator(save).dispatchEvent('click'); assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); await page.evaluate(() => pendingBadgeRequest.onload({status: 200, responseText: JSON.stringify({ Success: true, Data: {Content: 'Existing badge', BackgroundColor: '#112233', Color: '#ffffff'} @@ -247,7 +270,7 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.close(); }); } - for (const route of ['/modify_user_info.php', '/modifypage.php']) { + for (const route of ['/modify_user_info.php']) { await t.test('renders changelog on ' + route + ' even without an account form', async () => { const page = await Page(route + '?ByUserScript=1', 'Please log in', {export: true}); assert.equal(await page.title(), 'XMOJ-Script 更新日志'); @@ -269,10 +292,13 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await page.close(); }); } - await t.test('retains the legacy account enhancement', async () => { - const page = await Page('/modifypage.php', legacyForm, {noBadge: true}); - assert.equal(await page.locator('#Nickname').inputValue(), 'Nickname'); - assert.equal(await page.locator('#ModifyInfo').count(), 1); + await t.test('redirects defunct account links to the current page preserving the query and fragment', async () => { + const page = await Page('/modifypage.php?ByUserScript=1#latest'); + assert.equal(await page.locator('#UserScriptBadgeEditor').count(), 0); + await Promise.all([ + page.waitForURL('https://account-settings.test/modify_user_info.php?ByUserScript=1#latest'), + page.evaluate(() => location.replace(GetAccountSettingsRedirect())) + ]); await page.close(); }); await t.test('leaves the separate password form intact', async () => { @@ -295,3 +321,12 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => await browser.close(); } }); + +test('current account tools initialize before the monolithic page handler', () => { + const call = source.indexOf('InitializeAccountFeatures(logined'); + const main = source.indexOf('await main();'); + assert.ok(call >= 0); + assert.ok(main >= 0); + assert.ok(call < main); + assert.match(source, /const AccountRedirect = GetAccountSettingsRedirect\(\);/); +}); diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs index dddb5a93..eaec523a 100644 --- a/tests/chinese-language.test.cjs +++ b/tests/chinese-language.test.cjs @@ -132,7 +132,11 @@ test('hides classic and Vue language selectors while preserving forms and Chines test('startup enforces Chinese before either UI initializer and Vue refreshes reapply it', () => { const startup = Between('(async () => {\nif (GetContestWebRedirect()) return;', '//otherwise CurrentUsername might be undefined'); - assert.ok(startup.indexOf('await EnsureChinesePage()') < startup.indexOf('await InitializeContestWebApp()')); + const enforce = startup.indexOf('await EnsureChinesePage()'); + const initialize = startup.indexOf('await InitializeContestWebApp()'); + assert.ok(enforce >= 0, 'Chinese initialization must exist'); + assert.ok(initialize >= 0, 'Vue initialization must exist'); + assert.ok(enforce < initialize); const enhance = Between('function Enhance() {', 'function ScheduleEnhance() {'); assert.match(enhance, /EnforceChineseView\(root\)/); }); diff --git a/tests/contest-web.test.cjs b/tests/contest-web.test.cjs index 9528d2bb..7b7d94f1 100644 --- a/tests/contest-web.test.cjs +++ b/tests/contest-web.test.cjs @@ -178,7 +178,8 @@ test('the load-time hide keeps innerText readable for the page handlers', () => }); test('web and classic pages load the same monochrome fonts before either Bootstrap path', () => { - const early = between('// Set to true by the early block', 'const CaptchaSiteKey'); + const early = between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') + + between('// Set to true by the early block', 'const CaptchaSiteKey'); const fontURLs = new Set(); for (const pathname of ['/web/contest', '/problem.php?id=1000']) { for (const cachedBootstrap of [false, true]) { From 993220e0686e135870052995be0aa7c94e304c19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 05:45:33 +0000 Subject: [PATCH 036/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 4fba733c..ab29e46e 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791091969345, + "UpdateDate": 1791092727050, "Prerelease": true, "UpdateContents": [ { From 862b7f43c20a70089dbc1c89b54a7b968f7aba00 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 14:05:58 +0800 Subject: [PATCH 037/137] fix: render complete profiles before optional requests --- XMOJ.user.js | 405 +++++++++++++++++++++--------------- package.json | 2 +- tests/fixtures/profile.html | 21 ++ tests/profile-page.test.cjs | 222 ++++++++++++++++++++ 4 files changed, 486 insertions(+), 164 deletions(-) create mode 100644 tests/fixtures/profile.html create mode 100644 tests/profile-page.test.cjs diff --git a/XMOJ.user.js b/XMOJ.user.js index f607de5b..1af4f2e4 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2255,6 +2255,240 @@ function CreateProblemSwitcher(ProblemList, IsCurrent) { return problemSwitcher; } +// Read server-rendered links, or the numeric p(id, count) calls in unexecuted HTML. +// Never eval profile scripts: document.write after page load can replace the page. +function GetProfileSolvedProblems(root = document) { + let table = root.querySelector("#statics"); + let problems = new Set(); + for (let link of table?.querySelectorAll("a[href]") || []) { + try { + let url = new URL(link.getAttribute("href"), location.href); + let value = url.searchParams.get("id"); + let id = Number(value); + if (url.pathname == "/problem.php" && /^\d+$/.test(value) && Number.isSafeInteger(id) && id > 0) problems.add(id); + } catch { /* Ignore malformed links rather than aborting the profile. */ } + } + for (let script of table?.querySelectorAll("script") || []) { + for (let match of script.textContent.matchAll(/\bp\(\s*(\d+)\s*,\s*\d+\s*\)/g)) { + if (Number.isSafeInteger(Number(match[1])) && Number(match[1]) > 0) problems.add(Number(match[1])); + } + } + return [...problems]; +} + +function InitializeProfileBadge(container, userID, isAdmin) { + let badge = document.createElement("span"); + badge.className = "badge me-2"; + badge.id = "UserScriptProfileBadge"; + let status = document.createElement("span"); + status.setAttribute("role", "status"); + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary btn-sm"; + retry.textContent = "重试加载标签"; + let controls = document.createElement("span"); + container.append(badge, status, retry, controls); + const ClearCache = () => { + let keys = []; + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i); + if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key); + } + keys.forEach(key => localStorage.removeItem(key)); + }; + const Fail = message => { + status.textContent = "标签暂不可用:" + message; + retry.hidden = false; + retry.disabled = false; + }; + const Load = () => { + status.textContent = "正在加载标签..."; + retry.hidden = true; + retry.disabled = true; + controls.replaceChildren(); + RequestAPI("GetBadge", {UserID: userID}, response => { + if (!response?.Success) { Fail(response?.Message || "服务器响应异常,请重试"); return; } + let data = response.Data ?? {}; + let content = String(data.Content ?? ""); + badge.textContent = content; + badge.hidden = content === ""; + badge.style.backgroundColor = /^#[0-9a-f]{6}$/i.test(data.BackgroundColor) ? data.BackgroundColor : "#000000"; + badge.style.color = /^#[0-9a-f]{6}$/i.test(data.Color) ? data.Color : "#ffffff"; + status.textContent = ""; + retry.disabled = false; + if (!isAdmin) return; + let button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-outline-primary btn-sm"; + button.textContent = content === "" ? "添加标签" : "删除标签"; + controls.appendChild(button); + button.addEventListener("click", () => { + if (content !== "" && !confirm("您确定要删除此标签吗?")) return; + button.disabled = true; + const Failed = message => { status.textContent = message; button.disabled = false; }; + RequestAPI(content === "" ? "NewBadge" : "DeleteBadge", {UserID: userID}, result => { + if (!result?.Success) { Failed(result?.Message || "服务器响应异常,请重试"); return; } + ClearCache(); + Load(); + }, Failed); + }); + }, Fail); + }; + retry.addEventListener("click", Load); + Load(); +} + +function InitializeProfileChart(table) { + let chart = table.querySelector("#PieDiv"); + if (!chart) return; + let entries = [...table.rows].filter(row => row.cells[1]?.querySelector('a[href*="jresult="]') && !/^(解决|Solved)/i.test(row.cells[0]?.textContent.trim())) + .map(row => ({label: row.cells[0].textContent.trim(), value: Number(row.cells[1].textContent.trim())})) + .filter(entry => Number.isFinite(entry.value) && entry.value >= 0); + let total = entries.reduce((sum, entry) => sum + entry.value, 0); + if (!entries.length) return; + let colors = ["#FF8080", "#8080FF", "#80bb80", "#FF0066", "#9900FF", "#996633", "#006633", "#000000", "#66cddd", "#0066FF"]; + let pie = document.createElement("div"); + pie.setAttribute("role", "img"); + pie.setAttribute("aria-label", "判题结果分布"); + pie.style.cssText = "width:100px;height:100px;border-radius:50%;flex-shrink:0"; + pie.style.setProperty("border-radius", "50%", "important"); + let legend = document.createElement("ul"); + legend.className = "list-unstyled mb-0"; + legend.style.textAlign = "left"; + let position = 0; + let segments = entries.map((entry, index) => { + let start = position; + position += total ? entry.value / total * 100 : 0; + let color = colors[index % colors.length]; + let item = document.createElement("li"); + let marker = document.createElement("span"); + marker.style.cssText = "display:inline-block;width:10px;height:10px;margin-right:6px;background:" + color; + item.append(marker, document.createTextNode(entry.label + " [" + (total ? Math.round(entry.value / total * 100) : 0) + "%]")); + legend.appendChild(item); + return color + " " + start + "% " + position + "%"; + }); + pie.style.background = total ? "conic-gradient(" + segments.join(",") + ")" : "#808080"; + // The old canvas positions ten labels within 100px; normal flow prevents overlap. + chart.style.cssText = "display:flex;flex-wrap:wrap;align-items:center;gap:12px;width:auto;height:auto"; + chart.replaceChildren(pie, legend); +} + +// Commit the complete profile before any optional API call can fail or stall. +function InitializeUserProfile(isAdmin = false) { + if (location.pathname != "/userinfo.php" || new URLSearchParams(location.search).has("ByUserScript") || document.getElementById("UserScriptProfile")) return; + let table = document.getElementById("statics"); + if (!table) return; + let caption = table.caption?.cloneNode(true); + caption?.querySelectorAll("a").forEach(link => link.remove()); + let identity = caption?.textContent.trim() || ""; + let separator = identity.indexOf("--"); + let userID = separator >= 0 ? identity.slice(0, separator).trim() : new URLSearchParams(location.search).get("user"); + let nickname = separator >= 0 ? identity.slice(separator + 2).trim() : identity; + if (!userID) return; + let problems = GetProfileSolvedProblems(); + let rows = [...table.rows]; + const Value = pattern => rows.find(row => pattern.test(row.cells[0]?.textContent.trim() || ""))?.cells[1]?.textContent.trim(); + let email = Value(/^(电子邮箱|Email:?)$/i) || ""; + let submitted = Number(Value(/^(提交|Submit(?:ted)?|Submissions?:?)$/i)); + let accepted = Number(Value(/^(正确|Accepted:?)$/i)); + let rating = submitted > 0 && Number.isFinite(accepted) ? Math.round(accepted / submitted * 1000) : 0; + let profile = document.createElement("div"); + profile.id = "UserScriptProfile"; + profile.className = "row text-start text-left"; + let left = document.createElement("div"); + left.className = "col-md-5"; + let right = document.createElement("div"); + right.className = "col-md-7"; + profile.append(left, right); + let header = document.createElement("div"); + header.className = "row mb-2"; + let avatarContainer = document.createElement("div"); + avatarContainer.className = "col-auto"; + let avatar = document.createElement("img"); + avatar.className = "rounded me-2"; + avatar.alt = userID + " 的头像"; + avatar.width = avatar.height = 120; + avatar.src = "https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y"; + // The native profile already contains the email and statistics. No second + // profile fetch is needed to show the avatar or compute the rating. + if (email) { + try { avatar.src = "https://cravatar.cn/avatar/" + CryptoJS.MD5(email).toString() + "?d=retro"; } + catch (error) { console.error("[XMOJ-Script] Profile avatar:", error); } + } + avatarContainer.appendChild(avatar); + let info = document.createElement("div"); + info.className = "col-auto"; + info.style.lineHeight = "40px"; + const Line = text => { + let line = document.createElement("div"); + line.textContent = text; + info.appendChild(line); + return line; + }; + Line("用户名:" + userID); + Line("昵称:" + nickname); + if (UtilityEnabled("Rating")) Line("评分:" + rating); + let lastOnline = Line("最后在线:加载中..."); + lastOnline.id = "UserScriptProfileLastOnline"; + let badges = document.createElement("div"); + info.appendChild(badges); + header.append(avatarContainer, info); + left.appendChild(header); + let heading = document.createElement("h5"); + heading.textContent = "已解决题目"; + right.appendChild(heading); + let solved = document.createElement("div"); + solved.id = "UserScriptProfileSolved"; + solved.style.lineHeight = "1.8"; + for (let id of problems) { + let link = document.createElement("a"); + link.href = "/problem.php?id=" + id; + link.target = "_blank"; + link.textContent = id; + solved.append(link, document.createTextNode(" ")); + } + right.appendChild(solved); + + // Move the original statistics/chart nodes so their links and handlers survive. + table.insertAdjacentElement("beforebegin", profile); + for (let link of table.caption?.querySelectorAll("a") || []) info.appendChild(link); + table.caption?.remove(); + let solvedCell = [...table.querySelectorAll("td[rowspan]")].find(cell => + cell.querySelector('a[href*="problem.php"]') || [...cell.querySelectorAll("script")].some(script => /\bfunction\s+p\s*\(/.test(script.textContent))); + let activity = document.getElementById("submission"); + if (solvedCell?.querySelector('a[href*="problem.php"]')) { + // Keep the native per-problem submission-count links and their handlers. + solved.replaceChildren(); + let group = null; + for (let node of [...solvedCell.childNodes]) { + if (node.nodeName == "SCRIPT" || node === activity) continue; + if (node.nodeName == "A" && node.getAttribute("href")?.includes("problem.php")) { + group = document.createElement("span"); + group.style.cssText = "display:inline-block;white-space:nowrap;margin-right:8px"; + solved.appendChild(group); + } + (group || solved).appendChild(node); + } + } + if (activity && !UtilityEnabled("RemoveUseless")) right.appendChild(activity); + solvedCell?.remove(); + let firstRow = table.rows[0]; + if (firstRow?.cells.length >= 2 && !firstRow.cells[0].textContent.trim() && !firstRow.cells[1].textContent.trim()) firstRow.remove(); + for (let row of table.rows) { + if (row.cells[0]?.textContent.trim() == "Statistics") row.cells[0].textContent = "统计"; + if (row.cells[0]?.textContent.trim() == "Email:") row.cells[0].textContent = "电子邮箱"; + for (let cell of row.cells) cell.removeAttribute("align"); + } + table.removeAttribute("width"); + left.appendChild(table); + InitializeProfileChart(table); + document.title = "用户 " + userID + " 的个人中心"; + InitializeProfileBadge(badges, userID, isAdmin); + RequestAPI("LastOnline", {Username: userID}, response => { + lastOnline.textContent = "最后在线:" + (response?.Success && response.Data?.logintime != null ? GetRelativeTime(response.Data.logintime) : "暂无记录"); + }, () => { lastOnline.textContent = "最后在线:暂不可用"; }); +} + function IsAccountSettingsPage(pathname) { return pathname == "/modify_user_info.php"; } @@ -3291,6 +3525,7 @@ CurrentUsername = CurrentUsername.replaceAll(/[^a-zA-Z0-9]/g, ""); // Initialize migrated account tools independently of the legacy navbar/layout handler. InitializeAccountFeatures(logined || (CurrentUsername && !/^(Login|Guest)$/i.test(CurrentUsername))); let IsAdmin = AdminUserList.indexOf(CurrentUsername) !== -1; +InitializeUserProfile(IsAdmin); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); const applyTheme = (theme) => { @@ -3351,13 +3586,13 @@ async function main() { } // Preserve native account listeners during page-wide customization. RequestAPI("SendData", {}); - // These native forms rely on site-installed listeners. Whole-body - // replacements discard them even when the account handler keeps the form. - let preserveAccountForm = location.pathname == "/modify_user_info.php" || location.pathname == "/modify_password.php"; - if (UtilityEnabled("ReplaceLinks") && !preserveAccountForm) { + // Whole-body replacements discard native forms, profile charts, + // and listeners already installed on the enhanced profile. + let preserveNativePage = ["/modify_user_info.php", "/modify_password.php", "/userinfo.php"].includes(location.pathname); + if (UtilityEnabled("ReplaceLinks") && !preserveNativePage) { document.body.innerHTML = String(document.body.innerHTML).replaceAll(/\[([^<]*)<\/a>\]/g, ""); } - if (UtilityEnabled("ReplaceXM") && !preserveAccountForm) { + if (UtilityEnabled("ReplaceXM") && !preserveNativePage) { document.body.innerHTML = String(document.body.innerHTML).replaceAll("我", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("小明", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("下海", "上海"); @@ -5450,158 +5685,7 @@ async function main() { } else if (location.pathname == "/userinfo.php" && document.querySelector("body > div > div") == null) { //页面结构异常(如403/404页面),跳过处理 } else if (location.pathname == "/userinfo.php") { - if (SearchParams.get("ByUserScript") === null) { - if (UtilityEnabled("RemoveUseless")) { - let Temp = document.getElementById("submission").childNodes; - for (let i = 0; i < Temp.length; i++) { - Temp[i].remove(); - } - } - if (document.querySelector("body > script:nth-child(5)") != null) eval(document.querySelector("body > script:nth-child(5)").innerHTML); - if (document.querySelector("#statics > tbody > tr:nth-child(1)") != null) document.querySelector("#statics > tbody > tr:nth-child(1)").remove(); - - let Temp = (document.querySelector("#statics > tbody") != null) ? document.querySelector("#statics > tbody").children : []; - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].children[0] != undefined) { - if (Temp[i].children[0].innerText == "Statistics") { - Temp[i].children[0].innerText = "统计"; - } else if (Temp[i].children[0].innerText == "Email:") { - Temp[i].children[0].innerText = "电子邮箱"; - } - Temp[i].children[1].removeAttribute("align"); - } - } - - Temp = (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) ? document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").childNodes : []; - let ACProblems = []; - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].tagName == "A" && Temp[i].href.indexOf("problem.php?id=") != -1) { - ACProblems.push(Number(Temp[i].innerText.trim())); - } - } - if (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").remove(); - - let UserID, UserNick; - if (document.querySelector("#statics > caption") != null) [UserID, UserNick] = document.querySelector("#statics > caption").childNodes[0].data.trim().split("--"); - if (document.querySelector("#statics > caption") != null) document.querySelector("#statics > caption").remove(); - document.title = "用户 " + UserID + " 的个人中心"; - let Row = document.createElement("div"); - Row.className = "row"; - let LeftDiv = document.createElement("div"); - LeftDiv.className = "col-md-5"; - Row.appendChild(LeftDiv); - - let LeftTopDiv = document.createElement("div"); - LeftTopDiv.className = "row mb-2"; - LeftDiv.appendChild(LeftTopDiv); - let AvatarContainer = document.createElement("div"); - AvatarContainer.classList.add("col-auto"); - let AvatarElement = document.createElement("img"); - let UserEmailHash = (await GetUserInfo(UserID)).EmailHash; - if (UserEmailHash == undefined) { - AvatarElement.src = `https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y`; - } else { - AvatarElement.src = `https://cravatar.cn/avatar/${UserEmailHash}?d=retro`; - } - AvatarElement.classList.add("rounded", "me-2"); - AvatarElement.style.height = "120px"; - AvatarContainer.appendChild(AvatarElement); - LeftTopDiv.appendChild(AvatarContainer); - - let UserInfoElement = document.createElement("div"); - UserInfoElement.classList.add("col-auto"); - UserInfoElement.style.lineHeight = "40px"; - UserInfoElement.innerHTML += "用户名:" + escapeHTML(UserID) + "
"; - UserInfoElement.innerHTML += "昵称:" + escapeHTML(UserNick) + "
"; - if (UtilityEnabled("Rating")) { - UserInfoElement.innerHTML += "评分:" + ((await GetUserInfo(UserID)).Rating) + "
"; - } - // Create a placeholder for the last online time - let lastOnlineElement = document.createElement('div'); - lastOnlineElement.innerHTML = "最后在线:加载中...
"; - UserInfoElement.appendChild(lastOnlineElement); - let BadgeInfo = await GetUserBadge(UserID); - if (IsAdmin) { - if (BadgeInfo.Content !== "") { - let DeleteBadgeButton = document.createElement("button"); - DeleteBadgeButton.className = "btn btn-outline-danger btn-sm"; - DeleteBadgeButton.innerText = "删除标签"; - DeleteBadgeButton.addEventListener("click", async () => { - if (confirm("您确定要删除此标签吗?")) { - RequestAPI("DeleteBadge", { - "UserID": UserID - }, (Response) => { - if (UtilityEnabled("DebugMode")) console.log(Response); - if (Response.Success) { - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - window.location.reload(); - } else { - SmartAlert(Response.Message); - } - }); - } - }); - UserInfoElement.appendChild(DeleteBadgeButton); - } else { - let AddBadgeButton = document.createElement("button"); - AddBadgeButton.className = "btn btn-outline-primary btn-sm"; - AddBadgeButton.innerText = "添加标签"; - AddBadgeButton.addEventListener("click", async () => { - RequestAPI("NewBadge", { - "UserID": UserID - }, (Response) => { - if (Response.Success) { - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - window.location.reload(); - } else { - SmartAlert(Response.Message); - } - }); - }); - UserInfoElement.appendChild(AddBadgeButton); - } - } - RequestAPI("LastOnline", {"Username": UserID}, (result) => { - if (result.Success) { - if (UtilityEnabled("DebugMode")) { - console.log('lastOnline:' + result.Data.logintime); - } - lastOnlineElement.innerHTML = "最后在线:" + GetRelativeTime(result.Data.logintime) + "
"; - } else { - lastOnlineElement.innerHTML = "最后在线:近三个月内从未
"; - } - }); - LeftTopDiv.appendChild(UserInfoElement); - LeftDiv.appendChild(LeftTopDiv); - - let LeftTable = document.querySelector("body > div > div > center > table"); - LeftDiv.appendChild(LeftTable); - let RightDiv = document.createElement("div"); - RightDiv.className = "col-md-7"; - Row.appendChild(RightDiv); - RightDiv.innerHTML = "
已解决题目
"; - for (let i = 0; i < ACProblems.length; i++) { - RightDiv.innerHTML += "
" + ACProblems[i] + " "; - } - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = ""; - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").appendChild(Row); - } else { + if (SearchParams.get("ByUserScript") !== null) { document.title = "上传标程"; if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = ` @@ -5629,12 +5713,7 @@ async function main() { return Response.text(); }).then((Response) => { let ParsedDocument = new DOMParser().parseFromString(Response, "text/html"); - let ScriptData = (ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script") != null) ? ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script").innerText : ""; - ScriptData = ScriptData.substr(ScriptData.indexOf("}") + 1).trim(); - ScriptData = ScriptData.split(";"); - for (let i = 0; i < ScriptData.length; i++) { - ACList.push(Number(ScriptData[i].substring(2, ScriptData[i].indexOf(",")))); - } + ACList = GetProfileSolvedProblems(ParsedDocument); }); RequestAPI("GetStdList", {}, async (Result) => { if (Result.Success) { diff --git a/package.json b/package.json index f9d97bdc..5f8df3e5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { - "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs tests/chinese-language.test.cjs" + "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs tests/chinese-language.test.cjs tests/profile-page.test.cjs" }, "devDependencies": { "playwright": "1.59.1" diff --git a/tests/fixtures/profile.html b/tests/fixtures/profile.html new file mode 100644 index 00000000..fcdbbbf7 --- /dev/null +++ b/tests/fixtures/profile.html @@ -0,0 +1,21 @@ + +
+
+ + + + + + + + + + +
ProfileTarget--昵称--后缀短消息
Solved Problems List
解决2
Activity chart
提交20
正确8
答案错误12
Statistics
Old overlapping legend
Email:profile@example.test
+ diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs new file mode 100644 index 00000000..3c6e3430 --- /dev/null +++ b/tests/profile-page.test.cjs @@ -0,0 +1,222 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +const fixture = fs.readFileSync(path.join(__dirname, 'fixtures/profile.html'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'unique extraction marker: ' + marker); + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const profile = Between('function GetProfileSolvedProblems(', 'function IsAccountSettingsPage('); +const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); +const preprocessing = 'window.RunProfilePreprocessing = () => {' + Between( + '// Preserve native account listeners during page-wide customization.', + '// Bootstrap stylesheet and markup migration.') + '};'; + +test('profile page browser regressions', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + async function Page(options = {}) { + const page = await browser.newPage(); + await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: options.html ?? fixture})); + await page.goto('https://profile.test/userinfo.php?user=ProfileTarget' + (options.upload ? '&ByUserScript=1' : '')); + await page.evaluate(options => { + window.CurrentUsername = 'Viewer'; + window.GM_info = {script: {version: 'test'}}; + document.cookie = 'PHPSESSID=fixture; path=/'; + window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity); + window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }}; + window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); }; + window.GetRelativeTime = timestamp => 'relative(' + timestamp + ')'; + window.confirm = () => true; + window.apiCalls = []; + window.requests = {}; + window.badgeMode = options.badgeMode; + window.onlineMode = options.onlineMode; + window.badgeData = Object.hasOwn(options, 'badgeData') ? options.badgeData : {Content: '省一', BackgroundColor: '#112233', Color: '#ffffff'}; + window.actionMode = options.actionMode; + window.GM_xmlhttpRequest = request => { + const action = new URL(request.url).pathname.slice(1); + const data = JSON.parse(request.data).Data; + if (action === 'SendData') return; + apiCalls.push({action, data, timeout: request.timeout}); + requests[action] = request; + const mode = action === 'GetBadge' ? badgeMode : action === 'LastOnline' ? onlineMode : actionMode; + if (mode === 'pending') return; + if (mode === 'throw') throw new Error('Transport failure'); + if (['network', 'timeout', 'abort'].includes(mode)) { + request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); + request.onload({status: 200, responseText: '{"Success":true,"Data":{"Content":"late"}}'}); + return; + } + if (mode === 'invalid-json' || mode === 'http') { + request.onload({status: mode === 'http' ? 503 : 200, responseText: 'Error'}); + return; + } + if (!mode && action === 'NewBadge') window.badgeData = {Content: 'New badge'}; + if (!mode && action === 'DeleteBadge') window.badgeData = null; + request.onload({status: 200, responseText: JSON.stringify({Success: mode !== 'denied', Message: 'Denied', + Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: 1234} : {}})}); + }; + window.nativeStats = document.getElementById('statics'); + window.nativePie = document.getElementById('PieDiv'); + window.nativeActivity = document.getElementById('submission'); + window.nativeCountLink = document.querySelector('a[href*="problem_id=1000"]'); + window.pieClicks = window.countClicks = 0; + nativePie?.addEventListener('click', () => { pieClicks++; }); + nativeCountLink?.addEventListener('click', event => { event.preventDefault(); countClicks++; }); + }, options); + await page.addScriptTag({content: api + profile + preprocessing}); + await page.evaluate(isAdmin => { + InitializeUserProfile(isAdmin); + RunProfilePreprocessing(); + }, !!options.admin); + return page; + } + const AssertCore = async page => { + assert.equal(await page.locator('#UserScriptProfile').isVisible(), true); + assert.equal(await page.title(), '用户 ProfileTarget 的个人中心'); + assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('昵称:昵称--后缀', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('评分:400', {exact: true}).isVisible(), true); + assert.equal(await page.locator('#UserScriptProfile img').isVisible(), true); + assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /123456789012345678901234567890abcf/); + assert.equal(await page.evaluate(() => avatarEmail), 'profile@example.test'); + assert.deepEqual(await page.locator('#UserScriptProfileSolved a[href*="problem.php"]').allTextContents(), ['1000 ', '1010 ']); + assert.equal(await page.getByRole('link', {name: '短消息', exact: true}).getAttribute('href'), 'mail.php?to_user=ProfileTarget'); + assert.equal(await page.locator('#statics tr').count(), 6); + assert.equal(await page.locator('#statics a[href*="jresult=4"]').allTextContents().then(values => values.join(',')), '2,8'); + assert.equal(await page.evaluate(() => document.getElementById('statics') === nativeStats), true); + assert.equal(await page.evaluate(() => document.getElementById('PieDiv') === nativePie), true); + assert.equal(await page.evaluate(() => document.querySelector('a[href*="problem_id=1000"]') === nativeCountLink), true); + assert.equal(await page.evaluate(() => nativeProfileRuns), 1, 'never re-evaluate native document.write scripts'); + }; + try { + await t.test('restores identity, rating, badge, last online, solved links and statistics', async () => { + const page = await Page(); + await AssertCore(page); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); + assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:relative(1234)'); + assert.deepEqual(await page.evaluate(() => apiCalls), [ + {action: 'GetBadge', data: {UserID: 'ProfileTarget'}, timeout: 15000}, + {action: 'LastOnline', data: {Username: 'ProfileTarget'}, timeout: 15000} + ]); + assert.equal(await page.getByRole('button', {name: '添加标签'}).count(), 0); + await page.getByRole('link', {name: '7', exact: true}).click(); + await page.locator('#PieDiv').click(); + assert.deepEqual(await page.evaluate(() => [countClicks, pieClicks]), [1, 1]); + await page.evaluate(() => InitializeUserProfile()); + assert.equal(await page.locator('#UserScriptProfile').count(), 1); + assert.equal(await page.evaluate(() => apiCalls.length), 2); + await page.close(); + }); + await t.test('renders core content immediately while optional requests are pending', async () => { + const page = await Page({badgeMode: 'pending', onlineMode: 'pending'}); + await AssertCore(page); + assert.match(await page.getByRole('status').innerText(), /正在加载标签/); + await page.evaluate(() => requests.LastOnline.ontimeout()); + assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:暂不可用'); + await page.close(); + }); + for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'http', 'denied']) { + await t.test('preserves the profile and retries a ' + mode + ' badge failure', async () => { + const page = await Page({badgeMode: mode, onlineMode: mode}); + await AssertCore(page); + assert.match(await page.getByRole('status').innerText(), /标签暂不可用/); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isVisible(), true); + await page.evaluate(() => { window.badgeMode = null; }); + await page.getByRole('button', {name: '重试加载标签'}).click(); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isHidden(), true); + await page.close(); + }); + } + for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { + await t.test('handles missing badge data: ' + JSON.stringify(badgeData), async () => { + const page = await Page({badgeData, admin: true}); + await AssertCore(page); + assert.equal(await page.locator('#UserScriptProfileBadge').isHidden(), true); + assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true); + await page.close(); + }); + } + await t.test('admin badge actions target the viewed user and recover from failures', async () => { + const page = await Page({admin: true, actionMode: 'network'}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-ProfileTarget-Badge-Content', 'cached'); + localStorage.setItem('UserScript-User-Viewer-Badge-Content', 'keep'); + }); + await page.getByRole('button', {name: '删除标签'}).click(); + assert.equal(await page.getByRole('button', {name: '删除标签'}).isEnabled(), true); + assert.equal(await page.getByRole('status').innerText(), '网络错误,请重试'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), 'cached'); + await page.evaluate(() => { window.actionMode = null; }); + await page.getByRole('button', {name: '删除标签'}).click(); + assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true); + await page.getByRole('button', {name: '添加标签'}).click(); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), 'New badge'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Viewer-Badge-Content')), 'keep'); + assert.equal(await page.evaluate(() => apiCalls.filter(call => ['NewBadge', 'DeleteBadge'].includes(call.action)).every(call => call.data.UserID === 'ProfileTarget')), true); + await page.close(); + }); + await t.test('uses query identity with no caption and tolerates missing email, history and header rows', async () => { + const html = '
正确0
提交0
'; + const page = await Page({html}); + assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('评分:0', {exact: true}).isVisible(), true); + assert.equal(await page.locator('#statics tr').count(), 2); + assert.equal(await page.locator('#UserScriptProfileSolved a').count(), 0); + assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /d=mp/); + await page.close(); + }); + for (const removeActivity of [false, true]) { + await t.test('respects the activity-chart setting: ' + removeActivity, async () => { + const page = await Page({removeActivity}); + assert.equal(await page.locator('#submission').count(), removeActivity ? 0 : 1); + if (!removeActivity) assert.equal(await page.evaluate(() => document.getElementById('submission') === nativeActivity), true); + await page.close(); + }); + } + await t.test('chart legend uses separate non-overlapping rows', async () => { + const page = await Page(); + await page.addScriptTag({content: Between('const MonochromeSkinCSS', 'const NewBootstrapSkinCSS')}); + await page.addStyleTag({content: await page.evaluate(() => MonochromeSkinCSS)}); + assert.deepEqual(await page.locator('#PieDiv li').allTextContents(), ['正确 [40%]', '答案错误 [60%]']); + const boxes = await page.locator('#PieDiv li').evaluateAll(items => items.map(item => ({top: item.getBoundingClientRect().top, bottom: item.getBoundingClientRect().bottom}))); + assert.ok(boxes[1].top >= boxes[0].bottom); + assert.equal(await page.getByRole('img', {name: '判题结果分布'}).isVisible(), true); + assert.equal(await page.getByRole('img', {name: '判题结果分布'}).evaluate(element => getComputedStyle(element).borderRadius), '50%'); + await page.close(); + }); + await t.test('parses raw solved-problem calls for standard uploads without executing scripts', async () => { + const page = await Page({upload: true}); + assert.equal(await page.locator('#UserScriptProfile').count(), 0); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + const ids = await page.evaluate(() => { + window.shouldNeverRun = false; + const doc = new DOMParser().parseFromString('
1020bad
', 'text/html'); + return GetProfileSolvedProblems(doc); + }); + assert.deepEqual(ids, [1020, 1000, 1010]); + assert.equal(await page.evaluate(() => shouldNeverRun), false); + await page.close(); + }); + await t.test('leaves missing-user and error pages intact', async () => { + const page = await Page({html: '

No such User!

'}); + assert.equal(await page.locator('h1').innerText(), 'No such User!'); + assert.equal(await page.locator('#UserScriptProfile').count(), 0); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + await page.close(); + }); + } finally { await browser.close(); } +}); + +test('profile startup precedes the general page handler and standard uploads reuse solved parsing', () => { + const startup = source.indexOf('InitializeUserProfile(IsAdmin);'); + const main = source.indexOf('await main();'); + assert.ok(startup >= 0 && main >= 0 && startup < main); + assert.match(source, /ACList = GetProfileSolvedProblems\(ParsedDocument\);/); +}); From 65e57e63c52790de9cc29e526e8489b57217cf0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 06:06:40 +0000 Subject: [PATCH 038/137] Update time and description of 3.8.3 --- Update.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Update.json b/Update.json index ab29e46e..06958f51 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791092727050, + "UpdateDate": 1791093993813, "Prerelease": true, "UpdateContents": [ { @@ -3849,7 +3849,7 @@ "Description": "fix: restore account settings and enforce Chinese UI" } ], - "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单及标签编辑、AC 代码导出功能。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" } } } \ No newline at end of file From 7301f4f00f46e913456aed73516654be1399e449 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 06:07:19 +0000 Subject: [PATCH 039/137] Update time and description of 3.8.3 --- Update.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Update.json b/Update.json index 06958f51..54755aec 100644 --- a/Update.json +++ b/Update.json @@ -3841,15 +3841,15 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791093993813, + "UpdateDate": 1791094033746, "Prerelease": true, "UpdateContents": [ { "PR": 1040, - "Description": "fix: restore account settings and enforce Chinese UI" + "Description": "fix: restore account and profile pages and enforce Chinese UI" } ], - "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" } } } \ No newline at end of file From 8bf7af77e15a26dca0317dd62841e398e4ba90fb Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 14:36:17 +0800 Subject: [PATCH 040/137] fix: persist Chinese through current account settings and render profile time --- XMOJ.user.js | 59 +++++++++++++++++++++++------- tests/account-settings.test.cjs | 15 ++++---- tests/chinese-language.test.cjs | 64 ++++++++++++++++++++++++++++----- tests/profile-page.test.cjs | 18 +++++++--- 4 files changed, 125 insertions(+), 31 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 1af4f2e4..7db39af4 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -51,6 +51,47 @@ function InitializeChineseLanguage() { return wasEnglish; } +const chineseAccountForms = new WeakSet(); +function EnforceChineseAccountLanguage(root) { + const radios = [...root.querySelectorAll('input[type="radio"]')]; + const Matches = (radio, language) => { + const label = [...radio.labels || []].map(node => node.textContent.trim()); + return language == "zh" ? /^(cn|zh|zh[-_]cn)$/i.test(radio.value) || label.some(text => /^(中文|简体中文|Chinese)$/i.test(text)) : + /^en(?:[-_]us)?$/i.test(radio.value) || label.some(text => /^English$/i.test(text)); + }; + for (const chinese of radios.filter(radio => Matches(radio, "zh"))) { + const english = radios.find(radio => radio !== chinese && radio.name === chinese.name && radio.form === chinese.form && Matches(radio, "en")); + if (!chinese.name || !english) continue; + if (!chinese.checked) chinese.click(); + chinese.setAttribute("data-xmoj-script-chinese-language", ""); + let group = chinese.parentElement; + while (group && !group.contains(english)) group = group.parentElement; + if (group && !group.matches("form, body, html") && !group.querySelector('input:not([type="radio"]), select, textarea, button')) { + group.setAttribute("data-xmoj-script-language-selector", ""); + const heading = group.previousElementSibling; + if (heading && /^(界面语言|语言|Language)$/i.test(heading.textContent.trim())) heading.setAttribute("data-xmoj-script-language-selector", ""); + } else { + for (const radio of [chinese, english]) { + radio.setAttribute("data-xmoj-script-language-selector", ""); + for (const label of radio.labels || []) label.setAttribute("data-xmoj-script-language-selector", ""); + } + } + for (const label of root.querySelectorAll("label, legend")) { + if (/^(界面语言|语言|Language)$/i.test(label.textContent.trim())) label.setAttribute("data-xmoj-script-language-selector", ""); + } + const form = chinese.form; + if (form && !chineseAccountForms.has(form)) { + chineseAccountForms.add(form); + // Keep the native field enabled so the hidden Chinese value is submitted. + form.addEventListener("submit", () => EnforceChineseAccountLanguage(form), true); + form.addEventListener("formdata", event => { + EnforceChineseAccountLanguage(form); + for (const radio of form.querySelectorAll("input[data-xmoj-script-chinese-language]")) event.formData.set(radio.name, radio.value); + }); + } + } +} + function EnforceChineseView(root = document) { document.documentElement.lang = "zh-CN"; if (!document.getElementById("UserScript-ChineseLanguage")) { @@ -62,6 +103,7 @@ function EnforceChineseView(root = document) { .lang_cn { display: revert !important; }`; document.head.appendChild(style); } + if (location.pathname === "/modify_user_info.php") EnforceChineseAccountLanguage(root); // Keep Vue's nodes and handlers intact; it can replace the navbar on navigation. for (let link of root.querySelectorAll('#xmoj-navbar > ul.navbar-right > li > a[href="#"]:not(.dropdown-toggle)')) { if (/^(English|中文)$/.test(link.textContent.trim())) link.setAttribute("data-xmoj-script-language-selector", ""); @@ -76,24 +118,15 @@ async function EnsureChinesePage() { EnforceChineseView(); const retryKey = "UserScript-ChineseLanguageReload"; const pageKey = location.pathname + location.search + location.hash; - const classicEnglish = document.querySelector('a#lang_en_to_cn[href*="change_lang.php"]') != null; - if (!classicEnglish && !(IsContestWebApp() && initiallyEnglishLanguage)) { + // Classic pages persist the preference through the current account form. + // change_lang.php is defunct; do not call it or reload classic pages for it. + if (!(IsContestWebApp() && initiallyEnglishLanguage)) { sessionStorage.removeItem(retryKey); return false; } try { // A server that refuses the preference must not trap the user in a reload loop. if (sessionStorage.getItem(retryKey) == pageKey) throw new Error("中文设置未生效"); - if (classicEnglish) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - try { - const response = await fetch("/change_lang.php?lang=cn", {credentials: "same-origin", cache: "no-store", signal: controller.signal}); - if (!response.ok) throw new Error("HTTP " + response.status); - } finally { - clearTimeout(timeout); - } - } sessionStorage.setItem(retryKey, pageKey); location.reload(); } catch (error) { @@ -2485,7 +2518,7 @@ function InitializeUserProfile(isAdmin = false) { document.title = "用户 " + userID + " 的个人中心"; InitializeProfileBadge(badges, userID, isAdmin); RequestAPI("LastOnline", {Username: userID}, response => { - lastOnline.textContent = "最后在线:" + (response?.Success && response.Data?.logintime != null ? GetRelativeTime(response.Data.logintime) : "暂无记录"); + lastOnline.innerHTML = "最后在线:" + (response?.Success && response.Data?.logintime != null ? GetRelativeTime(response.Data.logintime) : "暂无记录"); }, () => { lastOnline.textContent = "最后在线:暂不可用"; }); } diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs index fc81ffee..dd1f0999 100644 --- a/tests/account-settings.test.cjs +++ b/tests/account-settings.test.cjs @@ -13,6 +13,8 @@ function Between(start, end) { return source.slice(source.indexOf(start), source.indexOf(end)); } const helpers = Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); +const earlyRedirect = Between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') + + Between('// Set to true by the early block', 'const CaptchaSiteKey'); const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); const preprocessing = 'window.RunAccountPreprocessing = () => {' + Between( '// Preserve native account listeners during page-wide customization.', @@ -293,12 +295,13 @@ test('account-page migration browser regressions', {timeout: 60000}, async t => }); } await t.test('redirects defunct account links to the current page preserving the query and fragment', async () => { - const page = await Page('/modifypage.php?ByUserScript=1#latest'); - assert.equal(await page.locator('#UserScriptBadgeEditor').count(), 0); - await Promise.all([ - page.waitForURL('https://account-settings.test/modify_user_info.php?ByUserScript=1#latest'), - page.evaluate(() => location.replace(GetAccountSettingsRedirect())) - ]); + const page = await browser.newPage(); + await page.route('**/*', request => request.fulfill({contentType: 'text/html', body: nativeForm})); + // Run the actual document-start block, rather than redirecting from the test. + await page.addInitScript({content: 'function GetContestWebRedirect() { return null; }\nfunction IsContestWebApp() { return false; }\nlocalStorage.setItem("UserScript-Setting-NewBootstrap", "false");\n' + earlyRedirect}); + await page.goto('https://account-settings.test/modifypage.php?ByUserScript=1#latest'); + await page.waitForURL('https://account-settings.test/modify_user_info.php?ByUserScript=1#latest'); + assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php'); await page.close(); }); await t.test('leaves the separate password form intact', async () => { diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs index eaec523a..3db28d04 100644 --- a/tests/chinese-language.test.cjs +++ b/tests/chinese-language.test.cjs @@ -51,13 +51,12 @@ test('forces the web cookie before requests and reloads an existing English app assert.equal(reloads(), 1, 'refusing the preference must not create a reload loop'); }); -test('switches the classic English session before processing the page', async () => { +test('classic English pages do not call the broken language endpoint or reload', async () => { const {scope, requests, reloads} = Context({english: true}); - assert.equal(await scope.EnsureChinesePage(), true); - assert.equal(requests[0].url, '/change_lang.php?lang=cn'); - assert.equal(requests[0].options.credentials, 'same-origin'); - assert.equal(requests[0].options.cache, 'no-store'); - assert.equal(reloads(), 1); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(scope.document.documentElement.lang, 'zh-CN'); + assert.equal(requests.length, 0); + assert.equal(reloads(), 0); }); test('Chinese classic pages do not reload merely because html declares lang=en', async () => { @@ -71,10 +70,12 @@ test('Chinese classic pages do not reload merely because html declares lang=en', assert.equal(storage.size, 0); }); -test('failed classic language switches show a retry message without reloading', async () => { - const {scope, reloads, nodes} = Context({english: true, fail: true}); +test('an English web app refusing Chinese shows a retry message without reloading again', async () => { + const {scope, reloads, nodes, storage, requests} = Context({cookie: 'XMOJ_LANG=en', pathname: '/web/contest/123/A'}); + storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash); assert.equal(await scope.EnsureChinesePage(), false); assert.equal(reloads(), 0); + assert.equal(requests.length, 0); assert.match(nodes.find(node => node.role === 'alert').textContent, /切换中文失败/); }); @@ -130,6 +131,53 @@ test('hides classic and Vue language selectors while preserving forms and Chines } }); +test('hides the native account language group and submits Chinese with other account fields intact', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + for (const [layout, fields, value] of [ + ['radio row', '
', 'cn'], + ['fieldset with numeric values', '
界面语言
', '0'], + ['direct form children', '', 'zh'] + ]) { + await t.test(layout, async () => { + const page = await browser.newPage(); + await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: '
' + fields + '
'})); + await page.goto('https://chinese-language.test/modify_user_info.php'); + await page.evaluate(() => { + window.accountForm = document.querySelector('form'); + window.nativeSubmits = 0; + accountForm.addEventListener('submit', event => { + event.preventDefault(); + nativeSubmits++; + window.submittedData = Object.fromEntries(new FormData(accountForm)); + }); + }); + await page.addScriptTag({content: route + language}); + await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); }); + assert.equal(await page.getByText('界面语言', {exact: true}).isHidden(), true); + assert.equal(await page.getByText('English', {exact: true}).isHidden(), true); + assert.equal(await page.getByText('中文', {exact: true}).isHidden(), true); + assert.equal(await page.locator('input[type="radio"]:checked').inputValue(), value); + assert.equal(await page.locator('input[type="radio"]').evaluateAll(inputs => inputs.every(input => !input.disabled)), true); + assert.equal(await page.locator('[name="nick"]').isVisible(), true); + assert.equal(await page.getByRole('button', {name: '提交'}).isVisible(), true); + assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true); + // Native submit handlers and FormData must receive Chinese even if + // another script reselects English after initialization. + await page.evaluate(() => { document.querySelector('input[value="en"], input[value="1"]').checked = true; }); + await page.getByRole('button', {name: '提交'}).click(); + assert.equal(await page.evaluate(() => nativeSubmits), 1); + assert.deepEqual(await page.evaluate(() => submittedData), {nick: 'boomzero', csrf: 'token', lang: value}); + assert.equal(await page.evaluate(() => { + document.querySelector('input[value="en"], input[value="1"]').checked = true; + return new FormData(accountForm).get('lang'); + }), value); + await page.close(); + }); + } + } finally { await browser.close(); } +}); + test('startup enforces Chinese before either UI initializer and Vue refreshes reapply it', () => { const startup = Between('(async () => {\nif (GetContestWebRedirect()) return;', '//otherwise CurrentUsername might be undefined'); const enforce = startup.indexOf('await EnsureChinesePage()'); diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs index 3c6e3430..a48e10d7 100644 --- a/tests/profile-page.test.cjs +++ b/tests/profile-page.test.cjs @@ -12,6 +12,7 @@ function Between(start, end) { } const profile = Between('function GetProfileSolvedProblems(', 'function IsAccountSettingsPage('); const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); +const relativeTime = Between('let GetRelativeTime = (', 'function compareVersions('); const preprocessing = 'window.RunProfilePreprocessing = () => {' + Between( '// Preserve native account listeners during page-wide customization.', '// Bootstrap stylesheet and markup migration.') + '};'; @@ -29,7 +30,7 @@ test('profile page browser regressions', {timeout: 60000}, async t => { window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity); window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }}; window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); }; - window.GetRelativeTime = timestamp => 'relative(' + timestamp + ')'; + window.onlineTimestamp = options.onlineTimestamp ?? 1234; window.confirm = () => true; window.apiCalls = []; window.requests = {}; @@ -58,7 +59,7 @@ test('profile page browser regressions', {timeout: 60000}, async t => { if (!mode && action === 'NewBadge') window.badgeData = {Content: 'New badge'}; if (!mode && action === 'DeleteBadge') window.badgeData = null; request.onload({status: 200, responseText: JSON.stringify({Success: mode !== 'denied', Message: 'Denied', - Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: 1234} : {}})}); + Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: onlineTimestamp} : {}})}); }; window.nativeStats = document.getElementById('statics'); window.nativePie = document.getElementById('PieDiv'); @@ -68,7 +69,7 @@ test('profile page browser regressions', {timeout: 60000}, async t => { nativePie?.addEventListener('click', () => { pieClicks++; }); nativeCountLink?.addEventListener('click', event => { event.preventDefault(); countClicks++; }); }, options); - await page.addScriptTag({content: api + profile + preprocessing}); + await page.addScriptTag({content: api + relativeTime + profile + preprocessing}); await page.evaluate(isAdmin => { InitializeUserProfile(isAdmin); RunProfilePreprocessing(); @@ -98,7 +99,7 @@ test('profile page browser regressions', {timeout: 60000}, async t => { const page = await Page(); await AssertCore(page); assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); - assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:relative(1234)'); + assert.match(await page.locator('#UserScriptProfileLastOnline').innerText(), /^最后在线:\d+年前$/); assert.deepEqual(await page.evaluate(() => apiCalls), [ {action: 'GetBadge', data: {UserID: 'ProfileTarget'}, timeout: 15000}, {action: 'LastOnline', data: {Username: 'ProfileTarget'}, timeout: 15000} @@ -120,6 +121,15 @@ test('profile page browser regressions', {timeout: 60000}, async t => { assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:暂不可用'); await page.close(); }); + await t.test('renders relative timestamps as readable text with the formatter tooltip', async () => { + const timestamp = Date.now() - 90 * 60 * 1000; + const page = await Page({onlineTimestamp: timestamp}); + const lastOnline = page.locator('#UserScriptProfileLastOnline'); + assert.equal(await lastOnline.innerText(), '最后在线:1小时前'); + assert.equal(await lastOnline.locator('span').count(), 1); + assert.equal(await lastOnline.locator('span').getAttribute('title'), await page.evaluate(value => new Date(value).toLocaleString(), timestamp)); + await page.close(); + }); for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'http', 'denied']) { await t.test('preserves the profile and retries a ' + mode + ' badge failure', async () => { const page = await Page({badgeMode: mode, onlineMode: mode}); From bfc261a0cf3e031fea1808c63ce6d0e5451ee6ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 06:36:48 +0000 Subject: [PATCH 041/137] Update time and description of 3.8.3 --- Update.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Update.json b/Update.json index 54755aec..c0f29e79 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791094033746, + "UpdateDate": 1791095802137, "Prerelease": true, "UpdateContents": [ { From cf481aa5ebea32ddf0a7cbc517fb37c9ddcc054d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 06:36:56 +0000 Subject: [PATCH 042/137] Update time and description of 3.8.3 --- Update.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Update.json b/Update.json index c0f29e79..9ffe6ed2 100644 --- a/Update.json +++ b/Update.json @@ -3841,7 +3841,7 @@ "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" }, "3.8.3": { - "UpdateDate": 1791095802137, + "UpdateDate": 1791095815533, "Prerelease": true, "UpdateContents": [ { @@ -3849,7 +3849,7 @@ "Description": "fix: restore account and profile pages and enforce Chinese UI" } ], - "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文。

" + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。

" } } } \ No newline at end of file From fb35aaf73bdeba42f060f85886a5d268074f4040 Mon Sep 17 00:00:00 2001 From: boomzero Date: Sun, 4 Oct 2026 14:46:58 +0800 Subject: [PATCH 043/137] fix: draw profile submission history without the native renderer --- XMOJ.user.js | 104 +++++++++++++++++++++++++++++++++++- tests/fixtures/profile.html | 14 +++++ tests/profile-page.test.cjs | 47 ++++++++++++++-- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/XMOJ.user.js b/XMOJ.user.js index 7db39af4..1320788f 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -2371,6 +2371,102 @@ function InitializeProfileBadge(container, userID, isAdmin) { Load(); } +function GetProfileActivityData(root = document) { + // Read only the numeric history statements emitted by userinfo.php. Never eval + // the page's graph initializer: the old Flot renderer is unreliable here. + const script = [...root.querySelectorAll("script")].find(node => /\$\.plot\s*\(/.test(node.textContent) && /["']#submission["']/.test(node.textContent)); + if (!script) return null; + const series = [new Map(), new Map()]; + for (const match of script.textContent.matchAll(/\bd([12])\.push\(\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]\s*\)\s*;?/g)) { + const timestamp = Number(match[2]), count = Number(match[3]); + if (!Number.isSafeInteger(timestamp) || timestamp <= 0 || timestamp > 8640000000000000 || !Number.isSafeInteger(count)) continue; + series[Number(match[1]) - 1].set(timestamp, count); + } + return series.map(points => [...points].sort((a, b) => a[0] - b[0])); +} + +function InitializeProfileActivityChart(chart) { + const data = GetProfileActivityData(); + chart.style.cssText = "width:100%;max-width:600px;text-align:left"; + chart.replaceChildren(); + if (!data || !data.some(points => points.length)) { + chart.textContent = data ? "暂无提交记录" : "暂时无法读取提交记录"; + chart.setAttribute("role", "status"); + return; + } + const colors = ["#d4a017", "#4799cc"], labels = ["提交", "正确"]; + const legend = document.createElement("div"); + legend.className = "d-flex gap-3 mb-2"; + for (let i = 0; i < labels.length; i++) { + const item = document.createElement("span"), marker = document.createElement("span"); + marker.style.cssText = "display:inline-block;width:12px;height:12px;margin-right:6px;background:" + colors[i]; + item.append(marker, document.createTextNode(labels[i])); + legend.appendChild(item); + } + chart.appendChild(legend); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", "提交与正确数量随时间的变化"); + svg.style.cssText = "display:block;width:100%;height:auto"; + chart.appendChild(svg); + const Node = (tag, attributes, text, parent = svg) => { + const node = document.createElementNS(svg.namespaceURI, tag); + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value); + if (text != null) node.textContent = text; + parent.appendChild(node); + return node; + }; + const date = new Intl.DateTimeFormat("zh-CN", {timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit"}); + const DateLabel = timestamp => date.format(new Date(timestamp)).replaceAll("/", "-"); + const points = data.flat(); + let min = Math.min(...points.map(point => point[0])), max = Math.max(...points.map(point => point[0])); + const day = 86400000; + min -= day / 2; + max += day / 2; + const maximum = Math.max(1, ...points.map(point => point[1])); + const power = 10 ** Math.floor(Math.log10(maximum / 4)); + const step = Math.max(1, [1, 2, 5, 10].find(value => value * power >= maximum / 4) * power); + const limit = Math.ceil(maximum / step) * step; + let previousWidth = 0; + const Draw = () => { + const width = Math.max(280, Math.min(600, chart.clientWidth || 600)); + if (width === previousWidth) return; + previousWidth = width; + svg.replaceChildren(); + svg.setAttribute("viewBox", "0 0 " + width + " 280"); + const left = 45, right = width - 12, top = 12, bottom = 238; + const X = timestamp => left + (timestamp - min) / (max - min) * (right - left); + const Y = count => bottom - count / limit * (bottom - top); + for (let tick = 0; tick <= limit; tick += step) { + Node("line", {x1: left, x2: right, y1: Y(tick), y2: Y(tick), stroke: "var(--bs-border-color, #adb5bd)", opacity: "0.5"}); + Node("text", {x: left - 6, y: Y(tick) + 4, "text-anchor": "end", "font-size": "12", fill: "var(--bs-body-color, currentColor)"}, Number(tick.toPrecision(12))); + } + const ticks = Math.max(2, Math.floor((right - left) / 110)); + for (let i = 0; i < ticks; i++) { + const timestamp = min + day / 2 + i / (ticks - 1) * (max - min - day); + Node("text", {x: X(timestamp), y: bottom + 22, "text-anchor": i === 0 ? "start" : i === ticks - 1 ? "end" : "middle", "font-size": "11", fill: "var(--bs-body-color, currentColor)"}, DateLabel(timestamp)); + } + const barWidth = Math.max(2, Math.min(12, (right - left) * day / (max - min) * 0.6)); + for (const [timestamp, count] of data[1]) { + const bar = Node("rect", {x: X(timestamp) - barWidth / 2, y: Y(count), width: barWidth, height: bottom - Y(count), fill: colors[1], "data-series": "accepted"}); + Node("title", {}, DateLabel(timestamp) + " 正确:" + count, bar); + } + if (data[0].length) Node("path", {d: data[0].map(([timestamp, count], index) => (index ? "L" : "M") + X(timestamp) + " " + Y(count)).join(" "), fill: "none", stroke: colors[0], "stroke-width": "2", "data-series": "submitted"}); + for (const [timestamp, count] of data[0]) { + const dot = Node("circle", {cx: X(timestamp), cy: Y(count), r: "3", fill: colors[0], "data-series": "submitted"}); + Node("title", {}, DateLabel(timestamp) + " 提交:" + count, dot); + } + }; + Draw(); + if (typeof ResizeObserver === "function") { + const observer = new ResizeObserver(() => { + if (!chart.isConnected) { observer.disconnect(); return; } + Draw(); + }); + observer.observe(chart); + } +} + function InitializeProfileChart(table) { let chart = table.querySelector("#PieDiv"); if (!chart) return; @@ -2482,7 +2578,7 @@ function InitializeUserProfile(isAdmin = false) { } right.appendChild(solved); - // Move the original statistics/chart nodes so their links and handlers survive. + // Move the original statistics nodes so their links and handlers survive. table.insertAdjacentElement("beforebegin", profile); for (let link of table.caption?.querySelectorAll("a") || []) info.appendChild(link); table.caption?.remove(); @@ -2503,7 +2599,11 @@ function InitializeUserProfile(isAdmin = false) { (group || solved).appendChild(node); } } - if (activity && !UtilityEnabled("RemoveUseless")) right.appendChild(activity); + // Submission history is functional and must survive the default cleanup setting. + if (activity) { + right.appendChild(activity); + InitializeProfileActivityChart(activity); + } solvedCell?.remove(); let firstRow = table.rows[0]; if (firstRow?.cells.length >= 2 && !firstRow.cells[0].textContent.trim() && !firstRow.cells[1].textContent.trim()) firstRow.remove(); diff --git a/tests/fixtures/profile.html b/tests/fixtures/profile.html index fcdbbbf7..6c3812ab 100644 --- a/tests/fixtures/profile.html +++ b/tests/fixtures/profile.html @@ -18,4 +18,18 @@ Statistics
Old overlapping legend
Email:profile@example.test
+ diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs index a48e10d7..56f59ef8 100644 --- a/tests/profile-page.test.cjs +++ b/tests/profile-page.test.cjs @@ -27,7 +27,7 @@ test('profile page browser regressions', {timeout: 60000}, async t => { window.CurrentUsername = 'Viewer'; window.GM_info = {script: {version: 'test'}}; document.cookie = 'PHPSESSID=fixture; path=/'; - window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity); + window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity !== false); window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }}; window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); }; window.onlineTimestamp = options.onlineTimestamp ?? 1234; @@ -183,13 +183,52 @@ test('profile page browser regressions', {timeout: 60000}, async t => { await page.close(); }); for (const removeActivity of [false, true]) { - await t.test('respects the activity-chart setting: ' + removeActivity, async () => { + await t.test('draws submission history without Flot with cleanup=' + removeActivity, async () => { const page = await Page({removeActivity}); - assert.equal(await page.locator('#submission').count(), removeActivity ? 0 : 1); - if (!removeActivity) assert.equal(await page.evaluate(() => document.getElementById('submission') === nativeActivity), true); + const graph = page.getByRole('img', {name: '提交与正确数量随时间的变化'}); + assert.equal(await graph.isVisible(), true); + assert.equal(await page.evaluate(() => document.getElementById('submission') === nativeActivity), true); + assert.equal(await page.locator('#submission canvas').count(), 0); + assert.equal(await page.evaluate(() => window.jQuery), undefined); + assert.equal(await page.evaluate(() => window.nativeHistoryRuns), undefined, 'never execute the broken native graph script'); + assert.deepEqual(await page.evaluate(() => GetProfileActivityData()), [ + [[1704067200000, 20], [1704153600000, 5], [1704240000000, 12]], + [[1704067200000, 8], [1704153600000, 2], [1704240000000, 4]] + ]); + assert.equal(await graph.locator('path[data-series="submitted"]').count(), 1); + assert.equal(await graph.locator('circle[data-series="submitted"]').count(), 3); + assert.equal(await graph.locator('rect[data-series="accepted"]').count(), 3); + assert.deepEqual(await graph.locator('circle title').allTextContents(), ['2024-01-01 提交:20', '2024-01-02 提交:5', '2024-01-03 提交:12']); + assert.deepEqual(await graph.locator('rect title').allTextContents(), ['2024-01-01 正确:8', '2024-01-02 正确:2', '2024-01-03 正确:4']); + assert.ok((await graph.locator('path').getAttribute('d')).startsWith('M')); + await page.addStyleTag({content: '#UserScriptProfile {display:flex} #UserScriptProfile > div {width:50%;min-width:0}'}); + await page.setViewportSize({width: 640, height: 800}); + await page.waitForFunction(() => Number(document.querySelector('#submission svg').getAttribute('viewBox').split(' ')[2]) < 400); + const boxes = await graph.locator('text').evaluateAll(nodes => nodes.map(node => ({x: node.getBBox().x, right: node.getBBox().x + node.getBBox().width}))); + assert.ok(boxes.every(box => box.x >= 0 && box.right <= 320), 'axis labels must fit the narrow viewBox'); + assert.ok(await graph.locator('rect').evaluateAll(nodes => nodes.every(node => Number(node.getAttribute('height')) > 0))); await page.close(); }); } + await t.test('history parsing ignores executable values, sorts dates and handles empty or missing data', async () => { + const page = await Page(); + const parsed = await page.evaluate(() => { + window.historyScriptExecuted = false; + const doc = new DOMParser().parseFromString(' + + + + + + + + + + + + +
+
+
+ +
404
+

这个页面没 AC。

+

地址可能打错了,或者页面已经搬走了。

+ +
+
+
+
+ 评测结果 + 已完成 +
+
+ + + + + + + + +
测试点结果说明
#1AC域名正确
#2WA页面不存在
+
+
请求路径:/
+
+
+
+
+ + + + + + + diff --git a/README.md b/README.md index be626280..663645e0 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ ![GitHub release](https://img.shields.io/github/v/release/XMOJ-Script-dev/XMOJ-Script) ![GitHub issues](https://img.shields.io/github/issues/XMOJ-Script-dev/XMOJ-Script) -[点击此处访问官网查看更多内容](https://xmoj-bbs.me) +[点击此处访问官网查看更多内容](https://www.xmoj-script.uk) 原项目: https://github.com/langningchen/XMOJ-Script 小明的OJ增强脚本 - 小明的OJ增强脚本 | Product Hunt ### 帮助文档 -地址:https://docs.xmoj-bbs.me +地址:https://docs.xmoj-script.uk 仓库:https://github.com/XMOJ-Script-dev/docs ### 新版比赛页面兼容性 @@ -63,7 +63,7 @@ ![](Images/1.png) ![](Images/2.png) -更多图片可在[官网](https://www.xmoj-bbs.me)上查看 +更多介绍见[官网](https://www.xmoj-script.uk)。 通过小明的OJ增强脚本,您将能够更便捷地参与竞赛,提高AC率,管理代码,优化学习体验,同时享受美化界面带来的愉悦感受。 无论是竞技还是学习,这个脚本都将成为您在小明的OJ平台上的得力助手。 @@ -72,7 +72,7 @@ ### 安装 -请参考 [官网介绍](https://www.xmoj-bbs.me) 。 +请参考 [官网介绍](https://www.xmoj-script.uk) 。 如果您无法打开该网站,请前往[这里](https://scriptcat.org/zh-CN/script-show-page/1500/)安装。 ### 贡献 diff --git a/child-protection.html b/child-protection.html index f2278440..d9a7d32b 100644 --- a/child-protection.html +++ b/child-protection.html @@ -1,36 +1,100 @@ - + + - 儿童保护协议 - 小明的OJ增强脚本 + 未成年人保护 · XMOJ-Script + - + + + + + + + + + - -
-

儿童保护协议

-

生效日期:2026-04-24

-

一、适用范围

-

本协议适用于未满十四周岁儿童相关信息处理活动,以及监护人对儿童使用服务的管理与监督。

+ + + -

二、监护人责任

-

监护人应指导并监督儿童合理使用网络服务,避免泄露隐私信息,防止沉迷与不当互动。

+
+ +

未成年人保护

+

生效日期:2026-10-04

+

小明的OJ的用户大多是中小学生,所以 XMOJ-Script 的大多数用户也是未成年人。我们按下面的方式对待这件事。

-

三、儿童信息保护

-

我们坚持最小必要原则处理信息,不会在无正当理由情况下收集与服务无关的儿童个人信息。

+

我们收集得很少

+

我们不收集真实姓名、年龄、手机号、住址或照片,只处理隐私说明里列出的数据:xmoj 用户名、你主动发布的内容和访问记录。我们没有办法知道某个用户的年龄,所以对所有用户采用同样的做法。

-

四、风险防护措施

-

我们将持续优化内容展示、交互提醒和异常行为识别机制,降低儿童接触不适宜内容和网络风险的可能性。

+

请家长留意

+
    +
  • 讨论区的帖子所有用户都能看到,提醒孩子不要在里面透露真实姓名、学校、联系方式等个人信息。
  • +
  • 短消息只在你和对方之间显示。如果孩子收到骚扰或不当内容,请联系我们处理。
  • +
-

五、监护人权利

-

监护人有权查阅、更正或删除儿童相关信息,并可通过反馈渠道提出限制处理、停止服务等申请。

+

申请删除

+

家长或监护人可以发邮件到 seanoj_noreply@yeah.net,写明孩子的 xmoj 用户名,要求查看或删除相关数据,或者封禁该账号的联网功能。我们会尽快处理。

-

六、联系我们

-

如您对儿童信息保护有疑问,请通过项目公开反馈渠道联系我们,我们会及时处理并反馈结果。

+

举报

+

看到针对未成年人的不当内容,请通过上面的邮箱或 GitHub 告诉我们,我们会优先删除。

+
-

返回首页

-
+ + + diff --git a/index.html b/index.html index fda3f205..b0940635 100644 --- a/index.html +++ b/index.html @@ -1,984 +1,337 @@ - - + + + + 小明的OJ增强脚本 · XMOJ-Script + + + + - - - - - 小明的OJ增强脚本 + (function (c, l, a, r, i, t, y) { + c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) }; + t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/" + i; + y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y); + })(window, document, "clarity", "script", "mdy1fivbnd"); + + + + + + + -