Skip to content
wiki.fftac.org

Migrator

**Site relevance:** Antichrist.net

**Memory type:** plugin source memory

**Source path:** Antichrist.net/wp-content/plugins/uaix-locale-router/src/Migrator.php

**Size:** 8 KB

Summary

namespace UAIXLocaleRouter;

Source Preview

This source file is short enough to preview directly on its source-memory page.

<?php

namespace UAIXLocaleRouter;

use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use FilesystemIterator;


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

/**
 * Versioned compatibility migrations from the old NS12 Locale Router.
 */
final class Migrator {
	/**
	 * Source-to-target option map.
	 *
	 * @return array
	 */
	private static function option_map() {
		return array(
			'ns12lr_settings'              => 'uaixlr_settings',
			'ns12lr_supported_locales'     => 'uaixlr_supported_locales',
			'ns12lr_default_locale'        => 'uaixlr_default_locale',
			'ns12lr_dictionary_manifest'   => 'uaixlr_dictionary_manifest',
			'ns12lr_install_version'       => 'uaixlr_install_version',
			'ns12lr_bootstrap_notice'      => 'uaixlr_bootstrap_notice',
			'ns12lr_bootstrap_notice_raw'  => 'uaixlr_bootstrap_notice_raw',
		);
	}

	/**
	 * Run all idempotent migrations.
	 *
	 * @return array
	 */
	public static function run() {
		$status = array(
			'version'             => UAIXLR_VERSION,
			'ran_at'              => time(),
			'options_copied'      => array(),
			'dictionary_files'    => array(),
			'dictionary_backups'  => array(),
			'validation_table'    => 'not-needed',
			'legacy_data_deleted' => false,
			'errors'              => array(),
		);

		try {
			$status['options_copied'] = self::migrate_options();
		} catch ( \Throwable $throwable ) {
			$status['errors'][] = 'options: ' . $throwable->getMessage();
		}

		try {
			$file_status                  = self::migrate_dictionary_files();
			$status['dictionary_files']   = $file_status['copied'];
			$status['dictionary_backups'] = $file_status['backups'];
		} catch ( \Throwable $throwable ) {
			$status['errors'][] = 'dictionaries: ' . $throwable->getMessage();
		}

		try {
			$status['validation_table'] = self::migrate_validation_table();
		} catch ( \Throwable $throwable ) {
			$status['errors'][] = 'validation table: ' . $throwable->getMessage();
		}

		update_option( 'uaixlr_migration_status', $status, false );

		if ( self::did_migrate_anything( $status ) && empty( $status['errors'] ) ) {
			Plugin::set_flash_notice(
				__( 'UAIX Locale Router migrated existing NS12 Locale Router settings, dictionaries, or validation history. The old NS12 data was left in place for rollback safety.', 'uaix-locale-router' ),
				'success'
			);
		}

		if ( ! empty( $status['errors'] ) ) {
			Plugin::set_flash_notice(
				__( 'UAIX Locale Router finished with migration warnings. Open Status for details.', 'uaix-locale-router' ),
				'warning',
				array(
					'details' => array(
						'Errors' => implode( '; ', $status['errors'] ),
					),
				)
			);
		}

		return $status;
	}

	/**
	 * Return the last migration status payload.
	 *
	 * @return array
	 */
	public static function status() {
		$status = get_option( 'uaixlr_migration_status', array() );

		return is_array( $status ) ? $status : array();
	}

	/**
	 * Copy old NS12 options to UAIX names when the UAIX option does not exist.
	 *
	 * @return array
	 */
	private static function migrate_options() {
		$copied = array();

		foreach ( self::option_map() as $old_option => $new_option ) {
			if ( false !== get_option( $new_option, false ) ) {
				continue;
			}

			$old_value = get_option( $old_option, false );

			if ( false === $old_value ) {
				continue;
			}

			add_option( $new_option, $old_value, '', false );
			$copied[] = $old_option . ' -> ' . $new_option;
		}

		return $copied;
	}

