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 '';
}
}