HEX
Server: Apache/2.4.6 () OpenSSL/1.0.2k-fips PHP/8.3.8
System: Linux gateway.rmc-logistics.net 4.1.12-124.48.6.el7uek.x86_64 #2 SMP Tue Mar 16 14:57:50 PDT 2021 x86_64
User: apache (48)
PHP: 8.3.8
Disabled: NONE
Upload Files
File: /var/www/awara-logistics.com/wp-content/plugins/cyr3lat/cyr-to-lat.php
<?php
/**
 * Plugin Name:       Cyr to Lat Enhanced
 * Plugin URI:        https://wordpress.org/plugins/cyr3lat/
 * Description:       Converts Cyrillic, European and Georgian characters in post and term slugs, and media file names to Latin characters. Useful for creating human-readable URLs.
 * Version:           3.7.3
 * Requires at least: 5.0
 * Requires PHP:      7.4
 * Author:            Ivijan Stefan Stipic
 * Author URI:        https://www.linkedin.com/in/ivijanstefanstipic/
 * License:           GPLv2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       cyr3lat
 * Domain Path:       /languages
 *
 * Credits:
 * - Based on cyr3lat / cyr2lat lineage (karevn, Sergey Biryukov, Atrax, webvitaly and others).
 * - Original Rus-To-Lat concept by Anton Skorobogatov.
 */

declare(strict_types=1);

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

final class CTL_Enhanced {

	/**
	 * Plugin version.
	 */
	private const VERSION = '3.7.3';

    /**
     * Cron hook name for background conversion.
     */
    private const CRON_HOOK = 'ctl_enhanced_convert_existing_slugs';

    /**
     * Option name that stores conversion progress.
     */
    private const OPTION_PROGRESS = 'ctl_enhanced_conversion_progress';

    /**
     * Batch size to reduce timeouts.
     */
    private const BATCH_SIZE = 200;
	
	/**
	 * WordPress.org plugin slug.
	 */
	private const WPORG_SLUG = 'cyr3lat';

	/**
	 * Transient key used to cache WordPress.org rating data.
	 */
	private const TRANSIENT_WPORG_RATE = 'ctl_enhanced_wporg_rating_v1';

    /**
     * Cached transliteration table per-locale.
     *
     * @var array<string, array<string, string>>
     */
    private static array $table_cache = [];

    /**
     * Bootstrap plugin.
     */
    public static function init(): void {
		add_filter( 'plugin_row_meta', [ __CLASS__, 'plugin_meta' ], 10, 2 );
        add_filter( 'sanitize_title', [ __CLASS__, 'filter_sanitize_title' ], 9, 3 );
        add_filter( 'sanitize_file_name', [ __CLASS__, 'filter_sanitize_file_name' ], 10, 2 );

        add_action( self::CRON_HOOK, [ __CLASS__, 'convert_existing_slugs_batch' ] );

        register_activation_hook( __FILE__, [ __CLASS__, 'on_activation' ] );
        register_deactivation_hook( __FILE__, [ __CLASS__, 'on_deactivation' ] );
    }
	
