Compare commits

...

No commits in common. "v1.0.7" and "main" have entirely different histories.
v1.0.7 ... main

11 changed files with 879 additions and 274 deletions

View file

@ -1,5 +1,7 @@
# 文派统一插件发布 CI Workflow
# 触发push tag v*
# 运行环境forgejo-ci-php:latest (Alpine + php-cli + git + rsync + zip + node)
# 基于 ci-workflows/wp-release.yml 2026-02-18 版本
name: Release Plugin

on:
@ -16,29 +18,47 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Verify tools
shell: bash
run: |
php -v | head -1 || true
git --version
rsync --version | head -1 || true
zip --version | head -2 || true
jq --version
curl --version | head -1 || true

- name: Extract version from tag
id: version
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Tag: $TAG, Version: $VERSION"

- name: Detect main plugin file
id: detect
shell: bash
run: |
set -euo pipefail
MAIN_FILE=$(grep -rl "Plugin Name:" *.php 2>/dev/null | head -1)
if [ -z "$MAIN_FILE" ]; then
echo "::error::No main plugin file found"
exit 1
fi
echo "main_file=$MAIN_FILE" >> $GITHUB_OUTPUT
echo "main_file=$MAIN_FILE" >> "$GITHUB_OUTPUT"
echo "Main file: $MAIN_FILE"

- name: Validate version consistency
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
MAIN_FILE="${{ steps.detect.outputs.main_file }}"
HEADER_VERSION=$(grep -i "Version:" "$MAIN_FILE" | grep -v "Requires" | grep -v "Tested" | head -1 | sed "s/.*Version:[[:space:]]*//" | sed "s/[[:space:]]*$//" | tr -d "\r")
@ -56,14 +76,62 @@ jobs:
fi
echo "Version consistency check passed: $VERSION"

- name: Install dependencies
- name: Generate Changelog
shell: bash
run: |
apt-get update -qq && apt-get install -y -qq php-cli rsync zip > /dev/null 2>&1
php -v | head -1 || true
echo "rsync and zip installed"
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"

PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v' | grep -v "^${TAG}$" | head -1)

if [ -n "$PREV_TAG" ]; then
echo "Changelog: ${PREV_TAG}..${TAG}"
COMMITS=$(git log "${PREV_TAG}..${TAG}" --no-merges --pretty=format:"%s (%h)" 2>/dev/null || true)
else
echo "Changelog: first release, using all commits"
COMMITS=$(git log --no-merges --pretty=format:"%s (%h)" 2>/dev/null || true)
fi

if [ -z "$COMMITS" ]; then
: > /tmp/changelog.md
echo "No commits found for changelog"
exit 0
fi

FEAT="" FIX="" OTHER=""
while IFS= read -r line; do
case "$line" in
feat:*|feat\(*) FEAT="${FEAT}\n- ${line}" ;;
fix:*|fix\(*) FIX="${FIX}\n- ${line}" ;;
*) OTHER="${OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"

CHANGELOG=""
HAS_CATEGORIES=false
if [ -n "$FEAT" ]; then
CHANGELOG="${CHANGELOG}#### New Features${FEAT}\n\n"
HAS_CATEGORIES=true
fi
if [ -n "$FIX" ]; then
CHANGELOG="${CHANGELOG}#### Bug Fixes${FIX}\n\n"
HAS_CATEGORIES=true
fi
if [ -n "$OTHER" ]; then
if [ "$HAS_CATEGORIES" = true ]; then
CHANGELOG="${CHANGELOG}#### Other Changes${OTHER}\n\n"
else
CHANGELOG="${OTHER#\\n}\n\n"
fi
fi

printf '%b' "$CHANGELOG" > /tmp/changelog.md
echo "Changelog generated ($(echo "$COMMITS" | wc -l) commits)"

- name: PHP Lint
shell: bash
run: |
set -euo pipefail
ERRORS=0
while IFS= read -r file; do
if ! php -l "$file" > /dev/null 2>&1; then
@ -71,7 +139,7 @@ jobs:
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*")
if [ $ERRORS -gt 0 ]; then
if [ "$ERRORS" -gt 0 ]; then
echo "::error::PHP lint found $ERRORS error(s)"
exit 1
fi
@ -79,13 +147,18 @@ jobs:

- name: Build ZIP
id: build
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
SLUG="${{ env.PLUGIN_SLUG }}"
ZIP_NAME="${SLUG}-${VERSION}.zip"
BUILD_DIR="/tmp/build/${SLUG}"
RELEASE_DIR="/tmp/release"
mkdir -p /tmp/build/${SLUG}
mkdir -p ${RELEASE_DIR}

rm -rf "$BUILD_DIR" "$RELEASE_DIR"
mkdir -p "$BUILD_DIR" "$RELEASE_DIR"

rsync -a \
--exclude=".git" \
--exclude=".github" \
@ -94,8 +167,13 @@ jobs:
--exclude=".gitattributes" \
--exclude=".editorconfig" \
--exclude=".env*" \
--exclude=".agent" \
--exclude=".vscode" \
--exclude="node_modules" \
--exclude="vendor" \
--exclude="tests" \
--exclude="docs" \
--exclude="lib" \
--exclude="phpunit.xml*" \
--exclude="phpcs.xml*" \
--exclude="phpstan.neon*" \
@ -108,42 +186,251 @@ jobs:
--exclude="*.md" \
--exclude="LICENSE" \
--exclude="Makefile" \
./ /tmp/build/${SLUG}/
./ "$BUILD_DIR/"

