hook suffix */ private $hooks = array(); /** * 注册后台钩子。 * * @return void */ public function init() { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_filter( 'plugin_action_links_' . APGT_PLUGIN_BASENAME, array( $this, 'add_action_links' ) ); add_action( 'admin_init', array( $this, 'handle_connect_callback' ) ); $ajax = new APGT_Ajax(); $ajax->init(); } /** * 侧边栏菜单图标。 * * 传 data URI 时 WordPress 把 SVG 当 background-image 用,颜色只能由 SVG 自己带, * 所以文件里填的是后台默认图标灰,选中和悬停的提亮交给 menu_icon_style()。 * * @return string data URI,图标文件缺失时退回 dashicon。 */ private static function menu_icon() { $file = APGT_PLUGIN_DIR . 'assets/images/menu-icon.svg'; if ( ! is_readable( $file ) ) { return 'dashicons-translation'; } // 读的是插件自己目录下的静态文件,不涉及远程请求,用不着 WP_Filesystem。 $svg = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return false === $svg || '' === $svg ? 'dashicons-translation' : 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * 菜单图标在选中和悬停时提亮。 * * 背景图不像 dashicons 那样能被 WordPress 自动着色,只能自己补一条 filter。 * 菜单在所有后台页面都在,所以这段不能放进只在插件页加载的 admin.css; * 挂成没有 src 的句柄再补内联样式,核心自己挂纯内联样式也是这个写法。 * * @return void */ private function enqueue_menu_icon_style() { $selector = '#adminmenu #toplevel_page_' . self::PAGE_SLUG; $css = sprintf( '%1$s:hover .wp-menu-image.svg,%1$s.current .wp-menu-image.svg,' . '%1$s.wp-has-current-submenu .wp-menu-image.svg{filter:brightness(1.8)}', $selector ); wp_register_style( 'apgt-menu-icon', false, array(), APGT_VERSION ); wp_enqueue_style( 'apgt-menu-icon' ); wp_add_inline_style( 'apgt-menu-icon', $css ); } /** * 注册后台菜单。 * * @return void */ public function register_menu() { $this->hooks[ self::PAGE_SLUG ] = add_menu_page( __( 'Aipogeo Context Translate', 'aipogeo-context-translate' ), __( 'Aipogeo Context Translate', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_settings' ), self::menu_icon(), 76 ); add_submenu_page( self::PAGE_SLUG, __( 'Translation Settings', 'aipogeo-context-translate' ), __( 'Translation Settings', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_settings' ) ); $this->hooks[ self::PAGE_LANGUAGES ] = add_submenu_page( self::PAGE_SLUG, __( 'Languages', 'aipogeo-context-translate' ), __( 'Languages', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_LANGUAGES, array( $this, 'render_languages' ) ); $this->hooks[ self::PAGE_TRANSLATE ] = add_submenu_page( self::PAGE_SLUG, __( 'Translate Pages', 'aipogeo-context-translate' ), __( 'Translate Pages', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_TRANSLATE, array( $this, 'render_translate' ) ); $this->hooks[ self::PAGE_DATA ] = add_submenu_page( self::PAGE_SLUG, __( 'Translation Data', 'aipogeo-context-translate' ), __( 'Translation Data', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_DATA, array( $this, 'render_data' ) ); $this->hooks[ self::PAGE_HELP ] = add_submenu_page( self::PAGE_SLUG, __( 'Help', 'aipogeo-context-translate' ), __( 'Help', 'aipogeo-context-translate' ), 'manage_options', self::PAGE_HELP, array( $this, 'render_help' ) ); // 表单保存要在页面输出之前跑(需要 redirect),挂在各页的 load 钩子上。 add_action( 'load-' . $this->hooks[ self::PAGE_SLUG ], array( $this, 'handle_settings_save' ) ); add_action( 'load-' . $this->hooks[ self::PAGE_LANGUAGES ], array( $this, 'handle_languages_save' ) ); } /** * 加载后台资源(仅本插件页面)。 * * @param string $hook_suffix 当前后台页面标识。 * @return void */ public function enqueue_assets( $hook_suffix ) { // 菜单和 worker 在后台任意页面都在,所以先于「是不是插件页」判断。 $this->enqueue_menu_icon_style(); $this->enqueue_worker(); $slug = array_search( $hook_suffix, $this->hooks, true ); if ( false === $slug ) { return; } // 页面下拉有几百项、语言下拉有一百多项,都用 Choices 提供搜索过滤。 $need_choices = in_array( $slug, array( self::PAGE_TRANSLATE, self::PAGE_SLUG, self::PAGE_LANGUAGES ), true ); $choices = APGT_PLUGIN_URL . 'assets/vendor/choices/'; $style_deps = array(); if ( $need_choices ) { wp_enqueue_style( 'apgt-choices', $choices . 'choices.min.css', array(), '10.2.0' ); $style_deps[] = 'apgt-choices'; } // 插件样式排在 Choices 之后:改 Choices 外观的那些选择器不少与它自带的 // 特异性相同(它给下拉写的是 .choices__list[aria-expanded],也是两级), // 同分时谁后加载谁说了算,声明依赖才能稳定压住。 wp_enqueue_style( 'apgt-admin', APGT_PLUGIN_URL . 'assets/css/admin.css', $style_deps, self::asset_version( 'assets/css/admin.css' ) ); // 翻译设置页要编辑指令、页面翻译页要编辑 JSON,都用 Ace。 $need_ace = in_array( $slug, array( self::PAGE_SLUG, self::PAGE_TRANSLATE ), true ); if ( $need_ace ) { $ace = APGT_PLUGIN_URL . 'assets/vendor/ace/'; wp_enqueue_script( 'apgt-ace', $ace . 'ace.js', array(), APGT_VERSION, true ); wp_enqueue_script( 'apgt-ace-mode-json', $ace . 'mode-json.js', array( 'apgt-ace' ), APGT_VERSION, true ); wp_enqueue_script( 'apgt-ace-mode-text', $ace . 'mode-text.js', array( 'apgt-ace' ), APGT_VERSION, true ); wp_enqueue_script( 'apgt-ace-theme', $ace . 'theme-github.js', array( 'apgt-ace' ), APGT_VERSION, true ); wp_enqueue_script( 'apgt-ace-searchbox', $ace . 'ext-searchbox.js', array( 'apgt-ace' ), APGT_VERSION, true ); } $deps = array( 'jquery' ); if ( $need_ace ) { $deps[] = 'apgt-ace'; } if ( $need_choices ) { wp_enqueue_script( 'apgt-choices', $choices . 'choices.min.js', array(), '10.2.0', true ); $deps[] = 'apgt-choices'; } wp_enqueue_script( 'apgt-admin', APGT_PLUGIN_URL . 'assets/js/admin.js', $deps, self::asset_version( 'assets/js/admin.js' ), true ); wp_localize_script( 'apgt-admin', 'apgtAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( APGT_Ajax::NONCE ), 'aceBase' => APGT_PLUGIN_URL . 'assets/vendor/ace/', 'page' => $slug, 'batchConcurrency' => max( 1, min( 5, (int) APGT_Settings::get_settings()['batch_concurrency'] ) ), // 模型名 / 协议标识 => 内置接口地址,设置页据此联动「接口地址」输入框。 'models' => self::PAGE_SLUG === $slug ? APGT_Translator::builtin_models() : array(), 'protocols' => self::PAGE_SLUG === $slug ? APGT_Translator::protocol_endpoints() : array(), 'i18n' => array( 'loading' => __( 'Loading…', 'aipogeo-context-translate' ), 'translating' => __( 'Submitting to AI…', 'aipogeo-context-translate' ), /* translators: 1: number of AI requests completed, 2: seconds elapsed */ 'jobRunning' => __( 'AI translation running in the background: %1$s AI requests completed, %2$s seconds elapsed', 'aipogeo-context-translate' ), 'saving' => __( 'Saving…', 'aipogeo-context-translate' ), 'pickPage' => __( 'Select a language and a page first', 'aipogeo-context-translate' ), 'badJson' => __( 'The translation JSON is malformed. Fix it before continuing.', 'aipogeo-context-translate' ), 'requestFail' => __( 'Request failed. Check the browser console or debug.log.', 'aipogeo-context-translate' ), /* translators: 1: total items, 2: translated items */ 'stats' => __( '%1$s items, %2$s translated', 'aipogeo-context-translate' ), 'searchPage' => __( 'Filter by title or URL…', 'aipogeo-context-translate' ), 'noResults' => __( 'No matching pages', 'aipogeo-context-translate' ), 'noChoices' => __( 'No pages available', 'aipogeo-context-translate' ), 'searchLang' => __( 'Type a language name or code to filter…', 'aipogeo-context-translate' ), 'noLangs' => __( 'No matching languages', 'aipogeo-context-translate' ), 'noLangChoice' => __( 'No languages available', 'aipogeo-context-translate' ), 'confirmAll' => __( 'This will translate every pending page, several at a time, starting with the language selected above and then moving on to the remaining target languages. It may use a large amount of AI credit. Start now?', 'aipogeo-context-translate' ), 'batchPrep' => __( 'Counting pending pages…', 'aipogeo-context-translate' ), /* translators: 1: pending pages, 2: translated pages, 3: total pages */ 'batchStat' => __( '%1$s pending / %2$s translated (%3$s in total, all target languages)', 'aipogeo-context-translate' ), /* translators: 1: target language, 2: title of the page being translated */ 'batchDoing' => __( '%1$s · Translating: %2$s', 'aipogeo-context-translate' ), /* translators: %1$s: target language */ 'batchSwitching' => __( 'Moving on to %1$s…', 'aipogeo-context-translate' ), /* translators: 1: pages succeeded, 2: pages failed */ 'batchDone' => __( 'All done: %1$s pages succeeded, %2$s failed', 'aipogeo-context-translate' ), /* translators: 1: pages done, 2: pages failed, 3: pages left unprocessed */ 'batchStopped' => __( 'Stopped: %1$s pages done, %2$s failed, %3$s left unprocessed', 'aipogeo-context-translate' ), /* translators: %1$s: seconds until translation resumes */ 'batchThrottled' => __( 'The server asked us to slow down (rate limit). Resuming in %1$s seconds…', 'aipogeo-context-translate' ), 'batchNothing' => __( 'No pending pages in any target language', 'aipogeo-context-translate' ), 'stop' => __( 'Stop', 'aipogeo-context-translate' ), 'stopping' => __( 'Stopping…', 'aipogeo-context-translate' ), 'viewSource' => __( 'Open the source page in a new window', 'aipogeo-context-translate' ), 'viewTrans' => __( 'Open the translated page in a new window', 'aipogeo-context-translate' ), 'ruleLike' => __( 'Word match', 'aipogeo-context-translate' ), 'ruleExact' => __( 'Exact match', 'aipogeo-context-translate' ), 'ruleRegex' => __( 'Regex replace', 'aipogeo-context-translate' ), 'ruleDelete' => __( 'Delete', 'aipogeo-context-translate' ), /* translators: %1$s: number of rules */ 'ruleCount' => __( '%1$s rules', 'aipogeo-context-translate' ), 'regexOk' => __( 'Valid regex', 'aipogeo-context-translate' ), 'regexBad' => __( 'This regex does not compile. Please fix it.', 'aipogeo-context-translate' ), 'regexChecking' => __( 'Checking regex…', 'aipogeo-context-translate' ), /* translators: %1$s: number of regex rules that do not compile */ 'regexInvalid' => __( '%1$s regex rules do not compile. Fix them before saving.', 'aipogeo-context-translate' ), /* translators: %1$s: list of missing variable names */ 'promptMissing' => __( "The translation prompt is missing required variables: %1\$s\n\nWithout them the AI never receives the languages or the source data, so the result is guaranteed to be wrong. Add them and save again.\n(Saving a completely empty prompt restores the default one.)", 'aipogeo-context-translate' ), 'confirmPromptReset' => __( 'This replaces the editor content with the plugin\'s current default prompt and discards your edits. Continue?', 'aipogeo-context-translate' ), 'dataSaving' => __( 'Saving…', 'aipogeo-context-translate' ), 'dataSaved' => __( 'Saved', 'aipogeo-context-translate' ), 'dataDeleting' => __( 'Deleting…', 'aipogeo-context-translate' ), 'confirmDelRow' => __( 'Delete this translation record?', 'aipogeo-context-translate' ), /* translators: %1$s: number of translation records that match the current filter */ 'confirmDelAll' => __( "This permanently deletes all %1\$s translation records matching the current filter.\n\nContinue?", 'aipogeo-context-translate' ), 'needFilter' => __( 'Set at least one filter first, otherwise the entire table would be wiped', 'aipogeo-context-translate' ), 'keyShow' => __( 'Show', 'aipogeo-context-translate' ), 'keyHide' => __( 'Hide', 'aipogeo-context-translate' ), 'aceMissing' => __( 'The Ace editor failed to load. Check that assets/vendor/ace is complete.', 'aipogeo-context-translate' ), /* translators: separator between variable names in a list; Chinese uses the ideographic comma here */ 'listSep' => _x( ', ', 'list separator', 'aipogeo-context-translate' ), ), ) ); $this->enqueue_settings_script( $slug ); } /** * 翻译设置页专用脚本:订阅面板的激活 / 刷新 / 解绑。 * * 订阅那块面板只有这一页有,脚本也就只在这一页加载;文案经 localize * 送进去,页面本身不带任何内联脚本。 * * @param string $slug 当前插件页 slug。 * @return void */ private function enqueue_settings_script( $slug ) { if ( self::PAGE_SLUG !== $slug ) { return; } // 切换器自定义 CSS 用核心自带的代码编辑器(CodeMirror + CSSLint)。 // 用户在个人资料里关了语法高亮时返回 false,编辑框就是普通 textarea。 $css_editor = wp_enqueue_code_editor( array( 'type' => 'text/css' ) ); $deps = array( 'apgt-admin' ); if ( false !== $css_editor ) { $deps[] = 'code-editor'; } wp_enqueue_script( 'apgt-settings', APGT_PLUGIN_URL . 'assets/js/settings.js', $deps, self::asset_version( 'assets/js/settings.js' ), true ); wp_localize_script( 'apgt-settings', 'apgtSettings', array( 'cssEditor' => $css_editor, 'i18n' => array( 'cssMarkup' => __( 'HTML tags are not allowed in the switcher CSS. Remove every "<" character and save again.', 'aipogeo-context-translate' ), 'confirmCssReset' => __( 'This replaces the editor content with the plugin\'s default switcher styles and discards your edits. Continue?', 'aipogeo-context-translate' ), 'stateGrace' => __( 'Grace period', 'aipogeo-context-translate' ), 'stateActive' => __( 'Active', 'aipogeo-context-translate' ), 'stateUnusable' => __( 'Unavailable', 'aipogeo-context-translate' ), 'quotaUnlimited' => __( 'Unlimited', 'aipogeo-context-translate' ), 'quotaUnit' => __( 'tokens', 'aipogeo-context-translate' ), 'boundYes' => __( 'Activated', 'aipogeo-context-translate' ), 'boundNo' => __( 'Not activated', 'aipogeo-context-translate' ), 'working' => __( 'Working…', 'aipogeo-context-translate' ), 'failed' => __( 'That did not work', 'aipogeo-context-translate' ), 'done' => __( 'Done', 'aipogeo-context-translate' ), 'requestFail' => __( 'The request failed. Please try again shortly.', 'aipogeo-context-translate' ), 'confirmRelease' => __( 'Releasing this site stops it translating through this subscription. Continue?', 'aipogeo-context-translate' ), ), ) ); } /** * 自有资源的缓存版本号:取文件修改时间。 * * 只用插件版本号做 ver 的话,开发期改了 JS 而版本没动,浏览器会一直 * 用缓存的旧脚本,新接口配旧脚本什么怪相都可能出现。vendor 资源不动, * 仍按库版本号标。 * * @param string $rel 相对插件根目录的路径。 * @return string */ private static function asset_version( $rel ) { $mtime = filemtime( APGT_PLUGIN_DIR . $rel ); return $mtime ? (string) $mtime : APGT_VERSION; } /** * 后台自动翻译 worker:登录后台的管理员浏览器定时来领任务。 * * 挂在所有后台页面上,只要开关开着就跑;服务端有锁,多人同时开着后台 * 也只有一个进程真正在翻译。 * * @return void */ private function enqueue_worker() { if ( ! current_user_can( 'manage_options' ) ) { return; } // 只在「管理员在后台时」这种方式下由浏览器驱动;计划任务方式由 WP-Cron 自己跑。 if ( APGT_Cron::MODE_BROWSER !== APGT_Cron::mode() ) { return; } $settings = APGT_Settings::get_settings(); wp_enqueue_script( 'apgt-worker', APGT_PLUGIN_URL . 'assets/js/worker.js', array( 'jquery' ), self::asset_version( 'assets/js/worker.js' ), true ); wp_localize_script( 'apgt-worker', 'apgtWorker', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( APGT_Ajax::NONCE ), 'interval' => max( 15, (int) $settings['worker_interval'] ), ) ); } /** * 保存「翻译设置」表单。 * * @return void */ /** * 处理订阅服务的连接回调。 * * 用户在服务站点点了「授权」后会被送回设置页,地址里带一次性 code。 * 这里把 code 换成密钥存好,再把 code 从地址栏抹掉重定向一次 —— * 免得用户刷新页面时拿着已作废的 code 再换一遍、看到一条莫名的报错。 * * @return void */ public function handle_connect_callback() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 校验用 state,见 handle_callback() if ( ! isset( $_GET['code'] ) || ! isset( $_GET['page'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( self::PAGE_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $res = APGT_Service::handle_callback(); if ( null === $res ) { return; } set_transient( 'apgt_connect_notice', $res, 60 ); wp_safe_redirect( remove_query_arg( array( 'code', 'state' ) ) ); exit; } public function handle_settings_save() { if ( ! isset( $_POST['apgt_save_settings'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions', 'aipogeo-context-translate' ) ); } check_admin_referer( 'apgt_settings' ); $settings = APGT_Settings::get_settings(); $settings['default_language'] = isset( $_POST['default_language'] ) ? sanitize_text_field( wp_unslash( $_POST['default_language'] ) ) : ''; $settings['is_trans_url'] = empty( $_POST['is_trans_url'] ) ? 0 : 1; $auto_worker = isset( $_POST['auto_worker'] ) ? (int) $_POST['auto_worker'] : 0; $settings['auto_worker'] = in_array( $auto_worker, array( APGT_Cron::MODE_BROWSER, APGT_Cron::MODE_CRON ), true ) ? $auto_worker : APGT_Cron::MODE_OFF; $settings['worker_interval'] = isset( $_POST['worker_interval'] ) ? max( 15, (int) $_POST['worker_interval'] ) : 60; $settings['batch_concurrency'] = isset( $_POST['batch_concurrency'] ) ? max( 1, min( 5, (int) $_POST['batch_concurrency'] ) ) : 2; $settings['realtime_trans'] = empty( $_POST['realtime_trans'] ) ? 0 : 1; $settings['realtime_ratio'] = isset( $_POST['realtime_ratio'] ) ? max( 0, min( 100, (int) $_POST['realtime_ratio'] ) ) : 100; $settings['show_hreflang'] = empty( $_POST['show_hreflang'] ) ? 0 : 1; $settings['show_switcher'] = empty( $_POST['show_switcher'] ) ? 0 : 1; $settings['show_flag'] = empty( $_POST['show_flag'] ) ? 0 : 1; $settings['menu_container'] = isset( $_POST['menu_container'] ) // 只留 id 里合法的字符,用户可能连 # 一起粘进来。 ? preg_replace( '/[^A-Za-z0-9_\-:.]/', '', sanitize_text_field( wp_unslash( $_POST['menu_container'] ) ) ) : ''; if ( isset( $_POST['switcher_style'] ) ) { $style = sanitize_key( wp_unslash( $_POST['switcher_style'] ) ); $settings['switcher_style'] = in_array( $style, APGT_Settings::SWITCHER_STYLES, true ) ? $style : 'default'; } // 抓取用 IP:填错了不能就这么存进去——写死一个连不通的地址, // 比不填还糟,页面从此一个也抓不到。存空并回一条提示。 $fetch_ip = isset( $_POST['fetch_ip'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['fetch_ip'] ) ) ) : ''; $bad_ip = '' !== $fetch_ip && ! rest_is_ip_address( $fetch_ip ); $settings['fetch_ip'] = $bad_ip ? '' : $fetch_ip; APGT_Settings::save_settings( $settings ); // 开关计划任务不必等下一个请求。 APGT_Cron::sync(); $ai = APGT_Settings::get_ai(); foreach ( array( 'model', 'api_key', 'proxy' ) as $key ) { if ( isset( $_POST[ $key ] ) ) { $ai[ $key ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); } } if ( isset( $_POST['provider'] ) ) { $provider = sanitize_key( wp_unslash( $_POST['provider'] ) ); $ai['provider'] = in_array( $provider, array( 'core', 'plugin', 'service' ), true ) ? $provider : ''; } // 订阅服务:密钥按原样存(它是随机串,任何"清理"都可能改坏字符); // 服务地址去掉尾斜杠,免得后面拼接出双斜杠。 if ( isset( $_POST['license_key'] ) ) { $ai['license_key'] = sanitize_text_field( wp_unslash( $_POST['license_key'] ) ); } if ( isset( $_POST['service_url'] ) ) { // 先 esc_url_raw 再收拾尾巴:清理必须紧跟着 unslash,中间夹别的调用 // 静态检查会认成「未清理就用了」。 $service_url = esc_url_raw( wp_unslash( $_POST['service_url'] ) ); $ai['service_url'] = untrailingslashit( trim( $service_url ) ); } if ( isset( $_POST['protocol'] ) ) { // 只认库里有的协议标识,留空表示由模型名自动识别。 $protocol = sanitize_key( wp_unslash( $_POST['protocol'] ) ); $ai['protocol'] = array_key_exists( $protocol, APGT_Translator::protocols() ) ? $protocol : ''; } if ( isset( $_POST['api_url'] ) ) { // trim() 必须在 sanitize_url() 之前:后者只 ltrim,尾部空格会被转成 %20, // 用户从文档里连空格一起粘贴时,接口地址就废了。 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- 去空格后紧接着 sanitize_url()。 $api_url = sanitize_url( trim( wp_unslash( $_POST['api_url'] ) ) ); // 填的就是默认地址时存空,这样以后库里换了地址能自动跟上。 $ai['api_url'] = $api_url === APGT_Translator::default_endpoint( $ai ) ? '' : $api_url; } foreach ( array( 'max_tokens', 'timeout' ) as $key ) { if ( isset( $_POST[ $key ] ) ) { $ai[ $key ] = max( 0, (int) $_POST[ $key ] ); } } APGT_Settings::save_ai( $ai ); $args = array( 'page' => self::PAGE_SLUG, 'updated' => 1, ); if ( $bad_ip ) { $args['bad_ip'] = 1; } // 切换器自定义样式:只存数据库、只经 wp_add_inline_style() 输出,不写任何文件。 // 不能走 sanitize_textarea_field(),它会把 CSS 里的 > 选择器之类削掉; // 这里只统一换行、去掉控制字符,能不能用由 switcher_css_error() 把关。 if ( isset( $_POST['switcher_css'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- 下一行即为清理。 $css = apgt_sanitize_css( wp_unslash( $_POST['switcher_css'] ) ); // 与默认样式一字不差就不存:编辑框本来就预填默认样式,没改过的 // 用户不该因此被记成「有自定义样式」,默认样式以后更新也跟得上。 if ( trim( APGT_Settings::default_switcher_css() ) === $css ) { $css = ''; } $error = APGT_Settings::switcher_css_error( $css ); if ( '' === $error ) { APGT_Settings::save_switcher_css( $css ); } else { // 其余设置照常保存,只把这段样式挡下。 unset( $args['updated'] ); $args['notice'] = 'css_' . $error; } } if ( isset( $_POST['prompt'] ) ) { // 指令是多行自由文本,里面既有 JSON 示例也可能提到 HTML 标签, // 交给 sanitize_textarea_field() 会被 strip_tags 削掉一截, // 所以走本插件按这类内容收紧的清理,见 apgt_sanitize_translatable_text()。 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- 下一行即为清理。 $prompt = apgt_sanitize_translatable_text( wp_unslash( $_POST['prompt'] ), self::MAX_PROMPT_LENGTH ); if ( '' === $prompt ) { // 空指令等于没法翻译,回落到默认,别把插件置于不可用状态。 APGT_Settings::save_prompt( APGT_Settings::default_prompt() ); $args['notice'] = 'prompt_reset'; } else { $missing = APGT_Prompt::missing_vars( $prompt ); if ( $missing ) { // 少了变量,AI 收到的指令里就没有语言或待译数据,翻译必然是错的。 // 其余设置照常保存,只把指令挡下。 unset( $args['updated'] ); $args['notice'] = 'prompt_missing'; $args['missing'] = implode( ',', $missing ); } else { APGT_Settings::save_prompt( $prompt ); } } } if ( isset( $_POST['url_prompt'] ) ) { // 同上,见 $_POST['prompt'] 处的说明。 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- 下一行即为清理。 $url_prompt = apgt_sanitize_translatable_text( wp_unslash( $_POST['url_prompt'] ), self::MAX_PROMPT_LENGTH ); if ( '' === $url_prompt ) { APGT_Settings::save_url_prompt( APGT_Settings::default_url_prompt() ); $args['notice'] = 'url_prompt_reset'; } else { $missing = APGT_Prompt::missing_vars( $url_prompt ); if ( $missing ) { unset( $args['updated'] ); $args['notice'] = 'url_prompt_missing'; $args['missing'] = implode( ',', $missing ); } else { APGT_Settings::save_url_prompt( $url_prompt ); } } } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** * 保存「语言管理」表单(添加/删除/更新目标语言)。 * * @return void */ public function handle_languages_save() { if ( ! isset( $_POST['apgt_languages_action'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions', 'aipogeo-context-translate' ) ); } check_admin_referer( 'apgt_languages' ); $action = sanitize_text_field( wp_unslash( $_POST['apgt_languages_action'] ) ); $settings = APGT_Settings::get_settings(); $langs = is_array( $settings['languages'] ) ? $settings['languages'] : array(); $meta = APGT_Settings::language_meta(); $notice = 'saved'; if ( 'add' === $action ) { $iso = isset( $_POST['new_language'] ) ? sanitize_text_field( wp_unslash( $_POST['new_language'] ) ) : ''; if ( '' === $iso || ! isset( $meta[ $iso ] ) ) { $notice = 'bad_language'; } elseif ( isset( $langs[ $iso ] ) ) { $notice = 'exists'; } elseif ( $iso === $settings['default_language'] ) { $notice = 'same_as_source'; } else { $row = APGT_Settings::default_language_row( $iso ); $row['sort'] = count( $langs ) + 1; $langs[ $iso ] = $row; $notice = 'added'; } } elseif ( 'delete' === $action ) { $iso = isset( $_POST['iso'] ) ? sanitize_text_field( wp_unslash( $_POST['iso'] ) ) : ''; if ( isset( $langs[ $iso ] ) ) { unset( $langs[ $iso ] ); $notice = 'deleted'; } } elseif ( 'update' === $action ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- 逐字段单独 sanitize。 $rows = isset( $_POST['lang'] ) && is_array( $_POST['lang'] ) ? wp_unslash( $_POST['lang'] ) : array(); foreach ( $rows as $iso => $row ) { $iso = sanitize_text_field( $iso ); if ( ! isset( $langs[ $iso ] ) ) { continue; } $langs[ $iso ]['dir'] = isset( $row['dir'] ) ? sanitize_title( $row['dir'] ) : $iso; $langs[ $iso ]['hreflang_name'] = isset( $row['hreflang_name'] ) ? sanitize_text_field( $row['hreflang_name'] ) : $iso; $langs[ $iso ]['hreflang_area'] = isset( $row['hreflang_area'] ) ? sanitize_text_field( $row['hreflang_area'] ) : ''; $langs[ $iso ]['sort'] = isset( $row['sort'] ) ? (int) $row['sort'] : 0; $langs[ $iso ]['mode'] = isset( $row['mode'] ) && in_array( $row['mode'], array( '0', '1', '2' ), true ) ? $row['mode'] : '0'; if ( '' === $langs[ $iso ]['dir'] ) { $langs[ $iso ]['dir'] = $iso; } // 替换规则由弹窗编辑后以 JSON 提交,解析或正则校验失败就保留原值并提示。 if ( isset( $row['text_replace'] ) ) { $rule_error = ''; $parsed = self::parse_text_replace( $row['text_replace'], $rule_error ); if ( '' !== $rule_error ) { $notice = $rule_error; } else { $langs[ $iso ]['text_replace'] = $parsed; } } } } $settings['languages'] = $langs; APGT_Settings::save_settings( $settings ); wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE_LANGUAGES, 'notice' => $notice, ), admin_url( 'admin.php' ) ) ); exit; } /** * 判断一段字符串能不能当正则用。 * * 与 website-dev 的 /apis/strings/validate_regex 同一判据:拿空串试跑一次, * 编译失败时 preg_match 返回 false。 * * @param string $pattern 正则(需自带分隔符)。 * @return bool */ public static function is_valid_regex( $pattern ) { $pattern = (string) $pattern; if ( '' === trim( $pattern ) ) { return false; } return false !== @preg_match( $pattern, '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors } /** * 解析替换规则 JSON。 * * @param string $json 规则 JSON(数组,每项含 type/search/replace)。 * @param string $error 出参:'bad_json' / 'bad_regex',无误时为空串。 * @return array 解析出的规则;出错时返回空数组,调用方应保留原值。 */ private static function parse_text_replace( $json, &$error = '' ) { $error = ''; $json = trim( (string) $json ); if ( '' === $json ) { return array(); } $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { $error = 'bad_json'; return array(); } $out = array(); foreach ( $data as $item ) { if ( ! is_array( $item ) || ! isset( $item['search'] ) || '' === (string) $item['search'] ) { continue; } $type = isset( $item['type'] ) ? sanitize_key( (string) $item['type'] ) : 'like_replace'; $type = in_array( $type, array( 'replace', 'like_replace', 'preg_replace' ), true ) ? $type : 'like_replace'; // 规则要逐字匹配译文,同样不能过标签过滤器,理由见 apgt_sanitize_translatable_text()。 $search = apgt_sanitize_translatable_text( $item['search'], self::MAX_RULE_LENGTH ); if ( '' === $search ) { continue; } // 正则写错时 preg_replace 会静默失败,这条规则就永远不起作用了, // 不如在保存这一步就拦住。 if ( 'preg_replace' === $type && ! self::is_valid_regex( $search ) ) { $error = 'bad_regex'; return array(); } $out[] = array( 'type' => $type, 'search' => $search, 'replace' => isset( $item['replace'] ) ? apgt_sanitize_translatable_text( $item['replace'], self::MAX_RULE_LENGTH ) : '', ); } return $out; } /** * 在插件列表页添加「设置」链接。 * * @param array $links 原有链接。 * @return array */ public function add_action_links( $links ) { $settings_link = sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ), esc_html__( 'Settings', 'aipogeo-context-translate' ) ); array_unshift( $links, $settings_link ); return $links; } /** * 渲染翻译设置页。 * * @return void */ public function render_settings() { if ( ! current_user_can( 'manage_options' ) ) { return; } require APGT_PLUGIN_DIR . 'admin/views/settings-page.php'; } /** * 渲染语言管理页。 * * @return void */ public function render_languages() { if ( ! current_user_can( 'manage_options' ) ) { return; } require APGT_PLUGIN_DIR . 'admin/views/languages-page.php'; } /** * 渲染页面翻译页。 * * @return void */ public function render_translate() { if ( ! current_user_can( 'manage_options' ) ) { return; } require APGT_PLUGIN_DIR . 'admin/views/translate-page.php'; } /** * 渲染译文数据页。 * * @return void */ public function render_data() { if ( ! current_user_can( 'manage_options' ) ) { return; } require APGT_PLUGIN_DIR . 'admin/views/data-page.php'; } /** * 渲染使用说明页。 * * @return void */ public function render_help() { if ( ! current_user_can( 'manage_options' ) ) { return; } require APGT_PLUGIN_DIR . 'admin/views/help-page.php'; } /** * 输出插件各页面的顶部导航(当前页高亮)。 * * @param string $current 当前页 slug。 * @return void */ public static function render_nav( $current ) { $tabs = array( self::PAGE_SLUG => __( 'Translation Settings', 'aipogeo-context-translate' ), self::PAGE_LANGUAGES => __( 'Languages', 'aipogeo-context-translate' ), self::PAGE_TRANSLATE => __( 'Translate Pages', 'aipogeo-context-translate' ), self::PAGE_DATA => __( 'Translation Data', 'aipogeo-context-translate' ), self::PAGE_HELP => __( 'Help', 'aipogeo-context-translate' ), ); echo ''; } }