gp-rest-api/gp-rest-api.php
wenpai 414c5c7c46 docs: add CLAUDE.md, update API reference and multisite support
- CLAUDE.md: 项目定位与治理规则
- API reference: 补充 multisite 参数文档
- Multisite blog_id 参数支持

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:14:44 +08:00

864 lines
25 KiB
PHP

<?php
/**
* Plugin Name: GlotPress REST API
* Description: REST API endpoints for GlotPress translation submission via Application Password.
* Version: 1.1.0
* Author: WenPai.org
* License: GPL-2.0-or-later
* Requires PHP: 7.4
*
* @package GP_REST_API
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Allow Application Passwords over non-SSL connections in local development.
*
* WordPress already allows Application Passwords on HTTPS requests and local
* environments. This plugin only overrides the default check when the site is
* running in a non-production environment behind a reverse proxy.
*/
add_filter( 'wp_is_application_passwords_available', 'gp_rest_api_enable_application_passwords' );
/**
* Allow Application Passwords in local development when WordPress sees HTTP.
*/
function gp_rest_api_enable_application_passwords( bool $available ): bool {
if ( $available ) {
return true;
}
return in_array( wp_get_environment_type(), array( 'local', 'development' ), true );
}
/**
* Check if GlotPress is loaded before registering routes.
*/
add_action( 'rest_api_init', 'gp_rest_api_register_routes' );
/**
* Register all REST API routes.
*/
function gp_rest_api_register_routes(): void {
if ( ! class_exists( 'GP' ) ) {
return;
}
$namespace = 'gp/v1';
register_rest_route(
$namespace,
'/translations',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'gp_rest_api_submit_translations',
'permission_callback' => 'gp_rest_api_check_can_translate',
'args' => gp_rest_api_translations_args(),
)
);
register_rest_route(
$namespace,
'/originals',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'gp_rest_api_get_originals',
'permission_callback' => 'gp_rest_api_check_authenticated',
'args' => gp_rest_api_originals_args(),
)
);
register_rest_route(
$namespace,
'/projects/(?P<path>.+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'gp_rest_api_get_project',
'permission_callback' => 'gp_rest_api_check_authenticated',
)
);
register_rest_route(
$namespace,
'/export',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'gp_rest_api_export_translations',
'permission_callback' => 'gp_rest_api_check_authenticated',
'args' => gp_rest_api_export_args(),
)
);
}
/**
* Permission callback: user must be authenticated.
*/
function gp_rest_api_check_authenticated(): bool {
return is_user_logged_in();
}
/**
* Permission callback for translation submission: user must have GP edit permission.
*
* GlotPress advanced-permissions grants 'edit' on 'translation-set' to all logged-in
* users by default. If that filter is disabled, this enforces the check explicitly.
*/
function gp_rest_api_check_can_translate( WP_REST_Request $request ): bool {
if ( ! is_user_logged_in() ) {
return false;
}
$switched = gp_rest_api_maybe_switch_blog( (int) ( $request->get_param( 'blog_id' ) ?? 0 ) );
// GP admin can always translate.
if ( GP::$permission->current_user_can( 'admin' ) ) {
gp_rest_api_maybe_restore_blog( $switched );
return true;
}
$project_path = $request->get_param( 'project_path' );
$locale = $request->get_param( 'locale' );
$slug = $request->get_param( 'slug' ) ?: 'default';
if ( ! $project_path || ! $locale ) {
gp_rest_api_maybe_restore_blog( $switched );
return false;
}
$project = GP::$project->by_path( $project_path );
if ( ! $project ) {
gp_rest_api_maybe_restore_blog( $switched );
return false;
}
$set = GP::$translation_set->by_project_id_slug_and_locale(
$project->id,
$slug,
$locale
);
if ( ! $set ) {
gp_rest_api_maybe_restore_blog( $switched );
return false;
}
$can = GP::$permission->current_user_can( 'edit', 'translation-set', $set->id );
gp_rest_api_maybe_restore_blog( $switched );
return $can;
}
/**
* Argument schema for POST /translations.
*/
function gp_rest_api_translations_args(): array {
return array(
'blog_id' => array(
'required' => false,
'type' => 'integer',
'default' => 0,
'description' => 'Blog ID for multisite. 0 = current site.',
),
'project_path' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'GlotPress project path, e.g. "woocommerce/woocommerce/stable".',
),
'locale' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Locale slug, e.g. "zh-cn".',
),
'slug' => array(
'required' => false,
'type' => 'string',
'default' => 'default',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Translation set slug.',
),
'translations' => array(
'required' => true,
'type' => 'array',
'maxItems' => 100,
'description' => 'Array of translations to submit.',
'items' => array(
'type' => 'object',
'properties' => array(
'original_id' => array(
'type' => 'integer',
'required' => true,
),
'translation_0' => array(
'type' => 'string',
'required' => true,
),
'translation_1' => array( 'type' => 'string' ),
'translation_2' => array( 'type' => 'string' ),
'translation_3' => array( 'type' => 'string' ),
'translation_4' => array( 'type' => 'string' ),
'translation_5' => array( 'type' => 'string' ),
),
),
),
);
}
/**
* Argument schema for GET /originals.
*/
function gp_rest_api_originals_args(): array {
return array(
'blog_id' => array(
'required' => false,
'type' => 'integer',
'default' => 0,
'description' => 'Blog ID for multisite. 0 = current site.',
),
'project_path' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'locale' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'slug' => array(
'required' => false,
'type' => 'string',
'default' => 'default',
'sanitize_callback' => 'sanitize_text_field',
),
'status' => array(
'required' => false,
'type' => 'string',
'default' => 'untranslated',
'enum' => array( 'untranslated', 'fuzzy', 'waiting', 'current', 'all' ),
'sanitize_callback' => 'sanitize_text_field',
),
'per_page' => array(
'required' => false,
'type' => 'integer',
'default' => 50,
'minimum' => 1,
'maximum' => 200,
),
'page' => array(
'required' => false,
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
);
}
/**
* Switch to the target blog if blog_id is provided (multisite support).
*
* @param int $blog_id Blog ID from request. 0 means current site.
* @return bool Whether a blog switch occurred.
*/
function gp_rest_api_maybe_switch_blog( int $blog_id ): bool {
if ( $blog_id > 0 && is_multisite() && get_current_blog_id() !== $blog_id ) {
switch_to_blog( $blog_id );
// Re-initialize GP table names for the switched blog.
if ( class_exists( 'GP' ) ) {
global $wpdb;
$prefix = $wpdb->prefix;
// Update both GP object tables and $wpdb properties
$wpdb->gp_projects = $prefix . 'gp_projects';
$wpdb->gp_translations = $prefix . 'gp_translations';
$wpdb->gp_translation_sets = $prefix . 'gp_translation_sets';
$wpdb->gp_originals = $prefix . 'gp_originals';
$wpdb->gp_permissions = $prefix . 'gp_permissions';
GP::$project->table = $wpdb->gp_projects;
GP::$translation->table = $wpdb->gp_translations;
GP::$translation_set->table = $wpdb->gp_translation_sets;
GP::$original->table = $wpdb->gp_originals;
GP::$permission->table = $wpdb->gp_permissions;
}
return true;
}
return false;
}
/**
* Restore blog context after a switch.
*
* @param bool $switched Whether a switch occurred.
*/
function gp_rest_api_maybe_restore_blog( bool $switched ): void {
if ( $switched ) {
restore_current_blog();
// Re-initialize GP table names for the original blog.
if ( class_exists( 'GP' ) ) {
global $wpdb;
$prefix = $wpdb->prefix;
// Update both GP object tables and $wpdb properties
$wpdb->gp_projects = $prefix . 'gp_projects';
$wpdb->gp_translations = $prefix . 'gp_translations';
$wpdb->gp_translation_sets = $prefix . 'gp_translation_sets';
$wpdb->gp_originals = $prefix . 'gp_originals';
$wpdb->gp_permissions = $prefix . 'gp_permissions';
GP::$project->table = $wpdb->gp_projects;
GP::$translation->table = $wpdb->gp_translations;
GP::$translation_set->table = $wpdb->gp_translation_sets;
GP::$original->table = $wpdb->gp_originals;
GP::$permission->table = $wpdb->gp_permissions;
}
}
}
/**
* Resolve project and translation set from request params.
*
* @param string $project_path Project path.
* @param string $locale Locale slug.
* @param string $slug Translation set slug.
* @return array{project: GP_Project, set: GP_Translation_Set}|WP_Error
*/
function gp_rest_api_resolve_set( string $project_path, string $locale, string $slug ) {
$project = GP::$project->by_path( $project_path );
if ( ! $project ) {
return new WP_Error(
'gp_project_not_found',
sprintf( 'Project not found: %s', $project_path ),
array( 'status' => 404 )
);
}
$set = GP::$translation_set->by_project_id_slug_and_locale(
$project->id,
$slug,
$locale
);
if ( ! $set ) {
return new WP_Error(
'gp_set_not_found',
sprintf( 'Translation set not found: %s/%s/%s', $project_path, $locale, $slug ),
array( 'status' => 404 )
);
}
return array(
'project' => $project,
'set' => $set,
);
}
/**
* POST /gp/v1/translations — Submit translations in batch.
*/
function gp_rest_api_submit_translations( WP_REST_Request $request ): WP_REST_Response {
$switched = gp_rest_api_maybe_switch_blog( (int) $request->get_param( 'blog_id' ) );
$resolved = gp_rest_api_resolve_set(
$request->get_param( 'project_path' ),
$request->get_param( 'locale' ),
$request->get_param( 'slug' )
);
if ( is_wp_error( $resolved ) ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => $resolved->get_error_code(), 'message' => $resolved->get_error_message() ),
$resolved->get_error_data()['status']
);
}
$project = $resolved['project'];
$set = $resolved['set'];
$user_id = get_current_user_id();
$can_approve = GP::$permission->current_user_can( 'approve', 'translation-set', $set->id );
$translations = $request->get_param( 'translations' );
if ( count( $translations ) > 100 ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => 'gp_too_many', 'message' => 'Maximum 100 translations per request.' ),
400
);
}
$locale_obj = GP_Locales::by_slug( $set->locale );
$results = array();
foreach ( $translations as $item ) {
$original_id = absint( $item['original_id'] ?? 0 );
$result = gp_rest_api_process_single_translation(
$original_id,
$item,
$project,
$set,
$locale_obj,
$user_id,
$can_approve
);
$results[] = $result;
}
$summary = array(
'submitted' => count( array_filter( $results, fn( $r ) => 'created' === $r['status'] ) ),
'skipped' => count( array_filter( $results, fn( $r ) => 'skipped' === $r['status'] ) ),
'errors' => count( array_filter( $results, fn( $r ) => 'error' === $r['status'] ) ),
);
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array(
'summary' => $summary,
'results' => $results,
),
200
);
}
/**
* Process a single translation submission.
*
* @return array{original_id: int, status: string, translation_id?: int, message?: string}
*/
function gp_rest_api_process_single_translation(
int $original_id,
array $item,
GP_Project $project,
GP_Translation_Set $set,
$locale_obj,
int $user_id,
bool $can_approve
): array {
if ( $original_id <= 0 ) {
return array(
'original_id' => $original_id,
'status' => 'error',
'message' => 'Invalid original_id.',
);
}
$original = GP::$original->get( $original_id );
if ( ! $original || (int) $original->project_id !== (int) $project->id ) {
return array(
'original_id' => $original_id,
'status' => 'error',
'message' => 'Original not found in this project.',
);
}
$translation_0 = $item['translation_0'] ?? '';
if ( '' === $translation_0 ) {
return array(
'original_id' => $original_id,
'status' => 'error',
'message' => 'translation_0 is required.',
);
}
// Duplicate detection: skip if identical current translation exists.
// Compare all plural fields explicitly, including empty values.
$find_args = array(
'original_id' => $original_id,
'translation_set_id' => $set->id,
'translation_0' => $translation_0,
'status' => 'current',
);
for ( $i = 1; $i <= 5; $i++ ) {
$key = "translation_{$i}";
$find_args[ $key ] = isset( $item[ $key ] ) && '' !== $item[ $key ] ? $item[ $key ] : null;
}
$existing = GP::$translation->find_one( $find_args );
if ( $existing ) {
return array(
'original_id' => $original_id,
'status' => 'skipped',
'translation_id' => (int) $existing->id,
'message' => 'Identical current translation exists.',
);
}
// Build translation fields.
$translation_data = array(
'original_id' => $original_id,
'translation_set_id' => $set->id,
'translation_0' => $translation_0,
'user_id' => $user_id,
'status' => 'waiting',
);
for ( $i = 1; $i <= 5; $i++ ) {
$key = "translation_{$i}";
if ( isset( $item[ $key ] ) && '' !== $item[ $key ] ) {
$translation_data[ $key ] = $item[ $key ];
}
}
// Run GP translation warnings check.
$plural_array = array( $translation_0 );
for ( $i = 1; $i <= 5; $i++ ) {
$key = "translation_{$i}";
if ( isset( $translation_data[ $key ] ) ) {
$plural_array[] = $translation_data[ $key ];
}
}
$warnings = GP::$translation_warnings->check(
$original->singular,
$original->plural,
$plural_array,
$locale_obj
);
if ( $warnings ) {
$translation_data['warnings'] = $warnings;
}
// Create the translation.
$translation = GP::$translation->create( $translation_data );
if ( ! $translation ) {
return array(
'original_id' => $original_id,
'status' => 'error',
'message' => 'Failed to create translation.',
);
}
// Set status: current if user can approve, waiting otherwise.
$target_status = $can_approve ? 'current' : 'waiting';
$translation->set_status( $target_status );
return array(
'original_id' => $original_id,
'status' => 'created',
'translation_id' => (int) $translation->id,
'gp_status' => $target_status,
'warnings' => ! empty( $warnings ),
);
}
/**
* GET /gp/v1/originals — Query originals with translation status.
*/
function gp_rest_api_get_originals( WP_REST_Request $request ): WP_REST_Response {
$switched = gp_rest_api_maybe_switch_blog( (int) $request->get_param( 'blog_id' ) );
$resolved = gp_rest_api_resolve_set(
$request->get_param( 'project_path' ),
$request->get_param( 'locale' ),
$request->get_param( 'slug' )
);
if ( is_wp_error( $resolved ) ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => $resolved->get_error_code(), 'message' => $resolved->get_error_message() ),
$resolved->get_error_data()['status']
);
}
$project = $resolved['project'];
$set = $resolved['set'];
$status = $request->get_param( 'status' );
$per_page = $request->get_param( 'per_page' );
$page = $request->get_param( 'page' );
global $wpdb;
$originals_table = GP::$original->table;
$translations_table = GP::$translation->table;
// Build WHERE clause for originals.
$where = $wpdb->prepare(
"o.project_id = %d AND o.status = '+active'",
$project->id
);
// Hide hidden-priority originals from non-project-writers.
if ( ! GP::$permission->current_user_can( 'write', 'project', $project->id ) ) {
$where .= ' AND o.priority > -2';
}
// Status-based filtering.
// "untranslated" = no non-old/non-rejected translation exists (matches GP semantics).
$join = '';
$having = '';
switch ( $status ) {
case 'untranslated':
$join = $wpdb->prepare(
"LEFT JOIN {$translations_table} t ON t.original_id = o.id AND t.translation_set_id = %d AND t.status NOT IN ('old', 'rejected')",
$set->id
);
$having = 'HAVING t.id IS NULL';
break;
case 'current':
case 'fuzzy':
case 'waiting':
$join = $wpdb->prepare(
"LEFT JOIN {$translations_table} t ON t.original_id = o.id AND t.translation_set_id = %d AND t.status = %s",
$set->id,
$status
);
$having = 'HAVING t.id IS NOT NULL';
break;
default: // 'all' — join current translations for display.
$join = $wpdb->prepare(
"LEFT JOIN {$translations_table} t ON t.original_id = o.id AND t.translation_set_id = %d AND t.status = 'current'",
$set->id
);
break;
}
$offset = ( $page - 1 ) * $per_page;
// Count query.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (SELECT o.id, t.id AS tid FROM {$originals_table} o {$join} WHERE {$where} GROUP BY o.id {$having}) AS cnt"
);
// Data query with pagination.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT o.id AS original_id, o.singular, o.plural, o.context, o.references,
t.id AS translation_id, t.translation_0, t.status AS translation_status
FROM {$originals_table} o
{$join}
WHERE {$where}
GROUP BY o.id
{$having}
ORDER BY o.id ASC
LIMIT %d OFFSET %d",
$per_page,
$offset
)
);
$items = array();
foreach ( $rows as $row ) {
$item = array(
'original_id' => (int) $row->original_id,
'singular' => $row->singular,
'plural' => $row->plural,
'context' => $row->context,
'references' => $row->references ? preg_split( '/\s+/', $row->references, -1, PREG_SPLIT_NO_EMPTY ) : array(),
);
if ( ! empty( $row->translation_id ) ) {
$item['translation_id'] = (int) $row->translation_id;
$item['translation_0'] = $row->translation_0;
$item['gp_status'] = $row->translation_status;
}
$items[] = $item;
}
$response = new WP_REST_Response(
array(
'project' => $project->path,
'locale' => $set->locale,
'slug' => $set->slug,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'items' => $items,
),
200
);
gp_rest_api_maybe_restore_blog( $switched );
return $response;
}
/**
* Argument schema for GET /export.
*/
function gp_rest_api_export_args(): array {
return array(
'blog_id' => array(
'required' => false,
'type' => 'integer',
'default' => 0,
'description' => 'Blog ID for multisite. 0 = current site.',
),
'project_path' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'GlotPress project path.',
),
'locale' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Locale slug, e.g. "zh-cn".',
),
'slug' => array(
'required' => false,
'type' => 'string',
'default' => 'default',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Translation set slug.',
),
'format' => array(
'required' => false,
'type' => 'string',
'default' => 'po',
'enum' => array( 'po', 'mo' ),
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Export format: po or mo.',
),
);
}
/**
* GET /gp/v1/export — Export translations as PO/MO file download.
*
* Uses the same GlotPress format API as the built-in export route and
* gp-bulk-download-translations plugin: GP::$translation->for_export()
* + GP::$formats[$format]->print_exported_file().
*/
function gp_rest_api_export_translations( WP_REST_Request $request ) {
$switched = gp_rest_api_maybe_switch_blog( (int) $request->get_param( 'blog_id' ) );
$resolved = gp_rest_api_resolve_set(
$request->get_param( 'project_path' ),
$request->get_param( 'locale' ),
$request->get_param( 'slug' )
);
if ( is_wp_error( $resolved ) ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => $resolved->get_error_code(), 'message' => $resolved->get_error_message() ),
$resolved->get_error_data()['status']
);
}
$project = $resolved['project'];
$set = $resolved['set'];
$format_slug = $request->get_param( 'format' );
$format = gp_array_get( GP::$formats, $format_slug, null );
if ( ! $format ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => 'gp_invalid_format', 'message' => "Unsupported format: {$format_slug}" ),
400
);
}
$locale = GP_Locales::by_slug( $set->locale );
$entries = GP::$translation->for_export( $project, $set );
if ( empty( $entries ) ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => 'gp_no_translations', 'message' => 'No current translations found for export.' ),
404
);
}
$content = $format->print_exported_file( $project, $locale, $set, $entries );
$export_locale = apply_filters( 'gp_export_locale', $locale->slug, $locale );
$filename = sprintf( '%s-%s.%s', str_replace( '/', '-', $project->path ), $export_locale, $format->extension );
gp_rest_api_maybe_restore_blog( $switched );
// Direct output for file download (same pattern as gp-bulk-download-translations).
while ( ob_get_level() ) {
ob_end_clean();
}
$content_type = ( 'mo' === $format_slug ) ? 'application/octet-stream' : 'text/x-po; charset=UTF-8';
header( 'Content-Description: File Transfer' );
header( 'Content-Type: ' . $content_type );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . strlen( $content ) );
header( 'Cache-Control: no-cache, must-revalidate' );
echo $content;
exit;
}
/**
* GET /gp/v1/projects/{path} — Project info with translation sets and stats.
*/
function gp_rest_api_get_project( WP_REST_Request $request ): WP_REST_Response {
$blog_id = (int) ( $request->get_param( 'blog_id' ) ?? 0 );
$switched = gp_rest_api_maybe_switch_blog( $blog_id );
$path = $request->get_param( 'path' );
$project = GP::$project->by_path( $path );
if ( ! $project ) {
gp_rest_api_maybe_restore_blog( $switched );
return new WP_REST_Response(
array( 'code' => 'gp_project_not_found', 'message' => sprintf( 'Project not found: %s', $path ) ),
404
);
}
$sets = GP::$translation_set->by_project_id( $project->id );
$set_data = array();
foreach ( $sets as $set ) {
$stats = array(
'all' => (int) $set->all_count(),
'current' => (int) $set->current_count(),
'waiting' => (int) $set->waiting_count(),
'fuzzy' => (int) $set->fuzzy_count(),
'untranslated' => (int) $set->untranslated_count(),
);
$percent = $stats['all'] > 0
? round( $stats['current'] / $stats['all'] * 100, 1 )
: 0;
$set_data[] = array(
'id' => (int) $set->id,
'locale' => $set->locale,
'slug' => $set->slug,
'name' => $set->name,
'stats' => $stats,
'percent' => $percent,
);
}
$sub_projects = GP::$project->many(
"SELECT * FROM {$project->table} WHERE parent_project_id = %d ORDER BY name ASC",
$project->id
);
$children = array();
foreach ( $sub_projects as $child ) {
$children[] = array(
'id' => (int) $child->id,
'name' => $child->name,
'slug' => $child->slug,
'path' => $child->path,
);
}
$response = new WP_REST_Response(
array(
'id' => (int) $project->id,
'name' => $project->name,
'slug' => $project->slug,
'path' => $project->path,
'description' => $project->description,
'parent_project_id' => (int) $project->parent_project_id,
'translation_sets' => $set_data,
'sub_projects' => $children,
),
200
);
gp_rest_api_maybe_restore_blog( $switched );
return $response;
}