(
cd /tmp/build
zip -r ${RELEASE_DIR}/${ZIP_NAME} ${SLUG}/
echo "zip_path=${RELEASE_DIR}/${ZIP_NAME}" >> $GITHUB_OUTPUT
echo "zip_name=${ZIP_NAME}" >> $GITHUB_OUTPUT
echo "release_dir=${RELEASE_DIR}" >> $GITHUB_OUTPUT
echo "Built: ${ZIP_NAME} ($(du -h ${RELEASE_DIR}/${ZIP_NAME} | cut -f1))"
zip -qr "${RELEASE_DIR}/${ZIP_NAME}" "${SLUG}/"
)

echo "zip_path=${RELEASE_DIR}/${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "release_dir=${RELEASE_DIR}" >> "$GITHUB_OUTPUT"
echo "Built: ${ZIP_NAME} ($(du -h "${RELEASE_DIR}/${ZIP_NAME}" | cut -f1))"

- name: Calculate SHA-256
id: checksum
shell: bash
run: |
set -euo pipefail
ZIP_PATH="${{ steps.build.outputs.zip_path }}"
ZIP_NAME="${{ steps.build.outputs.zip_name }}"
RELEASE_DIR="${{ steps.build.outputs.release_dir }}"
SHA256=$(sha256sum "$ZIP_PATH" | cut -d" " -f1)
echo "$SHA256 $ZIP_NAME" > "${RELEASE_DIR}/${ZIP_NAME}.sha256"
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
echo "SHA-256: $SHA256"
echo "Release dir contents:"
ls -la "$RELEASE_DIR"

- name: Create Release
uses: actions/forgejo-release@v2
with:
direction: upload
url: https://feicode.com
token: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ steps.version.outputs.tag }}
title: ${{ steps.version.outputs.tag }}
release-dir: ${{ steps.build.outputs.release_dir }}
verbose: true
release-notes: |
## ${{ env.PLUGIN_SLUG }} ${{ steps.version.outputs.tag }}
- name: Create or Update Release & Upload Assets
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail

### Checksums
AUTH_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
if [ -z "$AUTH_TOKEN" ]; then
echo "::error::Missing auth token: set RELEASE_TOKEN or use default GITHUB_TOKEN"
exit 1
fi

| File | SHA-256 |
|------|---------|
| ${{ steps.build.outputs.zip_name }} | ${{ steps.checksum.outputs.sha256 }} |
TAG="${{ steps.version.outputs.tag }}"
SLUG="${{ env.PLUGIN_SLUG }}"
RELEASE_DIR="${{ steps.build.outputs.release_dir }}"
ZIP_NAME="${{ steps.build.outputs.zip_name }}"
SHA256="${{ steps.checksum.outputs.sha256 }}"
API_URL="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}"
AUTH_HEADER="Authorization: token ${AUTH_TOKEN}"

{
echo "## ${SLUG} ${TAG}"
echo ""
CHANGELOG_FILE="/tmp/changelog.md"
if [ -f "$CHANGELOG_FILE" ] && [ -s "$CHANGELOG_FILE" ]; then
echo "### What's Changed"
echo ""
cat "$CHANGELOG_FILE"
fi
echo "### Checksums"
echo ""
echo "| File | SHA-256 |"
echo "|------|---------|"
echo "| ${ZIP_NAME} | ${SHA256} |"
} > /tmp/release-notes.md
RELEASE_NOTES=$(cat /tmp/release-notes.md)

echo ">>> Resolving release ${TAG}"
STATUS=$(curl -sS -o /tmp/release.json -w "%{http_code}" \
-H "$AUTH_HEADER" \
"${API_URL}/releases/tags/${TAG}")

if [ "$STATUS" = "200" ]; then
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
echo "Release exists (id=${RELEASE_ID}), patching metadata"
curl -sS -f -X PATCH \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg name "$TAG" --arg body "$RELEASE_NOTES" '{name: $name, body: $body, draft: false, prerelease: false}')" \
"${API_URL}/releases/${RELEASE_ID}" > /tmp/release.json
elif [ "$STATUS" = "404" ]; then
echo "Release not found, creating"
curl -sS -f -X POST \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$RELEASE_NOTES" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')" \
"${API_URL}/releases" > /tmp/release.json
else
echo "::error::Failed to query release (HTTP ${STATUS})"
cat /tmp/release.json
exit 1
fi

RELEASE_ID=$(jq -r '.id' /tmp/release.json)
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Failed to resolve release id"
cat /tmp/release.json
exit 1
fi

