commit 3eadb2540b34470e545e446aeb8d8cb2f2fe8456 Author: hanyixuanten <105723997+hanyixuanten@users.noreply.github.com> Date: Tue Aug 25 20:51:48 2026 +0800 init diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..a09d87a --- /dev/null +++ b/extension.js @@ -0,0 +1,252 @@ +const vscode = require('vscode'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const http = require('http'); + +// 本地化翻译表(与 PHP 版本一致) +const translations = { + zh: { + htmlLang: 'zh-CN', + pageTitle: '信息竞赛日程', + heading: '信息竞赛日程', + upcomingTitle: '即将到来的信息竞赛', + finishedTitle: '已结束的信息竞赛', + loadError: '暂时无法加载赛事数据,请稍后再试。', + finishedLoadError: '暂时无法加载已结束赛事数据,请稍后再试。', + emptyUpcoming: '暂无即将开始的赛事。', + emptyFinished: '暂无最近结束的赛事。', + ended: '已结束', + running: '进行中', + timezoneLabel: '时区', + lastUpdatedLabel: '最后更新时间', + day: '天', + hour: '时', + minute: '分', + second: '秒' + }, + en: { + htmlLang: 'en', + pageTitle: 'OI Contest Schedule', + heading: 'OI Contest Schedule', + upcomingTitle: 'Upcoming Contests', + finishedTitle: 'Finished Contests', + loadError: 'Contest data is temporarily unavailable. Please try again later.', + finishedLoadError: 'Finished contest data is temporarily unavailable. Please try again later.', + emptyUpcoming: 'No upcoming contests.', + emptyFinished: 'No recently finished contests.', + ended: 'Ended', + running: 'Running', + timezoneLabel: 'Time zone', + lastUpdatedLabel: 'Last updated', + day: 'd', + hour: 'h', + minute: 'm', + second: 's' + } +}; + +function normalizeLanguage(lang) { + lang = (lang || '').toLowerCase(); + if (lang.startsWith('zh')) return 'zh'; + if (lang.startsWith('en')) return 'en'; + return 'en'; +} + +function detectLanguage() { + // 优先使用 VS Code 语言设置 + const configLang = vscode.env.language; + return normalizeLanguage(configLang); +} + +function detectTimezone() { + // 从配置读取,若为空则尝试系统时区 + const config = vscode.workspace.getConfiguration('oiContestSchedule'); + const configuredTz = config.get('timezone', ''); + if (configuredTz) { + try { + // 验证时区是否有效 + new Intl.DateTimeFormat('en', { timeZone: configuredTz }); + return configuredTz; + } catch (e) { + console.warn(`Invalid timezone: ${configuredTz}, falling back to system timezone.`); + } + } + // 自动检测系统时区 + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch (e) { + return 'UTC'; + } +} + +// 获取远程 JSON(支持 http/https,带缓存) +function fetchWithCache(url, cacheMinutes) { + return new Promise((resolve, reject) => { + const cacheFile = path.join(__dirname, '.cache', 'contests.json'); + const cacheDir = path.dirname(cacheFile); + + // 检查缓存 + if (cacheMinutes > 0 && fs.existsSync(cacheFile)) { + const stat = fs.statSync(cacheFile); + const ageMinutes = (Date.now() - stat.mtimeMs) / 60000; + if (ageMinutes < cacheMinutes) { + fs.readFile(cacheFile, 'utf8', (err, data) => { + if (!err) return resolve(data); + }); + return; + } + } + + // 发起请求 + const client = url.startsWith('https') ? https : http; + const req = client.get(url, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + let rawData = ''; + res.on('data', chunk => rawData += chunk); + res.on('end', () => { + // 尝试解析 JSON 验证 + try { + JSON.parse(rawData); + // 写入缓存 + if (cacheMinutes > 0) { + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFile(cacheFile, rawData, err => {}); + } + resolve(rawData); + } catch (e) { + // JSON 无效,尝试读取旧缓存 + if (fs.existsSync(cacheFile)) { + fs.readFile(cacheFile, 'utf8', (err, data) => resolve(data)); + } else { + reject(e); + } + } + }); + }); + req.on('error', (err) => { + // 网络错误,尝试读旧缓存 + if (fs.existsSync(cacheFile)) { + fs.readFile(cacheFile, 'utf8', (readErr, data) => { + if (!readErr) resolve(data); + else reject(err); + }); + } else { + reject(err); + } + }); + req.setTimeout(20000, () => req.destroy()); + }); +} + +function parseContestPayload(json) { + const data = JSON.parse(json); + if (!data || typeof data !== 'object') return null; + let contests = []; + let generatedAt = 0; + if (Array.isArray(data.contests)) { + contests = data.contests; + generatedAt = data.generated_at || 0; + } else if (Array.isArray(data)) { + contests = data; + } else { + return null; + } + return { contests, generatedAt }; +} + +function splitContests(all) { + const upcoming = []; + const finished = []; + for (const c of all) { + if (c.status === 'finished') finished.push(c); + else upcoming.push(c); + } + upcoming.sort((a, b) => (a.start_time || 0) - (b.start_time || 0)); + finished.sort((a, b) => (a.end_time || 0) - (b.end_time || 0)); + return { upcoming, finished }; +} + +function getWebviewContent(webview, context, payload) { + const { contests, generatedAt } = payload; + const lang = detectLanguage(); + const t = translations[lang]; + const timezone = detectTimezone(); + + // 将数据注入到 HTML 中 + const dataScript = ` + window.contestsData = ${JSON.stringify(contests)}; + window.generatedAt = ${JSON.stringify(generatedAt)}; + window.translations = ${JSON.stringify(t)}; + window.serverLanguage = ${JSON.stringify(lang)}; + window.serverTimezone = ${JSON.stringify(timezone)}; + `; + + // 读取 webview.html 并替换占位符 + const htmlPath = path.join(context.extensionPath, 'media', 'webview.html'); + const html = fs.readFileSync(htmlPath, 'utf8'); + return html.replace('', ``); +} + +function activate(context) { + console.log('OI Contest Schedule extension activated'); + + let disposable = vscode.commands.registerCommand('oiContestSchedule.show', async () => { + // 创建 Webview Panel + const panel = vscode.window.createWebviewPanel( + 'oiContestSchedule', + 'OI Contest Schedule', + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))] + } + ); + + // 设置初始内容 + const config = vscode.workspace.getConfiguration('oiContestSchedule'); + const jsonUrl = config.get('jsonUrl', 'https://raw.githubusercontent.com/hanyixuanten/OI-contest-fetch/master/contests_all.json'); + const cacheMinutes = config.get('cacheMinutes', 5); + + panel.webview.html = '

Loading...

'; + + try { + const json = await fetchWithCache(jsonUrl, cacheMinutes); + const payload = parseContestPayload(json); + if (!payload) throw new Error('Invalid JSON payload'); + const { upcoming, finished } = splitContests(payload.contests); + const data = { + contests: { upcoming, finished }, + generatedAt: payload.generatedAt + }; + panel.webview.html = getWebviewContent(panel.webview, context, data); + } catch (err) { + vscode.window.showErrorMessage(`Failed to load contest data: ${err.message}`); + panel.webview.html = `

Error loading data.

`; + } + + // 监听配置变化,自动刷新 + const configListener = vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('oiContestSchedule')) { + // 简化:提示用户重新打开 + vscode.window.showInformationMessage('OI Contest Schedule configuration changed. Please reopen the view.'); + } + }); + + panel.onDidDispose(() => { + configListener.dispose(); + }); + }); + + context.subscriptions.push(disposable); +} + +function deactivate() {} + +module.exports = { + activate, + deactivate +}; \ No newline at end of file diff --git a/media/webview.html b/media/webview.html new file mode 100644 index 0000000..7fdd08a --- /dev/null +++ b/media/webview.html @@ -0,0 +1,178 @@ + + + + + + OI Contest Schedule + + + +
+

📅

+
+
+

+
+

+
+
+ + + + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8592904 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "oi-contest-schedule", + "displayName": "OI Contest Schedule", + "description": "View upcoming and finished OI contests in VS Code", + "version": "1.0.0", + "publisher": "your-name", + "engines": { + "vscode": "^1.60.0" + }, + "categories": ["Other"], + "activationEvents": [], + "main": "./extension.js", + "contributes": { + "commands": [ + { + "command": "oiContestSchedule.show", + "title": "OI Contest Schedule: Show" + } + ], + "configuration": { + "title": "OI Contest Schedule", + "properties": { + "oiContestSchedule.jsonUrl": { + "type": "string", + "default": "https://raw.githubusercontent.com/hanyixuanten/OI-contest-fetch/master/contests_all.json", + "description": "URL of the contests JSON feed" + }, + "oiContestSchedule.cacheMinutes": { + "type": "number", + "default": 5, + "minimum": 0, + "description": "Cache lifetime in minutes (0 to disable cache)" + }, + "oiContestSchedule.timezone": { + "type": "string", + "default": "", + "description": "Timezone (IANA name, e.g. Asia/Shanghai). Leave empty to auto-detect from system." + } + } + } + } +} \ No newline at end of file