	/**
	 * Add extra links to plugin row meta on Plugins page.
	 *
	 * @param array  $links Plugin row meta links.
	 * @param string $file  Plugin base file.
	 * @return array
	 */
	public static function plugin_meta( array $links, string $file ): array {

		if ( $file !== plugin_basename( __FILE__ ) ) {
			return $links;
		}

		$advanced_plugin = 'serbian-transliteration/serbian-transliteration.php';

		// is_plugin_active() is not always loaded on every admin screen.
		if ( is_admin() && ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		// Show link ONLY if the advanced plugin is NOT active.
		if ( function_exists( 'is_plugin_active' ) && ! is_plugin_active( $advanced_plugin ) ) {

			$links[] = sprintf(
				'<a href="%s" class="thickbox" title="%s">%s</a>',
				esc_url(
					admin_url(
						'plugin-install.php?tab=plugin-information&plugin=serbian-transliteration&TB_iframe=true&width=772&height=857'
					)
				),
				esc_attr__( 'Advanced Transliteration', 'cyr3lat' ),
				esc_html__( 'Advanced Transliteration', 'cyr3lat' )
			);
		}

		// Optional: neutral support link (safer than "5 stars" nudges).
		$links[] = sprintf(
			'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
			esc_url( 'https://wordpress.org/support/plugin/cyr3lat/' ),
			esc_html__( 'Support', 'cyr3lat' )
		);
		
		$stars = self::get_wporg_rating_stars();
		$links[] = sprintf(
			'<a href="%s" target="_blank" rel="noopener noreferrer" aria-label="%s" title="%s"><span style="color:#ffa000; font-size: 15px; bottom: -1px; position: relative;">%s</span></a>',
			esc_url( 'https://wordpress.org/support/plugin/cyr3lat/reviews/?filter=5#new-post' ),
			esc_attr__( 'Plugin reviews', 'cyr3lat' ),
			esc_attr__( 'View plugin reviews on WordPress.org', 'cyr3lat' ),
			esc_html( $stars )
		);

		return $links;
	}

    /**
     * Activation: schedule background conversion (single event).
     */
    public static function on_activation(): void {
        if ( ! get_option( self::OPTION_PROGRESS, false ) ) {
            add_option(
                self::OPTION_PROGRESS,
                [
                    'posts_offset' => 0,
                    'terms_offset' => 0,
                    'done_posts'   => 0,
                    'done_terms'   => 0,
                    'finished'     => 0,
                    'started_at'   => time(),
                ],
                '',
                false
            );
        }

        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            // Schedule soon, but not immediately, to avoid activation timeouts.
            wp_schedule_single_event( time() + 15, self::CRON_HOOK );
        }
    }

    /**
     * Deactivation: unschedule cron hook.
     */
    public static function on_deactivation(): void {
        if( function_exists( 'wp_clear_scheduled_hook' ) ) {
			wp_clear_scheduled_hook( self::CRON_HOOK );
			return;
		}
		
		$timestamp = wp_next_scheduled( self::CRON_HOOK );
        if ( $timestamp ) {
            wp_unschedule_event( $timestamp, self::CRON_HOOK );
        }
    }

    /**
     * Filter: sanitize_title.
     *
     * @param string $title     Sanitized title (may be empty depending on call order).
     * @param string $raw_title The title prior to sanitization.
     * @param string $context   The context for which the title is being sanitized.
     * @return string
     */
    public static function filter_sanitize_title( string $title, string $raw_title, string $context ): string {
        // Use raw title as source when available.
        $source = ( '' !== $raw_title ) ? $raw_title : $title;

        if ( '' === $source ) {
            return $title;
        }

        // Transliterate first, then let WP do its normal dash/cleanup later in the chain.
        $transliterated = self::transliterate( $source );

        // If WP calls this filter late (after dashify), keep it safe anyway.
        $transliterated = self::normalize_slug_string( $transliterated );

        /**
		 * Filter the sanitized and transliterated post or term slug.
		 *
		 * Runs after transliteration and normalization, before WordPress
		 * performs final processing.
		 *
		 * @since 3.7.0
		 *
		 * @param string $transliterated Resulting slug after transliteration.
		 * @param string $source         Original, unprocessed string.
		 * @param string $context        Sanitization context provided by WordPress.
		 * @return string Filtered slug value.
		 */
        return (string) apply_filters( 'ctl_enhanced_sanitized_title', $transliterated, $source, $context );
    }

