languages = $languages; $this->mapping = $mapping; $this->router = $router; $this->ai_translator = $ai_translator; } /** * Register hooks. * * @return void */ public function init() { if ( false === get_option( self::SETTINGS_OPTION, false ) ) { add_option( self::SETTINGS_OPTION, $this->defaults(), '', false ); } } /** * Default settings. * * @return array */ public function defaults() { return array( 'enabled' => 0, 'allowed_post_types' => array( 'page', 'post', 'product' ), 'max_links_per_source' => 3, 'max_links_per_target' => 1, 'skip_headings' => 1, 'avoid_same_anchor' => 1, 'same_tab' => 1, 'title_attribute' => 0, 'nofollow' => 0, 'same_language_only' => 1, 'allow_default_language' => 1, 'allow_translated_language' => 1, 'allow_cross_language' => 0, 'include_short_description' => 0, 'allow_system_pages' => 0, 'excluded_page_ids' => array(), ); } /** * Get saved settings. * * @return array */ public function get_settings() { $settings = get_option( self::SETTINGS_OPTION, array() ); $settings = array_merge( $this->defaults(), is_array( $settings ) ? $settings : array() ); $settings['allowed_post_types'] = $this->sanitize_post_types( (array) $settings['allowed_post_types'] ); $settings['max_links_per_source'] = max( 1, min( 20, absint( $settings['max_links_per_source'] ) ) ); $settings['max_links_per_target'] = max( 1, min( 5, absint( $settings['max_links_per_target'] ) ) ); $settings['excluded_page_ids'] = array_values( array_unique( array_filter( array_map( 'absint', (array) $settings['excluded_page_ids'] ) ) ) ); return $settings; } /** * Save settings from admin input. * * @param array $input Raw settings. * @return void */ public function save_settings( array $input ) { $current = $this->get_settings(); $saved = array( 'enabled' => ! empty( $input['enabled'] ) ? 1 : 0, 'allowed_post_types' => isset( $input['allowed_post_types'] ) ? $this->sanitize_post_types( (array) $input['allowed_post_types'] ) : $current['allowed_post_types'], 'max_links_per_source' => isset( $input['max_links_per_source'] ) ? max( 1, min( 20, absint( $input['max_links_per_source'] ) ) ) : $current['max_links_per_source'], 'max_links_per_target' => isset( $input['max_links_per_target'] ) ? max( 1, min( 5, absint( $input['max_links_per_target'] ) ) ) : $current['max_links_per_target'], 'skip_headings' => ! empty( $input['skip_headings'] ) ? 1 : 0, 'avoid_same_anchor' => ! empty( $input['avoid_same_anchor'] ) ? 1 : 0, 'same_tab' => ! empty( $input['same_tab'] ) ? 1 : 0, 'title_attribute' => ! empty( $input['title_attribute'] ) ? 1 : 0, 'nofollow' => ! empty( $input['nofollow'] ) ? 1 : 0, 'same_language_only' => ! empty( $input['same_language_only'] ) ? 1 : 0, 'allow_default_language' => ! empty( $input['allow_default_language'] ) ? 1 : 0, 'allow_translated_language' => ! empty( $input['allow_translated_language'] ) ? 1 : 0, 'allow_cross_language' => ! empty( $input['allow_cross_language'] ) ? 1 : 0, 'include_short_description' => ! empty( $input['include_short_description'] ) ? 1 : 0, 'allow_system_pages' => ! empty( $input['allow_system_pages'] ) ? 1 : 0, 'excluded_page_ids' => isset( $input['excluded_page_ids'] ) ? $this->parse_ids( $input['excluded_page_ids'] ) : $current['excluded_page_ids'], ); update_option( self::SETTINGS_OPTION, $saved, false ); } /** * Build language choices including default. * * @return array */ public function language_choices() { $choices = array( $this->languages->get_default_code() => $this->languages->get_default_native_label(), ); foreach ( $this->languages->get_enabled() as $code => $language ) { $choices[ $code ] = ! empty( $language['native_label'] ) ? $language['native_label'] : strtoupper( $code ); } return $choices; } /** * Generate preview rows from admin input. * * @param array $input Raw generation input. * @param int $user_id User ID. * @return array>|\WP_Error */ public function generate_preview( array $input, $user_id ) { $settings = $this->get_settings(); if ( empty( $settings['enabled'] ) ) { return new \WP_Error( 'sitelang_internal_links_disabled', __( 'Enable Internal Backlinks before generating suggestions.', 'sitelang' ) ); } $args = $this->sanitize_generation_args( $input ); $languages = $this->resolve_language_scope( $args['language_scope'], $args['language'] ); $rows = array(); foreach ( $languages as $language ) { $sources = $this->get_content_candidates( $args['source_mode'], $args['source_ids'], $language, 'source', $settings ); $targets = $this->get_content_candidates( $args['target_mode'], $args['target_ids'], $language, 'target', $settings ); if ( 'ai' === $args['mode'] && $this->ai_translator->is_configured() ) { $ai_rows = $this->generate_ai_preview( $sources, $targets, $language, $args, $settings ); if ( ! is_wp_error( $ai_rows ) ) { $rows = array_merge( $rows, $ai_rows ); continue; } } $rows = array_merge( $rows, $this->generate_manual_preview( $sources, $targets, $language, $args, $settings ) ); } $rows = array_slice( array_values( $rows ), 0, 200 ); update_user_meta( absint( $user_id ), self::PREVIEW_META, $rows ); return $rows; } /** * Get preview rows for a user. * * @param int $user_id User ID. * @return array> */ public function get_preview( $user_id ) { $rows = get_user_meta( absint( $user_id ), self::PREVIEW_META, true ); return is_array( $rows ) ? $rows : array(); } /** * Apply selected preview rows. * * @param array $keys Selected preview keys. * @param int $user_id User ID. * @return array */ public function apply_selected( array $keys, $user_id ) { $preview = $this->get_preview( $user_id ); $wanted = array_flip( array_map( 'sanitize_text_field', $keys ) ); $result = array( 'applied' => 0, 'skipped' => 0, ); foreach ( $preview as $row ) { if ( empty( $row['key'] ) || ! isset( $wanted[ $row['key'] ] ) || 'ready' !== ( $row['status_code'] ?? '' ) ) { continue; } $applied = $this->apply_row( $row ); if ( is_wp_error( $applied ) ) { $result['skipped']++; continue; } $result['applied']++; } return $result; } /** * Remove generated links by scope. * * @param array $input Removal input. * @return int Number of posts cleaned. */ public function remove_generated_links( array $input ) { $language = ! empty( $input['language'] ) ? $this->languages->sanitize_code( $input['language'] ) : ''; $post_id = ! empty( $input['source_id'] ) ? absint( $input['source_id'] ) : 0; $ids = array(); if ( $post_id ) { $ids[] = $post_id; } else { $query = new \WP_Query( array( 'post_type' => $this->supported_post_types(), 'post_status' => 'any', 'posts_per_page' => 500, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_sitelang_internal_links', 'compare' => 'EXISTS', ), ), ) ); $ids = array_map( 'absint', $query->posts ); } $count = 0; foreach ( $ids as $id ) { if ( $this->remove_links_from_post( $id, $language ) ) { $count++; } } return $count; } /** * Restore the latest SiteLang internal-links backup for a source post. * * @param int $post_id Source post ID. * @return bool|\WP_Error */ public function restore_latest_backup( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post ) { return new \WP_Error( 'sitelang_internal_links_missing_post', __( 'Source post was not found.', 'sitelang' ) ); } $meta = get_post_meta( $post_id ); $backups = array(); foreach ( $meta as $key => $values ) { if ( 0 !== strpos( $key, '_sitelang_internal_links_backup_' ) ) { continue; } $timestamp = substr( $key, strlen( '_sitelang_internal_links_backup_' ) ); $backups[ $timestamp ] = isset( $values[0] ) ? (string) maybe_unserialize( $values[0] ) : ''; } if ( empty( $backups ) ) { return new \WP_Error( 'sitelang_internal_links_no_backup', __( 'No SiteLang internal-links backup was found for that source post.', 'sitelang' ) ); } krsort( $backups ); $content = reset( $backups ); $result = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $content, ) ), true ); if ( is_wp_error( $result ) ) { return $result; } delete_post_meta( $post_id, '_sitelang_internal_links' ); return true; } /** * Build report. * * @return array */ public function report() { $query = new \WP_Query( array( 'post_type' => $this->supported_post_types(), 'post_status' => 'any', 'posts_per_page' => 500, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_sitelang_internal_links', 'compare' => 'EXISTS', ), ), ) ); $report = array( 'total' => 0, 'by_lang' => array(), 'by_type' => array(), 'top_targets'=> array(), 'top_sources'=> array(), 'broken' => 0, 'rows' => array(), ); foreach ( array_map( 'absint', $query->posts ) as $source_id ) { $links = $this->get_post_links_meta( $source_id ); if ( empty( $links ) ) { continue; } $report['top_sources'][ $source_id ] = count( $links ); foreach ( $links as $link ) { $target_id = ! empty( $link['target_post_id'] ) ? absint( $link['target_post_id'] ) : 0; $lang = ! empty( $link['language'] ) ? sanitize_key( $link['language'] ) : ''; $type = $target_id ? get_post_type( $target_id ) : ''; $report['total']++; $report['by_lang'][ $lang ] = isset( $report['by_lang'][ $lang ] ) ? $report['by_lang'][ $lang ] + 1 : 1; if ( $type ) { $report['by_type'][ $type ] = isset( $report['by_type'][ $type ] ) ? $report['by_type'][ $type ] + 1 : 1; } if ( $target_id ) { $report['top_targets'][ $target_id ] = isset( $report['top_targets'][ $target_id ] ) ? $report['top_targets'][ $target_id ] + 1 : 1; } if ( ! $target_id || ! get_post( $target_id ) || 'trash' === get_post_status( $target_id ) ) { $report['broken']++; } $report['rows'][] = array( 'source_id' => $source_id, 'target_id' => $target_id, 'language' => $lang, 'anchor' => $link['anchor_text'] ?? '', 'url' => $link['target_url'] ?? '', 'created' => $link['created_at'] ?? '', ); } } arsort( $report['top_sources'] ); arsort( $report['top_targets'] ); return $report; } /** * Output report CSV. * * @return void */ public function export_csv() { $report = $this->report(); nocache_headers(); header( 'Content-Type: text/csv; charset=' . get_option( 'blog_charset' ) ); header( 'Content-Disposition: attachment; filename=sitelang-internal-links.csv' ); $out = fopen( 'php://output', 'w' ); fputcsv( $out, array( 'Source ID', 'Source Title', 'Target ID', 'Target Title', 'Language', 'Anchor', 'URL', 'Created' ) ); foreach ( $report['rows'] as $row ) { fputcsv( $out, array( $row['source_id'], get_the_title( $row['source_id'] ), $row['target_id'], $row['target_id'] ? get_the_title( $row['target_id'] ) : '', $row['language'], $row['anchor'], $row['url'], $row['created'], ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- This closes the php://output stream opened for CSV download. fclose( $out ); exit; } /** * Generate manual preview. * * @param array $sources Source posts. * @param array $targets Target posts. * @param string $language Language code. * @param array $args Generation args. * @param array $settings Settings. * @return array> */ private function generate_manual_preview( array $sources, array $targets, $language, array $args, array $settings ) { $rows = array(); foreach ( $sources as $source ) { if ( ! $source instanceof \WP_Post ) { continue; } $source_count = $this->count_generated_links( $source->ID ); foreach ( $targets as $target ) { if ( ! $target instanceof \WP_Post || (int) $source->ID === (int) $target->ID ) { continue; } if ( $source_count >= absint( $settings['max_links_per_source'] ) ) { break; } $anchors = $this->anchor_candidates( $target, $args['custom_keywords'] ); $url = get_permalink( $target ); $row = $this->build_preview_row( $source, $target, $language, '', $url, 'manual', 0, __( 'Manual anchor match.', 'sitelang' ), $settings ); foreach ( $anchors as $anchor ) { if ( $this->content_has_linkable_anchor( $source->post_content, $anchor, $settings ) ) { $row = $this->build_preview_row( $source, $target, $language, $anchor, $url, 'manual', 0, __( 'Anchor text appears naturally in the source content.', 'sitelang' ), $settings ); break; } } $rows[] = $row; if ( 'ready' === $row['status_code'] ) { $source_count++; } } } return $rows; } /** * Generate AI-assisted preview rows. * * @param array $sources Source posts. * @param array $targets Target posts. * @param string $language Language code. * @param array $args Generation args. * @param array $settings Settings. * @return array>|\WP_Error */ private function generate_ai_preview( array $sources, array $targets, $language, array $args, array $settings ) { if ( empty( $sources ) || empty( $targets ) ) { return array(); } $sources = array_slice( $sources, 0, 10 ); $targets = array_slice( $targets, 0, 30 ); $payload = array( 'language' => $language, 'sources' => array(), 'targets' => array(), ); foreach ( $sources as $source ) { $payload['sources'][] = array( 'id' => (int) $source->ID, 'title' => get_the_title( $source ), 'type' => $source->post_type, 'excerpt' => $this->trim_text( $this->plain_text( $source->post_content ), 1800 ), ); } foreach ( $targets as $target ) { $payload['targets'][] = array( 'id' => (int) $target->ID, 'title' => get_the_title( $target ), 'type' => $target->post_type, 'url' => get_permalink( $target ), 'keywords' => $this->anchor_candidates( $target, $args['custom_keywords'] ), 'excerpt' => $this->trim_text( $this->plain_text( $target->post_excerpt . ' ' . $target->post_content ), 900 ), ); } $settings_ai = $this->ai_translator->get_settings(); $body = array( 'model' => sanitize_text_field( $settings_ai['model'] ), 'instructions' => 'You are an SEO internal linking assistant. Suggest only natural, contextually relevant internal links. Do not invent URLs. Avoid keyword stuffing. Return only JSON matching the schema.', 'input' => wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), 'max_output_tokens' => min( 6000, absint( $settings_ai['max_output_tokens'] ) ), 'text' => array( 'format' => array( 'type' => 'json_schema', 'name' => 'sitelang_internal_links', 'strict' => true, 'schema' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'links' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'source_post_id' => array( 'type' => 'integer' ), 'target_post_id' => array( 'type' => 'integer' ), 'language' => array( 'type' => 'string' ), 'anchor_text' => array( 'type' => 'string' ), 'target_url' => array( 'type' => 'string' ), 'reason' => array( 'type' => 'string' ), 'confidence' => array( 'type' => 'number' ), ), 'required' => array( 'source_post_id', 'target_post_id', 'language', 'anchor_text', 'target_url', 'reason', 'confidence' ), ), ), ), 'required' => array( 'links' ), ), ), ), ); if ( 0 === strpos( (string) $settings_ai['model'], 'gpt-5' ) ) { $body['reasoning'] = array( 'effort' => $settings_ai['reasoning_effort'] ); } elseif ( isset( $settings_ai['temperature'] ) ) { $body['temperature'] = (float) $settings_ai['temperature']; } $data = $this->ai_translator->managed_request( $body, 'translate_internal_link_suggestions', absint( $settings_ai['timeout'] ) ); if ( is_wp_error( $data ) ) { return $data; } $json = $this->decode_ai_json( is_array( $data ) ? $data : array() ); if ( empty( $json['links'] ) || ! is_array( $json['links'] ) ) { return new \WP_Error( 'sitelang_internal_links_ai_bad_response', __( 'OpenAI returned no readable internal link suggestions.', 'sitelang' ) ); } $sources_by_id = array(); $targets_by_id = array(); foreach ( $sources as $source ) { $sources_by_id[ $source->ID ] = $source; } foreach ( $targets as $target ) { $targets_by_id[ $target->ID ] = $target; } $rows = array(); foreach ( $json['links'] as $link ) { $source_id = isset( $link['source_post_id'] ) ? absint( $link['source_post_id'] ) : 0; $target_id = isset( $link['target_post_id'] ) ? absint( $link['target_post_id'] ) : 0; if ( empty( $sources_by_id[ $source_id ] ) || empty( $targets_by_id[ $target_id ] ) ) { continue; } $rows[] = $this->build_preview_row( $sources_by_id[ $source_id ], $targets_by_id[ $target_id ], $language, isset( $link['anchor_text'] ) ? sanitize_text_field( $link['anchor_text'] ) : '', get_permalink( $target_id ), 'ai', isset( $link['confidence'] ) ? (float) $link['confidence'] : 0, isset( $link['reason'] ) ? sanitize_text_field( $link['reason'] ) : '', $settings ); } return $rows; } /** * Build one preview row and validate it. * * @param \WP_Post $source Source post. * @param \WP_Post $target Target post. * @param string $language Language. * @param string $anchor Anchor text. * @param string $url Target URL. * @param string $mode Generation mode. * @param float $confidence Confidence. * @param string $reason Reason. * @param array $settings Settings. * @return array */ private function build_preview_row( \WP_Post $source, \WP_Post $target, $language, $anchor, $url, $mode, $confidence, $reason, array $settings ) { $anchor = trim( wp_strip_all_tags( (string) $anchor ) ); $status = $this->preview_status( $source, $target, $language, $anchor, $url, $settings ); $key = md5( implode( '|', array( $source->ID, $target->ID, $language, $anchor, $url, $mode ) ) ); return array( 'key' => $key, 'source_post_id' => (int) $source->ID, 'source_title' => get_the_title( $source ), 'source_type' => $source->post_type, 'source_language' => $this->detect_language( $source->ID ), 'target_post_id' => (int) $target->ID, 'target_title' => get_the_title( $target ), 'target_type' => $target->post_type, 'target_language' => $this->detect_language( $target->ID ), 'language' => $language, 'anchor_text' => $anchor, 'target_url' => esc_url_raw( $url ), 'mode' => sanitize_key( $mode ), 'confidence' => max( 0, min( 1, (float) $confidence ) ), 'reason' => $reason, 'status_code' => $status['code'], 'status' => $status['label'], ); } /** * Validate preview status. * * @param \WP_Post $source Source post. * @param \WP_Post $target Target post. * @param string $language Language. * @param string $anchor Anchor. * @param string $url Target URL. * @param array $settings Settings. * @return array */ private function preview_status( \WP_Post $source, \WP_Post $target, $language, $anchor, $url, array $settings ) { if ( ! $this->is_allowed_source( $source, $language, $settings ) ) { return array( 'code' => 'skipped_source', 'label' => __( 'Skipped because source is excluded or unpublished.', 'sitelang' ) ); } if ( ! $this->is_allowed_target( $target, $language, $settings ) ) { return array( 'code' => 'skipped_target', 'label' => __( 'Skipped because target is unavailable for this language.', 'sitelang' ) ); } if ( ( empty( $settings['allow_cross_language'] ) || ! empty( $settings['same_language_only'] ) ) && $this->detect_language( $source->ID ) !== $this->detect_language( $target->ID ) ) { return array( 'code' => 'skipped_language', 'label' => __( 'Skipped because source and target language mismatch.', 'sitelang' ) ); } if ( $this->count_generated_links( $source->ID ) >= absint( $settings['max_links_per_source'] ) ) { return array( 'code' => 'skipped_max', 'label' => __( 'Skipped because max links reached.', 'sitelang' ) ); } if ( $this->target_link_count( $source->ID, $target->ID ) >= absint( $settings['max_links_per_target'] ) ) { return array( 'code' => 'skipped_target_limit', 'label' => __( 'Skipped because this target is already linked enough.', 'sitelang' ) ); } if ( '' === $anchor || ! $this->valid_anchor_text( $anchor ) ) { return array( 'code' => 'skipped_anchor', 'label' => __( 'Skipped because anchor text is too generic or too short.', 'sitelang' ) ); } if ( $url && false !== strpos( (string) $source->post_content, $url ) ) { return array( 'code' => 'skipped_existing_target', 'label' => __( 'Skipped because target URL already exists in source.', 'sitelang' ) ); } if ( ! $this->content_has_linkable_anchor( $source->post_content, $anchor, $settings ) ) { return array( 'code' => 'skipped_missing_anchor', 'label' => __( 'Skipped because anchor text was not found in safe editable text.', 'sitelang' ) ); } if ( ! empty( $settings['avoid_same_anchor'] ) && $this->anchor_already_tracked( $source->ID, $anchor ) ) { return array( 'code' => 'skipped_repeated_anchor', 'label' => __( 'Skipped because this anchor text was already used on the source.', 'sitelang' ) ); } return array( 'code' => 'ready', 'label' => __( 'Ready', 'sitelang' ) ); } /** * Apply a preview row to post content. * * @param array $row Preview row. * @return true|\WP_Error */ private function apply_row( array $row ) { $settings = $this->get_settings(); $source_id = ! empty( $row['source_post_id'] ) ? absint( $row['source_post_id'] ) : 0; $source = get_post( $source_id ); if ( ! $source || 'publish' !== $source->post_status ) { return new \WP_Error( 'sitelang_internal_links_missing_source', __( 'Source post is unavailable.', 'sitelang' ) ); } $updated = $this->insert_anchor_link( $source->post_content, (string) $row['anchor_text'], (string) $row['target_url'], $settings ); if ( is_wp_error( $updated ) ) { return $updated; } $timestamp = gmdate( 'YmdHis' ); update_post_meta( $source_id, '_sitelang_internal_links_backup_' . $timestamp, $source->post_content ); $result = wp_update_post( wp_slash( array( 'ID' => $source_id, 'post_content' => $updated, ) ), true ); if ( is_wp_error( $result ) ) { return $result; } $links = $this->get_post_links_meta( $source_id ); $links[] = array( 'target_post_id' => absint( $row['target_post_id'] ), 'target_url' => esc_url_raw( $row['target_url'] ), 'anchor_text' => sanitize_text_field( $row['anchor_text'] ), 'language' => sanitize_key( $row['language'] ), 'created_at' => current_time( 'mysql' ), 'mode' => sanitize_key( $row['mode'] ), 'hash' => sanitize_text_field( $row['key'] ), ); update_post_meta( $source_id, '_sitelang_internal_links', $links ); return true; } /** * Insert one anchor safely using DOMDocument. * * @param string $content Post content. * @param string $anchor Anchor text. * @param string $url URL. * @param array $settings Settings. * @return string|\WP_Error */ private function insert_anchor_link( $content, $anchor, $url, array $settings ) { if ( '' === trim( $anchor ) || '' === trim( $url ) ) { return new \WP_Error( 'sitelang_internal_links_invalid_link', __( 'Missing anchor or URL.', 'sitelang' ) ); } if ( false !== strpos( $content, $url ) ) { return new \WP_Error( 'sitelang_internal_links_already_linked', __( 'Target URL already exists in source content.', 'sitelang' ) ); } if ( ! class_exists( '\DOMDocument' ) ) { return new \WP_Error( 'sitelang_internal_links_dom_missing', __( 'DOMDocument is not available on this server.', 'sitelang' ) ); } $previous = libxml_use_internal_errors( true ); $doc = new \DOMDocument( '1.0', get_option( 'blog_charset' ) ); $wrapped = ''; $doc->loadHTML( '' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $xpath = new \DOMXPath( $doc ); $nodes = $xpath->query( '//text()' ); if ( ! $nodes ) { return new \WP_Error( 'sitelang_internal_links_anchor_missing', __( 'Anchor text was not found.', 'sitelang' ) ); } foreach ( $nodes as $node ) { if ( ! $node instanceof \DOMText || $this->text_node_is_protected( $node, $settings ) ) { continue; } $text = $node->nodeValue; $pos = stripos( $text, $anchor ); if ( false === $pos ) { continue; } $before = substr( $text, 0, $pos ); $match = substr( $text, $pos, strlen( $anchor ) ); $after = substr( $text, $pos + strlen( $anchor ) ); $link = $doc->createElement( 'a' ); $link->setAttribute( 'href', esc_url_raw( $url ) ); $link->setAttribute( 'data-sitelang-internal-link', '1' ); if ( empty( $settings['same_tab'] ) ) { $link->setAttribute( 'target', '_blank' ); $link->setAttribute( 'rel', 'noopener' ); } if ( ! empty( $settings['nofollow'] ) ) { $link->setAttribute( 'rel', trim( $link->getAttribute( 'rel' ) . ' nofollow' ) ); } if ( ! empty( $settings['title_attribute'] ) ) { $link->setAttribute( 'title', $match ); } $link->appendChild( $doc->createTextNode( $match ) ); $fragment = $doc->createDocumentFragment(); if ( '' !== $before ) { $fragment->appendChild( $doc->createTextNode( $before ) ); } $fragment->appendChild( $link ); if ( '' !== $after ) { $fragment->appendChild( $doc->createTextNode( $after ) ); } $node->parentNode->replaceChild( $fragment, $node ); return $this->inner_html( $doc, 'sitelang-internal-link-root' ); } return new \WP_Error( 'sitelang_internal_links_protected_anchor', __( 'Anchor was only found in protected HTML.', 'sitelang' ) ); } /** * Remove SiteLang-generated links from one post. * * @param int $post_id Post ID. * @param string $language Optional language. * @return bool */ private function remove_links_from_post( $post_id, $language = '' ) { $post = get_post( absint( $post_id ) ); if ( ! $post || false === strpos( $post->post_content, 'data-sitelang-internal-link' ) ) { return false; } $links = $this->get_post_links_meta( $post->ID ); if ( $language ) { $has_language = false; foreach ( $links as $link ) { if ( ! empty( $link['language'] ) && $language === $link['language'] ) { $has_language = true; break; } } if ( ! $has_language ) { return false; } } $timestamp = gmdate( 'YmdHis' ); update_post_meta( $post->ID, '_sitelang_internal_links_backup_' . $timestamp, $post->post_content ); $content = preg_replace( '#]*\s)?data-sitelang-internal-link=("|\')1\2([^>]*)>(.*?)#is', '$4', $post->post_content ); wp_update_post( wp_slash( array( 'ID' => $post->ID, 'post_content' => $content, ) ) ); if ( $language ) { $links = array_values( array_filter( $links, static function ( $link ) use ( $language ) { return empty( $link['language'] ) || $language !== $link['language']; } ) ); update_post_meta( $post->ID, '_sitelang_internal_links', $links ); } else { delete_post_meta( $post->ID, '_sitelang_internal_links' ); } return true; } /** * Get source/target candidates. * * @param string $mode Mode. * @param array $selected_ids Selected IDs. * @param string $language Language. * @param string $role source|target. * @param array $settings Settings. * @return array */ private function get_content_candidates( $mode, array $selected_ids, $language, $role, array $settings ) { $post_types = $this->mode_post_types( $mode, $settings ); $ids = array(); if ( 'selected' === $mode && ! empty( $selected_ids ) ) { $ids = $selected_ids; } elseif ( $language === $this->languages->get_default_code() ) { $ids = $this->query_original_ids( $post_types ); } else { foreach ( $post_types as $post_type ) { foreach ( $this->mapping->get_for_language( $language, $post_type ) as $row ) { $translation_id = absint( $row->translation_id ); if ( $translation_id && 'publish' === get_post_status( $translation_id ) && ( 'complete' === $row->status || 'complete' === get_post_meta( $translation_id, '_sitelang_translation_status', true ) ) ) { $ids[] = $translation_id; } } } } $posts = array(); foreach ( array_slice( array_values( array_unique( array_map( 'absint', $ids ) ) ), 0, 200 ) as $id ) { $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status || ! in_array( $post->post_type, $post_types, true ) ) { continue; } if ( 'source' === $role && ! $this->is_allowed_source( $post, $language, $settings ) ) { continue; } if ( 'target' === $role && ! $this->is_allowed_target( $post, $language, $settings ) ) { continue; } $posts[] = $post; } return $posts; } /** * Query original-language IDs. * * @param array $post_types Post types. * @return array */ private function query_original_ids( array $post_types ) { $query = new \WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => 200, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => array( array( 'key' => '_sitelang_is_translation', 'compare' => 'NOT EXISTS', ), ), ) ); return array_map( 'absint', $query->posts ); } /** * Whether source is allowed. * * @param \WP_Post $post Post. * @param string $language Language. * @param array $settings Settings. * @return bool */ private function is_allowed_source( \WP_Post $post, $language, array $settings ) { return $this->is_allowed_content( $post, $language, $settings, true ); } /** * Whether target is allowed. * * @param \WP_Post $post Post. * @param string $language Language. * @param array $settings Settings. * @return bool */ private function is_allowed_target( \WP_Post $post, $language, array $settings ) { return $this->is_allowed_content( $post, $language, $settings, false ); } /** * Shared source/target validation. * * @param \WP_Post $post Post. * @param string $language Language. * @param array $settings Settings. * @param bool $is_source Is source. * @return bool */ private function is_allowed_content( \WP_Post $post, $language, array $settings, $is_source ) { if ( 'publish' !== $post->post_status || ! in_array( $post->post_type, $settings['allowed_post_types'], true ) ) { return false; } if ( $language === $this->languages->get_default_code() && empty( $settings['allow_default_language'] ) ) { return false; } if ( $language !== $this->languages->get_default_code() && empty( $settings['allow_translated_language'] ) ) { return false; } if ( $is_source && empty( $settings['allow_system_pages'] ) && $this->is_system_page( $post->ID ) ) { return false; } if ( $language === $this->languages->get_default_code() && $this->mapping->is_translation( $post->ID ) ) { return false; } if ( $language !== $this->languages->get_default_code() ) { if ( ! $this->mapping->is_translation( $post->ID ) || $language !== get_post_meta( $post->ID, '_sitelang_language', true ) ) { return false; } if ( ! $this->mapping->is_frontend_ready_translation( $post->ID ) ) { return false; } } return true; } /** * Detect language of a post. * * @param int $post_id Post ID. * @return string */ private function detect_language( $post_id ) { $lang = (string) get_post_meta( absint( $post_id ), '_sitelang_language', true ); return $lang ? $this->languages->sanitize_code( $lang ) : $this->languages->get_default_code(); } /** * Anchor candidates for target. * * @param \WP_Post $target Target. * @param array $custom_keywords Custom keywords. * @return array */ private function anchor_candidates( \WP_Post $target, array $custom_keywords ) { $anchors = array(); foreach ( $custom_keywords as $keyword ) { if ( $this->valid_anchor_text( $keyword ) ) { $anchors[] = $keyword; } } foreach ( array( '_yoast_wpseo_focuskw', 'rank_math_focus_keyword', '_aioseo_keywords' ) as $meta_key ) { $value = (string) get_post_meta( $target->ID, $meta_key, true ); foreach ( preg_split( '/[,;]/', $value ) as $keyword ) { $keyword = trim( $keyword ); if ( $this->valid_anchor_text( $keyword ) ) { $anchors[] = $keyword; } } } if ( 'product' === $target->post_type ) { $terms = wp_get_post_terms( $target->ID, 'product_cat', array( 'fields' => 'names' ) ); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $term_name ) { if ( $this->valid_anchor_text( $term_name ) ) { $anchors[] = $term_name; } } } } $title = get_the_title( $target ); if ( $this->valid_anchor_text( $title ) ) { $anchors[] = $title; } return array_values( array_unique( array_filter( array_map( 'sanitize_text_field', $anchors ) ) ) ); } /** * Validate anchor text. * * @param string $anchor Anchor. * @return bool */ private function valid_anchor_text( $anchor ) { $anchor = trim( wp_strip_all_tags( (string) $anchor ) ); if ( '' === $anchor ) { return false; } $generic = array( 'click here', 'read more', 'this page', 'learn more', 'here' ); if ( in_array( strtolower( $anchor ), $generic, true ) ) { return false; } $words = preg_split( '/\s+/', $anchor ); return strlen( $anchor ) >= 4 && ( count( $words ) >= 2 || strlen( $anchor ) >= 8 ); } /** * Does content contain linkable anchor text. * * @param string $content Content. * @param string $anchor Anchor. * @param array $settings Settings. * @return bool */ private function content_has_linkable_anchor( $content, $anchor, array $settings ) { if ( false === stripos( $content, $anchor ) ) { return false; } $inserted = $this->insert_anchor_link( $content, $anchor, home_url( '/sitelang-preview-only/' ), $settings ); return ! is_wp_error( $inserted ); } /** * Is text node protected. * * @param \DOMText $node Text node. * @param array $settings Settings. * @return bool */ private function text_node_is_protected( \DOMText $node, array $settings ) { $protected = array( 'a', 'button', 'script', 'style', 'textarea', 'select', 'option', 'code', 'pre' ); if ( ! empty( $settings['skip_headings'] ) ) { $protected = array_merge( $protected, array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ) ); } $parent = $node->parentNode; while ( $parent && $parent instanceof \DOMElement ) { if ( in_array( strtolower( $parent->tagName ), $protected, true ) ) { return true; } $parent = $parent->parentNode; } $text = $node->nodeValue; return false !== strpos( $text, '[' ) || false !== strpos( $text, ']' ); } /** * Get inner HTML from wrapper. * * @param \DOMDocument $doc Document. * @param string $id Wrapper ID. * @return string */ private function inner_html( \DOMDocument $doc, $id ) { $root = $doc->getElementById( $id ); if ( ! $root ) { return ''; } $html = ''; foreach ( $root->childNodes as $child ) { $html .= $doc->saveHTML( $child ); } return $html; } /** * Get generated link meta. * * @param int $post_id Post ID. * @return array> */ private function get_post_links_meta( $post_id ) { $links = get_post_meta( absint( $post_id ), '_sitelang_internal_links', true ); return is_array( $links ) ? $links : array(); } /** * Count generated links on source. * * @param int $source_id Source ID. * @return int */ private function count_generated_links( $source_id ) { return count( $this->get_post_links_meta( $source_id ) ); } /** * Count links to target from source. * * @param int $source_id Source ID. * @param int $target_id Target ID. * @return int */ private function target_link_count( $source_id, $target_id ) { $count = 0; foreach ( $this->get_post_links_meta( $source_id ) as $link ) { if ( ! empty( $link['target_post_id'] ) && absint( $link['target_post_id'] ) === absint( $target_id ) ) { $count++; } } return $count; } /** * Whether anchor is already tracked on source. * * @param int $source_id Source ID. * @param string $anchor Anchor. * @return bool */ private function anchor_already_tracked( $source_id, $anchor ) { foreach ( $this->get_post_links_meta( $source_id ) as $link ) { if ( ! empty( $link['anchor_text'] ) && 0 === strcasecmp( $link['anchor_text'], $anchor ) ) { return true; } } return false; } /** * Is system/sensitive page. * * @param int $post_id Post ID. * @return bool */ private function is_system_page( $post_id ) { $settings = $this->get_settings(); if ( in_array( absint( $post_id ), $settings['excluded_page_ids'], true ) ) { return true; } if ( function_exists( 'wc_get_page_id' ) ) { foreach ( array( 'cart', 'checkout', 'myaccount', 'terms' ) as $page_key ) { if ( absint( wc_get_page_id( $page_key ) ) === absint( $post_id ) ) { return true; } } } $title = strtolower( get_the_title( $post_id ) ); return (bool) preg_match( '/(cart|checkout|my account|privacy|terms|refund|shipping|contact)/', $title ); } /** * Supported post types. * * @return array */ private function supported_post_types() { return array( 'page', 'post', 'product' ); } /** * Sanitize allowed post types. * * @param array $types Types. * @return array */ private function sanitize_post_types( array $types ) { return array_values( array_intersect( array_map( 'sanitize_key', $types ), $this->supported_post_types() ) ); } /** * Parse IDs from scalar/list input. * * @param mixed $value Value. * @return array */ private function parse_ids( $value ) { if ( is_array( $value ) ) { return array_values( array_unique( array_filter( array_map( 'absint', $value ) ) ) ); } return array_values( array_unique( array_filter( array_map( 'absint', preg_split( '/[\s,]+/', (string) $value ) ) ) ) ); } /** * Sanitize generation args. * * @param array $input Raw input. * @return array */ private function sanitize_generation_args( array $input ) { return array( 'mode' => isset( $input['mode'] ) && 'ai' === sanitize_key( $input['mode'] ) ? 'ai' : 'manual', 'language_scope' => isset( $input['language_scope'] ) ? sanitize_key( $input['language_scope'] ) : 'default', 'language' => isset( $input['language'] ) ? $this->languages->sanitize_code( $input['language'] ) : '', 'source_mode' => isset( $input['source_mode'] ) ? sanitize_key( $input['source_mode'] ) : 'all_pages', 'target_mode' => isset( $input['target_mode'] ) ? sanitize_key( $input['target_mode'] ) : 'all_products', 'source_ids' => isset( $input['source_ids'] ) ? $this->parse_ids( $input['source_ids'] ) : array(), 'target_ids' => isset( $input['target_ids'] ) ? $this->parse_ids( $input['target_ids'] ) : array(), 'custom_keywords' => isset( $input['custom_keywords'] ) ? $this->parse_keywords( $input['custom_keywords'] ) : array(), ); } /** * Parse custom keywords. * * @param mixed $value Value. * @return array */ private function parse_keywords( $value ) { $parts = is_array( $value ) ? $value : preg_split( '/[\r\n,]+/', (string) $value ); return array_values( array_unique( array_filter( array_map( 'sanitize_text_field', array_map( 'trim', (array) $parts ) ) ) ) ); } /** * Resolve language scope. * * @param string $scope Scope. * @param string $language Selected language. * @return array */ private function resolve_language_scope( $scope, $language ) { $default = $this->languages->get_default_code(); if ( 'all' === $scope ) { return array_keys( $this->language_choices() ); } if ( 'one' === $scope && $language && isset( $this->language_choices()[ $language ] ) ) { return array( $language ); } return array( $default ); } /** * Map mode to post types. * * @param string $mode Mode. * @param array $settings Settings. * @return array */ private function mode_post_types( $mode, array $settings ) { $map = array( 'all_pages' => array( 'page' ), 'all_posts' => array( 'post' ), 'all_products' => array( 'product' ), 'products' => array( 'product' ), 'selected' => $settings['allowed_post_types'], 'all' => $settings['allowed_post_types'], ); $types = isset( $map[ $mode ] ) ? array_values( array_intersect( $map[ $mode ], $settings['allowed_post_types'] ) ) : array_values( $settings['allowed_post_types'] ); return ! empty( $types ) ? $types : array( 'page' ); } /** * Strip content to plain text. * * @param string $html HTML. * @return string */ private function plain_text( $html ) { return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( strip_shortcodes( (string) $html ) ) ) ); } /** * Trim text. * * @param string $text Text. * @param int $limit Limit. * @return string */ private function trim_text( $text, $limit ) { return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, absint( $limit ) ) : substr( $text, 0, absint( $limit ) ); } /** * Decode OpenAI response JSON. * * @param array $data API response data. * @return array */ private function decode_ai_json( array $data ) { $text = ''; if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) { $text = $data['output_text']; } elseif ( ! empty( $data['output'] ) && is_array( $data['output'] ) ) { foreach ( $data['output'] as $output ) { if ( empty( $output['content'] ) || ! is_array( $output['content'] ) ) { continue; } foreach ( $output['content'] as $content ) { if ( isset( $content['text'] ) && is_string( $content['text'] ) ) { $text .= $content['text']; } } } } $json = json_decode( trim( $text ), true ); return is_array( $json ) ? $json : array(); } }