echo ">>> Uploading assets to release ${RELEASE_ID}"
for FILE in "${RELEASE_DIR}"/*; do
FILENAME=$(basename "$FILE")
EXISTING_ASSET_ID=$(jq -r --arg n "$FILENAME" '.assets[]? | select(.name == $n) | .id' /tmp/release.json | head -1)

if [ -n "$EXISTING_ASSET_ID" ] && [ "$EXISTING_ASSET_ID" != "null" ]; then
echo " deleting old asset: ${FILENAME} (id=${EXISTING_ASSET_ID})"
curl -sS -f -X DELETE \
-H "$AUTH_HEADER" \
"${API_URL}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}" > /dev/null
fi

ENCODED_NAME=$(printf "%s" "$FILENAME" | jq -sRr @uri)
echo " uploading: ${FILENAME}"
curl -sS -f --retry 3 --retry-delay 2 --retry-all-errors \
-X POST \
-H "$AUTH_HEADER" \
-F "attachment=@${FILE}" \
"${API_URL}/releases/${RELEASE_ID}/assets?name=${ENCODED_NAME}" > /dev/null
done

echo ">>> Verifying uploaded assets"
curl -sS -f -H "$AUTH_HEADER" "${API_URL}/releases/${RELEASE_ID}" > /tmp/release-final.json
for FILE in "${RELEASE_DIR}"/*; do
FILENAME=$(basename "$FILE")
if ! jq -e --arg n "$FILENAME" '.assets[]? | select(.name == $n)' /tmp/release-final.json > /dev/null; then
echo "::error::Missing uploaded asset: ${FILENAME}"
jq -r '.assets[]?.name' /tmp/release-final.json
exit 1
fi
done

echo ">>> Release ${TAG} published successfully"
echo "Release URL: ${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"

- name: Run post-release composer repair on target site (optional)
if: ${{ secrets.DEPLOY_HOST != '' && secrets.DEPLOY_SSH_KEY != '' }}
shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_COMMAND_MODE: ${{ secrets.DEPLOY_COMMAND_MODE }}
DEPLOY_SITE_PATH: ${{ secrets.DEPLOY_SITE_PATH }}
DEPLOY_WP_USER: ${{ secrets.DEPLOY_WP_USER }}
DEPLOY_COMPOSER_SCRIPT: ${{ secrets.DEPLOY_COMPOSER_SCRIPT }}
run: |
set -euo pipefail

: "${DEPLOY_HOST:?DEPLOY_HOST is required when this step is enabled}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
DEPLOY_USER="${DEPLOY_USER:-wpdeploy}"
DEPLOY_COMMAND_MODE="${DEPLOY_COMMAND_MODE:-runner}"
SLUG="${{ env.PLUGIN_SLUG }}"

if ! command -v ssh >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null
else
echo "::error::ssh client not found in runner image"
exit 1
fi
fi

install -m 700 -d ~/.ssh
KEY_PATH="$HOME/.ssh/deploy_key"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_PATH"
chmod 600 "$KEY_PATH"
ssh-keyscan -t rsa,ecdsa,ed25519 -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true

if [[ "$DEPLOY_COMMAND_MODE" == "runner" ]]; then
DEPLOY_COMPOSER_SCRIPT="${DEPLOY_COMPOSER_SCRIPT:-/usr/local/sbin/wp-post-release-composer-runner}"
ssh -i "$KEY_PATH" -p "$DEPLOY_PORT" -o BatchMode=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \
"sudo '$DEPLOY_COMPOSER_SCRIPT' '$SLUG'"
else
DEPLOY_SITE_PATH="${DEPLOY_SITE_PATH:-/www/wwwroot/wptea.com}"
DEPLOY_WP_USER="${DEPLOY_WP_USER:-www}"
DEPLOY_COMPOSER_SCRIPT="${DEPLOY_COMPOSER_SCRIPT:-/opt/wenpai-infra/ops/wp-post-release-composer.sh}"
ssh -i "$KEY_PATH" -p "$DEPLOY_PORT" -o BatchMode=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \
"bash '$DEPLOY_COMPOSER_SCRIPT' --site '$DEPLOY_SITE_PATH' --wp-user '$DEPLOY_WP_USER' --plugin '$SLUG' --strict"
fi

- name: Verify update API metadata (optional)
if: ${{ secrets.UPDATE_API_BASE != '' }}
shell: bash
env:
UPDATE_API_BASE: ${{ secrets.UPDATE_API_BASE }}
VERIFY_FROM_VERSION: ${{ secrets.VERIFY_FROM_VERSION }}
run: |
set -euo pipefail

API_BASE="${UPDATE_API_BASE%/}"
FROM_VERSION="${VERIFY_FROM_VERSION:-0.0.0}"
SLUG="${{ env.PLUGIN_SLUG }}"
MAIN_FILE="${{ steps.detect.outputs.main_file }}"
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
PLUGIN_FILE="${SLUG}/${MAIN_FILE}"

echo "Verify update-check: ${PLUGIN_FILE} from ${FROM_VERSION} -> ${VERSION}"
REQUEST_JSON=$(jq -cn --arg plugin_file "$PLUGIN_FILE" --arg from "$FROM_VERSION" '{plugins: {($plugin_file): {Version: $from}}}')

UPDATE_OK=0
for attempt in $(seq 1 36); do
curl -sS -f -X POST \
-H "Content-Type: application/json" \
--data "$REQUEST_JSON" \
"${API_BASE}/api/v1/update-check" > /tmp/update-check.json

API_VERSION=$(jq -r --arg plugin_file "$PLUGIN_FILE" '.plugins[$plugin_file].version // empty' /tmp/update-check.json)
API_PACKAGE=$(jq -r --arg plugin_file "$PLUGIN_FILE" '.plugins[$plugin_file].package // empty' /tmp/update-check.json)

if [[ "$API_VERSION" == "$VERSION" && "$API_PACKAGE" == *"/releases/download/${TAG}/"* ]]; then
UPDATE_OK=1
break
fi

echo "[attempt ${attempt}/36] update-check not ready, version=${API_VERSION:-<empty>} package=${API_PACKAGE:-<empty>}"
sleep 10
done

if [[ "$UPDATE_OK" -ne 1 ]]; then
echo "::error::update-check verification timeout"
cat /tmp/update-check.json
exit 1
fi

echo "Verify plugin info: ${SLUG}"
INFO_OK=0
for attempt in $(seq 1 36); do
curl -sS -f "${API_BASE}/api/v1/plugins/${SLUG}/info" > /tmp/plugin-info.json
INFO_VERSION=$(jq -r '.version // empty' /tmp/plugin-info.json)
INFO_PACKAGE=$(jq -r '.download_link // empty' /tmp/plugin-info.json)

if [[ "$INFO_VERSION" == "$VERSION" && "$INFO_PACKAGE" == *"/releases/download/${TAG}/"* ]]; then
INFO_OK=1
break
fi

echo "[attempt ${attempt}/36] plugin-info not ready, version=${INFO_VERSION:-<empty>} download=${INFO_PACKAGE:-<empty>}"
sleep 10
done

if [[ "$INFO_OK" -ne 1 ]]; then
echo "::error::plugin-info verification timeout"
cat /tmp/plugin-info.json
exit 1
fi

echo "Update API verification passed"

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
vendor/
node_modules/
.env
*.log
.DS_Store

View file

@ -0,0 +1,266 @@
<?php
/**
* WenPai 插件更新器
*
* 为文派系插件提供自建更新服务支持。
* 通过文派云桥 (WenPai Bridge) 检查插件更新,
* 利用 WordPress 5.8+ 的 Update URI 机制。
*
* 当 wp-china-yes 插件激活并启用集中更新时,
* 此更新器会通过 wenpai_updater_override filter 自动让位。
*
* @package WenPai
* @version 1.0.0
* @requires WordPress 5.8+
* @requires PHP 7.4+
* @link https://feicode.com/WenPai-org
*/

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