    /**
     * Filter: sanitize_file_name.
     *
     * @param string $filename     Sanitized filename.
     * @param string $filename_raw Filename prior to sanitization.
     * @return string
     */
    public static function filter_sanitize_file_name( string $filename, string $filename_raw ): string {
        $source = ( '' !== $filename_raw ) ? $filename_raw : $filename;

        if ( '' === $source ) {
            return $filename;
        }

        $transliterated = self::transliterate( $source );

        // Keep dots for extensions, replace other invalid chars with hyphen.
        $transliterated = self::normalize_filename_string( $transliterated );

        /**
		 * Filter the sanitized and transliterated file name.
		 *
		 * Runs after transliteration and filename normalization,
		 * before WordPress finalizes the file name.
		 *
		 * @since 3.7.0
		 *
		 * @param string $transliterated Resulting filename after transliteration.
		 * @param string $source         Original, unprocessed filename.
		 * @return string Filtered filename value.
		 */
        return (string) apply_filters( 'ctl_enhanced_sanitized_file_name', $transliterated, $source );
    }

    /**
     * Transliterate using locale-specific table, with safe fallbacks.
     *
     * @param string $value
     * @return string
     */
    private static function transliterate( string $value ): string {
        $locale = (string) get_locale();

        if ( ! isset( self::$table_cache[ $locale ] ) ) {
            self::$table_cache[ $locale ] = self::build_table_for_locale( $locale );
        }

        $table = self::$table_cache[ $locale ];

        /**
		 * Filter the transliteration table.
		 *
		 * Allows themes and plugins to modify or extend the character
		 * mapping used for transliteration before it is applied.
		 *
		 * @since 3.7.0
		 *
		 * @param array<string, string> $table Associative array of character mappings.
		 * @return array<string, string> Modified transliteration table.
		 */
        $table = (array) apply_filters( 'ctl_table', $table );

        $value = strtr( $value, $table );

        // iconv is optional; do not assume it exists.
        if ( function_exists( 'iconv' ) ) {
            $converted = @iconv( 'UTF-8', 'UTF-8//TRANSLIT//IGNORE', $value );
            if ( false !== $converted && '' !== $converted ) {
                $value = $converted;
            }
        }

        return $value;
    }

