wpbridge/includes/Core/ChangelogManager.php
feibisi f78c7e29c1 feat: 更新日志聚合显示功能 (v0.9.0)
- 新增 ChangelogManager 类,支持从多种源获取更新日志
- 支持 WordPress.org、GitHub、Gitea(菲码源库)、自定义 JSON 源
- 添加模态框 UI 显示更新日志
- 在插件/主题列表添加查看更新日志按钮
- 简单的 Markdown 转 HTML 支持
- 缓存机制减少 API 请求

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 11:12:43 +08:00

598 lines
No EOL
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 更新日志管理器
*
* 聚合显示插件/主题的更新日志
*
* @package WPBridge
* @since 0.9.0
*/
namespace WPBridge\Core;
// 防止直接访问
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* 更新日志管理类
*/
class ChangelogManager {
/**
* 单例实例
*
* @var self|null
*/
private static ?self $instance = null;
/**
* 缓存前缀
*/
const CACHE_PREFIX = 'wpbridge_changelog_';
/**
* 缓存时间(秒)
*/
const CACHE_TTL = 3600;
/**
* 获取单例实例
*
* @return self
*/
public static function get_instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 获取插件更新日志
*
* @param string $slug 插件 slug
* @param string $source_type 源类型wporg, custom, git
* @param string $source_url 自定义源 URL可选
* @return array
*/
public function get_plugin_changelog( string $slug, string $source_type = 'wporg', string $source_url = '' ): array {
$cache_key = self::CACHE_PREFIX . 'plugin_' . md5( $slug . $source_type . $source_url );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$changelog = [];
switch ( $source_type ) {
case 'wporg':
$changelog = $this->fetch_wporg_plugin_changelog( $slug );
break;
case 'git':
case 'github':
case 'gitea':
$changelog = $this->fetch_git_changelog( $source_url );
break;
case 'json':
case 'custom':
$changelog = $this->fetch_custom_changelog( $source_url, 'plugin', $slug );
break;
default:
$changelog = $this->fetch_wporg_plugin_changelog( $slug );
}
if ( ! empty( $changelog ) ) {
set_transient( $cache_key, $changelog, self::CACHE_TTL );
}
return $changelog;
}
/**
* 获取主题更新日志
*
* @param string $slug 主题 slug
* @param string $source_type 源类型
* @param string $source_url 自定义源 URL
* @return array
*/
public function get_theme_changelog( string $slug, string $source_type = 'wporg', string $source_url = '' ): array {
$cache_key = self::CACHE_PREFIX . 'theme_' . md5( $slug . $source_type . $source_url );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$changelog = [];
switch ( $source_type ) {
case 'wporg':
$changelog = $this->fetch_wporg_theme_changelog( $slug );
break;
case 'git':
case 'github':
case 'gitea':
$changelog = $this->fetch_git_changelog( $source_url );
break;
case 'json':
case 'custom':
$changelog = $this->fetch_custom_changelog( $source_url, 'theme', $slug );
break;
default:
$changelog = $this->fetch_wporg_theme_changelog( $slug );
}
if ( ! empty( $changelog ) ) {
set_transient( $cache_key, $changelog, self::CACHE_TTL );
}
return $changelog;
}
/**
* 从 WordPress.org 获取插件更新日志
*
* @param string $slug 插件 slug
* @return array
*/
private function fetch_wporg_plugin_changelog( string $slug ): array {
$api_url = 'https://api.wordpress.org/plugins/info/1.2/';
$response = wp_remote_post( $api_url, [
'timeout' => 15,
'body' => [
'action' => 'plugin_information',
'request' => serialize( (object) [
'slug' => $slug,
'fields' => [
'sections' => true,
'versions' => true,
],
] ),
],
] );
if ( is_wp_error( $response ) ) {
return $this->error_response( $response->get_error_message() );
}
$body = wp_remote_retrieve_body( $response );
$data = maybe_unserialize( $body );
if ( ! is_object( $data ) || isset( $data->error ) ) {
return $this->error_response( __( '无法获取插件信息', 'wpbridge' ) );
}
return $this->format_wporg_changelog( $data, 'plugin' );
}
/**
* 从 WordPress.org 获取主题更新日志
*
* @param string $slug 主题 slug
* @return array
*/
private function fetch_wporg_theme_changelog( string $slug ): array {
$api_url = 'https://api.wordpress.org/themes/info/1.2/';
$response = wp_remote_post( $api_url, [
'timeout' => 15,
'body' => [
'action' => 'theme_information',
'request' => serialize( (object) [
'slug' => $slug,
'fields' => [
'sections' => true,
'versions' => true,
],
] ),
],
] );
if ( is_wp_error( $response ) ) {
return $this->error_response( $response->get_error_message() );
}
$body = wp_remote_retrieve_body( $response );
$data = maybe_unserialize( $body );
if ( ! is_object( $data ) || isset( $data->error ) ) {
return $this->error_response( __( '无法获取主题信息', 'wpbridge' ) );
}
return $this->format_wporg_changelog( $data, 'theme' );
}
/**
* 格式化 WordPress.org 更新日志
*
* @param object $data API 返回数据
* @param string $type 类型plugin/theme
* @return array
*/
private function format_wporg_changelog( object $data, string $type ): array {
$result = [
'success' => true,
'source' => 'WordPress.org',
'name' => $data->name ?? '',
'slug' => $data->slug ?? '',
'version' => $data->version ?? '',
'last_updated' => $data->last_updated ?? '',
'changelog_html' => '',
'versions' => [],
];
// 提取 changelog 部分
if ( isset( $data->sections['changelog'] ) ) {
$result['changelog_html'] = $data->sections['changelog'];
}
// 提取版本历史
if ( isset( $data->versions ) && is_array( $data->versions ) ) {
$versions = array_keys( $data->versions );
rsort( $versions, SORT_NATURAL );
$result['versions'] = array_slice( $versions, 0, 20 );
}
return $result;
}
/**
* 从 Git 仓库获取更新日志
*
* @param string $url 仓库 URL
* @return array
*/
private function fetch_git_changelog( string $url ): array {
$parsed = $this->parse_git_url( $url );
if ( ! $parsed ) {
return $this->error_response( __( '无效的 Git 仓库 URL', 'wpbridge' ) );
}
switch ( $parsed['platform'] ) {
case 'github':
return $this->fetch_github_releases( $parsed['owner'], $parsed['repo'] );
case 'gitea':
case 'wenpai':
return $this->fetch_gitea_releases( $parsed['base_url'], $parsed['owner'], $parsed['repo'] );
default:
return $this->error_response( __( '不支持的 Git 平台', 'wpbridge' ) );
}
}
/**
* 解析 Git URL
*
* @param string $url URL
* @return array|null
*/
private function parse_git_url( string $url ): ?array {
// GitHub
if ( preg_match( '#github\.com/([^/]+)/([^/]+)#', $url, $matches ) ) {
return [
'platform' => 'github',
'owner' => $matches[1],
'repo' => rtrim( $matches[2], '.git' ),
'base_url' => 'https://api.github.com',
];
}
// 菲码源库 (git.wenpai.org)
if ( preg_match( '#git\.wenpai\.org/([^/]+)/([^/]+)#', $url, $matches ) ) {
return [
'platform' => 'gitea',
'owner' => $matches[1],
'repo' => rtrim( $matches[2], '.git' ),
'base_url' => 'https://git.wenpai.org',
];
}
// 通用 Gitea
if ( preg_match( '#(https?://[^/]+)/([^/]+)/([^/]+)#', $url, $matches ) ) {
return [
'platform' => 'gitea',
'owner' => $matches[2],
'repo' => rtrim( $matches[3], '.git' ),
'base_url' => $matches[1],
];
}
return null;
}
/**
* 从 GitHub 获取 Releases
*
* @param string $owner 仓库所有者
* @param string $repo 仓库名
* @return array
*/
private function fetch_github_releases( string $owner, string $repo ): array {
$api_url = "https://api.github.com/repos/{$owner}/{$repo}/releases";
$response = wp_remote_get( $api_url, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'WPBridge/' . WPBRIDGE_VERSION,
],
] );
if ( is_wp_error( $response ) ) {
return $this->error_response( $response->get_error_message() );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( $status_code !== 200 ) {
return $this->error_response( sprintf( __( 'GitHub API 返回错误: %d', 'wpbridge' ), $status_code ) );
}
$body = wp_remote_retrieve_body( $response );
$releases = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $releases ) ) {
return $this->error_response( __( 'JSON 解析失败', 'wpbridge' ) );
}
return $this->format_git_releases( $releases, 'GitHub', "{$owner}/{$repo}" );
}
/**
* 从 Gitea 获取 Releases
*
* @param string $base_url API 基础 URL
* @param string $owner 仓库所有者
* @param string $repo 仓库名
* @return array
*/
private function fetch_gitea_releases( string $base_url, string $owner, string $repo ): array {
$api_url = "{$base_url}/api/v1/repos/{$owner}/{$repo}/releases";
$response = wp_remote_get( $api_url, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/json',
],
] );
if ( is_wp_error( $response ) ) {
return $this->error_response( $response->get_error_message() );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( $status_code !== 200 ) {
return $this->error_response( sprintf( __( 'Gitea API 返回错误: %d', 'wpbridge' ), $status_code ) );
}
$body = wp_remote_retrieve_body( $response );
$releases = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $releases ) ) {
return $this->error_response( __( 'JSON 解析失败', 'wpbridge' ) );
}
$source_name = strpos( $base_url, 'wenpai' ) !== false ? '菲码源库' : 'Gitea';
return $this->format_git_releases( $releases, $source_name, "{$owner}/{$repo}" );
}
/**
* 格式化 Git Releases
*
* @param array $releases Releases 数据
* @param string $source 来源名称
* @param string $repo 仓库标识
* @return array
*/
private function format_git_releases( array $releases, string $source, string $repo ): array {
$changelog_html = '<div class="wpbridge-changelog-releases">';
$versions = [];
foreach ( array_slice( $releases, 0, 10 ) as $release ) {
$tag = $release['tag_name'] ?? '';
$name = $release['name'] ?? $tag;
$body = $release['body'] ?? '';
$date = $release['published_at'] ?? $release['created_at'] ?? '';
$prerelease = $release['prerelease'] ?? false;
$versions[] = $tag;
$changelog_html .= '<div class="wpbridge-release-item">';
$changelog_html .= '<h4 class="wpbridge-release-title">';
$changelog_html .= esc_html( $name );
if ( $prerelease ) {
$changelog_html .= ' <span class="wpbridge-badge wpbridge-badge-warning">' . esc_html__( '预发布', 'wpbridge' ) . '</span>';
}
$changelog_html .= '</h4>';
if ( $date ) {
$formatted_date = wp_date( get_option( 'date_format' ), strtotime( $date ) );
$changelog_html .= '<p class="wpbridge-release-date">' . esc_html( $formatted_date ) . '</p>';
}
if ( $body ) {
$body_html = $this->markdown_to_html( $body );
$changelog_html .= '<div class="wpbridge-release-body">' . wp_kses_post( $body_html ) . '</div>';
}
$changelog_html .= '</div>';
}
$changelog_html .= '</div>';
return [
'success' => true,
'source' => $source,
'name' => $repo,
'slug' => $repo,
'version' => $versions[0] ?? '',
'last_updated' => $releases[0]['published_at'] ?? '',
'changelog_html' => $changelog_html,
'versions' => $versions,
];
}
/**
* 从自定义源获取更新日志
*
* @param string $url 源 URL
* @param string $type 类型
* @param string $slug Slug
* @return array
*/
private function fetch_custom_changelog( string $url, string $type, string $slug ): array {
if ( empty( $url ) ) {
return $this->error_response( __( '未配置更新源 URL', 'wpbridge' ) );
}
$response = wp_remote_get( $url, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/json',
],
] );
if ( is_wp_error( $response ) ) {
return $this->error_response( $response->get_error_message() );
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
return $this->error_response( __( '无法解析更新源数据', 'wpbridge' ) );
}
$changelog_html = '';
$version = '';
$versions = [];
if ( isset( $data['sections']['changelog'] ) ) {
$changelog_html = $data['sections']['changelog'];
$version = $data['version'] ?? '';
} elseif ( isset( $data['changelog'] ) ) {
$changelog_html = is_array( $data['changelog'] )
? $this->format_changelog_array( $data['changelog'] )
: $data['changelog'];
$version = $data['version'] ?? '';
} elseif ( isset( $data['releases'] ) && is_array( $data['releases'] ) ) {
return $this->format_git_releases( $data['releases'], __( '自定义源', 'wpbridge' ), $slug );
}
return [
'success' => true,
'source' => __( '自定义源', 'wpbridge' ),
'name' => $data['name'] ?? $slug,
'slug' => $slug,
'version' => $version,
'last_updated' => $data['last_updated'] ?? '',
'changelog_html' => $changelog_html,
'versions' => $versions,
];
}
/**
* 格式化 changelog 数组
*
* @param array $changelog Changelog 数组
* @return string
*/
private function format_changelog_array( array $changelog ): string {
$html = '<ul class="wpbridge-changelog-list">';
foreach ( $changelog as $version => $changes ) {
$html .= '<li>';
$html .= '<strong>' . esc_html( $version ) . '</strong>';
if ( is_array( $changes ) ) {
$html .= '<ul>';
foreach ( $changes as $change ) {
$html .= '<li>' . esc_html( $change ) . '</li>';
}
$html .= '</ul>';
} else {
$html .= '<p>' . esc_html( $changes ) . '</p>';
}
$html .= '</li>';
}
$html .= '</ul>';
return $html;
}
/**
* 简单的 Markdown 转 HTML
*
* @param string $markdown Markdown 文本
* @return string
*/
private function markdown_to_html( string $markdown ): string {
$html = esc_html( $markdown );
$html = preg_replace( '/^### (.+)$/m', '<h5>$1</h5>', $html );
$html = preg_replace( '/^## (.+)$/m', '<h4>$1</h4>', $html );
$html = preg_replace( '/^# (.+)$/m', '<h3>$1</h3>', $html );
$html = preg_replace( '/\*\*(.+?)\*\*/', '<strong>$1</strong>', $html );
$html = preg_replace( '/\*(.+?)\*/', '<em>$1</em>', $html );
$html = preg_replace( '/^[\-\*] (.+)$/m', '<li>$1</li>', $html );
$html = preg_replace( '/(<li>.*<\/li>\n?)+/', '<ul>$0</ul>', $html );
$html = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $html );
$html = nl2br( $html );
return $html;
}
/**
* 返回错误响应
*
* @param string $message 错误消息
* @return array
*/
private function error_response( string $message ): array {
return [
'success' => false,
'error' => $message,
'source' => '',
'name' => '',
'slug' => '',
'version' => '',
'last_updated' => '',
'changelog_html' => '',
'versions' => [],
];
}
/**
* 清除缓存
*
* @param string $type 类型plugin/theme
* @param string $slug Slug
*/
public function clear_cache( string $type, string $slug ): void {
global $wpdb;
$like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX . $type . '_' ) . '%';
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$like
)
);
}
/**
* 清除所有缓存
*/
public function clear_all_cache(): void {
global $wpdb;
$like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%';
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$like
)
);
}
}