	/**
	 * Copy old upload dictionaries forward without destroying rollback files.
	 *
	 * @return array
	 */
	private static function migrate_dictionary_files() {
		$upload_dir = wp_upload_dir();
		$base_dir   = isset( $upload_dir['basedir'] ) ? trailingslashit( $upload_dir['basedir'] ) : '';
		$old_dir    = $base_dir . 'ns12-locale-router/dictionaries';
		$new_dir    = $base_dir . 'uaix-locale-router/dictionaries';
		$result     = array(
			'copied'  => array(),
			'backups' => array(),
		);

		if ( '' === $base_dir || ! is_dir( $old_dir ) ) {
			return $result;
		}

		if ( ! is_dir( $new_dir ) ) {
			wp_mkdir_p( $new_dir );
		}

		if ( ! is_dir( $new_dir ) || ! is_writable( $new_dir ) ) {
			throw new \RuntimeException( 'The UAIX dictionary directory is not writable.' );
		}

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $old_dir, FilesystemIterator::SKIP_DOTS )
		);

		foreach ( $iterator as $file_info ) {
			if ( ! $file_info->isFile() ) {
				continue;
			}

			$source = (string) $file_info->getPathname();

			if ( ! self::is_safe_dictionary_filename( basename( $source ) ) ) {
				continue;
			}

			$target = trailingslashit( $new_dir ) . basename( $source );

			if ( file_exists( $target ) ) {
				$source_mtime = filemtime( $source );
				$target_mtime = filemtime( $target );

				if ( false !== $source_mtime && false !== $target_mtime && $target_mtime >= $source_mtime ) {
					continue;
				}

				$backup = $target . '.bak-' . gmdate( 'YmdHis' );

				if ( ! @copy( $target, $backup ) ) {
					throw new \RuntimeException( 'Could not create a backup for ' . basename( $target ) . '.' );
				}

				$result['backups'][] = $backup;
			}

			if ( ! @copy( $source, $target ) ) {
				throw new \RuntimeException( 'Could not copy dictionary ' . basename( $source ) . '.' );
			}

			$result['copied'][] = basename( $target );
		}

		return $result;
	}

	/**
	 * Copy the old validation table to the UAIX table when needed.
	 *
	 * @return string
	 */
	private static function migrate_validation_table() {
		global $wpdb;

		$old_table = self::safe_table_name( $wpdb->prefix . 'ns12lr_dictionary_validation' );
		$new_table = self::safe_table_name( $wpdb->prefix . 'uaixlr_dictionary_validation' );

		if ( ! self::table_exists( $old_table ) ) {
			return 'not-needed';
		}

		if ( self::table_exists( $new_table ) ) {
			return 'already-present';
		}

		if ( class_exists( __NAMESPACE__ . '\Plugin' ) ) {
			Plugin::create_validation_table();
		}

		if ( ! self::table_exists( $new_table ) ) {
			return 'failed';
		}

		$columns = array(
			'ValidationId',
			'LocaleTag',
			'FileHash',
			'TemplateVersion',
			'Status',
			'IssueCount',
			'IssuesJson',
			'CreatedUtc',
			'CreatedByUserId',
		);
		$sql_columns = '`' . implode( '`, `', $columns ) . '`';

		$wpdb->query( "INSERT INTO `{$new_table}` ({$sql_columns}) SELECT {$sql_columns} FROM `{$old_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return self::table_exists( $new_table ) ? 'copied' : 'failed';
	}

	/**
	 * Determine whether a database table exists.
	 *
	 * @param string $table_name Table name.
	 * @return bool
	 */
	private static function table_exists( $table_name ) {
		global $wpdb;

		$table_name = self::safe_table_name( $table_name );
		$found      = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );

		return $found === $table_name;
	}

	/**
	 * Whitelist generated table names before interpolating them into DDL.
	 *
	 * @param string $table_name Table name.
	 * @return string
	 */
	private static function safe_table_name( $table_name ) {
		return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $table_name );
	}

	/**
	 * Validate legacy dictionary file names before copying.
	 *
	 * @param string $filename File name.
	 * @return bool
	 */
	private static function is_safe_dictionary_filename( $filename ) {
		if ( ! is_string( $filename ) || strlen( $filename ) > 190 ) {
			return false;
		}

		if ( ! preg_match( '/^[A-Za-z0-9._-]+\.json$/', $filename ) ) {
			return false;
		}

		return false === strpos( $filename, '..' );
	}

	/**
	 * Determine whether this migration did real work.
	 *
	 * @param array $status Migration status.
	 * @return bool
	 */
	private static function did_migrate_anything( array $status ) {
		return ! empty( $status['options_copied'] )
			|| ! empty( $status['dictionary_files'] )
			|| 'copied' === $status['validation_table'];
	}
}