if ( class_exists( 'WenPai_Updater' ) ) {
return;
}

class WenPai_Updater {

/**
* 更新器版本号。
*
* @var string
*/
const VERSION = '1.1.0';

/**
* 云桥 API 地址。
*
* @var string
*/
const API_URL = 'https://updates.wenpai.net/api/v1';

/**
* 插件主文件 basename如 wpslug/wpslug.php
*
* @var string
*/
private $plugin_file;

/**
* 插件 slug如 wpslug
*
* @var string
*/
private $slug;

/**
* 当前插件版本。
*
* @var string
*/
private $version;

/**
* 初始化更新器。
*
* @param string $plugin_file 插件主文件路径plugin_basename 格式)。
* @param string $version 当前插件版本号。
*/
public function __construct( string $plugin_file, string $version ) {
$this->plugin_file = $plugin_file;
$this->slug = dirname( $plugin_file );
$this->version = $version;

// 检查是否被 wp-china-yes 集中更新接管
$is_overridden = apply_filters(
'wenpai_updater_override',
false,
$this->slug
);

if ( ! $is_overridden ) {
$this->register_hooks();
}
}

/**
* 注册 WordPress hooks。
*/
private function register_hooks(): void {
// Update URI: https://updates.wenpai.net 触发此 filter
add_filter(
'update_plugins_updates.wenpai.net',
[ $this, 'check_update' ],
10,
4
);

// 插件详情弹窗
add_filter( 'plugins_api', [ $this, 'plugin_info' ], 20, 3 );
}

/**
* 检查插件更新。
*
* WordPress 在检查更新时,对声明了 Update URI 的插件
* 触发 update_plugins_{hostname} filter。
*
* @param array|false $update 当前更新数据。
* @param array $plugin_data 插件头信息。
* @param string $plugin_file 插件文件路径。
* @param string[] $locales 语言列表。
* @return object|false 更新数据或 false。
*/
public function check_update( $update, array $plugin_data, string $plugin_file, array $locales ) {
if ( $plugin_file !== $this->plugin_file ) {
return $update;
}

$response = $this->api_request( 'update-check', [
'plugins' => [
$this->plugin_file => [
'Version' => $this->version,
],
],
] );

if ( is_wp_error( $response ) || empty( $response['plugins'][ $this->plugin_file ] ) ) {
return $update;
}

$data = $response['plugins'][ $this->plugin_file ];

return (object) [
'id' => $data['id'] ?? '',
'slug' => $data['slug'] ?? $this->slug,
'plugin' => $this->plugin_file,
'version' => $data['version'] ?? '',
'new_version' => $data['version'] ?? '',
'url' => $data['url'] ?? '',
'package' => $data['package'] ?? '',
'icons' => $data['icons'] ?? [],
'banners' => $data['banners'] ?? [],
'requires' => $data['requires'] ?? '',
'tested' => $data['tested'] ?? '',
'requires_php' => $data['requires_php'] ?? '',
];
}

/**
* 插件详情弹窗数据。
*
* 当用户在 WP 后台点击"查看详情"时触发。
*
* @param false|object|array $result 当前结果。
* @param string $action API 动作。
* @param object $args 请求参数。
* @return false|object 插件信息或 false。
*/
public function plugin_info( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
return $result;
}

if ( ! isset( $args->slug ) || $args->slug !== $this->slug ) {
return $result;
}

$response = $this->api_request( "plugins/{$this->slug}/info" );

if ( ! is_wp_error( $response ) && ! isset( $response['error'] ) && ! empty( $response['name'] ) ) {
$info = new stdClass();
$info->name = $response['name'];
$info->slug = $response['slug'] ?? $this->slug;
$info->version = $response['version'] ?? '';
$info->author = $response['author'] ?? '';
$info->homepage = $response['homepage'] ?? '';
$info->download_link = $response['download_link'] ?? '';
$info->requires = $response['requires'] ?? '';
$info->tested = $response['tested'] ?? '';
$info->requires_php = $response['requires_php'] ?? '';
$info->last_updated = $response['last_updated'] ?? '';
$info->icons = $response['icons'] ?? [];
$info->banners = $response['banners'] ?? [];
$info->sections = $response['sections'] ?? [];
$info->external = true;

return $info;
}

// API 不可用或插件未注册时,用本地插件头信息兜底
$plugin_path = WP_PLUGIN_DIR . '/' . $this->plugin_file;
if ( ! file_exists( $plugin_path ) ) {
return $result;
}

if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

$plugin_data = get_plugin_data( $plugin_path );

$info = new stdClass();
$info->name = $plugin_data['Name'] ?? $this->slug;
$info->slug = $this->slug;
$info->version = $this->version;
$info->author = $plugin_data['AuthorName'] ?? '';
$info->homepage = $plugin_data['PluginURI'] ?? '';
$info->requires = $plugin_data['RequiresWP'] ?? '';
$info->requires_php = $plugin_data['RequiresPHP'] ?? '';
$info->sections = [
'description' => $plugin_data['Description'] ?? '',
];
$info->external = true;

return $info;
}

