\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'source' ), 'permission_callback' => array( $this, 'canEdit' ), 'args' => array( 'source_id' => array( 'required' => true, 'sanitize_callback' => 'absint', ), ), ) ); $target = array( 'source_id' => array( 'required' => true, 'sanitize_callback' => 'absint', ), 'locale' => array( 'required' => true, 'sanitize_callback' => static fn( $value ): string => Locale::normalize( (string) $value ), ), ); register_rest_route( self::NAMESPACE, '/translations/(?P\d+)/(?P[A-Za-z0-9-]{2,20})', array( array( 'methods' => array( 'PUT', 'PATCH' ), 'callback' => array( $this, 'save' ), 'permission_callback' => array( $this, 'canEdit' ), 'args' => $target + array( 'translated_text' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => static fn( $value ): string => (string) wp_unslash( $value ), ), 'status' => array( 'required' => false, 'type' => 'string', 'enum' => TranslationStatus::all(), ), ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete' ), 'permission_callback' => array( $this, 'canEdit' ), 'args' => $target, ), ) ); register_rest_route( self::NAMESPACE, '/translations/(?P\d+)/(?P[A-Za-z0-9-]{2,20})/suggest', array( 'methods' => 'POST', 'callback' => array( $this, 'suggest' ), 'permission_callback' => array( $this, 'canEdit' ), 'args' => $target, ) ); } /** * Право менять переводы. */ public function canEdit(): bool { return current_user_can( self::CAPABILITY ); } /** * Отдаёт исходную строку со всеми её переводами (ТЗ 10.3). * * Нужен визуальному редактору: по клику на элементе он знает только * source_id и подтягивает остальное отсюда. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function source( WP_REST_Request $request ) { $sourceId = (int) $request->get_param( 'source_id' ); $source = $this->sources->find( $sourceId ); if ( null === $source ) { return new WP_Error( 'lingocanvas_source_not_found', __( 'Source string not found.', 'lingocanvas' ), array( 'status' => 404 ) ); } $translations = array(); foreach ( $this->settings->secondary() as $language ) { $row = $this->translations->find( $sourceId, $language->locale ); $translations[ $language->locale ] = array( 'label' => $language->label, 'translated_text' => (string) ( $row['translated_text'] ?? '' ), 'status' => (string) ( $row['status'] ?? TranslationStatus::MISSING ), ); } return new WP_REST_Response( array( 'id' => $sourceId, 'kind' => (string) $source['kind'], 'source_text' => (string) $source['source_text'], 'translations' => $translations, ) ); } /** * Удаляет перевод строки на выбранный язык. * * Исходная строка остаётся: она по-прежнему есть на сайте и должна * оставаться в списке для перевода. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function delete( WP_REST_Request $request ) { $sourceId = (int) $request->get_param( 'source_id' ); $locale = (string) $request->get_param( 'locale' ); $language = $this->settings->get( $locale ); if ( null === $language || $language->isDefault ) { return new WP_Error( 'lingocanvas_invalid_locale', __( 'This secondary language is not in the settings.', 'lingocanvas' ), array( 'status' => 400 ) ); } $this->translations->delete( $sourceId, $language->locale ); $this->cache->flush(); return new WP_REST_Response( array( 'source_id' => $sourceId, 'locale' => $language->locale, 'translated_text' => '', 'status' => TranslationStatus::MISSING, 'status_label' => TranslationStatus::label( TranslationStatus::MISSING ), ) ); } /** * Просит провайдера перевести строку и возвращает подсказку. * * Ничего не сохраняет: пользователь сам решает, редактировать ли текст * и нажимать ли «Сохранить» — плагин ничего не переводит автоматически. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function suggest( WP_REST_Request $request ) { $sourceId = (int) $request->get_param( 'source_id' ); $locale = (string) $request->get_param( 'locale' ); $language = $this->settings->get( $locale ); if ( null === $language || $language->isDefault ) { return new WP_Error( 'lingocanvas_invalid_locale', __( 'This secondary language is not in the settings.', 'lingocanvas' ), array( 'status' => 400 ) ); } $sourceLocale = $this->settings->defaultLanguage()->locale; if ( ! $this->provider->supports( $sourceLocale, $language->locale ) ) { return new WP_Error( 'lingocanvas_provider_unavailable', null !== $this->providers ? ProviderFactory::unavailableMessage( $this->providers->missing() ) : __( 'AI translation is not set up: enter the key in the plugin settings.', 'lingocanvas' ), array( 'status' => 400 ) ); } $source = $this->sources->find( $sourceId ); if ( null === $source ) { return new WP_Error( 'lingocanvas_source_not_found', __( 'Source string not found.', 'lingocanvas' ), array( 'status' => 404 ) ); } $sourceText = (string) $source['source_text']; $dailyLimit = $this->settings->openAiDailyCharLimit(); if ( $dailyLimit > 0 && $this->usage->remainingToday( $dailyLimit ) < mb_strlen( $sourceText ) ) { return new WP_Error( 'lingocanvas_daily_limit_reached', __( 'The daily character limit for translation has been reached. Change the limit in the settings or try again tomorrow.', 'lingocanvas' ), array( 'status' => 429 ) ); } $hash = $this->sourceRevision( $source ) ?? Hash::of( $sourceText ); $context = new TranslationContext( (string) $source['kind'], null, null, null, array(), $language->label ); $result = $this->provider->translateBatch( array( $hash => $sourceText ), $sourceLocale, $language->locale, $context ); $text = $result[ $hash ] ?? null; if ( ! is_string( $text ) || '' === trim( $text ) ) { $detail = $this->provider instanceof ProviderErrorInterface ? $this->provider->maskedLastError() : null; return new WP_Error( 'lingocanvas_suggest_failed', null !== $detail ? sprintf( /* translators: %s: reason returned by the provider. */ __( 'Could not get a translation from the AI provider: %s', 'lingocanvas' ), $detail ) : __( 'Could not get a translation from the AI provider.', 'lingocanvas' ), array( 'status' => 502 ) ); } if ( Segment::KIND_HTML_BLOCK === (string) $source['kind'] ) { $text = BlockSanitizer::sanitize( $text ); } $this->usage->record( mb_strlen( $sourceText ) + mb_strlen( $text ) ); return new WP_REST_Response( array( 'source_id' => $sourceId, 'locale' => $language->locale, 'suggested_text' => $text, 'status' => TranslationStatus::MACHINE, ) ); } /** * Сохраняет перевод строки. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function save( WP_REST_Request $request ) { $sourceId = (int) $request->get_param( 'source_id' ); $locale = (string) $request->get_param( 'locale' ); $language = $this->settings->get( $locale ); if ( null === $language || $language->isDefault ) { return new WP_Error( 'lingocanvas_invalid_locale', __( 'This secondary language is not in the settings.', 'lingocanvas' ), array( 'status' => 400 ) ); } $source = $this->sources->find( $sourceId ); if ( null === $source ) { return new WP_Error( 'lingocanvas_source_not_found', __( 'Source string not found.', 'lingocanvas' ), array( 'status' => 404 ) ); } $text = trim( (string) $request->get_param( 'translated_text' ) ); /* * Для translation block перевод — это разметка, а не обычный текст: * без неё исчезли бы / внутри абзаца, ради которых блок и * заводился. Узкий allowlist (BlockSanitizer) вместо wp_strip_all_tags. * Для остальных видов строк теги вырезаются целиком, как в Этапе 1. */ $text = Segment::KIND_HTML_BLOCK === (string) $source['kind'] ? BlockSanitizer::sanitize( $text ) : wp_strip_all_tags( $text ); if ( '' !== $text && Segment::KIND_ATTRIBUTE === (string) $source['kind'] && UrlGuard::looksLikeUrl( (string) ( $source['source_text'] ?? '' ) ) && ! UrlGuard::isSafe( $text ) ) { return new WP_Error( 'lingocanvas_unsafe_url', __( 'The link address uses a protocol that is not allowed (for example javascript: or data:).', 'lingocanvas' ), array( 'status' => 400 ) ); } $status = (string) ( $request->get_param( 'status' ) ?? '' ); if ( ! TranslationStatus::isValid( $status ) ) { // Правка человеком по умолчанию считается готовой. $status = '' !== $text ? TranslationStatus::APPROVED : TranslationStatus::MISSING; } $saved = $this->translations->save( $sourceId, $language->locale, $text, $status, get_current_user_id(), $this->sourceRevision( $source ) ); if ( ! $saved ) { return new WP_Error( 'lingocanvas_save_failed', __( 'Could not save the translation.', 'lingocanvas' ), array( 'status' => 500 ) ); } // Страницы могли закэшировать старое значение. $this->cache->flush(); return new WP_REST_Response( array( 'source_id' => $sourceId, 'locale' => $language->locale, 'translated_text' => $text, 'status' => $status, 'status_label' => TranslationStatus::label( $status ), ) ); } /** * Hex исходного хеша строки — чтобы позже увидеть, что оригинал изменился. * * @param array $source Строка таблицы sources. */ private function sourceRevision( array $source ): ?string { $hash = $source['source_hash'] ?? null; if ( ! is_string( $hash ) || '' === $hash ) { return null; } // $wpdb отдаёт binary(32) сырыми байтами. return 32 === strlen( $hash ) ? bin2hex( $hash ) : null; } }