diff --git a/src/Admin/Updater.php b/src/Admin/Updater.php new file mode 100644 index 0000000..c179b53 --- /dev/null +++ b/src/Admin/Updater.php @@ -0,0 +1,476 @@ +plugin_config = [ + 'plugin_file' => self::PLUGIN_SLUG . '/' . self::PLUGIN_SLUG . '.php', + 'slug' => self::PLUGIN_SLUG, + 'proper_folder_name' => self::PLUGIN_SLUG, + 'api_url' => 'https://api.wordpress.org/plugins/info/1.0/' . self::PLUGIN_SLUG . '.json', + 'repo_url' => 'https://wordpress.org/plugins/' . self::PLUGIN_SLUG, + ]; + } + + /** + * Sets up the hooks. + */ + public function init() : void { + add_filter( 'auto_update_plugin', [ $this, 'disable_autoupdate' ], 10, 2 ); + add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'api_check' ] ); + add_filter( 'plugins_api_result', [ $this, 'api_result' ], 10, 3 ); + add_filter( 'upgrader_source_selection', [ $this, 'upgrader_source_selection' ], 10, 2 ); + add_action( 'in_plugin_update_message-' . $this->plugin_config['plugin_file'], [ $this, 'update_message' ] ); + } + + /** + * Disable auto updates for major releases of this plugin. + * + * @param bool|null $update Whether to update. + * @param object $item The plugin object. + * + * @return bool|null + */ + public function disable_autoupdate( $update, $item ) { + // Return early if this is not our plugin. + if ( $item->slug !== $this->plugin_config['slug'] && $item->plugin !== $this->plugin_config['plugin_file'] ) { + return $update; + } + + // Bail if there's no new version. + if ( empty( $item->new_version ) ) { + return $update; + } + + // Get the update type. + $update_type = self::get_semver_update_type( $item->new_version, WPGRAPHQL_ACF_VERSION ); + + // Non-'major' updates are allowed. + if ( 'major' !== $update_type ) { + return $update; + } + + // Major updates should never happen automatically. + return false; + } + + /** + * Hooks into the plugin upgrader to get the correct plugin version we want to install. + * + * @param object $transient The plugin upgrader transient. + * + * @return object + */ + public function api_check( $transient ) { + // Clear the transient. + delete_site_transient( self::VERSION_TRANSIENT ); + + // Get the latest version we allow to be installed from this version. + // In the new codebase, we'll just do this on semver-autoupdates. + $plugin_data = $this->get_plugin_data(); + $version = $plugin_data['Version']; + $new_version = $this->get_latest_version(); + + // Check if this is a version update. + $is_update = version_compare( $new_version, $version, '>' ); + + if ( ! $is_update ) { + return $transient; + } + + // Get the download URL for reuse. + $download_url = $this->get_download_url( $new_version ); + + // Populate the transient data. + if ( ! isset( $transient->response[ $this->plugin_config['plugin_file'] ] ) ) { + $transient->response[ $this->plugin_config['plugin_file'] ] = (object) $this->plugin_config; + } + + $transient->response[ $this->plugin_config['plugin_file'] ]->new_version = $new_version; + $transient->response[ $this->plugin_config['plugin_file'] ]->package = $download_url; + $transient->response[ $this->plugin_config['plugin_file'] ]->zip_url = $download_url; + + $update_type = self::get_semver_update_type( $new_version, WPGRAPHQL_ACF_VERSION ); + if ( 'major' === $update_type ) { + $message = sprintf( + /* translators: %s: breaking change message. */ + __( '⚠ Warning: %s', 'wp-graphql-acf' ), + self::get_breaking_change_message( $new_version ) + ); + $transient->response[ $this->plugin_config['plugin_file'] ]->upgrade_notice = $message; + } + + + return $transient; + } + + /** + * Filters the Installation API response result + * + * @param object|\WP_Error $response The API response object. + * @param string $action The type of information being requested from the Plugin Installation API. + * @param object $args Plugin API arguments. + * + * @return object|\WP_Error + */ + public function api_result( $response, $action, $args ) { + /** + * Filters the slug used to check the API response. + * This is useful for testing. + * + * @param ?string $custom_slug The custom slug to use. If null, the default slug is used. + * @param object $args The API request arguments. + */ + $custom_slug = apply_filters( 'wpgraphql_acf_wporg_api_result_slug', null, $args ); + if ( null !== $custom_slug ) { + $args->slug = $custom_slug; + } + + // Bail if this is not checking our plugin. + if ( ! isset( $args->slug ) || ( $args->slug !== $this->plugin_config['slug'] && $args->slug !== $custom_slug ) ) { + return $response; + } + + // Get the latest version. + $new_version = $this->get_latest_version(); + + // Bail if the version is not newer. + if ( version_compare( $new_version, WPGRAPHQL_ACF_VERSION, '<=' ) ) { + return $response; + } + + // If we're returning a different version than the latest from WP.org, override the response. + $response->version = $new_version; + $response->download_link = $this->get_download_url( $new_version ); + + // If this is a major update, add a warning. + $update_type = self::get_semver_update_type( $new_version, WPGRAPHQL_ACF_VERSION ); + $warning = ''; + + if ( 'major' === $update_type ) { + $message = sprintf( + /* translators: %s: version number. */ + __( '⚠ Warning

%s

', 'wp-graphql-acf' ), + self::get_breaking_change_message( $new_version ) + ); + $warning = $this->get_inline_notice( 'major', $message ); + } + + // If there is a warning, append it to each section. + if ( '' !== $warning ) { + foreach ( $response->sections as $key => $section ) { + $response->sections[ $key ] = $warning . $section; + } + } + + return $response; + } + + /** + * Rename the downloaded zip + * + * @param string $source File source location. + * @param string $remote_source Remote file source location. + * + * @return string|\WP_Error + */ + public function upgrader_source_selection( $source, $remote_source ) { + global $wp_filesystem; + + if ( strstr( $source, '/wp-graphql-acf' ) ) { + $corrected_source = trailingslashit( $remote_source ) . trailingslashit( $this->plugin_config['proper_folder_name'] ); + + if ( $wp_filesystem->move( $source, $corrected_source, true ) ) { + return $corrected_source; + } else { + return new \WP_Error(); + } + } + + return $source; + } + + /** + * Outputs a warning message about a major update. + * + * @param array $args The update message arguments. + */ + public function update_message( $args ) : void { + if ( ! isset( $args['slug'] ) || $args['slug'] !== $this->plugin_config['slug'] ) { + return; + } + + // Get the latest version. + $new_version = $args['new_version']; + + // If this is a major update, add a warning. + $update_type = self::get_semver_update_type( $new_version, WPGRAPHQL_ACF_VERSION ); + + if ( 'major' !== $update_type ) { + return; + } + + $message = sprintf( + /* translators: %s: version number. */ + __( '⚠ Warning

%s

', 'wp-graphql-acf' ), + self::get_breaking_change_message( $new_version ) + ); + + echo '

' . wp_kses_post( $this->get_inline_notice( 'major', $message ) ); + } + + /** + * Returns the notice to display inline on the plugins page. + * + * @param string $upgrade_type The type of upgrade. + * @param string $message The message to display. + */ + public function get_inline_notice( string $upgrade_type, string $message, ) : string { + ob_start(); + ?> +
+ +
+ +
+
+

+
+
+

+
+ + +
+ + +
+ +
+
+
+ + get_wporg_data(); + + /** @var string $latest_version */ + $latest_version = isset( $data->version ) ? $data->version : ''; + + /** @var array $versions */ + $versions = isset( $data->versions ) ? (array) $data->versions : []; + + // Sort the versions by descending order. + uksort( + $versions, + static function ( $a, $b ) { + return version_compare( $b, $a ); + } + ); + + foreach ( $versions as $version => $download_url ) { + // Skip trunk. + if ( 'trunk' === $version ) { + continue; + } + + // If the current version is < 1.0.0, but this version is >= 2, skip it. + // @phpstan-ignore-next-line + if ( version_compare( WPGRAPHQL_ACF_VERSION, '1.0.0', '<' ) && version_compare( $version, '2.0.0', '>=' ) ) { + continue; + } + + // Return the first matching version. + $latest_version = $version; + break; + } + + if ( ! empty( $latest_version ) ) { + set_site_transient( self::VERSION_TRANSIENT, $latest_version, 6 * HOUR_IN_SECONDS ); + } + } + + return $latest_version; + } + + /** + * Gets the data from the WordPress plugin directory. + * + * @return ?object + */ + protected function get_wporg_data() { + // Return cached data if available. + if ( ! empty( $this->wporg_data ) ) { + return $this->wporg_data; + } + + // Get data from transient. + $data = get_site_transient( self::WPORG_DATA_TRANSIENT ); + + if ( empty( $data ) || ! is_object( $data ) ) { + /** + * Filters the API URL to use for fetching the plugin data. + * Useful for testing. + * + * @param string $api_url The API URL to use. + */ + $api_url = apply_filters( 'wpgraphql_acf_wporg_api_url', $this->plugin_config['api_url'] ); + + $req = wp_remote_get( $api_url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + + $body = wp_remote_retrieve_body( $req ); + + if ( empty( $body ) ) { + return null; + } + + $data = json_decode( $body ); + + // Bail because we couldn't decode the body. + if ( empty( $data ) || ! is_object( $data ) ) { + return null; + } + + // Refresh every 6 hours. + set_site_transient( self::WPORG_DATA_TRANSIENT, $data, 6 * HOUR_IN_SECONDS ); + } + + // Stash for reuse. + $this->wporg_data = $data; + + return $data; + } + + /** + * Gets the download URL for a specific version. + * + * @param string $version Version number. + * + * @return string|false + */ + protected function get_download_url( string $version ) { + $data = $this->get_wporg_data(); + + return ! empty( $data->versions->$version ) ? $data->versions->$version : false; + } + + /** + * Returns the SemVer type of update based on the new version. + * + * @param string $new_version The SemVer-compliant version number (x.y.z) + * @param string $current_version The SemVer-compliant version number (x.y.z) + * + * @return string{major|minor|patch} The type of update (major, minor, patch). + */ + protected static function get_semver_update_type( string $new_version, string $current_version ) : string { + $current = explode( '.', $current_version ); + $new = explode( '.', $new_version ); + + // If the first digit is 0, we need to compare the next digit. + if ( '0' === $new[0] && '0' !== $current[0] ) { + return self::get_semver_update_type( + implode( '.', array_slice( $new, 1 ) ), + implode( '.', array_slice( $current, 1 ) ) + ); + } + + // If the major version is different, this is a major update. + if ( $current[0] !== $new[0] ) { + return 'major'; + } + + // If the minor version is different, this is a minor update. + if ( $current[1] !== $new[1] ) { + return 'minor'; + } + + return 'patch'; + } + + /** + * Gets the message to display for a breaking change. + * + * @param string $version The version number. + */ + protected static function get_breaking_change_message( string $version ) : string { + return sprintf( + /* translators: %s: version number. */ + __( 'Version %s of WPGraphQL for ACF is a major update and may contain breaking changes. Please review the changelog and test before updating on a production site.', 'wp-graphql-acf' ), + $version + ); + } + +} diff --git a/src/class-acf.php b/src/class-acf.php index e7f8e21..1566641 100644 --- a/src/class-acf.php +++ b/src/class-acf.php @@ -8,6 +8,7 @@ namespace WPGraphQL\ACF; use GraphQL\Type\Definition\ResolveInfo; +use WPGraphQL\ACF\Admin\Updater; /** * Final class ACF @@ -17,7 +18,7 @@ final class ACF { /** * Stores the instance of the WPGraphQL\ACF class * - * @var ACF The one true WPGraphQL\Extensions\ACF + * @var \WPGraphQL\ACF\ACF The one true WPGraphQL\Extensions\ACF * @access private */ private static $instance; @@ -25,12 +26,11 @@ final class ACF { /** * Get the singleton. * - * @return ACF + * @return \WPGraphQL\ACF\ACF */ public static function instance() { - - if ( ! isset( self::$instance ) && ! ( self::$instance instanceof ACF ) ) { - self::$instance = new ACF(); + if ( ! isset( self::$instance ) && ! ( self::$instance instanceof self ) ) { + self::$instance = new self(); self::$instance->setup_constants(); self::$instance->includes(); self::$instance->actions(); @@ -41,7 +41,7 @@ public static function instance() { /** * Fire off init action * - * @param ACF $instance The instance of the WPGraphQL\ACF class + * @param \WPGraphQL\ACF\ACF $instance The instance of the WPGraphQL\ACF class */ do_action( 'graphql_acf_init', self::$instance ); @@ -63,7 +63,6 @@ public function __clone() { // Cloning instances of the class is forbidden. _doing_it_wrong( __FUNCTION__, esc_html__( 'The \WPGraphQL\ACF class should not be cloned.', 'wp-graphql-acf' ), '0.0.1' ); - } /** @@ -76,7 +75,6 @@ public function __wakeup() { // De-serializing instances of the class is forbidden. _doing_it_wrong( __FUNCTION__, esc_html__( 'De-serializing instances of the \WPGraphQL\ACF class is not allowed', 'wp-graphql-acf' ), '0.0.1' ); - } /** @@ -86,22 +84,23 @@ public function __wakeup() { * @return void */ private function setup_constants() { + $main_file_path = dirname( __DIR__ ) . '/wp-graphql-acf.php'; + // Plugin Folder Path. if ( ! defined( 'WPGRAPHQL_ACF_PLUGIN_DIR' ) ) { - define( 'WPGRAPHQL_ACF_PLUGIN_DIR', plugin_dir_path( __FILE__ . '/..' ) ); + define( 'WPGRAPHQL_ACF_PLUGIN_DIR', plugin_dir_path( $main_file_path ) ); } // Plugin Folder URL. if ( ! defined( 'WPGRAPHQL_ACF_PLUGIN_URL' ) ) { - define( 'WPGRAPHQL_ACF_PLUGIN_URL', plugin_dir_url( __FILE__ . '/..' ) ); + define( 'WPGRAPHQL_ACF_PLUGIN_URL', plugin_dir_url( $main_file_path ) ); } // Plugin Root File. if ( ! defined( 'WPGRAPHQL_ACF_PLUGIN_FILE' ) ) { - define( 'WPGRAPHQL_ACF_PLUGIN_FILE', __FILE__ . '/..' ); + define( 'WPGRAPHQL_ACF_PLUGIN_FILE', $main_file_path ); } - } /** @@ -121,7 +120,6 @@ private function includes() { * cycle */ private function actions() { - } /** @@ -133,28 +131,33 @@ private function filters() { * This filters any field that returns the `ContentTemplate` type * to pass the source node down to the template for added context */ - add_filter( 'graphql_resolve_field', function( $result, $source, $args, $context, ResolveInfo $info, $type_name, $field_key, $field, $field_resolver ) { - if ( isset( $info->returnType ) && strtolower( 'ContentTemplate' ) === strtolower( $info->returnType ) ) { - if ( is_array( $result ) && ! isset( $result['node'] ) && ! empty( $source ) ) { - $result['node'] = $source; + add_filter( + 'graphql_resolve_field', + static function ( $result, $source, $args, $context, ResolveInfo $info, $type_name, $field_key, $field, $field_resolver ) { + if ( isset( $info->returnType ) && strtolower( 'ContentTemplate' ) === strtolower( $info->returnType ) ) { + if ( is_array( $result ) && ! isset( $result['node'] ) && ! empty( $source ) ) { + $result['node'] = $source; + } } - } - return $result; - }, 10, 9 ); - + return $result; + }, + 10, + 9 + ); } /** * Initialize */ private function init() { - $config = new Config(); add_action( 'graphql_register_types', [ $config, 'init' ], 10, 1 ); $acf_settings = new ACF_Settings(); $acf_settings->init(); + $updater = new Updater(); + $updater->init(); } }