=')) {
update_option('aitmt_scanner_maintenance_notice', '1', false);
}
update_option('aitmt_runtime_release_version', $current, false);
}
/**
* Deactivation entry point.
*/
public static function deactivate()
{
wp_clear_scheduled_hook(self::CRON_HOOK);
}
/**
* Add settings without deleting or rewriting existing translations.
*/
private static function migrate_safe_settings()
{
$options = get_option('aitmt_options', array());
$options = is_array($options) ? $options : array();
$backup_option = 'aitmt_options_backup_pre_0_4_0';
if (false === get_option($backup_option, false)) {
add_option(
$backup_option,
array(
'plugin_version' => defined('AITMT_VERSION') ? AITMT_VERSION : '',
'created_at' => current_time('mysql'),
'options' => $options,
),
'',
false
);
}
$defaults = array(
'runtime_page_translation' => '0',
'runtime_db_storage' => '0',
'runtime_auto_cleanup' => '0',
'runtime_cache_ttl_days' => 30,
'runtime_max_rows_per_language' => 5000,
'runtime_min_text_length' => 3,
'runtime_max_text_length' => 800,
'runtime_excluded_paths' => "/wp-json/\n/feed/",
'runtime_excluded_selectors' => '.notranslate, [translate="no"], [data-aitmt-no-translate], [data-no-translate], [data-ai-translate-ignore]',
'runtime_excluded_source_types' => '',
'runtime_translate_woocommerce_private' => '0',
'default_frontend_language' => '',
'claude_api_key' => '',
'claude_model' => 'claude-haiku-4-5-20251001',
'yandex_translate_api_key' => '',
'yandex_translate_folder_id' => '',
'yandexgpt_api_key' => '',
'yandexgpt_folder_id' => '',
'yandexgpt_model' => 'yandexgpt-lite/latest',
'mistral_api_key' => '',
'mistral_model' => 'mistral-small-latest',
'deepseek_api_key' => '',
'deepseek_model' => 'deepseek-v4-flash',
'qwen_api_key' => '',
'qwen_model' => 'qwen3.7-plus',
'qwen_region' => 'us',
'baidu_api_key' => '',
'baidu_model' => 'ernie-5.0',
);
$changed = false;
foreach ($defaults as $key => $value) {
if (!array_key_exists($key, $options)) {
$options[$key] = $value;
$changed = true;
}
}
$legacy_excluded_paths = "/cart/\n/checkout/\n/my-account/\n/wp-json/\n/feed/";
if ($legacy_excluded_paths === ($options['runtime_excluded_paths'] ?? '')) {
$options['runtime_excluded_paths'] = $defaults['runtime_excluded_paths'];
$changed = true;
}
if ($changed && !update_option('aitmt_options', $options, false)) {
update_option('aitmt_migration_error', __('The settings migration could not be saved. Existing settings were left available in the migration backup.', 'ait-multilingual-translate'), false);
return;
}
$saved = get_option('aitmt_options', array());
if (!is_array($saved) || array_diff_key($defaults, $saved)) {
update_option('aitmt_migration_error', __('The settings migration verification failed. Review the migration backup before changing provider settings.', 'ait-multilingual-translate'), false);
return;
}
delete_option('aitmt_migration_error');
update_option('aitmt_runtime_safety_migration_version', self::SETTINGS_MIGRATION_VERSION, false);
}
/**
* Show a migration failure without exposing stored settings or API keys.
*/
public static function render_migration_notice()
{
if (!current_user_can('manage_options')) {
return;
}
$message = get_option('aitmt_migration_error', '');
if ('' === (string) $message) {
if ('1' !== (string) get_option('aitmt_scanner_maintenance_notice', '0')) {
return;
}
?>
get_charset_collate();
$sql = "CREATE TABLE {$table} (
translation_id bigint(20) unsigned NOT NULL,
source_hash char(64) NOT NULL,
target_language varchar(16) NOT NULL,
context varchar(80) NOT NULL DEFAULT 'html',
use_count bigint(20) unsigned NOT NULL DEFAULT 0,
first_used_at datetime NOT NULL,
last_used_at datetime NOT NULL,
PRIMARY KEY (translation_id),
KEY source_target (source_hash, target_language),
KEY last_used_at (last_used_at)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
update_option('aitmt_runtime_usage_schema_version', self::USAGE_SCHEMA_VERSION, false);
}
/**
* Return the validated translation table name.
*/
public static function translation_table_sql()
{
global $wpdb;
$table = $wpdb->prefix . 'aitmt_translations';
return preg_match('/^[A-Za-z0-9_]+$/', $table) ? esc_sql($table) : '';
}
/**
* Return the validated runtime usage table name.
*/
private static function usage_table_sql()
{
global $wpdb;
$table = $wpdb->prefix . 'aitmt_runtime_usage';
return preg_match('/^[A-Za-z0-9_]+$/', $table) ? esc_sql($table) : '';
}
/**
* Record a local runtime cache hit without storing visitor data.
*
* @param array $row Translation row.
*/
public static function record_usage($row)
{
global $wpdb;
if (!is_array($row) || empty($row['id']) || empty($row['source_hash'])) {
return;
}
if ((int) get_option('aitmt_runtime_usage_schema_version', 0) < self::USAGE_SCHEMA_VERSION) {
self::create_usage_table();
}
$table = self::usage_table_sql();
$now = current_time('mysql');
if ('' === $table) {
return;
}
$sql = $wpdb->prepare(
"INSERT INTO {$table} (translation_id, source_hash, target_language, context, use_count, first_used_at, last_used_at)
VALUES (%d, %s, %s, %s, 1, %s, %s)
ON DUPLICATE KEY UPDATE use_count = use_count + 1, last_used_at = VALUES(last_used_at)",
absint($row['id']),
sanitize_text_field((string) $row['source_hash']),
sanitize_key((string) ($row['target_language'] ?? '')),
sanitize_key((string) ($row['context'] ?? 'html')),
$now,
$now
);
$wpdb->query($sql); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is prepared above and table is validated.
}
/**
* Check the configured runtime cache ceiling.
*/
public static function runtime_capacity_available($target_language)
{
global $wpdb;
$options = function_exists('aitmt_fallback_options') ? aitmt_fallback_options() : array();
$limit = max(100, absint($options['runtime_max_rows_per_language'] ?? 5000));
$language = sanitize_key((string) $target_language);
$cache_key = 'aitmt_runtime_count_' . $language;
$count = get_transient($cache_key);
if (false === $count) {
$table = self::translation_table_sql();
if ('' === $table) {
return false;
}
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE target_language = %s AND source_type IN (%s, %s)",
$language,
'page_load',
'legacy_runtime'
)
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table is validated.
set_transient($cache_key, $count, 5 * MINUTE_IN_SECONDS);
}
return (int) $count < $limit;
}
/**
* Increment cached runtime row count after a successful insert.
*/
public static function increment_runtime_count($target_language)
{
$key = 'aitmt_runtime_count_' . sanitize_key((string) $target_language);
$count = get_transient($key);
if (false !== $count) {
set_transient($key, ((int) $count) + 1, 5 * MINUTE_IN_SECONDS);
}
}
/**
* Keep a small set of aggregate, non-sensitive runtime counters.
*/
public static function increment_counter($name, $amount = 1)
{
$name = sanitize_key((string) $name);
if ('' === $name) {
return;
}
$day = gmdate('Y-m-d');
$counters = get_option('aitmt_runtime_diagnostics', array());
$counters = is_array($counters) ? $counters : array();
if (!isset($counters[$day]) || !is_array($counters[$day])) {
$counters[$day] = array();
}
$counters[$day][$name] = absint($counters[$day][$name] ?? 0) + max(1, absint($amount));
if (count($counters) > 14) {
ksort($counters);
$counters = array_slice($counters, -14, null, true);
}
update_option('aitmt_runtime_diagnostics', $counters, false);
}
/**
* Return today's aggregate diagnostics without exposing translated content.
*
* @return array
*/
public static function diagnostics_today()
{
$counters = get_option('aitmt_runtime_diagnostics', array());
$counters = is_array($counters) ? $counters : array();
$day = gmdate('Y-m-d');
$today = isset($counters[$day]) && is_array($counters[$day]) ? $counters[$day] : array();
return array_map('absint', $today);
}
/**
* Register the maintenance page.
*/
public static function register_admin_page()
{
add_submenu_page(
AITMT_PUBLIC_SLUG,
__('Database Maintenance', 'ait-multilingual-translate'),
__('Database Maintenance', 'ait-multilingual-translate'),
'manage_options',
'ait-multilingual-translate-database',
array(__CLASS__, 'render_admin_page')
);
}
/**
* Render diagnostics and explicit maintenance controls.
*/
public static function render_admin_page()
{
if (!current_user_can('manage_options')) {
return;
}
delete_option('aitmt_scanner_maintenance_notice');
$result = get_transient('aitmt_maintenance_result_' . get_current_user_id());
$summary = self::database_summary();
$operations = self::operations();
$obsolete_preview = self::obsolete_scanner_candidates(20);
?>
wp aitmt maintenance stats
wp aitmt maintenance dry-run --operation=stale-page-load --age-days=30
wp aitmt maintenance cleanup --operation=stale-page-load --age-days=30 --batch-size=1000 --yes
wp aitmt maintenance classify --batch-size=1000 --yes
wp aitmt maintenance index --dry-run
wp aitmt maintenance analyze --yes / wp aitmt maintenance optimize --yes
__('Clear all queued jobs (translations are preserved)', 'ait-multilingual-translate'),
'failed' => __('Clear all failed jobs (published/manual/import translations are preserved)', 'ait-multilingual-translate'),
'stale-page-load' => __('Delete stale page-load cache rows', 'ait-multilingual-translate'),
'all-runtime' => __('Clear generated runtime translation cache (page-load and legacy rows)', 'ait-multilingual-translate'),
'legacy-runtime' => __('Delete confirmed legacy runtime rows', 'ait-multilingual-translate'),
'old-failed' => __('Delete old failed rows', 'ait-multilingual-translate'),
'empty-source-type' => __('Delete old unclassified runtime candidates', 'ait-multilingual-translate'),
'orphaned' => __('Delete rows linked to missing posts', 'ait-multilingual-translate'),
'obsolete-scanner' => __('Delete obsolete scanner-derived translations', 'ait-multilingual-translate'),
'classify' => __('Classify high-confidence legacy rows', 'ait-multilingual-translate'),
'analyze' => __('Analyze translation table', 'ait-multilingual-translate'),
'optimize' => __('Optimize translation table (manual only)', 'ait-multilingual-translate'),
);
foreach (self::scanner_source_types() as $source_type) {
$operations['queued-source-' . $source_type] = sprintf(
/* translators: %s: scanner source type. */
__('Clear queued jobs from source: %s', 'ait-multilingual-translate'),
$source_type
);
}
return $operations;
}
/**
* Render one compact grouped statistics table.
*
* @param string $heading Table heading.
* @param array $rows Grouped rows.
*/
private static function render_group_table($heading, $rows)
{
?>
false, 'message' => __('The scanner is unavailable.', 'ait-multilingual-translate'));
} else {
$scan = aitmt_fallback_scan_site();
$result = array(
'ok' => !empty($scan['ok']),
'message' => (string) ($scan['message'] ?? __('Scanner rebuild finished.', 'ait-multilingual-translate')),
);
}
set_transient('aitmt_maintenance_result_' . get_current_user_id(), $result, 5 * MINUTE_IN_SECONDS);
wp_safe_redirect(admin_url('admin.php?page=ait-multilingual-translate-database'));
exit;
}
/**
* Execute a controlled maintenance operation.
*/
private static function run_operation($operation, $mode, $age_days, $batch_size, $confirmed)
{
$operations = self::operations();
if (!isset($operations[$operation])) {
return array('ok' => false, 'message' => __('Unknown maintenance operation.', 'ait-multilingual-translate'));
}
if (in_array($operation, array('analyze', 'optimize'), true)) {
if ('apply' !== $mode || !$confirmed) {
return array('ok' => true, 'message' => sprintf('%s is manual and will run only after Apply confirmation.', $operations[$operation]));
}
return self::run_table_command($operation);
}
if ('classify' === $operation) {
$count = self::count_classifiable_rows($batch_size);
if ('dry-run' === $mode) {
self::store_dry_run_token($operation, $age_days, $count);
return array('ok' => true, 'message' => sprintf('Dry run: up to %d high-confidence row(s) can be classified in the next batch.', $count));
}
if (!$confirmed || !self::consume_dry_run_token($operation, $age_days)) {
return array('ok' => false, 'message' => __('Run Dry run first, then confirm Apply.', 'ait-multilingual-translate'));
}
$changed = self::classify_batch($batch_size);
return array('ok' => true, 'message' => sprintf('Classified %d row(s). Repeat dry run to continue.', $changed));
}
$stats = self::operation_stats($operation, $age_days);
if ('dry-run' === $mode) {
self::store_dry_run_token($operation, $age_days, $stats['rows']);
return array(
'ok' => true,
'message' => sprintf(
'Dry run: %1$d row(s), approximately %2$s. No data changed.',
$stats['rows'],
size_format($stats['characters'])
),
);
}
if (!$confirmed || !self::consume_dry_run_token($operation, $age_days)) {
return array('ok' => false, 'message' => __('Run Dry run first, then confirm Apply.', 'ait-multilingual-translate'));
}
$deleted = self::delete_batch($operation, $age_days, $batch_size);
return array('ok' => true, 'message' => sprintf('Deleted %d row(s) in this batch. Repeat dry run to review what remains.', $deleted));
}
/**
* Store short-lived proof that the administrator reviewed a dry run.
*/
private static function store_dry_run_token($operation, $age_days, $count)
{
update_user_meta(
get_current_user_id(),
'aitmt_maintenance_dry_run',
array(
'operation' => sanitize_key($operation),
'age_days' => absint($age_days),
'count' => absint($count),
'expires' => time() + 15 * MINUTE_IN_SECONDS,
)
);
}
/**
* Validate and consume dry-run proof.
*/
private static function consume_dry_run_token($operation, $age_days)
{
$token = get_user_meta(get_current_user_id(), 'aitmt_maintenance_dry_run', true);
delete_user_meta(get_current_user_id(), 'aitmt_maintenance_dry_run');
return is_array($token)
&& hash_equals((string) ($token['operation'] ?? ''), (string) $operation)
&& absint($token['age_days'] ?? 0) === absint($age_days)
&& absint($token['expires'] ?? 0) >= time();
}
/**
* Build a safe SQL condition for maintenance candidates.
*/
private static function operation_condition($operation, $age_days)
{
$cutoff = gmdate('Y-m-d H:i:s', time() - (max(1, absint($age_days)) * DAY_IN_SECONDS));
if (0 === strpos($operation, 'queued-source-')) {
$source_type = sanitize_key(substr($operation, strlen('queued-source-')));
if (in_array($source_type, self::scanner_source_types(), true)) {
return array("status = %s AND source_type = %s", array('queued', $source_type));
}
return array('1 = 0', array());
}
switch ($operation) {
case 'queued':
return array("status = %s", array('queued'));
case 'failed':
return array("status = %s", array('failed'));
case 'stale-page-load':
return array("source_type = %s AND updated_at < %s", array('page_load', $cutoff));
case 'all-runtime':
return array("source_type IN (%s, %s)", array('page_load', 'legacy_runtime'));
case 'legacy-runtime':
return array("source_type = %s AND updated_at < %s", array('legacy_runtime', $cutoff));
case 'old-failed':
return array("status = %s AND updated_at < %s", array('failed', $cutoff));
case 'empty-source-type':
return array(
"source_type = %s AND object_id = 0 AND field_key = %s AND status IN (%s, %s) AND updated_at < %s",
array('', '', 'queued', 'failed', $cutoff),
);
case 'orphaned':
return array("object_id > 0 AND updated_at < %s", array($cutoff));
default:
return array('1 = 0', array());
}
}
/**
* Count rows and characters without modifying data.
*/
private static function operation_stats($operation, $age_days)
{
global $wpdb;
$table = self::translation_table_sql();
if ('' === $table) {
return array('rows' => 0, 'characters' => 0);
}
if ('stale-page-load' === $operation) {
return self::stale_runtime_stats($age_days);
}
if ('orphaned' === $operation) {
return self::orphaned_stats($age_days);
}
if ('obsolete-scanner' === $operation) {
return self::obsolete_scanner_stats();
}
list($where, $args) = self::operation_condition($operation, $age_days);
$sql = "SELECT COUNT(*) AS rows_count, COALESCE(SUM(CHAR_LENGTH(source_text)), 0) AS characters FROM {$table} WHERE {$where}";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The SQL template and condition are selected from closed internal lists; only values are variable and prepared.
$prepared = empty($args) ? $sql : $wpdb->prepare($sql, ...$args);
$row = $wpdb->get_row($prepared, ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic condition is selected from a closed list and values are prepared.
return array(
'rows' => absint($row['rows_count'] ?? 0),
'characters' => absint($row['characters'] ?? 0),
);
}
/**
* Delete one bounded batch. Orphan checks are performed through WordPress.
*/
private static function delete_batch($operation, $age_days, $batch_size)
{
global $wpdb;
$table = self::translation_table_sql();
if ('' === $table) {
return 0;
}
if ('stale-page-load' === $operation) {
$rows = self::stale_runtime_rows($age_days, $batch_size);
} elseif ('orphaned' === $operation) {
$rows = self::orphaned_rows($age_days, $batch_size);
} elseif ('obsolete-scanner' === $operation) {
$rows = self::obsolete_scanner_rows($batch_size);
} else {
list($where, $args) = self::operation_condition($operation, $age_days);
$sql = "SELECT id, object_id FROM {$table} WHERE {$where} ORDER BY id ASC LIMIT %d";
$args[] = max(1, absint($batch_size));
$rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic condition is selected from a closed list and values are prepared.
}
$ids = array();
foreach ((array) $rows as $row) {
$ids[] = absint($row['id']);
}
if (empty($ids)) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$usage_table = self::usage_table_sql();
if ('' !== $usage_table) {
$usage_query = $wpdb->prepare("DELETE FROM {$usage_table} WHERE translation_id IN ({$placeholders})", ...$ids);
$wpdb->query($usage_query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- IDs are prepared and table is validated.
}
$query = $wpdb->prepare("DELETE FROM {$table} WHERE id IN ({$placeholders})", ...$ids);
return (int) $wpdb->query($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- IDs are prepared and table is validated.
}
/**
* Return scanner source types, including auto-detected public post types.
*
* @return array
*/
private static function scanner_source_types()
{
$types = array('site', 'post', 'page', 'product', 'elementor', 'elementor_library', 'sectionbuilder', 'litho-mega-menu', 'taxonomy', 'menu', 'widget', 'page_meta');
foreach ((array) get_post_types(array('public' => true), 'names') as $post_type) {
$post_type = sanitize_key((string) $post_type);
if ('' === $post_type || 'attachment' === $post_type) {
continue;
}
$types[] = $post_type;
$types[] = $post_type . '_meta';
}
if (function_exists('aitmt_fallback_current_scanner_manifest')) {
$manifest = aitmt_fallback_current_scanner_manifest();
$types = array_merge($types, (array) ($manifest['source_types'] ?? array()));
}
return array_values(array_unique(array_filter(array_map('sanitize_key', $types))));
}
/**
* Build the current source hash manifest without writing queue rows.
*
* @return array
*/
private static function current_scanner_hashes()
{
if (!function_exists('aitmt_fallback_current_scanner_manifest')) {
return array();
}
$manifest = aitmt_fallback_current_scanner_manifest();
return is_array($manifest['hashes'] ?? null) ? $manifest['hashes'] : array();
}
/**
* Iterate scanner-derived rows that are no longer present in the manifest.
*
* Manual/imported rows are excluded in SQL and still-valid hashes are
* excluded in PHP. This method never modifies data.
*
* @param int $result_limit Maximum obsolete rows to return; zero counts all.
* @return array{rows:array>,count:int,characters:int}
*/
private static function obsolete_scanner_candidates($result_limit = 0)
{
global $wpdb;
$table = self::translation_table_sql();
$types = self::scanner_source_types();
$current_hashes = self::current_scanner_hashes();
$result = array('rows' => array(), 'count' => 0, 'characters' => 0);
if ('' === $table || empty($types)) {
return $result;
}
$type_placeholders = implode(',', array_fill(0, count($types), '%s'));
$last_id = 0;
$page_size = 1000;
do {
$args = array_merge($types, array('manual', 'import', 'manual', 'import', $last_id, $page_size));
$sql = "SELECT id, source_hash, source_text, source_type, object_id, field_key, status, provider FROM {$table}
WHERE source_type IN ({$type_placeholders})
AND status NOT IN (%s, %s)
AND provider NOT IN (%s, %s)
AND id > %d
ORDER BY id ASC LIMIT %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholders and table are controlled; all values are prepared.
foreach ((array) $rows as $row) {
$last_id = max($last_id, absint($row['id'] ?? 0));
if (!self::is_obsolete_scanner_row($row, $current_hashes, $types)) {
continue;
}
$result['count']++;
$result['characters'] += function_exists('mb_strlen')
? mb_strlen((string) ($row['source_text'] ?? ''), 'UTF-8')
: strlen((string) ($row['source_text'] ?? ''));
if (0 === $result_limit || count($result['rows']) < $result_limit) {
$result['rows'][] = $row;
}
if ($result_limit > 0 && count($result['rows']) >= $result_limit) {
break 2;
}
}
} while (count($rows) === $page_size);
return $result;
}
/**
* Decide whether a scanner row is safe to offer for explicit cleanup.
*
* This guard is intentionally stricter than the SQL prefilter so the
* preservation rules remain testable and cannot be bypassed by a future
* query change.
*
* @param array $row Translation row.
* @param array $current_hashes Hashes in the current scan.
* @param array $scanner_types Scanner-owned source types.
* @return bool
*/
public static function is_obsolete_scanner_row($row, $current_hashes, $scanner_types)
{
$row = is_array($row) ? $row : array();
$current_hashes = is_array($current_hashes) ? $current_hashes : array();
$scanner_types = is_array($scanner_types) ? $scanner_types : array();
$source_hash = (string) ($row['source_hash'] ?? '');
$source_type = sanitize_key((string) ($row['source_type'] ?? ''));
$status = sanitize_key((string) ($row['status'] ?? ''));
$provider = sanitize_key((string) ($row['provider'] ?? ''));
if ('' === $source_hash || !in_array($source_type, $scanner_types, true)) {
return false;
}
if (in_array($status, array('manual', 'import'), true) || in_array($provider, array('manual', 'import'), true)) {
return false;
}
return !isset($current_hashes[$source_hash]);
}
/**
* Preview obsolete scanner-derived rows.
*
* @return array{rows:int,characters:int}
*/
private static function obsolete_scanner_stats()
{
$candidates = self::obsolete_scanner_candidates(0);
return array(
'rows' => absint($candidates['count'] ?? 0),
'characters' => absint($candidates['characters'] ?? 0),
);
}
/**
* Select one bounded batch of obsolete scanner-derived rows.
*
* @param int $batch_size Batch size.
* @return array>
*/
private static function obsolete_scanner_rows($batch_size)
{
$candidates = self::obsolete_scanner_candidates(max(1, absint($batch_size)));
return (array) ($candidates['rows'] ?? array());
}
/**
* Count page-load rows using last use time when metadata exists.
*/
private static function stale_runtime_stats($age_days)
{
global $wpdb;
$table = self::translation_table_sql();
$usage_table = self::usage_table_sql();
if ('' === $table || '' === $usage_table) {
return array('rows' => 0, 'characters' => 0);
}
$cutoff = gmdate('Y-m-d H:i:s', time() - (max(1, absint($age_days)) * DAY_IN_SECONDS));
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT COUNT(*) AS rows_count, COALESCE(SUM(CHAR_LENGTH(t.source_text)), 0) AS characters
FROM {$table} t
LEFT JOIN {$usage_table} u ON u.translation_id = t.id
WHERE t.source_type = %s AND COALESCE(u.last_used_at, t.updated_at) < %s",
'page_load',
$cutoff
),
ARRAY_A
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table names are validated.
return array(
'rows' => absint($row['rows_count'] ?? 0),
'characters' => absint($row['characters'] ?? 0),
);
}
/**
* Select one resumable page-load cleanup batch.
*/
private static function stale_runtime_rows($age_days, $batch_size)
{
global $wpdb;
$table = self::translation_table_sql();
$usage_table = self::usage_table_sql();
if ('' === $table || '' === $usage_table) {
return array();
}
$cutoff = gmdate('Y-m-d H:i:s', time() - (max(1, absint($age_days)) * DAY_IN_SECONDS));
return $wpdb->get_results(
$wpdb->prepare(
"SELECT t.id, t.object_id
FROM {$table} t
LEFT JOIN {$usage_table} u ON u.translation_id = t.id
WHERE t.source_type = %s AND COALESCE(u.last_used_at, t.updated_at) < %s
ORDER BY t.id ASC LIMIT %d",
'page_load',
$cutoff,
max(1, absint($batch_size))
),
ARRAY_A
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table names are validated.
}
/**
* Count only rows whose referenced post no longer exists.
*/
private static function orphaned_stats($age_days)
{
global $wpdb;
$table = self::translation_table_sql();
$posts = preg_match('/^[A-Za-z0-9_]+$/', $wpdb->posts) ? esc_sql($wpdb->posts) : '';
if ('' === $table || '' === $posts) {
return array('rows' => 0, 'characters' => 0);
}
$cutoff = gmdate('Y-m-d H:i:s', time() - (max(1, absint($age_days)) * DAY_IN_SECONDS));
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT COUNT(*) AS rows_count, COALESCE(SUM(CHAR_LENGTH(t.source_text)), 0) AS characters
FROM {$table} t
LEFT JOIN {$posts} p ON p.ID = t.object_id
WHERE t.object_id > 0 AND p.ID IS NULL AND t.updated_at < %s",
$cutoff
),
ARRAY_A
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Value is prepared and table names are validated.
return array(
'rows' => absint($row['rows_count'] ?? 0),
'characters' => absint($row['characters'] ?? 0),
);
}
/**
* Select one resumable orphan cleanup batch.
*/
private static function orphaned_rows($age_days, $batch_size)
{
global $wpdb;
$table = self::translation_table_sql();
$posts = preg_match('/^[A-Za-z0-9_]+$/', $wpdb->posts) ? esc_sql($wpdb->posts) : '';
if ('' === $table || '' === $posts) {
return array();
}
$cutoff = gmdate('Y-m-d H:i:s', time() - (max(1, absint($age_days)) * DAY_IN_SECONDS));
return $wpdb->get_results(
$wpdb->prepare(
"SELECT t.id, t.object_id
FROM {$table} t
LEFT JOIN {$posts} p ON p.ID = t.object_id
WHERE t.object_id > 0 AND p.ID IS NULL AND t.updated_at < %s
ORDER BY t.id ASC LIMIT %d",
$cutoff,
max(1, absint($batch_size))
),
ARRAY_A
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table names are validated.
}
/**
* Count high-confidence rows in the next classification batch.
*/
private static function count_classifiable_rows($batch_size)
{
global $wpdb;
$table = self::translation_table_sql();
if ('' === $table) {
return 0;
}
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM (
SELECT id FROM {$table}
WHERE source_type = %s
AND (
object_id > 0
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
)
ORDER BY id ASC LIMIT %d
) candidates",
'',
'dom\_%',
'elementor\_%',
'wpfd\_%',
'menu\_%',
'widget\_%',
'taxonomy\_%',
'seo\_%',
max(1, absint($batch_size))
)
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table is validated.
}
/**
* Classify only rows with reliable metadata.
*/
private static function classify_batch($batch_size)
{
global $wpdb;
$table = self::translation_table_sql();
if ('' === $table) {
return 0;
}
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, object_id, field_key, status FROM {$table}
WHERE source_type = %s
AND (
object_id > 0
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
OR field_key LIKE %s
)
ORDER BY id ASC LIMIT %d",
'',
'dom\_%',
'elementor\_%',
'wpfd\_%',
'menu\_%',
'widget\_%',
'taxonomy\_%',
'seo\_%',
max(1, absint($batch_size))
),
ARRAY_A
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values are prepared and table is validated.
$changed = 0;
foreach ((array) $rows as $row) {
$source_type = '';
if (!empty($row['object_id'])) {
$post_type = get_post_type(absint($row['object_id']));
if ($post_type) {
$source_type = sanitize_key($post_type);
}
} else {
$field_key = (string) $row['field_key'];
$field_types = array(
'dom_' => 'legacy_runtime',
'elementor_' => 'elementor',
'wpfd_' => 'wpfd_file',
'menu_' => 'menu',
'widget_' => 'widget',
'taxonomy_' => 'taxonomy',
'seo_' => 'seo',
);
foreach ($field_types as $prefix => $type) {
if (0 === strpos($field_key, $prefix)) {
$source_type = $type;
break;
}
}
}
if ('' === $source_type) {
continue;
}
$updated = $wpdb->update(
$table,
array('source_type' => $source_type),
array('id' => absint($row['id'])),
array('%s'),
array('%d')
);
if (false !== $updated) {
$changed += (int) $updated;
}
}
return $changed;
}
/**
* Basic table size and runtime summary.
*/
public static function database_summary()
{
global $wpdb;
$summary = array(
'rows' => 0,
'data_bytes' => 0,
'index_bytes' => 0,
'empty_source_type' => 0,
'page_load' => 0,
'legacy_runtime' => 0,
'by_source_type' => array(),
'by_status' => array(),
'by_target_language' => array(),
);
$table = self::translation_table_sql();
if ('' === $table) {
return $summary;
}
$summary['rows'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input and table is validated.
$groups = $wpdb->get_results("SELECT source_type, COUNT(*) AS rows_count FROM {$table} GROUP BY source_type", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input and table is validated.
foreach ((array) $groups as $group) {
$type = (string) ($group['source_type'] ?? '');
$summary['by_source_type'][] = array(
'label' => $type,
'rows' => absint($group['rows_count'] ?? 0),
);
if ('' === $type) {
$summary['empty_source_type'] = absint($group['rows_count'] ?? 0);
} elseif (isset($summary[$type])) {
$summary[$type] = absint($group['rows_count'] ?? 0);
}
}
$status_groups = $wpdb->get_results("SELECT status AS label, COUNT(*) AS rows_count FROM {$table} GROUP BY status ORDER BY rows_count DESC", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input and table is validated.
foreach ((array) $status_groups as $group) {
$summary['by_status'][] = array(
'label' => (string) ($group['label'] ?? ''),
'rows' => absint($group['rows_count'] ?? 0),
);
}
$language_groups = $wpdb->get_results("SELECT target_language AS label, COUNT(*) AS rows_count FROM {$table} GROUP BY target_language ORDER BY rows_count DESC", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input and table is validated.
foreach ((array) $language_groups as $group) {
$summary['by_target_language'][] = array(
'label' => (string) ($group['label'] ?? ''),
'rows' => absint($group['rows_count'] ?? 0),
);
}
$database = sanitize_text_field((string) DB_NAME);
$size = $wpdb->get_row(
$wpdb->prepare(
'SELECT COALESCE(data_length, 0) AS data_bytes, COALESCE(index_length, 0) AS index_bytes FROM information_schema.tables WHERE table_schema = %s AND table_name = %s LIMIT 1',
$database,
$wpdb->prefix . 'aitmt_translations'
),
ARRAY_A
);
if (is_array($size)) {
$summary['data_bytes'] = absint($size['data_bytes'] ?? 0);
$summary['index_bytes'] = absint($size['index_bytes'] ?? 0);
}
return $summary;
}
/**
* Run ANALYZE or OPTIMIZE only after explicit confirmation.
*/
private static function run_table_command($operation)
{
global $wpdb;
$table = self::translation_table_sql();
if ('' === $table) {
return array('ok' => false, 'message' => __('Translation table is unavailable.', 'ait-multilingual-translate'));
}
$command = 'optimize' === $operation ? 'OPTIMIZE' : 'ANALYZE';
$wpdb->query("{$command} TABLE {$table}"); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Command is selected from a closed list and table is validated.
return array('ok' => true, 'message' => sprintf('%s TABLE completed.', $command));
}
/**
* Daily runtime cleanup is opt-in and bounded.
*/
public static function sync_cleanup_schedule()
{
$options = function_exists('aitmt_fallback_options') ? aitmt_fallback_options() : array();
$enabled = '1' === (string) ($options['runtime_auto_cleanup'] ?? '0');
$scheduled = wp_next_scheduled(self::CRON_HOOK);
if ($enabled && !$scheduled) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK);
} elseif (!$enabled && $scheduled) {
wp_clear_scheduled_hook(self::CRON_HOOK);
}
}
/**
* Delete at most 250 expired runtime rows per daily run.
*/
public static function scheduled_cleanup()
{
$options = function_exists('aitmt_fallback_options') ? aitmt_fallback_options() : array();
if ('1' !== (string) ($options['runtime_auto_cleanup'] ?? '0')) {
return;
}
$days = max(1, absint($options['runtime_cache_ttl_days'] ?? 30));
self::delete_batch('stale-page-load', $days, 250);
}
/**
* Register WP-CLI maintenance commands.
*/
public static function register_cli_command()
{
WP_CLI::add_command('aitmt maintenance', array(__CLASS__, 'cli'));
}
/**
* WP-CLI dispatcher.
*
* ## EXAMPLES
*
* wp aitmt maintenance stats
* wp aitmt maintenance dry-run --operation=stale-page-load --age-days=30
* wp aitmt maintenance cleanup --operation=stale-page-load --age-days=30 --batch-size=1000 --yes
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public static function cli($args, $assoc_args)
{
$command = sanitize_key((string) ($args[0] ?? 'stats'));
$operation = sanitize_key((string) ($assoc_args['operation'] ?? 'stale-page-load'));
$age_days = max(1, absint($assoc_args['age-days'] ?? 30));
$batch_size = max(1, min(10000, absint($assoc_args['batch-size'] ?? 1000)));
if ('stats' === $command) {
WP_CLI::print_value(self::database_summary(), array('format' => 'json'));
return;
}
if ('dry-run' === $command) {
$stats = self::operation_stats($operation, $age_days);
WP_CLI::print_value($stats, array('format' => 'json'));
return;
}
if ('cleanup' === $command) {
WP_CLI::confirm('Delete one bounded batch of matching rows?', $assoc_args);
$deleted = self::delete_batch($operation, $age_days, $batch_size);
WP_CLI::success(sprintf('Deleted %d row(s). Run dry-run again to inspect remaining candidates.', $deleted));
return;
}
if ('classify' === $command) {
if (empty($assoc_args['yes'])) {
WP_CLI::line(sprintf('Dry run: up to %d row(s) are classifiable in the next batch.', self::count_classifiable_rows($batch_size)));
return;
}
WP_CLI::confirm('Classify one bounded batch of high-confidence legacy rows?', $assoc_args);
WP_CLI::success(sprintf('Classified %d row(s).', self::classify_batch($batch_size)));
return;
}
if ('analyze' === $command || 'optimize' === $command) {
WP_CLI::confirm(strtoupper($command) . ' the translation table?', $assoc_args);
$result = self::run_table_command($command);
WP_CLI::success($result['message']);
return;
}
if ('index' === $command) {
self::cli_index($assoc_args);
return;
}
if ('self-test' === $command) {
self::cli_self_test();
return;
}
WP_CLI::error('Unknown command. Use stats, dry-run, cleanup, classify, index, analyze, optimize, or self-test.');
}
/**
* Verify/add the fixed unique lookup index after collision checks.
*/
private static function cli_index($assoc_args)
{
global $wpdb;
$table = self::translation_table_sql();
$indexes = $wpdb->get_results("SHOW INDEX FROM {$table}", ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table is validated.
foreach ((array) $indexes as $index) {
if ('aitmt_lookup' === strtolower((string) ($index['Key_name'] ?? ''))) {
WP_CLI::success('Unique lookup index already exists.');
return;
}
}
$duplicates = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (
SELECT source_hash, source_language, target_language, context
FROM {$table}
GROUP BY source_hash, source_language, target_language, context
HAVING COUNT(*) > 1
) duplicate_groups"
); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user input and table is validated.
WP_CLI::line(sprintf('Duplicate lookup groups: %d.', $duplicates));
if ($duplicates > 0 || empty($assoc_args['yes'])) {
if ($duplicates > 0) {
WP_CLI::warning('Index was not added because collisions exist.');
} else {
WP_CLI::line('Dry run only. Add --yes to create the index.');
}
return;
}
WP_CLI::confirm('Add the unique lookup index? This may lock a large table.', $assoc_args);
$wpdb->query("ALTER TABLE {$table} ADD UNIQUE KEY aitmt_lookup (source_hash, source_language, target_language, context)"); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Fixed SQL and validated table.
WP_CLI::success('Unique lookup index added.');
}
/**
* Lightweight safety checks that do not call a provider.
*/
private static function cli_self_test()
{
$checks = array(
'unicode_whitespace_normalized' => function_exists('aitmt_fallback_normalize_text') && 'Hello world' === aitmt_fallback_normalize_text(" Hello\xc2\xa0 world "),
'data_uri_rejected' => function_exists('aitmt_fallback_non_translatable_reason') && 'data_uri' === aitmt_fallback_non_translatable_reason('data:text/plain;base64,SGVsbG8='),
'unknown_source_rejected' => function_exists('aitmt_fallback_normalize_source_type') && '' === aitmt_fallback_normalize_source_type('unknown'),
'runtime_default_off' => function_exists('aitmt_fallback_default_options') && '0' === (string) (aitmt_fallback_default_options()['runtime_page_translation'] ?? '1'),
'runtime_storage_default_off' => function_exists('aitmt_fallback_default_options') && '0' === (string) (aitmt_fallback_default_options()['runtime_db_storage'] ?? '1'),
'background_default_off' => function_exists('aitmt_fallback_default_options') && '0' === (string) (aitmt_fallback_default_options()['cron_enabled'] ?? '1'),
'scan_on_save_default_off' => function_exists('aitmt_fallback_default_options') && '0' === (string) (aitmt_fallback_default_options()['scan_on_save'] ?? '1'),
'unknown_not_allowed' => function_exists('aitmt_fallback_is_valid_queue_source_type') && !aitmt_fallback_is_valid_queue_source_type('unknown'),
'woocommerce_private_detected' => function_exists('aitmt_fallback_is_woocommerce_private_request') && self::with_request_uri('/checkout/', 'aitmt_fallback_is_woocommerce_private_request'),
);
foreach ($checks as $name => $passed) {
if (!$passed) {
WP_CLI::error('Self-test failed: ' . $name);
}
WP_CLI::line('PASS ' . $name);
}
WP_CLI::success('Runtime safety self-test passed.');
}
/**
* Run a request-path predicate without permanently changing the CLI request.
*
* @param string $request_uri Request path.
* @param callable $callback Predicate to execute.
* @return bool
*/
private static function with_request_uri($request_uri, $callback)
{
$had_request_uri = isset($_SERVER['REQUEST_URI']);
$previous = $had_request_uri ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : null;
$_SERVER['REQUEST_URI'] = $request_uri;
$result = (bool) call_user_func($callback);
if ($had_request_uri) {
$_SERVER['REQUEST_URI'] = $previous;
} else {
unset($_SERVER['REQUEST_URI']);
}
return $result;
}
}