From c37f917f5ba1885063127cd0dc294b161595529c Mon Sep 17 00:00:00 2001 From: hanyixuanten <105723997+hanyixuanten@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:06:44 +0800 Subject: [PATCH] init --- README.md | 41 ++++++ README_zh.md | 41 ++++++ assets/css/schedule.css | 188 +++++++++++++++++++++++++ assets/js/schedule.js | 69 +++++++++ includes/class-contest-client.php | 86 +++++++++++ includes/class-plugin.php | 93 ++++++++++++ includes/class-schedule-renderer.php | 84 +++++++++++ languages/oi-contest-schedule-zh_CN.mo | Bin 0 -> 1391 bytes languages/oi-contest-schedule-zh_CN.po | 60 ++++++++ languages/oi-contest-schedule.pot | 56 ++++++++ oi-contest-schedule.php | 33 +++++ readme.txt | 57 ++++++++ 12 files changed, 808 insertions(+) create mode 100644 README.md create mode 100644 README_zh.md create mode 100644 assets/css/schedule.css create mode 100644 assets/js/schedule.js create mode 100644 includes/class-contest-client.php create mode 100644 includes/class-plugin.php create mode 100644 includes/class-schedule-renderer.php create mode 100644 languages/oi-contest-schedule-zh_CN.mo create mode 100644 languages/oi-contest-schedule-zh_CN.po create mode 100644 languages/oi-contest-schedule.pot create mode 100644 oi-contest-schedule.php create mode 100644 readme.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..689b312 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# OI Contest Schedule WordPress Plugin + +This subproject packages the OI contest feed as a WordPress plugin. It follows the repository layout and release pattern used by `wp-translate`: the installable plugin lives in its slug directory, while `build.sh` creates a versioned ZIP archive under `build/`. + +## Features + +- Adds an **Upcoming OI Contests** widget to the WordPress dashboard. +- Provides the `[oi_contest_schedule]` shortcode for posts and pages. +- Formats timestamps in the visitor's browser time zone. +- Shows live countdowns and distinguishes currently running contests. +- Caches the public GitHub JSON feed with a five-minute WordPress transient. + +## Requirements + +- WordPress 6.4 or newer +- PHP 7.4 or newer + +## Usage + +Activate the plugin and add a Shortcode block containing: + +```text +[oi_contest_schedule] +``` + +The shortcode accepts `limit` from 1 to 50 and a compact layout flag: + +```text +[oi_contest_schedule limit="20" compact="true"] +``` + +## Build + +Run from this directory: + +```bash +chmod +x build.sh +./build.sh build +``` + +The archive is written to `build/oi-contest-schedule-.zip`. \ No newline at end of file diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..54b1c47 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,41 @@ +# OI Contest Schedule WordPress 插件 + +该子项目将 OI 赛程数据封装为 WordPress 插件。目录和发布方式参考 `wp-translate`:可安装插件位于同名 slug 目录中,`build.sh` 在 `build/` 下生成带版本号的 ZIP 包。 + +## 功能 + +- 在 WordPress 仪表盘添加“即将到来的 OI 赛事”小组件。 +- 提供 `[oi_contest_schedule]` 短代码,可插入文章或页面。 +- 根据访客浏览器时区显示比赛时间。 +- 显示实时倒计时,并标记正在进行的比赛。 +- 使用 WordPress transient 将 GitHub 上的公开 JSON 数据缓存五分钟。 + +## 环境要求 + +- WordPress 6.4 或更新版本 +- PHP 7.4 或更新版本 + +## 使用方式 + +启用插件后,在页面中添加“短代码”区块并输入: + +```text +[oi_contest_schedule] +``` + +`limit` 可设置 1 到 50 条比赛,`compact` 可启用紧凑布局: + +```text +[oi_contest_schedule limit="20" compact="true"] +``` + +## 构建 + +在本目录运行: + +```bash +chmod +x build.sh +./build.sh build +``` + +安装包将生成到 `build/oi-contest-schedule-<版本>.zip`。 \ No newline at end of file diff --git a/assets/css/schedule.css b/assets/css/schedule.css new file mode 100644 index 0000000..ecb9d65 --- /dev/null +++ b/assets/css/schedule.css @@ -0,0 +1,188 @@ +.oics-schedule { + --oics-ink: #20303c; + --oics-muted: #687782; + --oics-line: #dbe3e8; + --oics-surface: #ffffff; + --oics-accent: #d94b38; + box-sizing: border-box; + color: var(--oics-ink); + font-family: "Aptos", "Segoe UI", sans-serif; + max-width: 960px; +} + +.oics-schedule *, +.oics-schedule *::before, +.oics-schedule *::after { + box-sizing: inherit; +} + +.oics-schedule__header { + align-items: end; + border-bottom: 2px solid var(--oics-ink); + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 10px; +} + +.oics-schedule__heading { + color: var(--oics-ink); + font-size: 22px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.25; + margin: 0; +} + +.oics-schedule__meta, +.oics-schedule__updated { + color: var(--oics-muted); + font-size: 12px; + margin: 4px 0 0; +} + +.oics-schedule__count { + background: #edf2f5; + border-radius: 4px; + color: #42535f; + font-size: 12px; + font-weight: 600; + padding: 4px 8px; + white-space: nowrap; +} + +.oics-schedule__list { + display: grid; + gap: 8px; +} + +.oics-contest { + align-items: center; + background: var(--oics-surface); + border: 1px solid var(--oics-line); + border-left: 3px solid #8b99a3; + border-radius: 6px; + color: inherit; + display: grid; + gap: 14px; + grid-template-columns: minmax(88px, auto) minmax(0, 1fr) auto; + padding: 13px 14px; + text-decoration: none !important; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.oics-contest:hover, +.oics-contest:focus-visible { + border-color: #9eabb4; + box-shadow: 0 5px 16px rgba(31, 48, 60, 0.1); + color: inherit; + transform: translateY(-1px); +} + +.oics-contest:focus-visible { + outline: 2px solid #2271b1; + outline-offset: 2px; +} + +.oics-contest__platform { + background: #657681; + border-radius: 4px; + color: #fff; + font-size: 12px; + font-weight: 700; + line-height: 1; + padding: 6px 8px; + text-align: center; +} + +.oics-contest__platform--codeforces { background: #2176ae; } +.oics-contest__platform--atcoder { background: #527a52; } +.oics-contest__platform--uoj { background: #b33b32; } +.oics-contest__platform--nowcoder { background: #008c8c; } + +.oics-contest__info { + display: block; + min-width: 0; +} + +.oics-contest__title { + color: var(--oics-ink); + display: block; + font-size: 15px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.oics-contest__time { + color: var(--oics-muted); + display: flex; + flex-wrap: wrap; + font-size: 12px; + gap: 5px; + margin-top: 5px; +} + +.oics-contest__countdown { + color: var(--oics-accent); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 700; + min-width: 116px; + text-align: right; + white-space: nowrap; +} + +.oics-schedule__updated { + text-align: right; +} + +.oics-schedule__notice { + background: #f6f8f9; + border-left: 3px solid #8b99a3; + margin: 0; + padding: 12px 14px; +} + +.oics-schedule__notice--error { + background: #fcf0ef; + border-left-color: #b32d2e; + color: #8a2424; +} + +.oics-schedule--compact .oics-schedule__header { + display: none; +} + +.oics-schedule--compact .oics-contest { + gap: 10px; + grid-template-columns: minmax(72px, auto) minmax(0, 1fr); + padding: 10px; +} + +.oics-schedule--compact .oics-contest__countdown { + grid-column: 2; + min-width: 0; + text-align: left; +} + +@media (max-width: 600px) { + .oics-schedule__header { + align-items: start; + } + + .oics-schedule__heading { + font-size: 19px; + } + + .oics-contest { + gap: 10px; + grid-template-columns: auto minmax(0, 1fr); + } + + .oics-contest__countdown { + grid-column: 2; + min-width: 0; + text-align: left; + } +} \ No newline at end of file diff --git a/assets/js/schedule.js b/assets/js/schedule.js new file mode 100644 index 0000000..9ae9e69 --- /dev/null +++ b/assets/js/schedule.js @@ -0,0 +1,69 @@ +( function () { + 'use strict'; + + var strings = window.oicsScheduleL10n || {}; + + function formatTime( timestamp, includeSeconds ) { + var options = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }; + + if ( includeSeconds ) { + options.second = '2-digit'; + } + + return new Intl.DateTimeFormat( document.documentElement.lang || undefined, options ).format( new Date( timestamp * 1000 ) ); + } + + function updateTimes() { + document.querySelectorAll( '[data-oics-time]' ).forEach( function ( element ) { + var timestamp = Number.parseInt( element.dataset.oicsTime, 10 ); + if ( timestamp ) { + element.textContent = formatTime( timestamp, element.hasAttribute( 'data-oics-seconds' ) ); + } + } ); + } + + function countdownText( startTimestamp, endTimestamp ) { + var now = Date.now(); + if ( endTimestamp * 1000 <= now ) { + return strings.ended || 'Ended'; + } + if ( startTimestamp * 1000 <= now ) { + return strings.running || 'Running'; + } + + var remaining = startTimestamp * 1000 - now; + var days = Math.floor( remaining / 86400000 ); + var hours = Math.floor( ( remaining % 86400000 ) / 3600000 ); + var minutes = Math.floor( ( remaining % 3600000 ) / 60000 ); + var seconds = Math.floor( ( remaining % 60000 ) / 1000 ); + + return days + ( strings.day || 'd' ) + ' ' + hours + ( strings.hour || 'h' ) + ' ' + minutes + ( strings.minute || 'm' ) + ' ' + seconds + ( strings.second || 's' ); + } + + function updateCountdowns() { + document.querySelectorAll( '[data-oics-start]' ).forEach( function ( element ) { + var startTimestamp = Number.parseInt( element.dataset.oicsStart, 10 ); + var endTimestamp = Number.parseInt( element.dataset.oicsEnd, 10 ); + element.textContent = countdownText( startTimestamp, endTimestamp ); + } ); + } + + function initialize() { + updateTimes(); + updateCountdowns(); + window.setInterval( updateCountdowns, 1000 ); + } + + if ( 'loading' === document.readyState ) { + document.addEventListener( 'DOMContentLoaded', initialize ); + } else { + initialize(); + } +}() ); \ No newline at end of file diff --git a/includes/class-contest-client.php b/includes/class-contest-client.php new file mode 100644 index 0000000..ee9cb88 --- /dev/null +++ b/includes/class-contest-client.php @@ -0,0 +1,86 @@ + 10, + 'user-agent' => 'OI Contest Schedule/' . OICS_VERSION . '; ' . home_url( '/' ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return new WP_Error( 'oics_http_error', __( 'The contest data source returned an unexpected response.', 'oi-contest-schedule' ) ); + } + + $payload = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! $this->is_valid_payload( $payload ) ) { + return new WP_Error( 'oics_invalid_payload', __( 'The contest data source returned invalid data.', 'oi-contest-schedule' ) ); + } + + $normalized = array( + 'generated_at' => isset( $payload['generated_at'] ) ? absint( $payload['generated_at'] ) : 0, + 'contests' => $this->normalize_contests( $payload['contests'] ), + ); + + set_transient( self::TRANSIENT_KEY, $normalized, self::CACHE_TTL ); + + return $normalized; + } + + private function is_valid_payload( $payload ) { + return is_array( $payload ) && isset( $payload['contests'] ) && is_array( $payload['contests'] ); + } + + private function normalize_contests( $contests ) { + $normalized = array(); + $now = time(); + + foreach ( $contests as $contest ) { + if ( ! is_array( $contest ) ) { + continue; + } + + $start_time = isset( $contest['start_time'] ) ? absint( $contest['start_time'] ) : 0; + $end_time = isset( $contest['end_time'] ) ? absint( $contest['end_time'] ) : 0; + $url = isset( $contest['url'] ) ? esc_url_raw( $contest['url'] ) : ''; + if ( ! $start_time || ! $end_time || $end_time <= $now || ! $url ) { + continue; + } + + $normalized[] = array( + 'platform' => isset( $contest['platform'] ) ? sanitize_text_field( $contest['platform'] ) : '', + 'title' => isset( $contest['title'] ) ? sanitize_text_field( $contest['title'] ) : '', + 'start_time' => $start_time, + 'end_time' => $end_time, + 'status' => $start_time <= $now ? 'running' : 'upcoming', + 'url' => $url, + ); + } + + usort( + $normalized, + static function ( $left, $right ) { + return $left['start_time'] <=> $right['start_time']; + } + ); + + return $normalized; + } +} \ No newline at end of file diff --git a/includes/class-plugin.php b/includes/class-plugin.php new file mode 100644 index 0000000..4c4f4b5 --- /dev/null +++ b/includes/class-plugin.php @@ -0,0 +1,93 @@ +renderer = new OICS_Schedule_Renderer( new OICS_Contest_Client() ); + + add_action( 'wp_dashboard_setup', array( $this, 'register_dashboard_widget' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); + add_shortcode( 'oi_contest_schedule', array( $this, 'render_shortcode' ) ); + } + + private function register_assets() { + wp_register_style( 'oics-schedule', OICS_URL . 'assets/css/schedule.css', array(), OICS_VERSION ); + wp_register_script( 'oics-schedule', OICS_URL . 'assets/js/schedule.js', array(), OICS_VERSION, true ); + wp_localize_script( + 'oics-schedule', + 'oicsScheduleL10n', + array( + 'running' => __( 'Running', 'oi-contest-schedule' ), + 'ended' => __( 'Ended', 'oi-contest-schedule' ), + 'day' => __( 'd', 'oi-contest-schedule' ), + 'hour' => __( 'h', 'oi-contest-schedule' ), + 'minute' => __( 'm', 'oi-contest-schedule' ), + 'second' => __( 's', 'oi-contest-schedule' ), + ) + ); + } + + public function enqueue_frontend_assets() { + $post = get_post(); + if ( ! $post || ! has_shortcode( $post->post_content, 'oi_contest_schedule' ) ) { + return; + } + + $this->enqueue_assets(); + } + + public function enqueue_admin_assets( $hook_suffix ) { + if ( 'index.php' !== $hook_suffix ) { + return; + } + + $this->enqueue_assets(); + } + + private function enqueue_assets() { + $this->register_assets(); + wp_enqueue_style( 'oics-schedule' ); + wp_enqueue_script( 'oics-schedule' ); + } + + public function register_dashboard_widget() { + wp_add_dashboard_widget( + 'oics_contest_schedule', + esc_html__( 'Upcoming OI Contests', 'oi-contest-schedule' ), + array( $this, 'render_dashboard_widget' ) + ); + } + + public function render_dashboard_widget() { + echo $this->renderer->render( 8, true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + public function render_shortcode( $attributes ) { + $attributes = shortcode_atts( + array( + 'limit' => 10, + 'compact' => 'false', + ), + $attributes, + 'oi_contest_schedule' + ); + + $limit = min( 50, max( 1, absint( $attributes['limit'] ) ) ); + $compact = filter_var( $attributes['compact'], FILTER_VALIDATE_BOOLEAN ); + + return $this->renderer->render( $limit, $compact ); + } +} \ No newline at end of file diff --git a/includes/class-schedule-renderer.php b/includes/class-schedule-renderer.php new file mode 100644 index 0000000..c21110e --- /dev/null +++ b/includes/class-schedule-renderer.php @@ -0,0 +1,84 @@ +client = $client; + } + + public function render( $limit = 10, $compact = false ) { + $payload = $this->client->get_payload(); + if ( is_wp_error( $payload ) ) { + return $this->render_notice( __( 'Contest data is temporarily unavailable. Please try again later.', 'oi-contest-schedule' ), 'error' ); + } + + $contests = array_slice( $payload['contests'], 0, $limit ); + if ( empty( $contests ) ) { + return $this->render_notice( __( 'No upcoming contests.', 'oi-contest-schedule' ), 'empty' ); + } + + $classes = 'oics-schedule'; + if ( $compact ) { + $classes .= ' oics-schedule--compact'; + } + + ob_start(); + ?> +
+
+
+

