From 552a033038459a81e76af345c94cc0ca1d6f4836 Mon Sep 17 00:00:00 2001 From: Ian Jenkins Date: Mon, 7 Mar 2022 10:39:55 +0000 Subject: [PATCH] Refactor to PSR-12 --- .gitignore | 2 + .php-cs-fixer.dist.php | 15 + .travis.yml | 16 +- autoload.php | 31 -- composer.json | 15 +- dictator.php | 35 +- php/class-dictator-cli-command.php | 466 ------------------------- php/class-dictator-translator.php | 177 ---------- php/class-dictator.php | 195 ----------- php/regions/class-network-settings.php | 192 ---------- php/regions/class-network-sites.php | 424 ---------------------- php/regions/class-network-users.php | 7 - php/regions/class-region.php | 205 ----------- php/regions/class-site-settings.php | 281 --------------- php/regions/class-site-users.php | 8 - php/regions/class-terms.php | 238 ------------- php/regions/class-users.php | 237 ------------- php/states/class-network.php | 18 - php/states/class-site.php | 21 -- php/states/class-state.php | 88 ----- phpcs.xml.dist | 21 -- src/Command.php | 424 ++++++++++++++++++++++ src/Dictator.php | 121 +++++++ src/Region/NetworkSettings.php | 193 ++++++++++ src/Region/NetworkSites.php | 420 ++++++++++++++++++++++ src/Region/NetworkUsers.php | 9 + src/Region/Region.php | 195 +++++++++++ src/Region/SiteSettings.php | 281 +++++++++++++++ src/Region/SiteUsers.php | 9 + src/Region/Terms.php | 234 +++++++++++++ src/Region/Users.php | 239 +++++++++++++ src/State/Network.php | 23 ++ src/State/Site.php | 26 ++ src/State/State.php | 70 ++++ src/Utils.php | 40 +++ src/Validator.php | 166 +++++++++ tools/php-cs-fixer/composer.json | 6 + wp-cli.yml | 2 + 38 files changed, 2516 insertions(+), 2634 deletions(-) create mode 100644 .php-cs-fixer.dist.php delete mode 100644 autoload.php delete mode 100644 php/class-dictator-cli-command.php delete mode 100644 php/class-dictator-translator.php delete mode 100644 php/class-dictator.php delete mode 100644 php/regions/class-network-settings.php delete mode 100644 php/regions/class-network-sites.php delete mode 100644 php/regions/class-network-users.php delete mode 100644 php/regions/class-region.php delete mode 100644 php/regions/class-site-settings.php delete mode 100644 php/regions/class-site-users.php delete mode 100644 php/regions/class-terms.php delete mode 100644 php/regions/class-users.php delete mode 100644 php/states/class-network.php delete mode 100644 php/states/class-site.php delete mode 100644 php/states/class-state.php delete mode 100644 phpcs.xml.dist create mode 100644 src/Command.php create mode 100644 src/Dictator.php create mode 100644 src/Region/NetworkSettings.php create mode 100644 src/Region/NetworkSites.php create mode 100644 src/Region/NetworkUsers.php create mode 100644 src/Region/Region.php create mode 100644 src/Region/SiteSettings.php create mode 100644 src/Region/SiteUsers.php create mode 100644 src/Region/Terms.php create mode 100644 src/Region/Users.php create mode 100644 src/State/Network.php create mode 100644 src/State/Site.php create mode 100644 src/State/State.php create mode 100644 src/Utils.php create mode 100644 src/Validator.php create mode 100644 tools/php-cs-fixer/composer.json create mode 100644 wp-cli.yml diff --git a/.gitignore b/.gitignore index 391f7af..9a8e945 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ composer.phar /vendor .DS_Store composer.lock +.php-cs-fixer.cache +/tools/**/vendor diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..9bf40d7 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,15 @@ +exclude(['tools', 'vendor', 'wp']) + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); + +return $config->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + ]) + ->setFinder($finder); diff --git a/.travis.yml b/.travis.yml index 6ffd7b7..bb51ce7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: php +dist: focal + branches: only: - master @@ -22,26 +24,30 @@ env: - WP_CLI_TEST_DBHOST=127.0.0.1 install: - - composer install + - composer update - composer prepare-tests + - composer install-tools script: - composer behat || composer behat-rerun jobs: + allow_failures: + - php: 8.1.0 # PHP 8.1 times out currently. include: - stage: lint script: - composer lint - - composer phpcs + - composer php-cs-fixer:test + php: 7.4 env: BUILD=lint - stage: test - php: 5.6 + php: 7.4 env: - WP_VERSION=latest - stage: test - php: 7.4 + php: 8.0 env: WP_VERSION=latest - stage: test - php: 8.0 + php: 8.1.0 env: WP_VERSION=latest diff --git a/autoload.php b/autoload.php deleted file mode 100644 index 6a49938..0000000 --- a/autoload.php +++ /dev/null @@ -1,31 +0,0 @@ -=5.6" + "php": "^7.4 || ^8.0 || ^8.1", + "wp-cli/mustangostang-spyc": "^0.6.3" }, "autoload": { + "psr-4": { + "BoxUk\\Dictator\\": "src/" + }, "files": [ "dictator.php" ] }, "require-dev": { @@ -32,7 +36,7 @@ "scripts": { "behat": "run-behat-tests", "behat-rerun": "rerun-behat-tests", - "lint": "run-linter-tests", + "lint": "run-linter-tests --exclude tools", "phpcs": "run-phpcs-tests", "phpunit": "run-php-unit-tests", "prepare-tests": "install-package-tests", @@ -41,7 +45,10 @@ "@phpcs", "@phpunit", "@behat" - ] + ], + "install-tools": "@composer update -W --working-dir=tools/php-cs-fixer", + "php-cs-fixer:test": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix -v --diff --dry-run", + "php-cs-fixer:fix": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix -v --diff" }, "config": { "allow-plugins": { diff --git a/dictator.php b/dictator.php index 71cbcd7..6a81e75 100644 --- a/dictator.php +++ b/dictator.php @@ -1,25 +1,28 @@ - * : State to export - * - * - * : Where the state should be exported to - * - * [--regions=] - * : Limit the export to one or more regions. - * - * [--force] - * : Forcefully overwrite an existing state file if one exists. - * - * @subcommand export - * - * @param array $args Args. - * @param array $assoc_args Assoc Args. - * - * @throws \WP_CLI\ExitException Exits on error, such as bad state supplied. - */ - public function export( $args, $assoc_args ) { - - list( $state, $file ) = $args; - - if ( file_exists( $file ) && ! isset( $assoc_args['force'] ) ) { - WP_CLI::confirm( 'Are you sure you want to overwrite the existing state file?' ); - } - - $state_obj = Dictator::get_state_obj( $state ); - if ( ! $state_obj ) { - WP_CLI::error( 'Invalid state supplied.' ); - } - - $limited_regions = ! empty( $assoc_args['regions'] ) ? explode( ',', $assoc_args['regions'] ) : array(); - - // Build the state's data. - $state_data = array( 'state' => $state ); - foreach ( $state_obj->get_regions() as $region_obj ) { - - $region_name = $state_obj->get_region_name( $region_obj ); - - if ( $limited_regions && ! in_array( $region_name, $limited_regions, true ) ) { - continue; - } - - $state_data[ $region_name ] = $region_obj->get_current_data(); - } - - $this->write_state_file( $state_data, $file ); - - WP_CLI::success( 'State written to file.' ); - } - - /** - * Impose a given state file onto WordPress. - * - * ## OPTIONS - * - * - * : State file to impose - * - * [--regions=] - * : Limit the imposition to one or more regions. - * - * @subcommand impose - * - * @param array $args Args. - * @param array $assoc_args Assoc args. - */ - public function impose( $args, $assoc_args ) { - - list( $file ) = $args; - - $yaml = $this->load_state_file( $file ); - - $this->validate_state_data( $yaml ); - - $state_obj = Dictator::get_state_obj( $yaml['state'], $yaml ); - - $limited_regions = ! empty( $assoc_args['regions'] ) ? explode( ',', $assoc_args['regions'] ) : array(); - - foreach ( $state_obj->get_regions() as $region_obj ) { - - $region_name = $state_obj->get_region_name( $region_obj ); - - if ( $limited_regions && ! in_array( $region_name, $limited_regions, true ) ) { - continue; - } - - if ( $region_obj->is_under_accord() ) { - continue; - } - - WP_CLI::line( sprintf( '%s:', $region_name ) ); - - // Render the differences for the region. - $differences = $region_obj->get_differences(); - foreach ( $differences as $slug => $difference ) { - $this->show_difference( $slug, $difference ); - - $to_impose = \Dictator::array_diff_recursive( $difference['dictated'], $difference['current'] ); - $ret = $region_obj->impose( $slug, $difference['dictated'] ); - if ( is_wp_error( $ret ) ) { - WP_CLI::warning( $ret->get_error_message() ); - } - } - } - - WP_CLI::success( 'The Dictator has imposed upon the State of WordPress.' ); - - } - - /** - * Compare a given state file to the State of WordPress. - * Produces a colorized diff if differences, otherwise empty output. - * - * ## OPTIONS - * - * - * : State file to compare - * - * @subcommand compare - * @alias diff - * - * @param arrau $args Args. - * @param array $assoc_args Assoc args. - */ - public function compare( $args, $assoc_args ) { - - list( $file ) = $args; - - $yaml = $this->load_state_file( $file ); - - $this->validate_state_data( $yaml ); - - $state_obj = Dictator::get_state_obj( $yaml['state'], $yaml ); - - foreach ( $state_obj->get_regions() as $region_name => $region_obj ) { - - if ( $region_obj->is_under_accord() ) { - continue; - } - - WP_CLI::line( sprintf( '%s:', $region_name ) ); - - // Render the differences for the region. - $differences = $region_obj->get_differences(); - foreach ( $differences as $slug => $difference ) { - $this->show_difference( $slug, $difference ); - } - } - - } - - /** - * Validate the provided state file against each region's schema. - * - * ## OPTIONS - * - * - * : State file to load - * - * @subcommand validate - * - * @param array $args Args. - * @param array $assoc_args Assoc args. - */ - public function validate( $args, $assoc_args ) { - - list( $file ) = $args; - - $yaml = $this->load_state_file( $file ); - - $this->validate_state_data( $yaml ); - - WP_CLI::success( 'State validates against the schema.' ); - - } - - /** - * List registered states. - * - * @subcommand list-states - * - * @param array $args Args. - * @param array $assoc_args Assoc args. - */ - public function list_states( $args, $assoc_args ) { - - $states = Dictator::get_states(); - - $items = array(); - foreach ( $states as $name => $attributes ) { - - $state_obj = new $attributes['class'](); - $regions = implode( ',', array_keys( $state_obj->get_regions() ) ); - - $items[] = (object) array( - 'state' => $name, - 'regions' => $regions, - ); - } - - $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'state', 'regions' ) ); - $formatter->display_items( $items ); - } - - /** - * Load a given Yaml state file - * - * @param string $file Filename to load state from. - * @return object - */ - private function load_state_file( $file ) { - - if ( ! file_exists( $file ) ) { - WP_CLI::error( sprintf( "File doesn't exist: %s", $file ) ); - } - - $yaml = Mustangostang\Spyc::YAMLLoadString( file_get_contents( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - if ( empty( $yaml ) ) { - WP_CLI::error( sprintf( "Doesn't appear to be a Yaml file: %s", $file ) ); - } - - return $yaml; - } - - /** - * Validate the provided state file against each region's schema. - * - * @param array $yaml Data from the state file. - */ - private function validate_state_data( $yaml ) { - - if ( empty( $yaml['state'] ) - || ! Dictator::is_valid_state( $yaml['state'] ) ) { - WP_CLI::error( 'Incorrect state.' ); - } - - $yaml_data = $yaml; - unset( $yaml_data['state'] ); - - $state_obj = Dictator::get_state_obj( $yaml['state'], $yaml_data ); - - $has_errors = false; - foreach ( $state_obj->get_regions() as $region ) { - - $translator = new \Dictator\Dictator_Translator( $region ); - if ( ! $translator->is_valid_state_data() ) { - foreach ( $translator->get_state_data_errors() as $error_message ) { - WP_CLI::warning( $error_message ); - } - $has_errors = true; - } - } - - if ( $has_errors ) { - WP_CLI::error( "State doesn't validate." ); - } - - return true; - - } - - /** - * Write a state object to a file - * - * @param array $state_data State Data. - * @param string $file Filename to write to. - */ - private function write_state_file( $state_data, $file ) { - - $spyc = new Mustangostang\Spyc(); - $file_data = $spyc->dump( $state_data, 2, 0 ); - // Remove prepended "---\n" from output of the above call. - $file_data = substr( $file_data, 4 ); - file_put_contents( $file, $file_data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents - } - - /** - * Visually depict the difference between "dictated" and "current" - * - * @param string $slug Slug. - * @param array $difference Difference to show. - */ - private function show_difference( $slug, $difference ) { - - $this->output_nesting_level = 0; - - // Data already exists within WordPress. - if ( ! empty( $difference['current'] ) ) { - - $this->nested_line( $slug . ': ' ); - - $this->recursively_show_difference( $difference['dictated'], $difference['current'] ); - - } else { - - $this->add_line( $slug . ': ' ); - - $this->recursively_show_difference( $difference['dictated'] ); - - } - - $this->output_nesting_level = 0; - - } - - /** - * Recursively output the difference between "dictated" and "current" - * - * @param mixed $dictated Dictated state. - * @param mixed|null $current Current state. - */ - private function recursively_show_difference( $dictated, $current = null ) { - - $this->output_nesting_level++; - - if ( $this->is_assoc_array( $dictated ) ) { - - foreach ( $dictated as $key => $value ) { - - if ( $this->is_assoc_array( $value ) || is_array( $value ) ) { - - $new_current = isset( $current[ $key ] ) ? $current[ $key ] : null; - if ( $new_current ) { - $this->nested_line( $key . ': ' ); - } else { - $this->add_line( $key . ': ' ); - } - - $this->recursively_show_difference( $value, $new_current ); - - } elseif ( is_string( $value ) ) { - - $pre = $key . ': '; - - if ( isset( $current[ $key ] ) && $current[ $key ] !== $value ) { - - $this->remove_line( $pre . $current[ $key ] ); - $this->add_line( $pre . $value ); - - } elseif ( ! isset( $current[ $key ] ) ) { - - $this->add_line( $pre . $value ); - - } - } - } - } elseif ( is_array( $dictated ) ) { - - foreach ( $dictated as $value ) { - - if ( ! $current - || ! in_array( $value, $current, true ) ) { - $this->add_line( '- ' . $value ); - } - } - } elseif ( is_string( $value ) ) { - - $pre = $key . ': '; - - if ( isset( $current[ $key ] ) && $current[ $key ] !== $value ) { - - $this->remove_line( $pre . $current[ $key ] ); - $this->add_line( $pre . $value ); - - } elseif ( ! isset( $current[ $key ] ) ) { - - $this->add_line( $pre . $value ); - - } else { - - $this->nested_line( $pre ); - - } - } - - $this->output_nesting_level--; - - } - - /** - * Output a line to be added - * - * @param string $line Line to add. - */ - private function add_line( $line ) { - $this->nested_line( $line, 'add' ); - } - - /** - * Output a line to be removed - * - * @param string $line Line to remove. - */ - private function remove_line( $line ) { - $this->nested_line( $line, 'remove' ); - } - - /** - * Output a line that's appropriately nested - * - * @param string $line Line to show. - * @param mixed|bool $change Whether to display green or red. 'add' for green, 'remove' for red. - */ - private function nested_line( $line, $change = false ) { - - if ( 'add' === $change ) { - $color = '%G'; - $label = '+ '; - } elseif ( 'remove' === $change ) { - $color = '%R'; - $label = '- '; - } else { - $color = false; - $label = false; - } - - \cli\Colors::colorize( '%n' ); - - $spaces = ( $this->output_nesting_level * 2 ) + 2; - if ( $color && $label ) { - $line = \cli\Colors::colorize( "{$color}{$label}" ) . $line . \cli\Colors::colorize( '%n' ); - $spaces = $spaces - 2; - } - WP_CLI::line( str_pad( ' ', $spaces ) . $line ); - } - - /** - * Whether or not this is an associative array - * - * @param array $array Array to check. - * @return bool - */ - private function is_assoc_array( $array ) { - - if ( ! is_array( $array ) ) { - return false; - } - - return array_keys( $array ) !== range( 0, count( $array ) - 1 ); - } - - -} - -WP_CLI::add_command( 'dictator', 'Dictator_CLI_Command' ); diff --git a/php/class-dictator-translator.php b/php/class-dictator-translator.php deleted file mode 100644 index 790078c..0000000 --- a/php/class-dictator-translator.php +++ /dev/null @@ -1,177 +0,0 @@ -region = $region; - - } - - /** - * Whether or not the state data provided is valid - * - * @return bool - */ - public function is_valid_state_data() { - - $this->current_schema_attribute = 'region'; - - $this->recursively_validate_state_data( $this->region->get_schema(), $this->region->get_imposed_data() ); - - $this->current_schema_attribute = null; - - if ( empty( $this->state_data_errors ) ) { - return true; - } else { - return false; - } - } - - /** - * Get the errors generated when validating the state data - * - * @return array - */ - public function get_state_data_errors() { - return $this->state_data_errors; - } - - /** - * Dive into the schema to see if the provided state data validates - * - * @param mixed $schema Schema to validate against. - * @param mixed $state_data Data to validate. - */ - protected function recursively_validate_state_data( $schema, $state_data ) { - - if ( ! empty( $schema['_required'] ) && is_null( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' is required for the region.", $this->current_schema_attribute ); - return; - } elseif ( is_null( $state_data ) ) { - return; - } - - switch ( $schema['_type'] ) { - - case 'prototype': - if ( 'prototype' === $schema['_prototype']['_type'] ) { - - foreach ( $state_data as $key => $attribute_data ) { - - $this->current_schema_attribute = $key; - - $this->recursively_validate_state_data( $schema['_prototype']['_prototype'], $attribute_data ); - } - } elseif ( 'array' === $schema['_prototype']['_type'] ) { - - foreach ( $state_data as $key => $child_data ) { - - foreach ( $schema['_prototype']['_children'] as $schema_key => $child_schema ) { - - $this->current_schema_attribute = $schema_key; - - if ( ! empty( $child_schema['_required'] ) && empty( $child_data[ $schema_key ] ) ) { - $this->state_data_errors[] = sprintf( "'%s' is required for the region.", $this->current_schema_attribute ); - continue; - } - - $this->recursively_validate_state_data( - $child_schema, - isset( $child_data[ $schema_key ] ) ? $child_data[ $schema_key ] : null - ); - } - } - } - - break; - - case 'array': - if ( $state_data && ! is_array( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' needs to be an array.", $this->current_schema_attribute ); - } - - // Arrays can have schemas defined for each child attribute. - if ( ! empty( $schema['_children'] ) ) { - - foreach ( $schema['_children'] as $attribute => $attribute_schema ) { - - $this->current_schema_attribute = $attribute; - - $this->recursively_validate_state_data( - $attribute_schema, - isset( $state_data[ $attribute ] ) ? $state_data[ $attribute ] : null - ); - - } - } - - break; - - case 'bool': - if ( ! is_bool( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' needs to be true or false.", $this->current_schema_attribute ); - } - - break; - - case 'numeric': - if ( ! is_numeric( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' needs to be numeric.", $this->current_schema_attribute ); - } - - break; - - case 'text': - // Nothing to do here. - if ( $state_data && ! is_string( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' needs to be a string.", $this->current_schema_attribute ); - } - - break; - - case 'email': - if ( $state_data && ! is_email( $state_data ) ) { - $this->state_data_errors[] = sprintf( "'%s' needs to be an email address.", $this->current_schema_attribute ); - } - - break; - - } - - } - -} diff --git a/php/class-dictator.php b/php/class-dictator.php deleted file mode 100644 index 6c079e3..0000000 --- a/php/class-dictator.php +++ /dev/null @@ -1,195 +0,0 @@ -add_state( $name, $class ); - } - - // @todo validate the class is callable and the schema exists - - $state = array( - 'class' => $class, - ); - - self::$instance->states[ $name ] = $state; - } - - /** - * Get all of the states registered with Dictator - * - * @return array - */ - public static function get_states() { - - if ( self::called_statically() ) { - return self::get_instance()->get_states(); - } - - return self::$instance->states; - } - - /** - * Whether or not the state is valid - * - * @param string $name Name of the state. - * @return bool - */ - public static function is_valid_state( $name ) { - - if ( self::called_statically() ) { - return self::get_instance()->is_valid_state( $name ); - } - - if ( isset( self::$instance->states[ $name ] ) ) { - return true; - } else { - return false; - } - - } - - /** - * Get the object for a given state - * - * @param string $name Name of the state. - * @param array $yaml Data from the state file. - * @return object|false - */ - public static function get_state_obj( $name, $yaml = null ) { - - if ( self::called_statically() ) { - return self::get_instance()->get_state_obj( $name, $yaml ); - } - - if ( ! isset( self::$instance->states[ $name ] ) ) { - return false; - } - - $class = self::$instance->states[ $name ]['class']; - - return new $class( $yaml ); - } - - /** - * Get the schema object for a state - * - * @param string $name Name of the state. - * @return object|false - */ - public static function get_state_schema_obj( $name ) { - - if ( self::called_statically() ) { - return self::get_instance()->get_state_schema( $name ); - } - - if ( ! isset( self::$instance->states[ $name ] ) ) { - return false; - } - - $state = self::$instance->states[ $name ]; - - $schema_file = $state['schema']; - if ( ! file_exists( $schema_file ) ) { - $schema_file = dirname( __DIR__ ) . '/schemas/' . $schema_file; - } - - $schema_yaml = Mustangostang\Spyc::YAMLLoadString( file_get_contents( $schema_file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - - return new MetaYaml( $schema_yaml ); - - } - - /** - * Recursive difference of an array - * - * @see https://gist.github.com/vincenzodibiaggio/5965342 - * - * @param array $array_1 First array. - * @param array $array_2 Second array. - * @return array - */ - public static function array_diff_recursive( $array_1, $array_2 ) { - - $ret = array(); - - foreach ( $array_1 as $key => $value ) { - - if ( array_key_exists( $key, $array_2 ) ) { - - if ( is_array( $value ) ) { - - $recursive_diff = self::array_diff_recursive( $value, $array_2[ $key ] ); - - if ( count( $recursive_diff ) ) { - $ret[ $key ] = $recursive_diff; - } - } else { - - if ( $value !== $array_2[ $key ] ) { - - $ret[ $key ] = $value; - - } - } - } else { - - $ret[ $key ] = $value; - - } - } - - return $ret; - - } - -} diff --git a/php/regions/class-network-settings.php b/php/regions/class-network-settings.php deleted file mode 100644 index dd079c6..0000000 --- a/php/regions/class-network-settings.php +++ /dev/null @@ -1,192 +0,0 @@ - 'array', - '_children' => array( - 'title' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'admin_email' => array( - '_type' => 'email', - '_required' => false, - '_get_callback' => 'get', - ), - 'super_admins' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get', - ), - 'registration' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'notify_registration' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'upload_filetypes' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'site_unlimited_upload' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'site_upload_space' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - 'site_max_upload' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - 'enabled_themes' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get', - ), - 'active_plugins' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get', - ), - ), - ); - - /** - * Correct core's confusing option names - * - * @var array $options_map - */ - protected $options_map = array( - 'title' => 'site_name', - 'super_admins' => 'site_admins', - 'notify_registration' => 'registrationnotification', - 'site_unlimited_upload' => 'upload_space_check_disabled', - 'site_upload_space' => 'blog_upload_space', - 'site_max_upload' => 'fileupload_maxk', - 'enabled_themes' => 'allowedthemes', - 'active_plugins' => 'active_sitewide_plugins', - ); - - /** - * Impose some data onto the region - * How the data is interpreted depends - * on the region - * - * @param null $_ Unused. - * @param array $options Options to impose. - * - * @return true|WP_Error - */ - public function impose( $_, $options ) { - - foreach ( $options as $key => $value ) { - - if ( array_key_exists( $key, $this->options_map ) ) { - $key = $this->options_map[ $key ]; - } - - switch ( $key ) { - case 'allowedthemes': - $allowedthemes = array(); - foreach ( $value as $theme ) { - $allowedthemes[ $theme ] = true; - } - update_site_option( 'allowedthemes', $allowedthemes ); - break; - - case 'active_sitewide_plugins': - foreach ( $value as $plugin ) { - activate_plugin( $plugin, '', true ); - } - break; - - case 'registrationnotification': - if ( $value ) { - update_site_option( $key, 'yes' ); - } else { - update_site_option( $key, 'no' ); - } - break; - - case 'upload_space_check_disabled': - case 'blog_upload_space': - case 'fileupload_maxk': - update_site_option( $key, intval( $value ) ); - break; - - default: - update_site_option( $key, $value ); - break; - } - } - - return true; - } - - /** - * Get the differences between the state file and WordPress - * - * @return array - */ - public function get_differences() { - - $result = array( - 'dictated' => $this->get_imposed_data(), - 'current' => $this->get_current_data(), - ); - - if ( \Dictator::array_diff_recursive( $result['dictated'], $result['current'] ) ) { - return array( 'option' => $result ); - } else { - return array(); - } - } - - /** - * Get the value for the setting - * - * @param string $name Name to get value for. - * @return mixed - */ - public function get( $name ) { - - if ( array_key_exists( $name, $this->options_map ) ) { - $name = $this->options_map[ $name ]; - } - - // Data transformation if we need to. - switch ( $name ) { - case 'allowedthemes': - case 'active_sitewide_plugins': - // Coerce to array of names. - return array_keys( get_site_option( $name, array() ) ); - - case 'registrationnotification': - // Coerce to boolean. - return ( 'yes' === get_site_option( $name ) ); - default: - return get_site_option( $name ); - } - - } - -} diff --git a/php/regions/class-network-sites.php b/php/regions/class-network-sites.php deleted file mode 100644 index 9457466..0000000 --- a/php/regions/class-network-sites.php +++ /dev/null @@ -1,424 +0,0 @@ - 'prototype', - '_get_callback' => 'get_sites', - '_prototype' => array( - '_type' => 'array', - '_children' => array( - 'custom_domain' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'title' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'description' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'active_theme' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'active_plugins' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'users' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'timezone_string' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - 'WPLANG' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_site_value', - ), - ), - ), - ); - - /** - * Object-level cache. - * - * @var $sites - */ - protected $sites; - - /** - * Get the differences between declared sites and sites on network - * - * @return array - */ - public function get_differences() { - - if ( isset( $this->differences ) ) { - return $this->differences; - } - - $this->differences = array(); - // Check each declared site in state data against WordPress. - foreach ( $this->get_imposed_data() as $site_label => $site_data ) { - - $custom_domain = isset( $site_data['custom_domain'] ) ? $site_data['custom_domain'] : ''; - $site_slug = $this->get_site_slug( get_current_site(), $site_label, $custom_domain ); - $site_result = $this->get_site_difference( $site_slug, $site_data ); - - if ( ! empty( $site_result ) ) { - $this->differences[ $site_label ] = $site_result; - } - } - - return $this->differences; - - } - - /** - * Impose some state data onto a region - * - * @param string $key Site slug. - * @param array $value Site data. - * @return true|WP_Error - */ - public function impose( $key, $value ) { - $custom_domain = isset( $value['custom_domain'] ) ? $value['custom_domain'] : ''; - $site_slug = $this->get_site_slug( get_current_site(), $key, $custom_domain ); - - $site = $this->get_site( $site_slug ); - if ( ! $site ) { - $site = $this->create_site( $key, $value ); - if ( is_wp_error( $site ) ) { - return $site; - } - } - - switch_to_blog( $site->blog_id ); - foreach ( $value as $field => $single_value ) { - - switch ( $field ) { - - case 'title': - case 'description': - $map = array( - 'title' => 'blogname', - 'description' => 'blogdescription', - ); - update_option( $map[ $field ], $single_value ); - break; - - case 'active_theme': - if ( $single_value !== get_option( 'stylesheet' ) ) { - switch_theme( $single_value ); - } - - break; - - case 'active_plugins': - foreach ( $single_value as $plugin ) { - - if ( ! is_plugin_active( $plugin ) ) { - activate_plugin( $plugin ); - } - } - - break; - - case 'users': - foreach ( $single_value as $user_login => $role ) { - $user = get_user_by( 'login', $user_login ); - if ( ! $user ) { - continue; - } - - add_user_to_blog( $site->blog_id, $user->ID, $role ); - } - - break; - - case 'WPLANG': - add_network_option( $site->blog_id, $field, $single_value ); - break; - - default: - update_option( $field, $single_value ); - - break; - - } - } - restore_current_blog(); - - return true; - - } - - /** - * Get a list of all the sites on the network - * - * @return array - */ - protected function get_sites() { - - if ( isset( $this->sites ) && is_array( $this->sites ) ) { - return array_keys( $this->sites ); - } - - $args = array( - 'limit' => 200, - 'offset' => 0, - ); - $sites = array(); - if ( ! is_multisite() ) { - return $this->sites; - } - do { - - $sites_results = get_sites( $args ); - $sites = array_merge( $sites, $sites_results ); - - $args['offset'] += $args['limit']; - - } while ( $sites_results ); - - $this->sites = array(); - foreach ( $sites as $site ) { - $site_slug = $this->get_site_slug( $site ); - $this->sites[ $site_slug ] = $site; - } - return array_keys( $this->sites ); - } - - /** - * Get the value on a given site - * - * @param string $key Key to get value for. - * @return mixed - */ - protected function get_site_value( $key ) { - - $site_slug = $this->current_schema_attribute_parents[0]; - $site = $this->get_site( $site_slug ); - - switch_to_blog( $site->blog_id ); - - switch ( $key ) { - - case 'custom_domain': - $value = isset( $site->domain ) ? $site->domain : ''; - break; - case 'title': - case 'description': - case 'active_theme': - $map = array( - 'title' => 'blogname', - 'description' => 'blogdescription', - 'active_theme' => 'stylesheet', - ); - $value = get_option( $map[ $key ] ); - break; - - case 'active_plugins': - $value = get_option( $key, array() ); - break; - - case 'users': - $value = array(); - - $site_users = get_users(); - foreach ( $site_users as $site_user ) { - $value[ $site_user->user_login ] = array_shift( $site_user->roles ); - } - break; - - case 'WPLANG': - $value = get_network_option( $site->blog_id, $key ); - break; - - default: - $value = get_option( $key ); - break; - - } - restore_current_blog(); - - return $value; - - } - - /** - * Get the difference of the site data to the site on the network - * - * @param string $site_slug Site slug. - * @param array $site_data Site data. - * @return array|false - */ - protected function get_site_difference( $site_slug, $site_data ) { - - $site_result = array( - 'dictated' => $site_data, - 'current' => array(), - ); - - $sites = $this->get_current_data(); - - // If there wasn't a matched site, the site must not exist. - if ( empty( $sites[ $site_slug ] ) ) { - return $site_result; - } - - $site_result['current'] = $sites[ $site_slug ]; - - if ( \Dictator::array_diff_recursive( $site_result['dictated'], $site_result['current'] ) ) { - return $site_result; - } else { - return false; - } - - } - - /** - * Get a site by its slug - * - * @param string $site_slug Site slug. - * @return WP_Site|false - */ - protected function get_site( $site_slug ) { - - // Maybe prime the cache. - $this->get_sites(); - if ( ! empty( $this->sites[ $site_slug ] ) ) { - return $this->sites[ $site_slug ]; - } - - return false; - - } - - /** - * Create a new site - * - * @param string $key Key of site. - * @param mixed $value Value. - * @return array|WP_Error - */ - protected function create_site( $key, $value ) { - - global $wpdb, $current_site; - - $base = $key; - $title = ucfirst( $base ); - $network = $current_site; - $meta = $value; - if ( ! $network ) { - $networks = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $wpdb->site WHERE id = %d", 1 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - if ( ! empty( $networks ) ) { - $network = $networks[0]; - } - } - - // Sanitize. - if ( preg_match( '|^([a-zA-Z0-9-])+$|', $base ) ) { - $base = strtolower( $base ); - } - - // If not a subdomain install, make sure the domain isn't a reserved word. - if ( ! is_subdomain_install() ) { - $subdirectory_reserved_names = apply_filters( 'subdirectory_reserved_names', array( 'page', 'comments', 'blog', 'files', 'feed' ) ); - if ( in_array( $base, $subdirectory_reserved_names, true ) ) { - return new \WP_Error( 'reserved-word', 'The following words are reserved and cannot be used as blog names: ' . implode( ', ', $subdirectory_reserved_names ) ); - } - } - - if ( is_subdomain_install() ) { - $path = '/'; - $prefix = ''; - if ( $base !== '' ) { - $prefix = $base . '.'; - } - $newdomain = $prefix . preg_replace( '|^www\.|', '', $network->domain ); - } else { - $newdomain = $network->domain; - $path = '/' . trim( $base, '/' ) . '/'; - } - - // Custom domain trumps all. - if ( ! empty( $value['custom_domain'] ) ) { - $newdomain = $value['custom_domain']; - $path = '/'; - unset( $value['custom_domain'] ); - } - - $user_id = 0; - $super_admins = get_super_admins(); - if ( ! empty( $super_admins ) && is_array( $super_admins ) ) { - // Just get the first one. - $super_login = $super_admins[0]; - $super_user = get_user_by( 'login', $super_login ); - if ( $super_user ) { - $user_id = $super_user->ID; - } - } - - $wpdb->hide_errors(); - $id = wpmu_create_blog( $newdomain, $path, $title, $user_id, $meta, $network->id ); - $wpdb->show_errors(); - - if ( is_wp_error( $id ) ) { - return $id; - } else { - // Reset our internal cache. - unset( $this->sites ); - return $this->get_site( $this->get_site_slug( get_site( $id ) ) ); - } - - } - - /** - * Use the domain plus path for the slug of or sites array. We can pass a key to overwrite path, - * we can pass a custom domain which overwrites the domain and 'resets' the path. - * - * @param \WP_Site | \WP_Network $site_or_network A site or network object. - * @param string $key A key to overwrite path if not using a custom domain. - * @param string $custom_domain A custom domain to overwrite the domain and reset the path. - */ - protected function get_site_slug( $site_or_network, $key = '', $custom_domain = '' ) { - $domain = $site_or_network->domain; - $path = $key !== '' ? '/' . $key : $site_or_network->path; - - if ( ! empty( $custom_domain ) && $domain !== $custom_domain ) { - $domain = $custom_domain; - $path = '/'; - } - - if ( $path !== '/' && is_subdomain_install() ) { - return trim( $path . '.' . $domain, '/' ); - } - - return trim( $domain . $path, '/' ); - } - -} diff --git a/php/regions/class-network-users.php b/php/regions/class-network-users.php deleted file mode 100644 index af6b82b..0000000 --- a/php/regions/class-network-users.php +++ /dev/null @@ -1,7 +0,0 @@ -data = $data; - - } - - /** - * Whether or not the current state of the region - * matches the state file - * - * @return bool - */ - public function is_under_accord() { - - $results = $this->get_differences(); - if ( empty( $results ) ) { - return true; - } else { - return false; - } - - } - - /** - * Get the schema for this region - * - * @return array - */ - public function get_schema() { - return $this->schema; - } - - /** - * Impose some data onto the region - * How the data is interpreted depends - * on the region - * - * @param string $key Key of the data to impose. - * @param mixed $value Value of the data to impose. - * @return true|WP_Error - */ - abstract public function impose( $key, $value ); - - /** - * Get the differences between the state file and WordPress - * - * @return array - */ - abstract public function get_differences(); - - /** - * Get the current data for the region - * - * @return array - */ - public function get_current_data() { - - if ( isset( $this->current_data ) ) { - return $this->current_data; - } - - $this->current_data = $this->recursively_get_current_data( $this->get_schema() ); - return $this->current_data; - } - - /** - * Get the imposed data for the region - */ - public function get_imposed_data() { - - return $this->data; - - } - - /** - * Recursively get the current data for the region - * - * @param array $schema Schema array. - * @return mixed - */ - private function recursively_get_current_data( $schema ) { - - switch ( $schema['_type'] ) { - - case 'prototype': - if ( isset( $schema['_get_callback'] ) ) { - $prototype_vals = call_user_func( array( $this, $schema['_get_callback'] ), $this->current_schema_attribute ); - - $data = array(); - if ( ! empty( $prototype_vals ) ) { - foreach ( $prototype_vals as $prototype_val ) { - $this->current_schema_attribute = $prototype_val; - - $this->current_schema_attribute_parents[] = $prototype_val; - $data[ $prototype_val ] = $this->recursively_get_current_data( $schema['_prototype'] ); - array_pop( $this->current_schema_attribute_parents ); - - } - } - return $data; - } - - break; - - case 'array': - // Arrays can have schemas defined for each child attribute. - if ( ! empty( $schema['_children'] ) ) { - - $data = array(); - foreach ( $schema['_children'] as $attribute => $attribute_schema ) { - - $this->current_schema_attribute = $attribute; - - $data[ $attribute ] = $this->recursively_get_current_data( $attribute_schema ); - - } - return $data; - - } else { - - if ( isset( $schema['_get_callback'] ) ) { - return call_user_func( array( $this, $schema['_get_callback'] ), $this->current_schema_attribute ); - } - } - - break; - - case 'text': - case 'email': - case 'bool': - case 'numeric': - if ( isset( $schema['_get_callback'] ) ) { - $value = call_user_func( array( $this, $schema['_get_callback'] ), $this->current_schema_attribute ); - if ( $schema['_type'] === 'bool' ) { - $value = (bool) $value; - } elseif ( $schema['_type'] === 'numeric' ) { - $value = intval( $value ); - } - - return $value; - } - - break; - - } - - } - -} diff --git a/php/regions/class-site-settings.php b/php/regions/class-site-settings.php deleted file mode 100644 index 072c8f1..0000000 --- a/php/regions/class-site-settings.php +++ /dev/null @@ -1,281 +0,0 @@ - 'array', - '_children' => array( - /** - * General - */ - 'title' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'description' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'admin_email' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'timezone' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'WPLANG' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'date_format' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'time_format' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - /** - * Reading - */ - 'public' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'posts_per_page' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - 'posts_per_feed' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - 'feed_uses_excerpt' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'show_on_front' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'page_on_front' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - 'page_for_posts' => array( - '_type' => 'numeric', - '_required' => false, - '_get_callback' => 'get', - ), - /** - * Discussion - */ - 'allow_comments' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'allow_pingbacks' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'notify_comments' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - 'notify_moderation' => array( - '_type' => 'bool', - '_required' => false, - '_get_callback' => 'get', - ), - /** - * Permalinks - */ - 'permalink_structure' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'category_base' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'tag_base' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - /** - * Theme / plugins - */ - 'active_theme' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get', - ), - 'active_plugins' => array( - '_type' => 'array', - '_required' => false, - '_get_callback' => 'get', - ), - ), - ); - - /** - * Correct core's confusing option names. - * - * @var array $options_map - */ - protected $options_map = array( - 'title' => 'blogname', - 'description' => 'blogdescription', - 'timezone' => 'timezone_string', - 'public' => 'blog_public', - 'posts_per_feed' => 'posts_per_rss', - 'feed_uses_excerpt' => 'rss_use_excerpt', - 'allow_comments' => 'default_comment_status', - 'allow_pingbacks' => 'default_ping_status', - 'notify_comments' => 'comments_notify', - 'notify_moderation' => 'moderation_notify', - ); - - /** - * Impose some data onto the region - * How the data is interpreted depends - * on the region - * - * @param null $_ Unused. - * @param array $options Options to impose. - * @return true|WP_Error - */ - public function impose( $_, $options ) { - - foreach ( $options as $key => $value ) { - - if ( array_key_exists( $key, $this->options_map ) ) { - $key = $this->options_map[ $key ]; - } - - switch ( $key ) { - - case 'active_theme': - if ( $value !== get_option( 'stylesheet' ) ) { - switch_theme( $value ); - } - break; - - case 'active_plugins': - foreach ( $value as $plugin ) { - - if ( ! is_plugin_active( $plugin ) ) { - activate_plugin( $plugin ); - } - } - break; - - // Boolean stored as 0 or 1. - case 'blog_public': - case 'rss_use_excerpt': - case 'comments_notify': - case 'moderation_notify': - update_option( $key, intval( $value ) ); - break; - - // Boolean stored as 'open' or 'closed'. - case 'default_comment_status': - case 'default_ping_status': - if ( $value ) { - update_option( $key, 'open' ); - } else { - update_option( $key, 'closed' ); - } - break; - - default: - update_option( $key, $value ); - break; - } - } - - return true; - } - - /** - * Get the differences between the state file and WordPress - * - * @return array - */ - public function get_differences() { - - $result = array( - 'dictated' => $this->get_imposed_data(), - 'current' => $this->get_current_data(), - ); - - if ( \Dictator::array_diff_recursive( $result['dictated'], $result['current'] ) ) { - return array( 'option' => $result ); - } else { - return array(); - } - } - - /** - * Get the value for the setting - * - * @param string $name Name to get value for. - * @return mixed - */ - public function get( $name ) { - - if ( array_key_exists( $name, $this->options_map ) ) { - $name = $this->options_map[ $name ]; - } - - switch ( $name ) { - case 'active_theme': - $value = get_option( 'stylesheet' ); - break; - - default: - $value = get_option( $name ); - break; - } - - // Data transformation if we need to. - switch ( $name ) { - case 'default_comment_status': - case 'default_ping_status': - $value = ( 'open' === $value ) ? true : false; - break; - - } - - return $value; - - } - -} diff --git a/php/regions/class-site-users.php b/php/regions/class-site-users.php deleted file mode 100644 index 57b7a2c..0000000 --- a/php/regions/class-site-users.php +++ /dev/null @@ -1,8 +0,0 @@ - 'prototype', - '_get_callback' => 'get_taxonomies', - '_prototype' => array( - '_type' => 'prototype', - '_get_callback' => 'get_terms', - '_prototype' => array( - '_type' => 'array', - '_children' => array( - 'name' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_term_value', - '_update_callback' => '', - ), - 'description' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_term_value', - '_update_callback' => '', - ), - 'parent' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_term_value', - '_update_callback' => '', - ), - ), - ), - ), - ); - - /** - * Object-level cache of the term data - * - * @var array $terms - */ - protected $terms = array(); - - /** - * Impose some data onto the region - * How the data is interpreted depends - * on the region - * - * @param string $key Key of the data to impose. - * @param mixed $value Value to impose. - * @return true|WP_Error - */ - public function impose( $key, $value ) { - - if ( ! taxonomy_exists( $key ) ) { - return new \WP_Error( 'invalid-taxonomy', 'Invalid taxonomy' ); - } - - foreach ( $value as $slug => $term_values ) { - - $term = get_term_by( 'slug', $slug, $key ); - if ( ! $term ) { - - $ret = wp_insert_term( $slug, $key ); - if ( is_wp_error( $ret ) ) { - return $ret; - } - $term = get_term_by( 'id', $ret['term_id'], $key ); - } - - foreach ( $term_values as $yml_field => $term_value ) { - - switch ( $yml_field ) { - case 'name': - case 'description': - if ( $term_value === $term->$yml_field ) { - break; - } - - wp_update_term( $term->term_id, $key, array( $yml_field => $term_value ) ); - - break; - - case 'parent': - if ( $term_value ) { - - $parent_term = get_term_by( 'slug', $term_value, $key ); - if ( ! $parent_term ) { - return new \WP_Error( 'invalid-parent', sprintf( 'Parent is invalid for term: %s', $slug ) ); - } - - if ( $parent_term->term_id === $term->parent ) { - break; - } - - wp_update_term( $term->term_id, $key, array( 'parent' => $parent_term->term_id ) ); - } elseif ( ! $term_value && $term->parent ) { - wp_update_term( $term->term_id, $key, array( 'parent' => 0 ) ); - } - - break; - } - } - } - - return true; - } - - /** - * Get the differences between the state file and WordPress - * - * @return array - */ - public function get_differences() { - - $this->differences = array(); - // Check each declared term in state data against WordPress. - foreach ( $this->get_imposed_data() as $taxonomy => $taxonomy_data ) { - - $result = $this->get_taxonomy_difference( $taxonomy, $taxonomy_data ); - - if ( ! empty( $result ) ) { - $this->differences[ $taxonomy ] = $result; - } - } - - return $this->differences; - } - - /** - * Get the taxonomies on this site - * - * @return array - */ - public function get_taxonomies() { - return get_taxonomies( array( 'public' => true ) ); - } - - /** - * Get the terms associated with a taxonomy on the site - * - * @param string $taxonomy Taxonomy to get terms for. - * - * @return array - */ - public function get_terms( $taxonomy ) { - - $terms = get_terms( array( $taxonomy ), array( 'hide_empty' => 0 ) ); - if ( is_wp_error( $terms ) ) { - $terms = array(); - } - - $this->terms[ $taxonomy ] = $terms; - - return wp_list_pluck( $terms, 'slug' ); - } - - /** - * Get the value associated with a given term - * - * @param string $key Key to get term value for. - * @return string - */ - public function get_term_value( $key ) { - - $taxonomy = $this->current_schema_attribute_parents[0]; - $term_slug = $this->current_schema_attribute_parents[1]; - foreach ( $this->terms[ $taxonomy ] as $term ) { - if ( $term->slug === $term_slug ) { - break; - } - } - - switch ( $key ) { - - case 'parent': - $parent = false; - foreach ( $this->terms[ $taxonomy ] as $maybe_parent_term ) { - if ( $maybe_parent_term->term_id === $term->parent ) { - $parent = $maybe_parent_term; - } - } - if ( ! empty( $parent ) ) { - $value = $parent->slug; - } else { - $value = ''; - } - break; - - default: - $value = $term->$key; - break; - - } - - return $value; - } - - /** - * Get the difference between the declared taxonomy state and - * the actual taxonomy state - * - * @param string $taxonomy Taxonomy to get difference for. - * @param array $taxonomy_data Taxonomy data. - * @return array|false - */ - protected function get_taxonomy_difference( $taxonomy, $taxonomy_data ) { - - $result = array( - 'dictated' => $taxonomy_data, - 'current' => array(), - ); - - $current_data = $this->get_current_data(); - if ( ! isset( $current_data[ $taxonomy ] ) ) { - return $result; - } - - $result['current'] = $current_data[ $taxonomy ]; - - if ( \Dictator::array_diff_recursive( $result['dictated'], $result['current'] ) ) { - return $result; - } else { - return false; - } - - } - - - -} diff --git a/php/regions/class-users.php b/php/regions/class-users.php deleted file mode 100644 index a5c1e16..0000000 --- a/php/regions/class-users.php +++ /dev/null @@ -1,237 +0,0 @@ - 'prototype', - '_get_callback' => 'get_users', - '_prototype' => array( - '_type' => 'array', - '_children' => array( - 'display_name' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_user_value', - ), - 'first_name' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_user_value', - ), - 'last_name' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_user_value', - ), - 'email' => array( - '_type' => 'email', - '_required' => false, - '_get_callback' => 'get_user_value', - ), - 'role' => array( - '_type' => 'text', - '_required' => false, - '_get_callback' => 'get_user_value', - ), - ), - ), - ); - - /** - * Object-level cache for user data - * - * @var $users - */ - protected $users; - - /** - * Get the difference between the state file and WordPress - * - * @return array - */ - public function get_differences() { - - $this->differences = array(); - // Check each declared user in state data against WordPress. - foreach ( $this->get_imposed_data() as $user_login => $user_data ) { - - $result = $this->get_user_difference( $user_login, $user_data ); - - if ( ! empty( $result ) ) { - $this->differences[ $user_login ] = $result; - } - } - - return $this->differences; - - } - - /** - * Get the users on the network on the site - * - * @return array - */ - protected function get_users() { - - $args = array(); - - if ( 'network' === $this->get_context() ) { - $args['blog_id'] = 0; // all users. - } else { - $args['blog_id'] = get_current_blog_id(); - } - - $this->users = get_users( $args ); - return wp_list_pluck( $this->users, 'user_login' ); - } - - /** - * Get the value from a user object - * - * @param string $key Key to retrieve data for. - * @return mixed - */ - protected function get_user_value( $key ) { - - $user_login = $this->current_schema_attribute_parents[0]; - foreach ( $this->users as $user ) { - if ( $user->user_login === $user_login ) { - break; - } - } - - switch ( $key ) { - - case 'email': - $value = $user->user_email; - break; - - case 'role': - if ( 'site' === $this->get_context() ) { - $value = array_shift( $user->roles ); - } else { - $value = ''; - } - break; - - default: - $value = $user->$key; - break; - } - - return $value; - } - - /** - * Impose some state data onto a region - * - * @param string $key User login. - * @param array $value User's data. - * @return true|WP_Error - */ - public function impose( $key, $value ) { - - // We'll need to create the user if they don't exist. - $user = get_user_by( 'login', $key ); - if ( ! $user ) { - $user_obj = array( - 'user_login' => $key, - 'user_email' => $value['email'], // 'email' is required. - 'user_pass' => wp_generate_password( 24 ), - ); - $user_id = wp_insert_user( $user_obj ); - if ( is_wp_error( $user_id ) ) { - return $user_id; - } - - // Network users should default to no roles / capabilities. - if ( 'network' === $this->get_context() ) { - delete_user_option( $user_id, 'capabilities' ); - delete_user_option( $user_id, 'user_level' ); - } - - $user = get_user_by( 'id', $user_id ); - } - - // Update any values needing to be updated. - foreach ( $value as $yml_field => $single_value ) { - - // Users have no role in the network context. - // @todo needs a better abstraction. - if ( 'role' === $yml_field && 'network' === $this->get_context() ) { - continue; - } - - switch ( $yml_field ) { - case 'email': - $model_field = 'user_email'; - break; - - default: - $model_field = $yml_field; - break; - } - - if ( $user->$model_field !== $single_value ) { - wp_update_user( - array( - 'ID' => $user->ID, - $model_field => $single_value, - ) - ); - } - } - return true; - - } - - /** - * Get the difference between the declared user and the actual user - * - * @param string $user_login User login. - * @param array $user_data User's data. - * @return array|false - */ - protected function get_user_difference( $user_login, $user_data ) { - - $result = array( - 'dictated' => $user_data, - 'current' => array(), - ); - - $users = $this->get_current_data(); - if ( ! isset( $users[ $user_login ] ) ) { - return $result; - } - - $result['current'] = $users[ $user_login ]; - - if ( array_diff_assoc( $result['dictated'], $result['current'] ) ) { - return $result; - } else { - return false; - } - - } - - /** - * Get the context in which this class was called - */ - protected function get_context() { - $class_name = get_class( $this ); - if ( 'Dictator\Regions\Network_Users' === $class_name ) { - return 'network'; - } elseif ( 'Dictator\Regions\Site_Users' === $class_name ) { - return 'site'; - } - return false; - } - -} diff --git a/php/states/class-network.php b/php/states/class-network.php deleted file mode 100644 index 395d786..0000000 --- a/php/states/class-network.php +++ /dev/null @@ -1,18 +0,0 @@ - '\Dictator\Regions\Network_Settings', - 'users' => '\Dictator\Regions\Network_Users', - 'sites' => '\Dictator\Regions\Network_Sites', - ); - -} diff --git a/php/states/class-site.php b/php/states/class-site.php deleted file mode 100644 index c021d1e..0000000 --- a/php/states/class-site.php +++ /dev/null @@ -1,21 +0,0 @@ - '\Dictator\Regions\Site_Settings', - 'users' => '\Dictator\Regions\Site_Users', - 'terms' => '\Dictator\Regions\Terms', - ); - -} diff --git a/php/states/class-state.php b/php/states/class-state.php deleted file mode 100644 index c7d5c10..0000000 --- a/php/states/class-state.php +++ /dev/null @@ -1,88 +0,0 @@ -yaml = $yaml; - - } - - /** - * Get the regions associated with this state - * - * @return array - */ - public function get_regions() { - - $regions = array(); - foreach ( $this->regions as $name => $class ) { - - $data = ( ! empty( $this->yaml[ $name ] ) ) ? $this->yaml[ $name ] : array(); - - $regions[ $name ] = new $class( $data ); - - } - return $regions; - - } - - /** - * Get the name of the region - * - * @param Region $region_obj Region to get name from. - * @return string - */ - public function get_region_name( Region $region_obj ) { - - foreach ( $this->regions as $name => $class ) { - - if ( is_a( $region_obj, $class ) ) { - return $name; - } - } - - return ''; - - } - - /** - * Get the Yaml associated with the State - */ - public function get_yaml() { - - // Yaml was passed when the state was instantiated. - if ( ! is_null( $this->yaml ) ) { - return $this->yaml; - } - - } - - -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 205a23c..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,21 +0,0 @@ - - - Dictator PHPCS rules - - . - - - - - - - - */vendor/* - - - - - - - - diff --git a/src/Command.php b/src/Command.php new file mode 100644 index 0000000..fee7e4b --- /dev/null +++ b/src/Command.php @@ -0,0 +1,424 @@ + + * : State to export + * + * + * : Where the state should be exported to + * + * [--regions=] + * : Limit the export to one or more regions. + * + * [--force] + * : Forcefully overwrite an existing state file if one exists. + * + * @subcommand export + * + * @param array $args Args. + * @param array $assocArgs Assoc Args. + * + * @throws \WP_CLI\ExitException Exits on error, such as bad state supplied. + */ + public function export(array $args, array $assocArgs): void + { + [$state, $file] = $args; + + if (! isset($assocArgs['force']) && file_exists($file)) { + WP_CLI::confirm('Are you sure you want to overwrite the existing state file?'); + } + + $stateObj = Dictator::getStateObj($state); + if (! $stateObj) { + WP_CLI::error('Invalid state supplied.'); + } + + $limitedRegions = ! empty($assocArgs['regions']) ? explode(',', $assocArgs['regions']) : []; + + // Build the state's data. + $stateData = ['state' => $state]; + foreach ($stateObj->getRegions() as $regionObj) { + $regionName = $stateObj->getRegionName($regionObj); + + if ($limitedRegions && ! in_array($regionName, $limitedRegions, true)) { + continue; + } + + $stateData[ $regionName ] = $regionObj->getCurrentData(); + } + + $this->writeStateFile($stateData, $file); + + WP_CLI::success('State written to file.'); + } + + /** + * Impose a given state file onto WordPress. + * + * ## OPTIONS + * + * + * : State file to impose + * + * [--regions=] + * : Limit the imposition to one or more regions. + * + * @subcommand impose + * + * @param array $args Args. + * @param array $assocArgs Assoc args. + */ + public function impose(array $args, array $assocArgs): void + { + [$file] = $args; + + $yaml = $this->loadStateFile($file); + + $this->validateStateData($yaml); + + $stateObj = Dictator::getStateObj($yaml['state'], $yaml); + + $limitedRegions = ! empty($assocArgs['regions']) ? explode(',', $assocArgs['regions']) : []; + + foreach ($stateObj->getRegions() as $regionObj) { + $regionName = $stateObj->getRegionName($regionObj); + + if ($limitedRegions && ! in_array($regionName, $limitedRegions, true)) { + continue; + } + + if ($regionObj->isUnderAccord()) { + continue; + } + + WP_CLI::line(sprintf('%s:', $regionName)); + + // Render the differences for the region. + $differences = $regionObj->getDifferences(); + foreach ($differences as $slug => $difference) { + $this->showDifference($slug, $difference); + + $to_impose = Utils::arrayDiffRecursive($difference['dictated'], $difference['current']); + $ret = $regionObj->impose($slug, $difference['dictated']); + if (is_wp_error($ret)) { + WP_CLI::warning($ret->get_error_message()); + } + } + } + + WP_CLI::success('The Dictator has imposed upon the State of WordPress.'); + } + + /** + * List registered states. + * + * @subcommand list-states + * + * @param array $args Args. + * @param array $assocArgs Assoc args. + */ + public function listStates(array $args, array $assocArgs): void + { + $states = Dictator::getStates(); + + $items = []; + foreach ($states as $name => $attributes) { + $stateObj = new $attributes['class'](); + $regions = implode(',', array_keys($stateObj->getRegions())); + + $items[] = (object) [ + 'state' => $name, + 'regions' => $regions, + ]; + } + + $formatter = new Formatter($assocArgs, ['state', 'regions']); + $formatter->display_items($items); + } + + /** + * Compare a given state file to the State of WordPress. + * Produces a colorized diff if differences, otherwise empty output. + * + * ## OPTIONS + * + * + * : State file to compare + * + * @subcommand compare + * @alias diff + * + * @param array $args Args. + * @param array $assocArgs Assoc args. + */ + public function compare(array $args, array $assocArgs): void + { + [$file] = $args; + + $yaml = $this->loadStateFile($file); + + $this->validateStateData($yaml); + + $stateObj = Dictator::getStateObj($yaml['state'], $yaml); + + foreach ($stateObj->getRegions() as $regionName => $regionObj) { + if ($regionObj->isUnderAccord()) { + continue; + } + + WP_CLI::line(sprintf('%s:', $regionName)); + + // Render the differences for the region. + $differences = $regionObj->getDifferences(); + foreach ($differences as $slug => $difference) { + $this->showDifference($slug, $difference); + } + } + } + + /** + * Validate the provided state file against each region's schema. + * + * ## OPTIONS + * + * + * : State file to load + * + * @subcommand validate + * + * @param array $args Args. + * @param array $assocArgs Assoc args. + */ + public function validate(array $args, array $assocArgs): void + { + [$file] = $args; + + $yaml = $this->loadStateFile($file); + + $this->validateStateData($yaml); + + WP_CLI::success('State validates against the schema.'); + } + + /** + * Load a given Yaml state file + * + * @param string $file Filename to load state from. + * @return array + */ + private function loadStateFile(string $file): array + { + if (! file_exists($file)) { + WP_CLI::error(sprintf("File doesn't exist: %s", $file)); + } + + $yaml = Spyc::YAMLLoadString(file_get_contents($file)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if (empty($yaml)) { + WP_CLI::error(sprintf("Doesn't appear to be a Yaml file: %s", $file)); + } + + return $yaml; + } + + /** + * Validate the provided state file against each region's schema. + * + * @param array $yaml Data from the state file. + * @return void + */ + private function validateStateData(array $yaml): void + { + if (empty($yaml['state']) || ! Dictator::isValidState($yaml['state'])) { + WP_CLI::error('Incorrect state.'); + } + + $yamlData = $yaml; + unset($yamlData['state']); + + $stateObj = Dictator::getStateObj($yaml['state'], $yamlData); + + $hasErrors = false; + foreach ($stateObj->getRegions() as $region) { + $validator = new Validator($region); + if (! $validator->isValidStateData()) { + foreach ($validator->getStateDataErrors() as $errorMessage) { + WP_CLI::warning($errorMessage); + } + $hasErrors = true; + } + } + + if ($hasErrors) { + WP_CLI::error("State doesn't validate."); + } + } + + /** + * Write a state object to a file + * + * @param array $stateData State Data. + * @param string $file Filename to write to. + */ + private function writeStateFile(array $stateData, string $file): void + { + $fileData = Spyc::YAMLDump($stateData, 2, 0); + // Remove prepended "---\n" from output of the above call. + $fileData = substr($fileData, 4); + file_put_contents($file, $fileData); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents + } + + /** + * Visually depict the difference between "dictated" and "current" + * + * @param string $slug Slug. + * @param array $difference Difference to show. + * @return void + */ + private function showDifference(string $slug, array $difference): void + { + $this->outputNestingLevel = 0; + + // Data already exists within WordPress. + if (! empty($difference['current'])) { + $this->nestedLine($slug . ': '); + + $this->recursivelyShowDifference($difference['dictated'], $difference['current']); + } else { + $this->addLine($slug . ': '); + + $this->recursivelyShowDifference($difference['dictated']); + } + + $this->outputNestingLevel = 0; + } + + /** + * Recursively output the difference between "dictated" and "current" + * + * @param mixed $dictated Dictated state. + * @param mixed|null $current Current state. + * @return void + */ + private function recursivelyShowDifference($dictated, $current = null): void + { + $this->outputNestingLevel++; + + if (is_array($dictated) && $this->isAssocArray($dictated)) { + foreach ($dictated as $key => $value) { + if (is_array($value)) { + $newCurrent = $current[$key] ?? null; + if ($newCurrent) { + $this->nestedLine($key . ': '); + } else { + $this->addLine($key . ': '); + } + + $this->recursivelyShowDifference($value, $newCurrent); + } elseif (is_string($value)) { + $pre = $key . ': '; + + if (isset($current[ $key ]) && $current[ $key ] !== $value) { + $this->removeLine($pre . $current[ $key ]); + $this->addLine($pre . $value); + } elseif (! isset($current[ $key ])) { + $this->addLine($pre . $value); + } + } + } + } elseif (is_array($dictated)) { + foreach ($dictated as $value) { + if (! $current || ! in_array($value, $current, true)) { + $this->addLine('- ' . $value); + } + } + } + + $this->outputNestingLevel--; + } + + /** + * Output a line to be added + * + * @param string $line Line to add. + */ + private function addLine(string $line): void + { + $this->nestedLine($line, 'add'); + } + + /** + * Output a line to be removed + * + * @param string $line Line to remove. + */ + private function removeLine(string $line): void + { + $this->nestedLine($line, 'remove'); + } + + /** + * Output a line that's appropriately nested + * + * @param string $line Line to show. + * @param mixed|bool $change Whether to display green or red. 'add' for green, 'remove' for red. + */ + private function nestedLine(string $line, $change = false): void + { + if ('add' === $change) { + $color = '%G'; + $label = '+ '; + } elseif ('remove' === $change) { + $color = '%R'; + $label = '- '; + } else { + $color = false; + $label = false; + } + + Colors::colorize('%n'); + + $spaces = ($this->outputNestingLevel * 2) + 2; + if ($color && $label) { + $line = Colors::colorize("{$color}{$label}") . $line . Colors::colorize('%n'); + $spaces -= 2; + } + WP_CLI::line(str_pad(' ', $spaces) . $line); + } + + /** + * Whether this is an associative array + * + * @param array $array Array to check. + * @return bool + */ + private function isAssocArray(array $array): bool + { + return array_keys($array) !== range(0, count($array) - 1); + } +} diff --git a/src/Dictator.php b/src/Dictator.php new file mode 100644 index 0000000..bf45bb0 --- /dev/null +++ b/src/Dictator.php @@ -0,0 +1,121 @@ +addState($name, $class); + } + + // @todo validate the class is callable and the schema exists + + $state = [ + 'class' => $class, + ]; + + self::$instance->states[ $name ] = $state; + } + + /** + * Get all states registered with Dictator + * + * @return array + */ + public static function getStates(): array + { + if (self::calledStatically()) { + return self::getInstance()->getStates(); + } + + return self::$instance->states; + } + + /** + * Whether the state is valid + * + * @param string $name Name of the state. + * @return bool + */ + public static function isValidState(string $name): bool + { + if (self::calledStatically()) { + return self::getInstance()->isValidState($name); + } + + if (isset(self::$instance->states[ $name ])) { + return true; + } + + return false; + } + + /** + * Get the object for a given state + * + * @param string $name Name of the state. + * @param array|null $yaml Data from the state file. + * @return State|false + */ + public static function getStateObj(string $name, ?array $yaml = null) + { + if (self::calledStatically()) { + return self::getInstance()->getStateObj($name, $yaml); + } + + if (! isset(self::$instance->states[ $name ])) { + return false; + } + + $class = self::$instance->states[ $name ]['class']; + + return new $class($yaml); + } +} diff --git a/src/Region/NetworkSettings.php b/src/Region/NetworkSettings.php new file mode 100644 index 0000000..a165386 --- /dev/null +++ b/src/Region/NetworkSettings.php @@ -0,0 +1,193 @@ + 'array', + '_children' => [ + 'title' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'admin_email' => [ + '_type' => 'email', + '_required' => false, + '_get_callback' => 'get', + ], + 'super_admins' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'get', + ], + 'registration' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'notify_registration' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'upload_filetypes' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'site_unlimited_upload' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'site_upload_space' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + 'site_max_upload' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + 'enabled_themes' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'get', + ], + 'active_plugins' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'get', + ], + ], + ]; + + /** + * Correct core's confusing option names + * + * @var array $optionsMap + */ + protected array $optionsMap = [ + 'title' => 'site_name', + 'super_admins' => 'site_admins', + 'notify_registration' => 'registrationnotification', + 'site_unlimited_upload' => 'upload_space_check_disabled', + 'site_upload_space' => 'blog_upload_space', + 'site_max_upload' => 'fileupload_maxk', + 'enabled_themes' => 'allowedthemes', + 'active_plugins' => 'active_sitewide_plugins', + ]; + + /** + * Impose some data onto the region + * How the data is interpreted depends + * on the region + * + * @param string $_ Unused. + * @param array $options Options to impose. + * + * @return true|\WP_Error + */ + public function impose(string $_, $options) + { + foreach ($options as $key => $value) { + if (array_key_exists($key, $this->optionsMap)) { + $key = $this->optionsMap[ $key ]; + } + + switch ($key) { + case 'allowedthemes': + $allowedThemes = []; + foreach ($value as $theme) { + $allowedThemes[ $theme ] = true; + } + update_site_option('allowedthemes', $allowedThemes); + break; + + case 'active_sitewide_plugins': + foreach ($value as $plugin) { + activate_plugin($plugin, '', true); + } + break; + + case 'registrationnotification': + if ($value) { + update_site_option($key, 'yes'); + } else { + update_site_option($key, 'no'); + } + break; + + case 'upload_space_check_disabled': + case 'blog_upload_space': + case 'fileupload_maxk': + update_site_option($key, (int)$value); + break; + + default: + update_site_option($key, $value); + break; + } + } + + return true; + } + + /** + * Get the differences between the state file and WordPress + * + * @return array + */ + public function getDifferences(): array + { + $result = [ + 'dictated' => $this->getImposedData(), + 'current' => $this->getCurrentData(), + ]; + + if (Utils::arrayDiffRecursive($result['dictated'], $result['current'])) { + return ['option' => $result]; + } + + return []; + } + + /** + * Get the value for the setting + * + * @param string $name Name to get value for. + * @return mixed + */ + public function get(string $name) + { + if (array_key_exists($name, $this->optionsMap)) { + $name = $this->optionsMap[ $name ]; + } + + // Data transformation if we need to. + switch ($name) { + case 'allowedthemes': + case 'active_sitewide_plugins': + // Coerce to array of names. + return array_keys(get_site_option($name, [])); + + case 'registrationnotification': + // Coerce to boolean. + return ('yes' === get_site_option($name)); + default: + return get_site_option($name); + } + } +} diff --git a/src/Region/NetworkSites.php b/src/Region/NetworkSites.php new file mode 100644 index 0000000..b623d7a --- /dev/null +++ b/src/Region/NetworkSites.php @@ -0,0 +1,420 @@ + 'prototype', + '_get_callback' => 'getSites', + '_prototype' => [ + '_type' => 'array', + '_children' => [ + 'custom_domain' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'title' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'description' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'active_theme' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'active_plugins' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'users' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'timezone_string' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + 'WPLANG' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getSiteValue', + ], + ], + ], + ]; + + /** + * Object-level cache. + * + * @var $sites + */ + protected $sites; + + /** + * Get the differences between declared sites and sites on network + * + * @return array + */ + public function getDifferences(): array + { + if (isset($this->differences)) { + return $this->differences; + } + + $this->differences = []; + // Check each declared site in state data against WordPress. + foreach ($this->getImposedData() as $siteLabel => $siteData) { + $customDomain = $siteData['custom_domain'] ?? ''; + $siteSlug = $this->getSiteSlug(get_current_site(), $siteLabel, $customDomain); + $siteResult = $this->getSiteDifference($siteSlug, $siteData); + + if (! empty($siteResult)) { + $this->differences[ $siteLabel ] = $siteResult; + } + } + + return $this->differences; + } + + /** + * Impose some state data onto a region + * + * @param string $key Site slug. + * @param array $value Site data. + * @return bool|\WP_Error + */ + public function impose(string $key, $value) + { + $customDomain = $value['custom_domain'] ?? ''; + $siteSlug = $this->getSiteSlug(get_current_site(), $key, $customDomain); + + $site = $this->getSite($siteSlug); + if (! $site) { + $site = $this->createSite($key, $value); + if (is_wp_error($site)) { + return $site; + } + } + + switch_to_blog($site->blog_id); + foreach ($value as $field => $singleValue) { + switch ($field) { + + case 'title': + case 'description': + $map = [ + 'title' => 'blogname', + 'description' => 'blogdescription', + ]; + update_option($map[ $field ], $singleValue); + break; + + case 'active_theme': + if ($singleValue !== get_option('stylesheet')) { + switch_theme($singleValue); + } + + break; + + case 'active_plugins': + foreach ($singleValue as $plugin) { + if (! is_plugin_active($plugin)) { + activate_plugin($plugin); + } + } + + break; + + case 'users': + foreach ($singleValue as $userLogin => $role) { + $user = get_user_by('login', $userLogin); + if (! $user) { + continue; + } + + add_user_to_blog($site->blog_id, $user->ID, $role); + } + + break; + + case 'WPLANG': + add_network_option($site->blog_id, $field, $singleValue); + break; + + default: + update_option($field, $singleValue); + + break; + + } + } + restore_current_blog(); + + return true; + } + + /** + * Get a list of all the sites on the network + * + * @return array + */ + protected function getSites(): array + { + if (isset($this->sites) && is_array($this->sites)) { + return array_keys($this->sites); + } + + $args = [ + 'limit' => 200, + 'offset' => 0, + ]; + $sites = []; + if (! is_multisite()) { + return $this->sites; + } + do { + $sitesResults = get_sites($args); + $sites = array_merge($sites, $sitesResults); + + $args['offset'] += $args['limit']; + } while ($sitesResults); + + $this->sites = []; + foreach ($sites as $site) { + $siteSlug = $this->getSiteSlug($site); + $this->sites[ $siteSlug ] = $site; + } + return array_keys($this->sites); + } + + /** + * Get the value on a given site + * + * @param string $key Key to get value for. + * @return mixed + */ + protected function getSiteValue(string $key) + { + $siteSlug = $this->currentSchemaAttributeParents[0]; + $site = $this->getSite($siteSlug); + + switch_to_blog($site->blog_id); + + switch ($key) { + + case 'custom_domain': + $value = $site->domain ?? ''; + break; + case 'title': + case 'description': + case 'active_theme': + $map = [ + 'title' => 'blogname', + 'description' => 'blogdescription', + 'active_theme' => 'stylesheet', + ]; + $value = get_option($map[ $key ]); + break; + + case 'active_plugins': + $value = get_option($key, []); + break; + + case 'users': + $value = []; + + $siteUsers = get_users(); + foreach ($siteUsers as $siteUser) { + $value[ $siteUser->user_login ] = array_shift($siteUser->roles); + } + break; + + case 'WPLANG': + $value = get_network_option($site->blog_id, $key); + break; + + default: + $value = get_option($key); + break; + + } + restore_current_blog(); + + return $value; + } + + /** + * Get the difference of the site data to the site on the network + * + * @param string $siteSlug Site slug. + * @param array $siteData Site data. + * @return array|false + */ + protected function getSiteDifference(string $siteSlug, array $siteData) + { + $siteResult = [ + 'dictated' => $siteData, + 'current' => [], + ]; + + $sites = $this->getCurrentData(); + + // If there wasn't a matched site, the site must not exist. + if (empty($sites[ $siteSlug ])) { + return $siteResult; + } + + $siteResult['current'] = $sites[ $siteSlug ]; + + if (Utils::arrayDiffRecursive($siteResult['dictated'], $siteResult['current'])) { + return $siteResult; + } + + return false; + } + + /** + * Get a site by its slug + * + * @param string $siteSlug Site slug. + * @return \WP_Site|false + */ + protected function getSite(string $siteSlug) + { + + // Maybe prime the cache. + $this->getSites(); + if (! empty($this->sites[ $siteSlug ])) { + return $this->sites[ $siteSlug ]; + } + + return false; + } + + /** + * Create a new site + * + * @param string $key Key of site. + * @param mixed $value Value. + * @return \WP_Site|\WP_Error|bool + */ + protected function createSite(string $key, $value) + { + global $wpdb, $current_site; + + $base = $key; + $title = ucfirst($base); + $network = $current_site; + $meta = $value; + if (! $network) { + $networks = $wpdb->get_results($wpdb->prepare("SELECT * FROM $wpdb->site WHERE id = %d", 1)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + if (! empty($networks)) { + $network = $networks[0]; + } + } + + // Sanitize. + if (preg_match('|^([a-zA-Z0-9-])+$|', $base)) { + $base = strtolower($base); + } + + // If not a subdomain install, make sure the domain isn't a reserved word. + if (! is_subdomain_install()) { + $subdirectoryReservedNames = apply_filters('subdirectory_reserved_names', [ 'page', 'comments', 'blog', 'files', 'feed' ]); + if (in_array($base, $subdirectoryReservedNames, true)) { + return new \WP_Error('reserved-word', 'The following words are reserved and cannot be used as blog names: ' . implode(', ', $subdirectory_reserved_names)); + } + } + + if (is_subdomain_install()) { + $path = '/'; + $prefix = ''; + if ($base !== '') { + $prefix = $base . '.'; + } + $newDomain = $prefix . preg_replace('|^www\.|', '', $network->domain); + } else { + $newDomain = $network->domain; + $path = '/' . trim($base, '/') . '/'; + } + + // Custom domain trumps all. + if (! empty($value['custom_domain'])) { + $newDomain = $value['custom_domain']; + $path = '/'; + unset($value['custom_domain']); + } + + $userId = 0; + $superAdmins = get_super_admins(); + if (! empty($superAdmins) && is_array($superAdmins)) { + // Just get the first one. + $superLogin = $superAdmins[0]; + $superUser = get_user_by('login', $superLogin); + if ($superUser) { + $userId = $superUser->ID; + } + } + + $wpdb->hide_errors(); + $id = wpmu_create_blog($newDomain, $path, $title, $userId, $meta, $network->id); + $wpdb->show_errors(); + + if (is_wp_error($id)) { + return $id; + } + + // Reset our internal cache. + unset($this->sites); + + return $this->getSite($this->getSiteSlug(get_site($id))); + } + + /** + * Use the domain plus path for the slug of or sites array. We can pass a key to overwrite path, + * we can pass a custom domain which overwrites the domain and 'resets' the path. + * + * @param \WP_Site | \WP_Network $siteOrNetwork A site or network object. + * @param string $key A key to overwrite path if not using a custom domain. + * @param string $customDomain A custom domain to overwrite the domain and reset the path. + */ + protected function getSiteSlug($siteOrNetwork, string $key = '', string $customDomain = ''): string + { + $domain = $siteOrNetwork->domain; + $path = $key !== '' ? '/' . $key : $siteOrNetwork->path; + + if (! empty($customDomain) && $domain !== $customDomain) { + $domain = $customDomain; + $path = '/'; + } + + if ($path !== '/' && is_subdomain_install()) { + return trim($path . '.' . $domain, '/'); + } + + return trim($domain . $path, '/'); + } +} diff --git a/src/Region/NetworkUsers.php b/src/Region/NetworkUsers.php new file mode 100644 index 0000000..547d2da --- /dev/null +++ b/src/Region/NetworkUsers.php @@ -0,0 +1,9 @@ +data = $data; + } + + /** + * Whether the current state of the region + * matches the state file + * + * @return bool + */ + public function isUnderAccord(): bool + { + $results = $this->getDifferences(); + if (empty($results)) { + return true; + } + + return false; + } + + /** + * Get the schema for this region + * + * @return array + */ + public function getSchema(): array + { + return $this->schema; + } + + /** + * Impose some data onto the region + * How the data is interpreted depends + * on the region + * + * @param string $key Key of the data to impose. + * @param mixed $value Value of the data to impose. + * @return true|\WP_Error + */ + abstract public function impose(string $key, $value); + + /** + * Get the differences between the state file and WordPress + * + * @return array + */ + abstract public function getDifferences(): array; + + /** + * Get the current data for the region + * + * @return array + */ + public function getCurrentData(): array + { + if (isset($this->currentData)) { + return $this->currentData; + } + + $this->currentData = $this->recursivelyGetCurrentData($this->getSchema()); + return $this->currentData; + } + + /** + * Get the imposed data for the region + */ + public function getImposedData(): array + { + return $this->data; + } + + /** + * Recursively get the current data for the region + * + * @param array $schema Schema array. + * @return mixed + */ + private function recursivelyGetCurrentData(array $schema) + { + switch ($schema['_type']) { + + case 'prototype': + if (isset($schema['_get_callback'])) { + $prototypeVals = $this->{$schema['_get_callback']}($this->currentSchemaAttribute); + + $data = []; + if (! empty($prototypeVals)) { + foreach ($prototypeVals as $prototypeVal) { + $this->currentSchemaAttribute = $prototypeVal; + + $this->currentSchemaAttributeParents[] = $prototypeVal; + $data[ $prototypeVal ] = $this->recursivelyGetCurrentData($schema['_prototype']); + array_pop($this->currentSchemaAttributeParents); + } + } + return $data; + } + + break; + + case 'array': + // Arrays can have schemas defined for each child attribute. + if (! empty($schema['_children'])) { + $data = []; + foreach ($schema['_children'] as $attribute => $attributeSchema) { + $this->currentSchemaAttribute = $attribute; + + $data[ $attribute ] = $this->recursivelyGetCurrentData($attributeSchema); + } + return $data; + } + + if (isset($schema['_get_callback'])) { + return $this->{$schema['_get_callback']}($this->currentSchemaAttribute); + } + + break; + + case 'text': + case 'email': + case 'bool': + case 'numeric': + if (isset($schema['_get_callback'])) { + $value = $this->{$schema['_get_callback']}($this->currentSchemaAttribute); + if ($schema['_type'] === 'bool') { + $value = (bool) $value; + } elseif ($schema['_type'] === 'numeric') { + $value = (int)$value; + } + + return $value; + } + break; + } + } +} diff --git a/src/Region/SiteSettings.php b/src/Region/SiteSettings.php new file mode 100644 index 0000000..9ed8cf7 --- /dev/null +++ b/src/Region/SiteSettings.php @@ -0,0 +1,281 @@ + 'array', + '_children' => [ + /** + * General + */ + 'title' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'description' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'admin_email' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'timezone' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'WPLANG' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'date_format' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'time_format' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + /** + * Reading + */ + 'public' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'posts_per_page' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + 'posts_per_feed' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + 'feed_uses_excerpt' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'show_on_front' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'page_on_front' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + 'page_for_posts' => [ + '_type' => 'numeric', + '_required' => false, + '_get_callback' => 'get', + ], + /** + * Discussion + */ + 'allow_comments' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'allow_pingbacks' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'notify_comments' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + 'notify_moderation' => [ + '_type' => 'bool', + '_required' => false, + '_get_callback' => 'get', + ], + /** + * Permalinks + */ + 'permalink_structure' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'category_base' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'tag_base' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + /** + * Theme / plugins + */ + 'active_theme' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'get', + ], + 'active_plugins' => [ + '_type' => 'array', + '_required' => false, + '_get_callback' => 'get', + ], + ], + ]; + + /** + * Correct core's confusing option names. + * + * @var array $optionsMap + */ + protected array $optionsMap = [ + 'title' => 'blogname', + 'description' => 'blogdescription', + 'timezone' => 'timezone_string', + 'public' => 'blog_public', + 'posts_per_feed' => 'posts_per_rss', + 'feed_uses_excerpt' => 'rss_use_excerpt', + 'allow_comments' => 'default_comment_status', + 'allow_pingbacks' => 'default_ping_status', + 'notify_comments' => 'comments_notify', + 'notify_moderation' => 'moderation_notify', + ]; + + /** + * Impose some data onto the region + * How the data is interpreted depends + * on the region + * + * @param string $_ Unused. + * @param array $options Options to impose. + * @return true|\WP_Error + */ + public function impose(string $_, $options) + { + foreach ($options as $key => $value) { + if (array_key_exists($key, $this->optionsMap)) { + $key = $this->optionsMap[ $key ]; + } + + switch ($key) { + + case 'active_theme': + if ($value !== get_option('stylesheet')) { + switch_theme($value); + } + break; + + case 'active_plugins': + foreach ($value as $plugin) { + if (! is_plugin_active($plugin)) { + activate_plugin($plugin); + } + } + break; + + // Boolean stored as 0 or 1. + case 'blog_public': + case 'rss_use_excerpt': + case 'comments_notify': + case 'moderation_notify': + update_option($key, (int)$value); + break; + + // Boolean stored as 'open' or 'closed'. + case 'default_comment_status': + case 'default_ping_status': + if ($value) { + update_option($key, 'open'); + } else { + update_option($key, 'closed'); + } + break; + + default: + update_option($key, $value); + break; + } + } + + return true; + } + + /** + * Get the differences between the state file and WordPress + * + * @return array + */ + public function getDifferences(): array + { + $result = [ + 'dictated' => $this->getImposedData(), + 'current' => $this->getCurrentData(), + ]; + + if (Utils::arrayDiffRecursive($result['dictated'], $result['current'])) { + return ['option' => $result]; + } + + return []; + } + + /** + * Get the value for the setting + * + * @param string $name Name to get value for. + * @return mixed + */ + public function get(string $name) + { + if (array_key_exists($name, $this->optionsMap)) { + $name = $this->optionsMap[ $name ]; + } + + switch ($name) { + case 'active_theme': + $value = get_option('stylesheet'); + break; + + default: + $value = get_option($name); + break; + } + + // Data transformation if we need to. + switch ($name) { + case 'default_comment_status': + case 'default_ping_status': + $value = 'open' === $value; + break; + + } + + return $value; + } +} diff --git a/src/Region/SiteUsers.php b/src/Region/SiteUsers.php new file mode 100644 index 0000000..b07b333 --- /dev/null +++ b/src/Region/SiteUsers.php @@ -0,0 +1,9 @@ + 'prototype', + '_get_callback' => 'getTaxonomies', + '_prototype' => [ + '_type' => 'prototype', + '_get_callback' => 'getTerms', + '_prototype' => [ + '_type' => 'array', + '_children' => [ + 'name' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getTermValue', + '_update_callback' => '', + ], + 'description' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getTermValue', + '_update_callback' => '', + ], + 'parent' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getTermValue', + '_update_callback' => '', + ], + ], + ], + ], + ]; + + /** + * Object-level cache of the term data + * + * @var array $terms + */ + protected array $terms = []; + + /** + * Impose some data onto the region + * How the data is interpreted depends + * on the region + * + * @param string $key Key of the data to impose. + * @param mixed $value Value to impose. + * @return bool|\WP_Error + */ + public function impose(string $key, $value) + { + if (! taxonomy_exists($key)) { + return new \WP_Error('invalid-taxonomy', 'Invalid taxonomy'); + } + + foreach ($value as $slug => $termValues) { + $term = get_term_by('slug', $slug, $key); + if (! $term) { + $ret = wp_insert_term($slug, $key); + if (is_wp_error($ret)) { + return $ret; + } + $term = get_term_by('id', $ret['term_id'], $key); + } + + foreach ($termValues as $ymlField => $termValue) { + switch ($ymlField) { + case 'name': + case 'description': + if ($termValue === $term->$ymlField) { + break; + } + + wp_update_term($term->term_id, $key, [ $ymlField => $termValue ]); + + break; + + case 'parent': + if ($termValue) { + $parent_term = get_term_by('slug', $termValue, $key); + if (! $parent_term) { + return new \WP_Error('invalid-parent', sprintf('Parent is invalid for term: %s', $slug)); + } + + if ($parent_term->term_id === $term->parent) { + break; + } + + wp_update_term($term->term_id, $key, [ 'parent' => $parent_term->term_id ]); + } elseif (! $termValue && $term->parent) { + wp_update_term($term->term_id, $key, [ 'parent' => 0 ]); + } + + break; + } + } + } + + return true; + } + + /** + * Get the differences between the state file and WordPress + * + * @return array + */ + public function getDifferences(): array + { + $this->differences = []; + // Check each declared term in state data against WordPress. + foreach ($this->getImposedData() as $taxonomy => $taxonomyData) { + $result = $this->getTaxonomyDifference($taxonomy, $taxonomyData); + + if (! empty($result)) { + $this->differences[ $taxonomy ] = $result; + } + } + + return $this->differences; + } + + /** + * Get the taxonomies on this site + * + * @return array + */ + public function getTaxonomies(): array + { + return get_taxonomies(['public' => true]); + } + + /** + * Get the terms associated with a taxonomy on the site + * + * @param string $taxonomy Taxonomy to get terms for. + * + * @return array + */ + public function getTerms(string $taxonomy): array + { + $terms = get_terms([$taxonomy], ['hide_empty' => 0]); + if (is_wp_error($terms)) { + $terms = []; + } + + $this->terms[ $taxonomy ] = $terms; + + return wp_list_pluck($terms, 'slug'); + } + + /** + * Get the value associated with a given term + * + * @param string $key Key to get term value for. + * @return string + */ + public function getTermValue(string $key): string + { + $taxonomy = $this->currentSchemaAttributeParents[0]; + $termSlug = $this->currentSchemaAttributeParents[1]; + foreach ($this->terms[ $taxonomy ] as $term) { + if ($term->slug === $termSlug) { + break; + } + } + + switch ($key) { + + case 'parent': + $parent = false; + foreach ($this->terms[ $taxonomy ] as $maybeParentTerm) { + if ($maybeParentTerm->term_id === $term->parent) { + $parent = $maybeParentTerm; + } + } + if (! empty($parent)) { + $value = $parent->slug; + } else { + $value = ''; + } + break; + + default: + $value = $term->$key; + break; + + } + + return $value; + } + + /** + * Get the difference between the declared taxonomy state and + * the actual taxonomy state + * + * @param string $taxonomy Taxonomy to get difference for. + * @param array $taxonomyData Taxonomy data. + * @return array|false + */ + protected function getTaxonomyDifference(string $taxonomy, array $taxonomyData) + { + $result = [ + 'dictated' => $taxonomyData, + 'current' => [], + ]; + + $currentData = $this->getCurrentData(); + if (! isset($currentData[ $taxonomy ])) { + return $result; + } + + $result['current'] = $currentData[ $taxonomy ]; + + if (Utils::arrayDiffRecursive($result['dictated'], $result['current'])) { + return $result; + } + + return false; + } +} diff --git a/src/Region/Users.php b/src/Region/Users.php new file mode 100644 index 0000000..34ec656 --- /dev/null +++ b/src/Region/Users.php @@ -0,0 +1,239 @@ + 'prototype', + '_get_callback' => 'getUsers', + '_prototype' => [ + '_type' => 'array', + '_children' => [ + 'display_name' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getUserValue', + ], + 'first_name' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getUserValue', + ], + 'last_name' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getUserValue', + ], + 'email' => [ + '_type' => 'email', + '_required' => false, + '_get_callback' => 'getUserValue', + ], + 'role' => [ + '_type' => 'text', + '_required' => false, + '_get_callback' => 'getUserValue', + ], + ], + ], + ]; + + /** + * Object-level cache for user data + * + * @var $users + */ + protected $users; + + /** + * Get the difference between the state file and WordPress + * + * @return array + */ + public function getDifferences(): array + { + $this->differences = []; + // Check each declared user in state data against WordPress. + foreach ($this->getImposedData() as $userLogin => $userData) { + $result = $this->getUserDifference($userLogin, $userData); + + if (! empty($result)) { + $this->differences[ $userLogin ] = $result; + } + } + + return $this->differences; + } + + /** + * Get the users on the network on the site + * + * @return array + */ + protected function getUsers(): array + { + $args = []; + + if ('network' === $this->getContext()) { + $args['blog_id'] = 0; // all users. + } else { + $args['blog_id'] = get_current_blog_id(); + } + + $this->users = get_users($args); + return wp_list_pluck($this->users, 'user_login'); + } + + /** + * Get the value from a user object + * + * @param string $key Key to retrieve data for. + * @return mixed + */ + protected function getUserValue(string $key) + { + $userLogin = $this->currentSchemaAttributeParents[0]; + foreach ($this->users as $user) { + if ($user->user_login === $userLogin) { + break; + } + } + + switch ($key) { + + case 'email': + $value = $user->user_email; + break; + + case 'role': + if ('site' === $this->getContext()) { + $value = array_shift($user->roles); + } else { + $value = ''; + } + break; + + default: + $value = $user->$key; + break; + } + + return $value; + } + + /** + * Impose some state data onto a region + * + * @param string $key User login. + * @param array $value User's data. + * @return true|\WP_Error + */ + public function impose(string $key, $value) + { + + // We'll need to create the user if they don't exist. + $user = get_user_by('login', $key); + if (! $user) { + $userObj = [ + 'user_login' => $key, + 'user_email' => $value['email'], // 'email' is required. + 'user_pass' => wp_generate_password(24), + ]; + $userId = wp_insert_user($userObj); + if (is_wp_error($userId)) { + return $userId; + } + + // Network users should default to no roles / capabilities. + if ('network' === $this->getContext()) { + delete_user_option($userId, 'capabilities'); + delete_user_option($userId, 'user_level'); + } + + $user = get_user_by('id', $userId); + } + + // Update any values needing to be updated. + foreach ($value as $ymlField => $singleValue) { + + // Users have no role in the network context. + // @todo needs a better abstraction. + if ('role' === $ymlField && 'network' === $this->getContext()) { + continue; + } + + switch ($ymlField) { + case 'email': + $modelField = 'user_email'; + break; + + default: + $modelField = $ymlField; + break; + } + + if ($user->$modelField !== $singleValue) { + wp_update_user( + [ + 'ID' => $user->ID, + $modelField => $singleValue, + ] + ); + } + } + return true; + } + + /** + * Get the difference between the declared user and the actual user + * + * @param string $userLogin User login. + * @param array $userData User's data. + * @return array|false + */ + protected function getUserDifference(string $userLogin, array $userData) + { + $result = [ + 'dictated' => $userData, + 'current' => [], + ]; + + $users = $this->getCurrentData(); + if (! isset($users[ $userLogin ])) { + return $result; + } + + $result['current'] = $users[ $userLogin ]; + + if (array_diff_assoc($result['dictated'], $result['current'])) { + return $result; + } + + return false; + } + + /** + * Get the context in which this class was called + */ + protected function getContext() + { + $className = get_class($this); + if (NetworkUsers::class === $className) { + return 'network'; + } + + if (SiteUsers::class === $className) { + return 'site'; + } + + return false; + } +} diff --git a/src/State/Network.php b/src/State/Network.php new file mode 100644 index 0000000..b9169fb --- /dev/null +++ b/src/State/Network.php @@ -0,0 +1,23 @@ + NetworkSettings::class, + 'users' => NetworkUsers::class, + 'sites' => NetworkSites::class, + ]; +} diff --git a/src/State/Site.php b/src/State/Site.php new file mode 100644 index 0000000..4ef36be --- /dev/null +++ b/src/State/Site.php @@ -0,0 +1,26 @@ + SiteSettings::class, + 'users' => SiteUsers::class, + 'terms' => Terms::class, + ]; +} diff --git a/src/State/State.php b/src/State/State.php new file mode 100644 index 0000000..29b73c9 --- /dev/null +++ b/src/State/State.php @@ -0,0 +1,70 @@ +yaml = $yaml; + } + + /** + * Get the regions associated with this state + * + * @return array + */ + public function getRegions(): array + { + $regions = []; + foreach ($this->regions as $name => $class) { + $data = (! empty($this->yaml[ $name ])) ? $this->yaml[ $name ] : []; + + $regions[ $name ] = new $class($data); + } + return $regions; + } + + /** + * Get the name of the region + * + * @param Region $regionObj Region to get name from. + * @return string + */ + public function getRegionName(Region $regionObj): string + { + foreach ($this->regions as $name => $class) { + if (is_a($regionObj, $class)) { + return $name; + } + } + + return ''; + } +} diff --git a/src/Utils.php b/src/Utils.php new file mode 100644 index 0000000..09a44e2 --- /dev/null +++ b/src/Utils.php @@ -0,0 +1,40 @@ + $value) { + if (array_key_exists($key, $array2)) { + if (is_array($value)) { + $recursiveDiff = self::arrayDiffRecursive($value, $array2[ $key ]); + + if (count($recursiveDiff)) { + $ret[ $key ] = $recursiveDiff; + } + } elseif ($value !== $array2[ $key ]) { + $ret[ $key ] = $value; + } + } else { + $ret[ $key ] = $value; + } + } + + return $ret; + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..ea2e93f --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,166 @@ +region = $region; + } + + /** + * Whether the state data provided is valid + * + * @return bool + */ + public function isValidStateData(): bool + { + $this->currentSchemaAttribute = 'region'; + + $this->recursivelyValidateStateData($this->region->getSchema(), $this->region->getImposedData()); + + $this->currentSchemaAttribute = null; + + return empty($this->stateDataErrors); + } + + /** + * Get the errors generated when validating the state data + * + * @return array + */ + public function getStateDataErrors(): array + { + return $this->stateDataErrors; + } + + /** + * Dive into the schema to see if the provided state data validates + * + * @param array $schema Schema to validate against. + * @param mixed $stateData Data to validate. + */ + protected function recursivelyValidateStateData(array $schema, $stateData): void + { + if (! empty($schema['_required']) && is_null($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' is required for the region.", $this->currentSchemaAttribute); + return; + } + + if (is_null($stateData)) { + return; + } + + switch ($schema['_type']) { + + case 'prototype': + if ('prototype' === $schema['_prototype']['_type']) { + foreach ($stateData as $key => $attributeData) { + $this->currentSchemaAttribute = $key; + + $this->recursivelyValidateStateData($schema['_prototype']['_prototype'], $attributeData); + } + } elseif ('array' === $schema['_prototype']['_type']) { + foreach ($stateData as $key => $childData) { + foreach ($schema['_prototype']['_children'] as $schemaKey => $childSchema) { + $this->currentSchemaAttribute = $schemaKey; + + if (! empty($childSchema['_required']) && empty($childData[ $schemaKey ])) { + $this->stateDataErrors[] = sprintf("'%s' is required for the region.", $this->currentSchemaAttribute); + continue; + } + + $this->recursivelyValidateStateData( + $childSchema, + $child_data[$schemaKey] ?? null + ); + } + } + } + + break; + + case 'array': + if ($stateData && ! is_array($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' needs to be an array.", $this->currentSchemaAttribute); + } + + // Arrays can have schemas defined for each child attribute. + if (! empty($schema['_children'])) { + foreach ($schema['_children'] as $attribute => $attributeSchema) { + $this->currentSchemaAttribute = $attribute; + + $this->recursivelyValidateStateData( + $attributeSchema, + $stateData[$attribute] ?? null + ); + } + } + + break; + + case 'bool': + if (! is_bool($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' needs to be true or false.", $this->currentSchemaAttribute); + } + + break; + + case 'numeric': + if (! is_numeric($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' needs to be numeric.", $this->currentSchemaAttribute); + } + + break; + + case 'text': + // Nothing to do here. + if ($stateData && ! is_string($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' needs to be a string.", $this->currentSchemaAttribute); + } + + break; + + case 'email': + if ($stateData && ! is_email($stateData)) { + $this->stateDataErrors[] = sprintf("'%s' needs to be an email address.", $this->currentSchemaAttribute); + } + + break; + } + } +} diff --git a/tools/php-cs-fixer/composer.json b/tools/php-cs-fixer/composer.json new file mode 100644 index 0000000..59d76fe --- /dev/null +++ b/tools/php-cs-fixer/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "php": "^7.4 || ^8.0 || ^8.1", + "friendsofphp/php-cs-fixer": "^3.0" + } +} diff --git a/wp-cli.yml b/wp-cli.yml new file mode 100644 index 0000000..6ccf5b6 --- /dev/null +++ b/wp-cli.yml @@ -0,0 +1,2 @@ +require: + - dictator.php