deep-web-solutions/wordpress-framework-core

View on GitHub
src/includes/Functionalities/InstallationFunctionality.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

namespace DeepWebSolutions\Framework\Core\Functionalities;

use DeepWebSolutions\Framework\Core\AbstractPluginFunctionality;
use DeepWebSolutions\Framework\Core\Actions\Installable\InstallFailureException;
use DeepWebSolutions\Framework\Core\Actions\Installable\UninstallFailureException;
use DeepWebSolutions\Framework\Core\Actions\InstallableInterface;
use DeepWebSolutions\Framework\Core\Actions\UninstallableInterface;
use DeepWebSolutions\Framework\Foundations\Logging\LoggingService;
use DeepWebSolutions\Framework\Foundations\States\ActiveableInterface;
use DeepWebSolutions\Framework\Foundations\States\DisableableInterface;
use DeepWebSolutions\Framework\Helpers\Assets;
use DeepWebSolutions\Framework\Helpers\DataTypes\Strings;
use DeepWebSolutions\Framework\Helpers\Users;
use DeepWebSolutions\Framework\Utilities\AdminNotices\Actions\InitializeAdminNoticesServiceTrait;
use DeepWebSolutions\Framework\Utilities\AdminNotices\Actions\SetupAdminNoticesTrait;
use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService;
use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesServiceAwareInterface;
use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesServiceRegisterInterface;
use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticeTypesEnum;
use DeepWebSolutions\Framework\Utilities\AdminNotices\Helpers\AdminNoticesServiceHelpers;
use DeepWebSolutions\Framework\Utilities\AdminNotices\Notices\DismissibleAdminNotice;
use DeepWebSolutions\Framework\Utilities\AdminNotices\Notices\SimpleAdminNotice;
use DeepWebSolutions\Framework\Utilities\Hooks\Actions\SetupHooksTrait;
use DeepWebSolutions\Framework\Utilities\Hooks\HooksService;
use DeepWebSolutions\Framework\Utilities\Hooks\HooksServiceRegisterInterface;
use function DeepWebSolutions\Framework\dws_wp_framework_get_core_base_path;

\defined( 'ABSPATH' ) || exit;

/**
 * Standardizes the actions of install, update, uninstall, and reinstall of any derived plugins.
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 *
 * @since   1.0.0
 * @version 1.0.0
 * @author  Antonius Hegyes <a.hegyes@deep-web-solutions.com>
 * @package DeepWebSolutions\WP-Framework\Core\Functionalities
 */
class InstallationFunctionality extends AbstractPluginFunctionality implements AdminNoticesServiceAwareInterface, AdminNoticesServiceRegisterInterface, HooksServiceRegisterInterface {
    // region TRAITS

    use InitializeAdminNoticesServiceTrait;
    use SetupAdminNoticesTrait;
    use SetupHooksTrait;

    // endregion

    // region FIELDS AND CONSTANTS

    /**
     * Whether the user notice has been outputted during the current request or not.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @access  protected
     * @var     bool
     */
    protected bool $has_notice_output = false;

    // endregion

    // region MAGIC METHODS

    /**
     * {@inheritDoc}
     */
    public function __construct( LoggingService $logging_service, ?string $component_id = null, ?string $component_name = null ) {
        parent::__construct( $logging_service, $component_id ?: 'installation', $component_name ?: 'Installation' );
    }

    // endregion

    // region INHERITED METHODS

    /**
     * {@inheritDoc}
     *
     * @since   1.0.0
     * @version 1.0.0
     */
    public function register_hooks( HooksService $hooks_service ): void {
        $hooks_service->add_action( 'admin_footer', $this, 'output_installation_js' );
        $hooks_service->add_action( 'wp_ajax_' . $this->get_hook_tag( 'installation_routine' ), $this, 'handle_ajax_installation' );
    }

    /**
     * Displays an admin notice if there are any installables that need installation or an update routine.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @param   AdminNoticesService     $notices_service    Instance of the admin notices service.
     */
    public function register_admin_notices( AdminNoticesService $notices_service ): void {
        if ( \doing_action( 'activate_' . $this->get_plugin()->get_plugin_file_path() ) ) {
            return;
        }

        $installable_version = $this->get_installable_versions();
        if ( empty( $installable_version ) ) {
            return;
        }

        $installed_version  = $this->get_installed_versions();
        $installation_delta = \array_diff_assoc( $installable_version, $installed_version );
        if ( empty( $installation_delta ) ) {
            return;
        }

        \ob_start();

        if ( \is_null( $this->get_original_version() ) ) {
            /* @noinspection PhpIncludeInspection */
            require_once dws_wp_framework_get_core_base_path() . '/src/templates/installation/required-original.php';
        } else {
            /* @noinspection PhpIncludeInspection */
            require_once dws_wp_framework_get_core_base_path() . '/src/templates/installation/required-update.php';
        }
        $message = \ob_get_clean();

        $this->has_notice_output = true;
        $notices_service->add_notice(
            new SimpleAdminNotice(
                $this->get_admin_notice_handle(),
                $message,
                AdminNoticeTypesEnum::INFO,
                array(
                    'html'       => true,
                    'capability' => 'activate_plugins',
                )
            )
        );
    }