+

+ +

+
+ +
+
+ + render_contest( $contest ); ?> + +
+ +

+ : + +

+ +
+ + + + + + + + + + + + + + + +

%2$s

', + esc_attr( $type ), + esc_html( $message ) + ); + } +} \ No newline at end of file diff --git a/languages/oi-contest-schedule-zh_CN.mo b/languages/oi-contest-schedule-zh_CN.mo new file mode 100644 index 0000000000000000000000000000000000000000..c32fdd571bb9e21395eb2aa970dc8cd41cd75a4c GIT binary patch literal 1391 zcmaiy?Q0xG9LL96pUy{$4-`b^jrc7dKKT5$?oKK>fY?KGaGV! zRieabauRH=k*3uMky@lpwAF^QZC?A*f1q#7?wzm1x4!Z_x103dIWYT~`OWX)JHOdS zM-I0M#w(a_VqU|1!>pgc2V)L=9c+T{gDc<*;2+>q;3M!ga1VS2{1>c(hYk@^28Tcj zUI3p3m%vxSRS>S^X2Bl|-YU2Snp!`BrrycJguDk%fJeYjz~{g#;9KBEvEK#X!T$Gx z{}ks(A=c!nf=9to@JaA|LAPMN;K!iRXEd98nPL24CNG%{#)~;f2{ipfDe`<_+sDgT zJdJ5;n_)ELN`$=aQ8$#DD?PAOWF)syk7-7QqMA3NFlMm`nlzDYmWhB(2fR!t1I`qu zI&M-{XF}3|X&#r!DHE7Pi02*|l^(ulLKxhzA!L2OrYe)EL`qml&FA~pys8S5*yS|l zI*BFsD3hqn=OXSRCCn-crQ+rP4-|5i1;XoRg}Z2QMH6Ulq5$>ytUwhFE4>&>-yYF0#5Dnv9ev7;^>~X##LQ- z*2$!HO?9iJu4bf zAZ7%5JK_t;6+iHB=tckh$eGgk*!bw<+nRgz+agh<89{~SbGi}*_zQ-p>$6yKJ$$Zq z+NwVGGh$|VY>i4c^ziHstxgMFnhcVd1=i^>ZYYP!sJ~Ui$A)OW9JYr_A6ciu268(z z6bF4#MW0LN?@h0DdcUmib(TnWbv|4BHCx-r?zGb{HhK>pt6SV zR=V{Px7;D{cd(;8^M3A-6iS0pS!J^!}yv#!~m{ zr+H2j+uL35eYd=~bBhet$vP{&-PLsco4pQ#=F^>>^l~fhto9@NLW`OMenxY9F}L?s z^_67HpZ9J*OgBG+uynbTef_Yz*)hk1iuN~)+u8N+A literal 0 HcmV?d00001 diff --git a/languages/oi-contest-schedule-zh_CN.po b/languages/oi-contest-schedule-zh_CN.po new file mode 100644 index 0000000..30bfb09 --- /dev/null +++ b/languages/oi-contest-schedule-zh_CN.po @@ -0,0 +1,60 @@ +msgid "" +msgstr "" +"Project-Id-Version: OI Contest Schedule 0.1.0\n" +"Report-Msgid-Bugs-To: https://github.com/hanyixuanten/OI-contest-fetch/issues\n" +"POT-Creation-Date: 2026-08-06 00:00+0000\n" +"PO-Revision-Date: 2026-08-06 00:00+0000\n" +"Last-Translator: hanyixuanten\n" +"Language-Team: Simplified Chinese\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Domain: oi-contest-schedule\n" + +msgid "Upcoming OI Contests" +msgstr "即将到来的 OI 赛事" + +msgid "Running" +msgstr "进行中" + +msgid "Ended" +msgstr "已结束" + +msgid "Upcoming" +msgstr "即将开始" + +msgid "d" +msgstr "天" + +msgid "h" +msgstr "时" + +msgid "m" +msgstr "分" + +msgid "s" +msgstr "秒" + +msgid "The contest data source returned an unexpected response." +msgstr "赛事数据源返回了异常响应。" + +msgid "The contest data source returned invalid data." +msgstr "赛事数据源返回了无效数据。" + +msgid "Contest data is temporarily unavailable. Please try again later." +msgstr "暂时无法加载赛事数据,请稍后再试。" + +msgid "No upcoming contests." +msgstr "暂无即将开始的赛事。" + +msgid "Times use your device time zone." +msgstr "时间按设备时区显示。" + +msgid "%d contest" +msgid_plural "%d contests" +msgstr[0] "%d 场赛事" + +msgid "Data updated" +msgstr "数据更新时间" \ No newline at end of file diff --git a/languages/oi-contest-schedule.pot b/languages/oi-contest-schedule.pot new file mode 100644 index 0000000..4fee4d4 --- /dev/null +++ b/languages/oi-contest-schedule.pot @@ -0,0 +1,56 @@ +msgid "" +msgstr "" +"Project-Id-Version: OI Contest Schedule 0.1.0\n" +"Report-Msgid-Bugs-To: https://github.com/hanyixuanten/OI-contest-fetch/issues\n" +"POT-Creation-Date: 2026-08-06 00:00+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Domain: oi-contest-schedule\n" + +msgid "Upcoming OI Contests" +msgstr "" + +msgid "Running" +msgstr "" + +msgid "Ended" +msgstr "" + +msgid "Upcoming" +msgstr "" + +msgid "d" +msgstr "" + +msgid "h" +msgstr "" + +msgid "m" +msgstr "" + +msgid "s" +msgstr "" + +msgid "The contest data source returned an unexpected response." +msgstr "" + +msgid "The contest data source returned invalid data." +msgstr "" + +msgid "Contest data is temporarily unavailable. Please try again later." +msgstr "" + +msgid "No upcoming contests." +msgstr "" + +msgid "Times use your device time zone." +msgstr "" + +msgid "%d contest" +msgid_plural "%d contests" +msgstr[0] "" +msgstr[1] "" + +msgid "Data updated" +msgstr "" \ No newline at end of file diff --git a/oi-contest-schedule.php b/oi-contest-schedule.php new file mode 100644 index 0000000..6057861 --- /dev/null +++ b/oi-contest-schedule.php @@ -0,0 +1,33 @@ +