/**
* 向云桥 API 发送请求。
*
* @param string $endpoint API 端点(不含 /api/v1/ 前缀)。
* @param array|null $body POST 请求体null 则用 GET
* @return array|WP_Error 解码后的响应或错误。
*/
private function api_request( string $endpoint, ?array $body = null ) {
$url = self::API_URL . '/' . ltrim( $endpoint, '/' );

$args = [
'timeout' => 10,
'headers' => [
'Accept' => 'application/json',
],
];

if ( null !== $body ) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode( $body );
$response = wp_remote_post( $url, $args );
} else {
$response = wp_remote_get( $url, $args );
}

if ( is_wp_error( $response ) ) {
return $response;
}

$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error(
'wenpai_bridge_error',
sprintf( 'WenPai Bridge API returned %d', $code )
);
}

$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new WP_Error(
'wenpai_bridge_parse_error',
'Invalid JSON response from WenPai Bridge'
);
}

return $data;
}
}

View file

@ -127,10 +127,10 @@ class WPSlug_Admin
<span style="font-size: 13px; padding-left: 10px;">
<?php printf( esc_html__( 'Version: %s', 'wpslug' ), esc_html( WPSLUG_VERSION ) ); ?>
</span>
<a href="https://wpslug.com/document" target="_blank" class="button button-secondary" style="margin-left: 10px;">
<a href="https://wpcy.com/slug/" target="_blank" class="button button-secondary" style="margin-left: 10px;">
<?php esc_html_e( 'Documentation', 'wpslug' ); ?>
</a>
<a href="https://sharecms.com/forums/" target="_blank" class="button button-secondary">
<a href="https://wpcy.com/c/wpslug/" target="_blank" class="button button-secondary">
<?php esc_html_e( 'Support', 'wpslug' ); ?>
</a>
</h1>
@ -740,6 +740,35 @@ class WPSlug_Admin
</tr>
</table>
</div>

<div class="wpslug-api-section" data-service="wpmind">
<h4><?php _e("WPMind AI Translation", "wpslug"); ?></h4>
<div class="wpslug-wpmind-status">
<?php if (function_exists('wpmind_is_available') && wpmind_is_available()): ?>
<p class="description" style="color: #2e7d32;">
<span class="dashicons dashicons-yes-alt"></span>
<?php _e("WPMind is active and configured. No additional settings required.", "wpslug"); ?>
</p>
<p class="description">
<?php _e("WPMind uses AI to translate titles to SEO-friendly slugs. It provides more accurate and context-aware translations compared to traditional translation services.", "wpslug"); ?>
</p>
<p class="description">
<a href="<?php echo admin_url('options-general.php?page=wpmind'); ?>">
<?php _e("Configure WPMind Settings", "wpslug"); ?> →
</a>
</p>
<?php else: ?>
<p class="description" style="color: #d32f2f;">
<span class="dashicons dashicons-warning"></span>
<?php _e("WPMind plugin is not active or not configured.", "wpslug"); ?>
</p>
<p class="description">
<?php _e("Please install and activate the WPMind plugin to use AI-powered translation.", "wpslug"); ?>
<a href="https://wpcy.com/mind/" target="_blank"><?php _e("Learn more", "wpslug"); ?></a>
</p>
<?php endif; ?>
</div>
</div>
</div>
<?php
}

View file

@ -30,6 +30,9 @@ class WPSlug_Converter {
case 'pinyin':
$result = $this->convertPinyin($text, $options);
break;
case 'semantic_pinyin':
$result = $this->convertSemanticPinyin($text, $options);
break;
case 'transliteration':
$result = $this->convertTransliteration($text, $options);
break;
@ -71,6 +74,89 @@ class WPSlug_Converter {
}
}

/**
* 语义化拼音转换(使用 WPMind AI
*
* 与普通拼音不同,这个方法会按词语分隔而非按字分隔
* 例如 "你好世界" → "nihao-shijie" 而非 "ni-hao-shi-jie"
*/
private function convertSemanticPinyin($text, $options) {
$debug_mode = isset($options['debug_mode']) && $options['debug_mode'];
try {
// 1. 检查是否是中文
if ($this->detectLanguage($text) !== 'zh') {
return $this->cleanBasicSlug($text, $options);
}

// 2. 检查 WPMind 是否可用
if (!function_exists('wpmind_pinyin') || !function_exists('wpmind_is_available') || !wpmind_is_available()) {
if ($debug_mode) {
error_log('[WPSlug] WPMind not available for semantic pinyin, falling back to regular pinyin');
}
return $this->convertPinyin($text, $options);
}

// 3. 文本长度限制(避免超时)
if (mb_strlen($text) > 200) {
if ($debug_mode) {
error_log('[WPSlug] Text too long for semantic pinyin, using regular pinyin');
}
return $this->convertPinyin($text, $options);
}

// 4. 调用 WPMind AI 进行语义化拼音转换
$result = wpmind_pinyin($text, [
'context' => 'wpslug_semantic_pinyin',
'cache_ttl' => 604800, // 7天
]);

// 5. 处理结果
if (is_wp_error($result)) {
if ($debug_mode) {
error_log('[WPSlug] WPMind pinyin error: ' . $result->get_error_message());
}
return $this->convertPinyin($text, $options);
}

// 6. 清理结果
$slug = $this->cleanSemanticPinyinResult($result, $options);

if (empty($slug)) {
if ($debug_mode) {
error_log('[WPSlug] WPMind returned empty pinyin, using regular pinyin');
}
return $this->convertPinyin($text, $options);
}

if ($debug_mode) {
error_log('[WPSlug] Semantic pinyin: "' . $text . '" → "' . $slug . '"');
}

return $slug;

} catch (Exception $e) {
$this->settings->logError('Semantic pinyin error: ' . $e->getMessage());
return $this->convertPinyin($text, $options);
}
}