    // endregion

    // region HOOKS

    /**
     * Outputs the JS that handles the installation/update action.
     *
     * @since   1.0.0
     * @version 1.0.0
     */
    public function output_installation_js() {
        if ( false === $this->has_notice_output ) {
            return; // The installation/upgrade notice has not been outputted.
        }

        \ob_start();

        ?>

        ( function( $ ) {
            $( 'div[id^="%div_id%"]' ).on( 'click', '.dws-install, .dws-update', function( e ) {
                var $clicked_button = $( e.target );
                if ( $clicked_button.hasClass('disabled') ) {
                    return;
                }

                $( e.target ).addClass('disabled').html('%disabled_message%');
                $.ajax( {
                    url: ajaxurl,
                    method: 'POST',
                    data: {
                        action: '%action%',
                        _wpnonce: '%nonce%'
                    },
                    complete: function() {
                        window.location.reload();
                    }
                } );
            } );
        } ) ( jQuery );

        <?php

        $js_script = Strings::replace_placeholders(
            \ob_get_clean(),
            array(
                '%div_id%'           => \esc_js( $this->get_admin_notice_handle() ),
                '%disabled_message%' => \esc_html__( 'Please wait...', 'dws-wp-framework-core' ),
                '%action%'           => \esc_js( $this->get_hook_tag( 'installation_routine' ) ),
                '%nonce%'            => \esc_js( \wp_create_nonce( $this->get_plugin()->get_plugin_safe_slug() . '_installation_routine' ) ),
            )
        );

        if ( \function_exists( 'wp_print_inline_script_tag' ) ) {
            \wp_print_inline_script_tag( $js_script );
        } else {
            echo "<script type='text/javascript'>$js_script</script>"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }
    }

    /**
     * Intercepts an AJAX request for running the installation routine.
     *
     * @since   1.0.0
     * @version 1.0.0
     */
    public function handle_ajax_installation() {
        if ( \check_ajax_referer( $this->get_plugin()->get_plugin_safe_slug() . '_installation_routine', false, false ) ) {
            try {
                $this->install_or_update();
            } catch ( \Exception $exception ) {
                $this->get_admin_notices_service()->add_notice(
                    new DismissibleAdminNotice(
                        $this->get_admin_notice_handle( 'install-update_fail', 'ajax' ),
                        \sprintf(
                            /* translators: 1. Installation node name, 2. Error message. */
                            __( '<strong>%1$s</strong> failed to complete the installation routine. The error is: %2$s', 'dws-wp-framework-core' ),
                            AdminNoticesServiceHelpers::get_registrant_name( $this ),
                            $exception->getMessage()
                        ),
                        AdminNoticeTypesEnum::ERROR,
                        array( 'persistent' => true )
                    ),
                    'user-meta'
                );
            }
        }

        \wp_die();
    }

    // endregion

    // region METHODS

    /**
     * Gets the first installed version of this plugin on the current WP installation.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  array|null  Null if the plugin hasn't been installed yet, the first installed version otherwise.
     */
    public function get_original_version(): ?array {
        return \get_option( $this->get_plugin()->get_plugin_safe_slug() . '_original_version', null );
    }

    /**
     * Gathers all installable classes and runs their installation or upgrade routines.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  null|InstallFailureException
     */
    public function install_or_update(): ?InstallFailureException {
        if ( ! Users::has_capabilities( 'activate_plugins' ) ) {
            return new InstallFailureException( 'User does not have enough permissions to run the installation routine' );
        }

        $installed_versions = $this->get_installed_versions();
        $installation_delta = \array_diff_assoc( $this->get_installable_versions(), $installed_versions );
        $notices_service    = $this->get_admin_notices_service();

        foreach ( $installation_delta as $class => $version ) {
            $instance = $this->get_plugin()->get_container_entry( $class );
            $result   = ( ! isset( $installed_versions[ $class ] ) )
                ? $instance->install()
                : $instance->update( $installed_versions[ $class ] );

            if ( \is_null( $result ) ) {
                $installed_versions[ $class ] = $version;
                $this->update_installed_version( $installed_versions );
            } else {
                $this->maybe_set_original_version( $installed_versions );
                $notices_service->add_notice(
                    new DismissibleAdminNotice(
                        $this->get_admin_notice_handle( 'install-update_fail', $class ),
                        \sprintf(
                            /* translators: 1. Installation node name, 2. Error message. */
                            __( '<strong>%1$s</strong> failed to complete the installation routine. The error is: %2$s', 'dws-wp-framework-core' ),
                            AdminNoticesServiceHelpers::get_registrant_name( $this ),
                            $result->getMessage()
                        ),
                        AdminNoticeTypesEnum::ERROR,
                        array( 'persistent' => true )
                    ),
                    'user-meta'
                );

                return $result;
            }
        }

        $result  = $this->maybe_set_original_version( $installed_versions );
        $message = \is_null( $result )
            ? /* translators: 1. Plugin name. */ __( '<strong>%1$s</strong> was successfully updated.', 'dws-wp-framework-core' )
            : /* translators: 1. Plugin name. */ __( '<strong>%1$s</strong> was successfully installed.', 'dws-wp-framework-core' );

        $notices_service->add_notice(
            new SimpleAdminNotice(
                $this->get_admin_notice_handle( 'install-update_success', \md5( \wp_json_encode( $installation_delta ) ) ),
                \sprintf( $message, $this->get_plugin()->get_plugin_name() ),
                AdminNoticeTypesEnum::SUCCESS
            ),
            'user-meta'
        );

        return null;
    }

