Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
67dccb0
fix: support migrated account settings page
boomzero Oct 3, 2026
c9399b8
3.8.2
github-actions[bot] Oct 3, 2026
8a8a511
Update version info to 3.8.2
github-actions[bot] Oct 3, 2026
bc914f4
fix: preserve native account listeners and recover badge saves
boomzero Oct 4, 2026
1a9c480
Update time and description of 3.8.2
github-actions[bot] Oct 4, 2026
7350dfa
Update time and description of 3.8.2
github-actions[bot] Oct 4, 2026
977942a
Merge remote-tracking branch 'origin/dev' into codex/fix-account-page…
boomzero Oct 4, 2026
4da8acd
3.8.3
boomzero Oct 4, 2026
cc8aa97
Update version info to 3.8.3
github-actions[bot] Oct 4, 2026
149a6e6
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
4b4e262
fix: recover badge loads without overwriting unloaded values
boomzero Oct 4, 2026
f93813f
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
b88db84
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
719ed4b
fix: enforce Chinese across classic and web pages
boomzero Oct 4, 2026
7b2f167
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
c5b593d
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
7796bdc
fix: initialize badge editor on the current account page
boomzero Oct 4, 2026
993220e
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
862b7f4
fix: render complete profiles before optional requests
boomzero Oct 4, 2026
65e57e6
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
7301f4f
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
8bf7af7
fix: persist Chinese through current account settings and render prof…
boomzero Oct 4, 2026
bfc261a
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
cf481aa
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
fb35aaf
fix: draw profile submission history without the native renderer
boomzero Oct 4, 2026
616eb6b
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
076f82a
fix: separate dense profile history series into aligned panels
boomzero Oct 4, 2026
6737a97
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
a49a942
fix: soften overlapping history series and recover stashed API safegu…
boomzero Oct 4, 2026
f5eaedb
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
d1b7047
fix: add a theme-aware border around profile history
boomzero Oct 4, 2026
3544492
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
9f9c568
fix: add padding and spacing around profile history
boomzero Oct 4, 2026
4593892
Update time and description of 3.8.3
github-actions[bot] Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

### 新版比赛页面兼容性

支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航、语言切换和排序,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。
支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航和排序,统一使用中文,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。

### 介绍

Expand Down
11 changes: 11 additions & 0 deletions Update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3839,6 +3839,17 @@
}
],
"Notes": "<p>修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。</p>"
},
"3.8.3": {
"UpdateDate": 1791106754384,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1040,
"Description": "fix: restore account and profile pages and enforce Chinese UI"
}
],
"Notes": "<p>修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。重新绘制个人中心提交与正确数量的历史图表,兼容浅色、深色主题及移动端,默认清理设置不再隐藏图表。提交与正确数量在同一图表中以细线和半透明柱形叠加展示,减少密集曲线遮挡。恢复暂存更改中的 API 请求超时和错误日志。</p>"
}
}
}
1,071 changes: 678 additions & 393 deletions XMOJ.user.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "xmoj-script",
"version": "3.8.2",
"version": "3.8.3",
"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 tests/chinese-language.test.cjs tests/profile-page.test.cjs"
},
"devDependencies": {
"playwright": "1.59.1"
Expand Down
358 changes: 358 additions & 0 deletions tests/account-settings.test.cjs

Large diffs are not rendered by default.

190 changes: 190 additions & 0 deletions tests/chinese-language.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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('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(), 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 () => {
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('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, /切换中文失败/);
});

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: `<html lang="en"><body>
<a id="lang_cn_to_en" href="/change_lang.php?lang=en">English</a>
<button id="lang_en_to_cn">中文</button>
<form><input name="nick" value="Account"><button type="submit">Save account</button></form>
<span class="lang_cn" style="display:none">中文题面</span><span class="lang_en">English statement</span>
<div id="app"><nav id="xmoj-navbar"><ul class="navbar-right">
<li><a href="#" class="dropdown-toggle">English</a></li><li><a href="#">English</a></li>
</ul></nav><div class="xmoj-lang-switch"><button class="hidden">English</button><button>中文</button></div>
<div id="statement">English content</div></div>
</body></html>`}));
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 = '<a href="#">中文</a>';
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('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', '<label>界面语言</label><div class="row"><div><label><input type="radio" name="lang" value="cn"> 中文</label></div><div><label><input type="radio" name="lang" value="en" checked> English</label></div></div>', 'cn'],
['fieldset with numeric values', '<fieldset><legend>界面语言</legend><label><input type="radio" name="lang" value="0">中文</label><label><input type="radio" name="lang" value="1" checked>English</label></fieldset>', '0'],
['direct form children', '<label>界面语言</label><input id="cn" type="radio" name="lang" value="zh"><label for="cn">中文</label><input id="en" type="radio" name="lang" value="en" checked><label for="en">English</label>', 'zh']
]) {
await t.test(layout, async () => {
const page = await browser.newPage();
await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: '<form><label>昵称<input name="nick" value="boomzero"></label><input type="hidden" name="csrf" value="token">' + fields + '<button type="submit">提交</button></form>'}));
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()');
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\)/);
});
3 changes: 2 additions & 1 deletion tests/contest-web.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
35 changes: 35 additions & 0 deletions tests/fixtures/profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body>
<div class="container"><nav id="navbar"><span id="profile">Viewer</span></nav>
<div class="jumbotron"><center>
<table class="table table-striped" id="statics" width="70%">
<caption>ProfileTarget--昵称--后缀<a href="mail.php?to_user=ProfileTarget">短消息</a></caption>
<tr><td width="15%"></td><td width="25%" align="center"></td><td width="70%" align="center">Solved Problems List</td></tr>
<tr><td>解决</td><td align="center"><a href="status.php?user_id=ProfileTarget&jresult=4">2</a></td>
<td rowspan="6" align="center"><script>
window.nativeProfileRuns = (window.nativeProfileRuns || 0) + 1;
function p(id,c) {
document.write("<a href='problem.php?id="+id+"' target='_blank'>"+id+" </a>(<a href='status.php?user_id=ProfileTarget&problem_id="+id+"'>"+c+"</a>)");
}
p(1000,7);p(1010,1);
</script><div id="submission" style="width:600px;height:300px">Activity chart</div></td></tr>
<tr><td>提交</td><td align="center"><a href="status.php?user_id=ProfileTarget">20</a></td></tr>
<tr><td>正确</td><td align="center"><a href="status.php?user_id=ProfileTarget&jresult=4">8</a></td></tr>
<tr><td>答案错误</td><td align="center"><a href="status.php?user_id=ProfileTarget&jresult=6">12</a></td></tr>
<tr id="pie"><td>Statistics</td><td><div id="PieDiv" style="position:relative;height:105px;width:120px"><span>Old overlapping legend</span></div></td></tr>
<tr><td>Email:</td><td align="center">profile@example.test</td></tr>
</table></center></div></div>
<script>
// Match the history data supplied by the native profile. Its renderer is absent.
if (window.jQuery) $(function () {
window.nativeHistoryRuns = (window.nativeHistoryRuns || 0) + 1;
var d1 = [], d2 = [];
d1.push([1704240000000, 12]);
d1.push([1704067200000, 20]);
d1.push([1704153600000, 5]);
d2.push([1704240000000, 4]);
d2.push([1704067200000, 8]);
d2.push([1704153600000, 2]);
$.plot($("#submission"), [{label:"提交",data:d1}, {label:"正确",data:d2}], {xaxis:{mode:"time"}});
});
</script>
</body></html>
Loading