/**
* 清理语义化拼音结果
*/
private function cleanSemanticPinyinResult($text, $options) {
// 移除可能的引号和空格
$text = trim($text, " \t\n\r\0\x0B\"'");
// 确保只有小写字母、数字和连字符
$text = preg_replace('/[^a-zA-Z0-9\-]/', '-', $text);
$text = strtolower($text);
$text = preg_replace('/-+/', '-', $text);
$text = trim($text, '-');
return $text;
}

private function convertTransliteration($text, $options) {
try {
$detected_lang = $this->detectLanguage($text);
@ -163,7 +249,7 @@ class WPSlug_Converter {
}

public function getSupportedModes() {
return array('pinyin', 'transliteration', 'translation');
return array('pinyin', 'semantic_pinyin', 'transliteration', 'translation');
}

public function isModeSupported($mode) {

View file

@ -41,27 +41,12 @@ class WPSlug_Core {
add_action('manage_pages_custom_column', array($this, 'displaySlugColumn'), 10, 2);
}
}
/**
* Disabled: migrated to WPSlug_Updater (Update URI)
*/
private function init_update_checker()
{
if (
file_exists(
plugin_dir_path(__FILE__) .
"../lib/plugin-update-checker/plugin-update-checker.php"
)
) {
require_once plugin_dir_path(__FILE__) .
"../lib/plugin-update-checker/plugin-update-checker.php";

if (
class_exists('YahnisElsts\PluginUpdateChecker\v5p3\PucFactory')
) {
$this->update_checker = \YahnisElsts\PluginUpdateChecker\v5p3\PucFactory::buildUpdateChecker(
"https://updates.weixiaoduo.com/wpslug.json",
WPSLUG_PLUGIN_DIR . "wpslug.php",
"wpslug"
);
}
}
return;
}

public function processSanitizeTitle($title, $raw_title = '', $context = 'display') {

View file

@ -237,14 +237,24 @@ class WPSlug_Settings

public function getConversionModes()
{
return [
$modes = [
"pinyin" => __("Chinese Pinyin Conversion", "wpslug"),
"transliteration" => __(
];
// 如果 WPMind 可用,添加语义化拼音选项
if (function_exists('wpmind_is_available') && wpmind_is_available()) {
$modes["semantic_pinyin"] = __("Semantic Pinyin (WPMind AI)", "wpslug");
} elseif (class_exists('\\WPMind\\WPMind')) {
$modes["semantic_pinyin"] = __("Semantic Pinyin (Requires WPMind)", "wpslug");
}
$modes["transliteration"] = __(
"Foreign Language Transliteration",
"wpslug"
),
"translation" => __("Multi-language Translation", "wpslug"),
];
);
$modes["translation"] = __("Multi-language Translation", "wpslug");
return $modes;
}

public function getTransliterationMethods()
@ -266,11 +276,25 @@ class WPSlug_Settings

public function getTranslationServices()
{
return [
$services = [
"none" => __("None", "wpslug"),
"google" => __("Google Translate", "wpslug"),
"baidu" => __("Baidu Translate", "wpslug"),
];
// 动态检测 WPMind 是否可用
if (function_exists('wpmind_is_available') && wpmind_is_available()) {
// WPMind 可用,添加到服务列表顶部(推荐)
$services = array_merge(
["wpmind" => __("WPMind AI (Recommended)", "wpslug")],
$services
);
} elseif (class_exists('\\WPMind\\WPMind')) {
// WPMind 已安装但未配置
$services["wpmind"] = __("WPMind AI (Not Configured)", "wpslug");
}
return $services;
}

public function getLanguages()

View file

@ -7,6 +7,15 @@ if (!defined('ABSPATH')) {
class WPSlug_Translator {
private $converter;

/**
* 防止循环调用的静态标志
* 当使用 WPMind 翻译时WPMind 内部可能触发 sanitize_title filter
* 如果没有保护,会导致无限递归
*
* @var bool
*/
private static $is_translating = false;

public function __construct() {
$this->converter = null;
}
@ -16,9 +25,23 @@ class WPSlug_Translator {
return '';
}

// 防止循环调用:如果正在翻译中,直接回退到拼音
if (self::$is_translating) {
return $this->fallbackTranslate($text, $options);
}

$service = isset($options['translation_service']) ? $options['translation_service'] : 'none';
switch ($service) {
case 'wpmind':
// WPMind 服务需要循环保护
self::$is_translating = true;
try {
$result = $this->translateWPMind($text, $options);
} finally {
self::$is_translating = false;
}
return $result;
case 'google':
return $this->translateGoogle($text, $options);
case 'baidu':
@ -166,6 +189,96 @@ class WPSlug_Translator {
}
}

/**
* 使用 WPMind AI 翻译
*
* @param string $text 要翻译的文本
* @param array $options 选项
* @return string 翻译后的文本slug 格式)
*/
private function translateWPMind($text, $options) {
$debug_mode = isset($options['debug_mode']) && $options['debug_mode'];
// 获取语言设置(提前获取以便用于缓存键)
$source_lang = isset($options['translation_source_lang']) ? $options['translation_source_lang'] : 'zh';
$target_lang = isset($options['translation_target_lang']) ? $options['translation_target_lang'] : 'en';
// 1. 先检查本地缓存(缓存键包含语言设置)
$cache_key = 'wpslug_wpmind_' . md5($text . '_' . $source_lang . '_' . $target_lang);
$cached = get_transient($cache_key);
if ($cached !== false) {
if ($debug_mode) {
error_log('[WPSlug] WPMind cache hit for: ' . $text . ' (' . $source_lang . ' -> ' . $target_lang . ')');
}
return $cached;
}

// 2. 检查 WPMind 是否可用
if (!function_exists('wpmind_is_available') || !wpmind_is_available()) {
if ($debug_mode) {
error_log('[WPSlug] WPMind not available, falling back to pinyin');
}
return $this->fallbackTranslate($text, $options);
}

// 3. 文本长度限制(避免超时)
if (mb_strlen($text) > 200) {
if ($debug_mode) {
error_log('[WPSlug] Text too long (' . mb_strlen($text) . ' chars), using pinyin');
}
return $this->fallbackTranslate($text, $options);
}

// 4. 中文字符数限制
$chinese_count = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $text);
if ($chinese_count > 50) {
if ($debug_mode) {
error_log('[WPSlug] Too many Chinese characters (' . $chinese_count . '), using pinyin');
}
return $this->fallbackTranslate($text, $options);
}

// 5. 调用 WPMind API语言设置已在前面获取
$start_time = microtime(true);
$result = wpmind_translate($text, $source_lang, $target_lang, [
'context' => 'wpslug_translation',
'format' => 'slug',
'cache_ttl' => 86400, // WPMind 内部缓存 1 天
'max_tokens' => 100,
'temperature' => 0.3,
]);

$elapsed_time = round((microtime(true) - $start_time) * 1000);

// 7. 处理结果
if (is_wp_error($result)) {
if ($debug_mode) {
error_log('[WPSlug] WPMind error: ' . $result->get_error_message() . ' (took ' . $elapsed_time . 'ms)');
}
return $this->fallbackTranslate($text, $options);
}

$slug = $this->cleanTranslatedText($result);

// 8. 验证结果有效性
if (empty($slug)) {
if ($debug_mode) {
error_log('[WPSlug] WPMind returned empty result, using pinyin');
}
return $this->fallbackTranslate($text, $options);
}

// 9. 缓存结果7 天)
set_transient($cache_key, $slug, 7 * DAY_IN_SECONDS);

if ($debug_mode) {
error_log('[WPSlug] WPMind translated "' . $text . '" to "' . $slug . '" in ' . $elapsed_time . 'ms');
}

return $slug;
}

private function fallbackTranslate($text, $options) {
if ($this->converter === null) {
$this->converter = new WPSlug_Converter();

View file

@ -1,200 +0,0 @@
<?php
/**
* WPSlug 自动更新器
*
* 通过文派云桥 (WenPai Bridge) 检查插件更新。
* 利用 WordPress 5.8+ 的 Update URI 机制,注册
* update_plugins_updates.wenpai.net filter。
*
* @package WPSlug
*/

if (!defined("ABSPATH")) {
exit();
}

class WPSlug_Updater
{
/** @var string 云桥 API 地址 */
private $api_url = "https://updates.wenpai.net/api/v1";

/** @var string 插件主文件 basename */
private $plugin_file;

/** @var string 插件 slug */
private $slug;

/** @var string 当前版本 */
private $version;

/**
* 初始化更新器。
*
* @param string $plugin_file 插件主文件路径plugin_basename 格式)
* @param string $version 当前插件版本
*/
public function __construct(string $plugin_file, string $version)
{
$this->plugin_file = $plugin_file;
$this->slug = dirname($plugin_file);
$this->version = $version;

$this->register_hooks();
}

/**
* 注册 WordPress hooks。
*/
private function register_hooks(): void
{
// Update URI: https://updates.wenpai.net 触发此 filter
add_filter(
"update_plugins_updates.wenpai.net",
[$this, "check_update"],
10,
4
);

// 插件详情弹窗
add_filter("plugins_api", [$this, "plugin_info"], 20, 3);
}

/**
* 检查插件更新。
*
* WordPress 在检查更新时,对声明了 Update URI 的插件
* 触发 update_plugins_{hostname} filter。
*
* @param array|false $update 当前更新数据
* @param array $plugin_data 插件头信息
* @param string $plugin_file 插件文件路径
* @param string[] $locales 语言列表
* @return array|false 更新数据或 false
*/
public function check_update($update, array $plugin_data, string $plugin_file, array $locales)
{
if ($plugin_file !== $this->plugin_file) {
return $update;
}

$response = $this->api_request("update-check", [
"plugins" => [
$this->plugin_file => [
"Version" => $this->version,
],
],
]);

if (is_wp_error($response) || empty($response["plugins"][$this->plugin_file])) {
return $update;
}

$data = $response["plugins"][$this->plugin_file];

return (object) [
"slug" => $data["slug"] ?? $this->slug,
"plugin" => $this->plugin_file,
"new_version" => $data["version"] ?? "",
"url" => $data["url"] ?? "",
"package" => $data["package"] ?? "",
"icons" => $data["icons"] ?? [],
"banners" => $data["banners"] ?? [],
"requires" => $data["requires"] ?? "",
"tested" => $data["tested"] ?? "",
"requires_php" => $data["requires_php"] ?? "",
];
}

/**
* 插件详情弹窗数据。
*
* 当用户在 WP 后台点击"查看详情"时触发。
*
* @param false|object|array $result 当前结果
* @param string $action API 动作
* @param object $args 请求参数
* @return false|object 插件信息或 false
*/
public function plugin_info($result, string $action, object $args)
{
if ($action !== "plugin_information") {
return $result;
}

if (!isset($args->slug) || $args->slug !== $this->slug) {
return $result;
}

$response = $this->api_request("plugins/{$this->slug}/info");

if (is_wp_error($response)) {
return $result;
}

$info = new \stdClass();
$info->name = $response["name"] ?? "";
$info->slug = $response["slug"] ?? $this->slug;
$info->version = $response["version"] ?? "";
$info->author = $response["author"] ?? "";
$info->homepage = $response["homepage"] ?? "";
$info->download_link = $response["download_link"] ?? "";
$info->requires = $response["requires"] ?? "";
$info->tested = $response["tested"] ?? "";
$info->requires_php = $response["requires_php"] ?? "";
$info->last_updated = $response["last_updated"] ?? "";
$info->icons = $response["icons"] ?? [];
$info->banners = $response["banners"] ?? [];
$info->sections = $response["sections"] ?? [];

return $info;
}

/**
* 向云桥 API 发送请求。
*
* @param string $endpoint API 端点(不含 /api/v1/ 前缀)
* @param array|null $body POST 请求体null 则用 GET
* @return array|\WP_Error 解码后的响应或错误
*/
private function api_request(string $endpoint, ?array $body = null)
{
$url = rtrim($this->api_url, "/") . "/" . ltrim($endpoint, "/");

$args = [
"timeout" => 15,
"headers" => [
"Accept" => "application/json",
],
];

if ($body !== null) {
$args["headers"]["Content-Type"] = "application/json";
$args["body"] = wp_json_encode($body);
$response = wp_remote_post($url, $args);
} else {
$response = wp_remote_get($url, $args);
}

if (is_wp_error($response)) {
return $response;
}

$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
return new \WP_Error(
"wenpai_bridge_error",
sprintf("WenPai Bridge API returned %d", $code)
);
}

$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data)) {
return new \WP_Error(
"wenpai_bridge_parse_error",
"Invalid JSON response from WenPai Bridge"
);
}

return $data;
}
}

View file

@ -130,12 +130,13 @@ class WPSlug_Validator {
}
public static function validateConversionMode($mode) {
$valid_modes = array('pinyin', 'transliteration', 'translation');
$valid_modes = array('pinyin', 'semantic_pinyin', 'transliteration', 'translation');
return self::validateSelect($mode, $valid_modes, 'pinyin');
}
public static function validateTranslationService($service) {
$valid_services = array('none', 'google', 'baidu');
// 注意wpmind 服务需要在此列表中才能保存
$valid_services = array('none', 'google', 'baidu', 'wpmind');
return self::validateSelect($service, $valid_services, 'none');
}
@ -157,6 +158,15 @@ class WPSlug_Validator {
if ($service === 'baidu' && (empty($options['baidu_app_id']) || empty($options['baidu_secret_key']))) {
$errors[] = __('Baidu App ID and Secret Key are required for Baidu Translate service.', 'wpslug');
}
// WPMind 需要已配置
if ($service === 'wpmind') {
if (!function_exists('wpmind_is_available')) {
$errors[] = __('WPMind plugin is not installed.', 'wpslug');
} elseif (!wpmind_is_available()) {
$errors[] = __('WPMind is not configured. Please configure it in WPMind settings.', 'wpslug');
}
}
}
return empty($errors) ? true : $errors;

View file

@ -2,11 +2,11 @@

/*
Plugin Name: WPSlug
Plugin URI: https://wpslug.com
Plugin URI: https://wpcy.com/slug
Description: Advanced slug management plugin with Chinese Pinyin support and SEO optimization.
Version: 1.0.7
Author: WPSlug.com
Author URI: https://wpslug.com
Version: 1.1.0
Author: WPSlug
Author URI: https://wpcy.com/slug
License: GPL2
Text Domain: wpslug
Domain Path: /languages
@ -19,7 +19,7 @@ if (!defined("ABSPATH")) {
exit();
}

define("WPSLUG_VERSION", "1.0.7");
define("WPSLUG_VERSION", "1.1.0");
define("WPSLUG_PLUGIN_DIR", plugin_dir_path(__FILE__));
define("WPSLUG_PLUGIN_URL", plugin_dir_url(__FILE__));
define("WPSLUG_PLUGIN_BASENAME", plugin_basename(__FILE__));
@ -56,7 +56,7 @@ class WPSlug
$this->loadDependencies();
$this->loadTextdomain();
$this->core = new WPSlug_Core();
new WPSlug_Updater(WPSLUG_PLUGIN_BASENAME, WPSLUG_VERSION);
new WenPai_Updater( WPSLUG_PLUGIN_BASENAME, WPSLUG_VERSION );
}

private function checkRequirements()
@ -92,7 +92,7 @@ class WPSlug
require_once WPSLUG_PLUGIN_DIR . "includes/class-wpslug-translator.php";
require_once WPSLUG_PLUGIN_DIR . "includes/class-wpslug-converter.php";
require_once WPSLUG_PLUGIN_DIR . "includes/class-wpslug-core.php";
require_once WPSLUG_PLUGIN_DIR . "includes/class-wpslug-updater.php";
require_once WPSLUG_PLUGIN_DIR . "includes/class-wenpai-updater.php";

if (is_admin()) {
require_once WPSLUG_PLUGIN_DIR . "includes/class-wpslug-admin.php";