    /**
     * Gathers all installable classes and runs their uninstall routines.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  UninstallFailureException|null
     */
    public function uninstall(): ?UninstallFailureException {
        if ( ! Users::has_capabilities( (array) 'delete_plugins' ) ) {
            return new UninstallFailureException( 'User does not have enough permissions to run the uninstallation routine' );
        }

        $installed_versions = $this->get_installed_versions();
        $uninstallables     = $this->get_uninstallable_classes();

        foreach ( $uninstallables as $class ) {
            $instance = $this->get_plugin()->get_container_entry( $class );
            if ( \is_null( $instance ) ) {
                continue;
            }

            $installed_version = $installed_versions[ $class ] ?? null;
            $result            = $instance->uninstall( $installed_version );

            if ( ! \is_null( $result ) ) {
                return $result;
            } elseif ( ! \is_null( $installed_version ) ) {
                unset( $installed_versions[ $class ] );
                $this->update_installed_version( $installed_versions );
            }
        }

        \delete_option( $this->get_plugin()->get_plugin_safe_slug() . '_version' );
        \delete_option( $this->get_plugin()->get_plugin_safe_slug() . '_original_version' );

        return null;
    }

    // endregion

    // region HELPERS

    /**
     * Gets the currently installable version of the installables of the plugin.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  array
     */
    protected function get_installable_versions(): array {
        $installable_versions = array();

        foreach ( \get_declared_classes() as $declared_class ) {
            if ( ! \is_a( $declared_class, InstallableInterface::class, true ) ) {
                continue;
            }

            $instance = $this->get_plugin()->get_container_entry( $declared_class );
            if ( ! \is_null( $instance ) ) {
                if ( $instance instanceof DisableableInterface && $instance->is_disabled() ) {
                    continue;
                } elseif ( $instance instanceof ActiveableInterface && ! $instance->is_active() ) {
                    continue;
                }

                $installable_versions[ $declared_class ] = $instance->get_current_version();
            }
        }

        return $installable_versions;
    }

    /**
     * Gets all the declared uninstallables of the plugin.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  string[]
     */
    protected function get_uninstallable_classes(): array {
        return \array_filter( \get_declared_classes(), fn( string $declared_class ) => \is_a( $declared_class, UninstallableInterface::class, true ) );
    }

    /**
     * Gets the currently installed version of the installables from the database.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @return  array
     */
    protected function get_installed_versions(): array {
        return \get_option( $this->get_plugin()->get_plugin_safe_slug() . '_version', array() );
    }

    /**
     * Stores the newly installed version of the installables to the database.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @param   array   $version    The current version of the installable components.
     *
     * @return  bool
     */
    protected function update_installed_version( array $version ): bool {
        return \update_option( $this->get_plugin()->get_plugin_safe_slug() . '_version', $version );
    }

    /**
     * If not set yet, sets the given version as the originally installed version on the current WP installation.
     *
     * @since   1.0.0
     * @version 1.0.0
     *
     * @param   array   $version    The version that should be potentially set as the originally installed one.
     *
     * @return  bool|null   Null if the plugin has been installed yet or the result of update_option otherwise.
     */
    protected function maybe_set_original_version( array $version ): ?bool {
        $original_version = $this->get_original_version();
        if ( ! \is_null( $original_version ) ) {
            return null;
        }

        $data = array(
            'timestamp'                            => \time(),
            $this->get_plugin()->get_plugin_slug() => $this->get_plugin()->get_plugin_version(),
        );

        return \update_option(
            $this->get_plugin()->get_plugin_safe_slug() . '_original_version',
            $data + $version
        );
    }

    // endregion
}