    /**
     * Build transliteration table (Cyrillic + Georgian) and adjust by locale.
     *
     * @param string $locale
     * @return array<string, string>
     */
    private static function build_table_for_locale( string $locale ): array {
        $iso9_table = [
            'А' => 'A',  'Б' => 'B',  'В' => 'V',  'Г' => 'G',  'Ѓ' => 'G',
            'Ґ' => 'G',  'Д' => 'D',  'Е' => 'E',  'Ё' => 'YO', 'Є' => 'YE',
            'Ж' => 'ZH', 'З' => 'Z',  'Ѕ' => 'Z',  'И' => 'I',  'Й' => 'J',
            'Ј' => 'J',  'І' => 'I',  'Ї' => 'YI', 'К' => 'K',  'Ќ' => 'K',
            'Л' => 'L',  'Љ' => 'L',  'М' => 'M',  'Н' => 'N',  'Њ' => 'N',
            'О' => 'O',  'П' => 'P',  'Р' => 'R',  'С' => 'S',  'Т' => 'T',
            'У' => 'U',  'Ў' => 'U',  'Ф' => 'F',  'Х' => 'H',  'Ц' => 'TS',
            'Ч' => 'CH', 'Џ' => 'DH', 'Ш' => 'SH', 'Щ' => 'SHH','Ъ' => '',
            'Ы' => 'Y',  'Ь' => '',   'Э' => 'E',  'Ю' => 'YU', 'Я' => 'YA',

            'а' => 'a',  'б' => 'b',  'в' => 'v',  'г' => 'g',  'ѓ' => 'g',
            'ґ' => 'g',  'д' => 'd',  'е' => 'e',  'ё' => 'yo', 'є' => 'ye',
            'ж' => 'zh', 'з' => 'z',  'ѕ' => 'z',  'и' => 'i',  'й' => 'j',
            'ј' => 'j',  'і' => 'i',  'ї' => 'yi', 'к' => 'k',  'ќ' => 'k',
            'л' => 'l',  'љ' => 'l',  'м' => 'm',  'н' => 'n',  'њ' => 'n',
            'о' => 'o',  'п' => 'p',  'р' => 'r',  'с' => 's',  'т' => 't',
            'у' => 'u',  'ў' => 'u',  'ф' => 'f',  'х' => 'h',  'ц' => 'ts',
            'ч' => 'ch', 'џ' => 'dh', 'ш' => 'sh', 'щ' => 'shh','ъ' => '',
            'ы' => 'y',  'ь' => '',   'э' => 'e',  'ю' => 'yu', 'я' => 'ya',
        ];

        $geo2lat = [
            'ა' => 'a',  'ბ' => 'b',  'გ' => 'g',  'დ' => 'd',  'ე' => 'e',  'ვ' => 'v',
            'ზ' => 'z',  'თ' => 'th', 'ი' => 'i',  'კ' => 'k',  'ლ' => 'l',  'მ' => 'm',
            'ნ' => 'n',  'ო' => 'o',  'პ' => 'p',  'ჟ' => 'zh', 'რ' => 'r',  'ს' => 's',
            'ტ' => 't',  'უ' => 'u',  'ფ' => 'ph', 'ქ' => 'q',  'ღ' => 'gh', 'ყ' => 'qh',
            'შ' => 'sh', 'ჩ' => 'ch', 'ც' => 'ts', 'ძ' => 'dz', 'წ' => 'ts', 'ჭ' => 'tch',
            'ხ' => 'kh', 'ჯ' => 'j',  'ჰ' => 'h',
        ];

        $table = array_merge( $iso9_table, $geo2lat );

        // Locale adjustments.
        switch ( $locale ) {
            case 'bg_BG':
                $table['Щ'] = 'SHT';
                $table['щ'] = 'sht';
                $table['Ъ'] = 'A';
                $table['ъ'] = 'a';
                break;

            case 'uk':
            case 'uk_ua':
            case 'uk_UA':
                $table['И'] = 'Y';
                $table['и'] = 'y';
                break;
        }

        return $table;
    }

    /**
     * Normalize string for slugs: keep [A-Za-z0-9_-] and convert others to hyphen.
     *
     * @param string $value
     * @return string
     */
    private static function normalize_slug_string( string $value ): string {
        $value = preg_replace( "/[^A-Za-z0-9'_\-\.]+/u", '-', $value );
        $value = preg_replace( '/-+/', '-', (string) $value );
        $value = trim( (string) $value, '-' );

        return (string) $value;
    }

    /**
     * Normalize string for filenames: keep dots, underscores and dashes.
     *
     * @param string $value
     * @return string
     */
    private static function normalize_filename_string( string $value ): string {
        $value = preg_replace( "/[^A-Za-z0-9_\-\.]+/u", '-', $value );
        $value = preg_replace( '/-+/', '-', (string) $value );
        $value = preg_replace( '/\.-+/', '.', (string) $value );
        $value = trim( (string) $value, '-.' );

        return (string) $value;
    }

