- CLAUDE.md: 项目定位与治理规则 - API reference: 补充 multisite 参数文档 - Multisite blog_id 参数支持 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
864 lines
25 KiB
PHP
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;
|
|
}
|