string 站内路径, 'label'=>string, 'group'=>string ] */ public static function all() { $items = array( array( 'url' => self::to_relative( home_url( '/' ) ), 'label' => __( 'Site home page', 'aipogeo-context-translate' ), 'group' => __( 'Home', 'aipogeo-context-translate' ), ), ); $post_types = get_post_types( array( 'public' => true, 'publicly_queryable' => true, ), 'objects' ); // page 的 publicly_queryable 为 false,需要单独补回。 $page_type = get_post_type_object( 'page' ); if ( $page_type && ! isset( $post_types['page'] ) ) { $post_types['page'] = $page_type; } unset( $post_types['attachment'] ); foreach ( $post_types as $type => $obj ) { $posts = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'numberposts' => self::MAX_PER_TYPE, 'orderby' => 'menu_order title', 'order' => 'ASC', 'suppress_filters' => false, ) ); $group = isset( $obj->labels->name ) ? $obj->labels->name : $type; foreach ( $posts as $post ) { $url = self::to_relative( get_permalink( $post ) ); if ( '' === $url ) { continue; } $title = trim( wp_strip_all_tags( get_the_title( $post ) ) ); if ( '' === $title ) { /* translators: %d: post ID */ $title = sprintf( __( '(untitled #%d)', 'aipogeo-context-translate' ), $post->ID ); } $items[] = array( 'url' => $url, 'label' => $title, 'group' => $group, ); } } // 首页往往同时是某个「页面」,去掉重复 URL,保留先出现的那条。 $seen = array(); $out = array(); foreach ( $items as $item ) { if ( isset( $seen[ $item['url'] ] ) ) { continue; } $seen[ $item['url'] ] = true; $out[] = $item; } return $out; } /** * 按指定语言把页面分成「待翻译」与「已翻译」。 * * 判据取自页面进度表:这一页提取到的文本是否都已有译文。不能拿译文记录的 * source_url 来判断——导航、页脚这类共享文本的归属会被后翻的页面抢走, * 先翻的页面又会变回「未翻译」,批量翻译和 worker 会因此无休止重译。 * * @param string $to 目标语言。 * @return array [ 'pending'=>array, 'done'=>array ],元素在 all() 基础上带 total/done */ public static function split_by_progress( $to ) { $progress = APGT_Progress::map( $to ); $pending = array(); $done = array(); foreach ( self::all() as $page ) { $row = isset( $progress[ $page['url'] ] ) ? $progress[ $page['url'] ] : null; $page['total'] = $row ? $row['total'] : 0; $page['done'] = $row ? $row['done'] : 0; if ( APGT_Progress::is_complete( $row ) ) { $done[] = $page; } else { $pending[] = $page; } } return array( 'pending' => $pending, 'done' => $done, ); } /** * 各目标语言的待翻译 / 已翻译页面数。 * * 「全部翻译」据此规划整轮任务:翻完眼前这门语言不停下,接着翻其余语言。 * 页面清单只枚举一次,逐语言比对进度表,避免每种语言重跑一遍 get_posts()。 * * @param string $priority 排在最前的目标语言,留空则按设置里的顺序。 * @return array 每项 [ 'language'=>string, 'label'=>string, 'pending'=>int, 'done'=>int ] */ public static function progress_summary( $priority = '' ) { $pages = self::all(); $languages = array_keys( APGT_Settings::target_languages() ); if ( '' !== $priority && in_array( $priority, $languages, true ) ) { $languages = array_merge( array( $priority ), array_values( array_diff( $languages, array( $priority ) ) ) ); } $out = array(); foreach ( $languages as $iso ) { $progress = APGT_Progress::map( $iso ); $pending = 0; foreach ( $pages as $page ) { $row = isset( $progress[ $page['url'] ] ) ? $progress[ $page['url'] ] : null; if ( ! APGT_Progress::is_complete( $row ) ) { ++$pending; } } $out[] = array( 'language' => $iso, 'label' => apgt_language_label( $iso ), 'pending' => $pending, 'done' => count( $pages ) - $pending, ); } return $out; } /** * 给后台 worker 取一个待办任务:按语言顺序找第一个还没翻译过的页面。 * * @return array|null [ 'url'=>路径, 'label'=>页面名, 'language_to'=>语言 ] */ public static function next_task() { foreach ( APGT_Settings::target_languages() as $iso => $row ) { $split = self::split_by_progress( $iso ); if ( ! empty( $split['pending'] ) ) { $page = $split['pending'][0]; return array( 'url' => $page['url'], 'label' => $page['label'], 'language_to' => $iso, ); } } return null; } /** * 归一化 URL:去掉查询串与锚点,其余原样保留。 * * @param string $url 原始 URL。 * @return string */ public static function normalize_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) { return ''; } $url = strtok( $url, '#' ); $url = strtok( $url, '?' ); return $url; } /** * 取 URL 的站内路径部分(以 / 开头)。 * * 库里的 source_url 一律存路径而不是完整地址:站点换域名(或 http 改 https) * 后,按完整地址分组的「已翻译」标记会全部对不上号。译文本身按原文 md5 * 索引,不受影响,但页面维度的标记没必要跟着域名一起废掉。 * * 传入的已经是路径时原样返回,所以对旧数据和旧书签同样适用。 * * @param string $url 完整 URL 或站内路径。 * @return string 以 / 开头的路径;无法解析时返回空串。 */ public static function to_relative( $url ) { $url = self::normalize_url( $url ); if ( '' === $url ) { return ''; } // 已经是路径。注意排除 //host/path 这种协议相对写法。 if ( '/' === $url[0] ) { return '//' === substr( $url, 0, 2 ) ? '' : $url; } $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { // http://example.com 这类没有路径的地址视为首页。 return wp_parse_url( $url, PHP_URL_HOST ) ? '/' : ''; } return $path; } /** * 把站内路径还原成可抓取的完整地址(域名取当前 home_url)。 * * @param string $path 站内路径。 * @return string 无法还原时返回空串。 */ public static function to_absolute( $path ) { $path = self::to_relative( $path ); if ( '' === $path ) { return ''; } $home = wp_parse_url( home_url() ); if ( empty( $home['host'] ) ) { return ''; } return ( empty( $home['scheme'] ) ? 'http' : $home['scheme'] ) . '://' . $home['host'] . ( empty( $home['port'] ) ? '' : ':' . $home['port'] ) . $path; } /** * 校验是否为合法的站内路径(AJAX 入参校验,避免被当成任意地址抓取器)。 * * @param string $path 待校验路径。 * @return bool */ public static function is_site_path( $path ) { return is_string( $path ) && '' !== $path && '/' === $path[0] && '//' !== substr( $path, 0, 2 ); } }