    /**
     * Convert existing post and term slugs in batches via cron.
     * Uses WP APIs to keep caches and hooks correct.
     */
    public static function convert_existing_slugs_batch(): void {
        $progress = get_option( self::OPTION_PROGRESS, [] );

        if ( empty( $progress ) || ! is_array( $progress ) ) {
            $progress = [
                'posts_offset' => 0,
                'terms_offset' => 0,
                'done_posts'   => 0,
                'done_terms'   => 0,
                'finished'     => 0,
                'started_at'   => time(),
            ];
        }

        if ( ! empty( $progress['finished'] ) ) {
            return;
        }

        $did_work = false;

        // 1) Convert posts first.
        $did_work = self::convert_posts_batch( $progress ) || $did_work;

        // 2) Then convert terms.
        $did_work = self::convert_terms_batch( $progress ) || $did_work;

        // Decide whether to reschedule.
        if ( empty( $progress['finished'] ) ) {
            update_option( self::OPTION_PROGRESS, $progress, false );

            // Reschedule only if we actually worked or there may still be more.
            if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
                wp_schedule_single_event( time() + 30, self::CRON_HOOK );
            }
        } else {
            update_option( self::OPTION_PROGRESS, $progress, false );
        }
    }

    /**
     * Convert a batch of posts.
     *
     * @param array $progress
     * @return bool True if any work was done.
     */
    private static function convert_posts_batch( array &$progress ): bool {
	
		$batch_size = self::get_batch_size( 'convert_posts_batch' );
        $offset = isset( $progress['posts_offset'] ) ? (int) $progress['posts_offset'] : 0;

        $args = [
            'post_type'      => 'any',
            'post_status'    => [ 'publish', 'future', 'private' ],
            'fields'         => 'ids',
            'posts_per_page' => $batch_size,
            'offset'         => $offset,
            'orderby'        => 'ID',
            'order'          => 'ASC',
            'no_found_rows'  => true,
        ];
		
        $query = new WP_Query( $args );

        if ( empty( $query->posts ) ) {
            return false;
        }

        $did_work = false;

        foreach ( $query->posts as $post_id ) {
            $post = get_post( (int) $post_id );
            if ( ! $post ) {
                continue;
            }

            $old_slug = (string) $post->post_name;
            $new_slug = self::normalize_slug_string( self::transliterate( urldecode( $old_slug ) ) );

            if ( '' === $new_slug || $new_slug === $old_slug ) {
                continue;
            }

            // Store old slug for redirects, WP uses this meta key as well.
            add_post_meta( (int) $post_id, '_wp_old_slug', $old_slug );

            // Update via API to ensure caches and hooks are correct.
            wp_update_post(
                [
                    'ID'        => (int) $post_id,
                    'post_name' => $new_slug,
                ]
            );

            $progress['done_posts'] = isset( $progress['done_posts'] ) ? (int) $progress['done_posts'] + 1 : 1;
            $did_work               = true;
        }

        $progress['posts_offset'] = $offset + $batch_size;

        return $did_work;
    }

    /**
     * Convert a batch of terms.
     *
     * @param array $progress
     * @return bool True if any work was done.
     */
    private static function convert_terms_batch( array &$progress ): bool {
	
		$batch_size = self::get_batch_size( 'convert_terms_batch' );
        $offset = isset( $progress['terms_offset'] ) ? (int) $progress['terms_offset'] : 0;

        $terms = get_terms(
            [
                'hide_empty' => false,
                'fields'     => 'all',
                'number'     => $batch_size,
                'offset'     => $offset,
                'orderby'    => 'term_id',
                'order'      => 'ASC',
            ]
        );
		
		// Temporary failure: do not mark as finished. Next cron run will retry.
		if ( is_wp_error( $terms ) ) {
			return false;
		}

		// If terms return empty, we are finished.
        if ( empty( $terms ) ) {
            $progress['finished'] = 1;
            return false;
        }

        $did_work = false;

        foreach ( $terms as $term ) {
            if ( empty( $term->term_id ) ) {
                continue;
            }

            $old_slug = (string) $term->slug;
            $new_slug = self::normalize_slug_string( self::transliterate( urldecode( $old_slug ) ) );

            if ( '' === $new_slug || $new_slug === $old_slug ) {
                continue;
            }

            // Update via API to ensure taxonomy caches and hooks are correct.
            $updated = wp_update_term(
                (int) $term->term_id,
                (string) $term->taxonomy,
                [
                    'slug' => $new_slug,
                ]
            );

            if ( ! is_wp_error( $updated ) ) {
                $progress['done_terms'] = isset( $progress['done_terms'] ) ? (int) $progress['done_terms'] + 1 : 1;
                $did_work               = true;
            }
        }

        $progress['terms_offset'] = $offset + $batch_size;

        return $did_work;
    }
	
	/**
	 * Get batch size for background processing.
	 *
	 * @param string $context
	 * @return int
	 */
	private static function get_batch_size( string $context = '' ): int {
		/**
		 * Filter the batch size used for background slug conversion.
		 *
		 * Allows developers to adjust how many posts or terms are processed
		 * per cron execution, which can be useful for low-memory or
		 * shared hosting environments.
		 *
		 * @since 3.7.2
		 *
		 * @param int    $size    Default batch size.
		 * @param string $context Execution context (e.g. 'convert_posts_batch', 'convert_terms_batch').
		 * @return int Adjusted batch size.
		 */
		$size = (int) apply_filters( 'ctl_enhanced_batch_size', self::BATCH_SIZE, $context );

		if ( $size < 1 ) {
			$size = self::BATCH_SIZE;
		} elseif ( $size > 2000 ) {
			$size = 2000;
		}

		return $size;
	}
	
	/**
	 * Get WordPress.org rating stars string, cached.
	 *
	 * @return string
	 */
	private static function get_wporg_rating_stars(): string {
		$cached = get_transient( self::TRANSIENT_WPORG_RATE );

		if ( is_array( $cached ) && isset( $cached['stars'] ) && is_string( $cached['stars'] ) ) {
			return $cached['stars'];
		}

		$percent = self::fetch_wporg_rating_percent();

		// If API fails, avoid saving a bad value.
		if ( $percent <= 0 ) {
			return '★★★★★';
		}

		$stars = self::format_stars_from_percent( $percent );

		set_transient(
			self::TRANSIENT_WPORG_RATE,
			[
				'percent' => $percent,
				'stars'   => $stars,
				'ts'      => time(),
			],
			12 * HOUR_IN_SECONDS
		);

		return $stars;
	}

	/**
	 * Fetch rating percent (0-100) from WordPress.org Plugin API.
	 *
	 * @return int
	 */
	private static function fetch_wporg_rating_percent(): int {
		$url = add_query_arg(
			[
				'action' => 'plugin_information',
				'slug'   => self::WPORG_SLUG,
			],
			'https://api.wordpress.org/plugins/info/1.2/'
		);

		$response = wp_remote_get(
			$url,
			[
				'timeout'     => 3,
				'redirection' => 2,
				'user-agent' => 'CTL-Enhanced/' . self::VERSION . '; ' . home_url(),
			]
		);

		if ( is_wp_error( $response ) ) {
			return 0;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( $code < 200 || $code >= 300 ) {
			return 0;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			return 0;
		}

		$data = json_decode( $body, true );
		if ( ! is_array( $data ) || ! isset( $data['rating'] ) ) {
			return 0;
		}

		$percent = (int) $data['rating'];
		if ( $percent < 0 ) {
			$percent = 0;
		} elseif ( $percent > 100 ) {
			$percent = 100;
		}

		return $percent;
	}

	/**
	 * Convert rating percent (0-100) to star string using ★⯪☆ symbols.
	 * Example: 90% -> 4.5 -> "★★★★⯪"
	 *
	 * @param int $percent
	 * @return string
	 */
	private static function format_stars_from_percent( int $percent ): string {
		$score = $percent / 20; // 0..5
		$score = round( $score * 2 ) / 2; // round to 0.5 steps

		$full = (int) floor( $score );
		$half = ( ( $score - $full ) >= 0.5 ) ? 1 : 0;

		$full  = max( 0, min( 5, $full ) );
		$half  = max( 0, min( 1, $half ) );
		$empty = 5 - $full - $half;

		return str_repeat( '★', $full ) . ( $half ? '⯪' : '' ) . str_repeat( '☆', $empty );
	}
}

CTL_Enhanced::init();