diff --git a/.actrc b/.actrc deleted file mode 100644 index 99e6b7ecc..000000000 --- a/.actrc +++ /dev/null @@ -1,3 +0,0 @@ -# Configuration file for nektos/act. -# See https://github.com/nektos/act#configuration --P ubuntu-latest=shivammathur/node:latest diff --git a/.distignore b/.distignore index 95b52fb02..b964b40c7 100644 --- a/.distignore +++ b/.distignore @@ -6,8 +6,6 @@ .travis.yml behat.yml circle.yml -phpcs.xml.dist -phpunit.xml.dist bin/ features/ utils/ diff --git a/.editorconfig b/.editorconfig index 84f918ed5..fa483b1bb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,8 +4,6 @@ # WordPress Coding Standards # https://make.wordpress.org/core/handbook/coding-standards/ -# From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions. - root = true [*] @@ -15,12 +13,13 @@ insert_final_newline = true trim_trailing_whitespace = true indent_style = tab -[{*.yml,*.feature,.jshintrc,*.json}] +[{.jshintrc,*.json,*.yml,*.feature}] indent_style = space indent_size = 2 -[*.md] -trim_trailing_whitespace = false - [{*.txt,wp-config-sample.php}] end_of_line = crlf + +[composer.json] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index d84f4ade2..000000000 --- a/.gitattributes +++ /dev/null @@ -1,14 +0,0 @@ -/.actrc export-ignore -/.distignore export-ignore -/.editorconfig export-ignore -/.github export-ignore -/.gitignore export-ignore -/.typos.toml export-ignore -/AGENTS.md export-ignore -/behat.yml export-ignore -/features export-ignore -/phpcs.xml.dist export-ignore -/phpstan.neon.dist export-ignore -/phpunit.xml.dist export-ignore -/tests export-ignore -/wp-cli.yml export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index f69375fb2..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @wp-cli/committers diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d6c7b8b04..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 -updates: - - package-ecosystem: composer - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 - labels: - - scope:distribution - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 - labels: - - scope:distribution - diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 000000000..df774490e --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,318 @@ +# Used by Probot Settings: https://probot.github.io/apps/settings/ +repository: + description: Manage WordPress core entities. +labels: + - name: bug + color: fc2929 + - name: scope:documentation + color: 0e8a16 + - name: scope:testing + color: 5319e7 + - name: good-first-issue + color: eb6420 + - name: help-wanted + color: 159818 + - name: maybelater + color: c2e0c6 + - name: state:unconfirmed + color: bfe5bf + - name: state:unsupported + color: bfe5bf + - name: wontfix + color: c2e0c6 + - name: command:comment + color: c5def5 + - name: command:comment-approve + color: c5def5 + - name: command:comment-count + color: c5def5 + - name: command:comment-create + color: c5def5 + - name: command:comment-delete + color: c5def5 + - name: command:comment-exists + color: c5def5 + - name: command:comment-generate + color: c5def5 + - name: command:comment-get + color: c5def5 + - name: command:comment-list + color: c5def5 + - name: command:comment-meta + color: c5def5 + - name: command:comment-meta-add + color: c5def5 + - name: command:comment-meta-delete + color: c5def5 + - name: command:comment-meta-get + color: c5def5 + - name: command:comment-meta-list + color: c5def5 + - name: command:comment-meta-patch + color: c5def5 + - name: command:comment-meta-pluck + color: c5def5 + - name: command:comment-meta-update + color: c5def5 + - name: command:comment-recount + color: c5def5 + - name: command:comment-spam + color: c5def5 + - name: command:comment-status + color: c5def5 + - name: command:comment-trash + color: c5def5 + - name: command:comment-unapprove + color: c5def5 + - name: command:comment-unspam + color: c5def5 + - name: command:comment-untrash + color: c5def5 + - name: command:comment-update + color: c5def5 + - name: command:menu + color: c5def5 + - name: command:menu-create + color: c5def5 + - name: command:menu-delete + color: c5def5 + - name: command:menu-item + color: c5def5 + - name: command:menu-item-add-custom + color: c5def5 + - name: command:menu-item-add-post + color: c5def5 + - name: command:menu-item-add-term + color: c5def5 + - name: command:menu-item-delete + color: c5def5 + - name: command:menu-item-list + color: c5def5 + - name: command:menu-item-update + color: c5def5 + - name: command:menu-list + color: c5def5 + - name: command:menu-location + color: c5def5 + - name: command:menu-location-assign + color: c5def5 + - name: command:menu-location-list + color: c5def5 + - name: command:menu-location-remove + color: c5def5 + - name: command:network-meta + color: c5def5 + - name: command:network-meta-add + color: c5def5 + - name: command:network-meta-delete + color: c5def5 + - name: command:network-meta-get + color: c5def5 + - name: command:network-meta-list + color: c5def5 + - name: command:network-meta-patch + color: c5def5 + - name: command:network-meta-pluck + color: c5def5 + - name: command:network-meta-update + color: c5def5 + - name: command:option + color: c5def5 + - name: command:option-add + color: c5def5 + - name: command:option-delete + color: c5def5 + - name: command:option-get + color: c5def5 + - name: command:option-list + color: c5def5 + - name: command:option-patch + color: c5def5 + - name: command:option-pluck + color: c5def5 + - name: command:option-update + color: c5def5 + - name: command:post + color: c5def5 + - name: command:post-create + color: c5def5 + - name: command:post-delete + color: c5def5 + - name: command:post-edit + color: c5def5 + - name: command:post-generate + color: c5def5 + - name: command:post-get + color: c5def5 + - name: command:post-list + color: c5def5 + - name: command:post-meta + color: c5def5 + - name: command:post-meta-add + color: c5def5 + - name: command:post-meta-delete + color: c5def5 + - name: command:post-meta-get + color: c5def5 + - name: command:post-meta-list + color: c5def5 + - name: command:post-meta-patch + color: c5def5 + - name: command:post-meta-pluck + color: c5def5 + - name: command:post-meta-update + color: c5def5 + - name: command:post-term + color: c5def5 + - name: command:post-term-add + color: c5def5 + - name: command:post-term-list + color: c5def5 + - name: command:post-term-remove + color: c5def5 + - name: command:post-term-set + color: c5def5 + - name: command:post-update + color: c5def5 + - name: command:post-type + color: c5def5 + - name: command:post-type-get + color: c5def5 + - name: command:post-type-list + color: c5def5 + - name: command:site + color: c5def5 + - name: command:site-activate + color: c5def5 + - name: command:site-archive + color: c5def5 + - name: command:site-create + color: c5def5 + - name: command:site-deactivate + color: c5def5 + - name: command:site-delete + color: c5def5 + - name: command:site-empty + color: c5def5 + - name: command:site-list + color: c5def5 + - name: command:site-mature + color: c5def5 + - name: command:site-option + color: c5def5 + - name: command:site-private + color: c5def5 + - name: command:site-public + color: c5def5 + - name: command:site-spam + color: c5def5 + - name: command:site-unarchive + color: c5def5 + - name: command:site-unmature + color: c5def5 + - name: command:site-unspam + color: c5def5 + - name: command:taxonomy + color: c5def5 + - name: command:taxonomy-get + color: c5def5 + - name: command:taxonomy-list + color: c5def5 + - name: command:term + color: c5def5 + - name: command:term-create + color: c5def5 + - name: command:term-delete + color: c5def5 + - name: command:term-generate + color: c5def5 + - name: command:term-get + color: c5def5 + - name: command:term-list + color: c5def5 + - name: command:term-meta + color: c5def5 + - name: command:term-meta-add + color: c5def5 + - name: command:term-meta-delete + color: c5def5 + - name: command:term-meta-get + color: c5def5 + - name: command:term-meta-list + color: c5def5 + - name: command:term-meta-patch + color: c5def5 + - name: command:term-meta-pluck + color: c5def5 + - name: command:term-meta-update + color: c5def5 + - name: command:term-recount + color: c5def5 + - name: command:term-update + color: c5def5 + - name: command:user + color: c5def5 + - name: command:user-add-cap + color: c5def5 + - name: command:user-add-role + color: c5def5 + - name: command:user-create + color: c5def5 + - name: command:user-delete + color: c5def5 + - name: command:user-generate + color: c5def5 + - name: command:user-get + color: c5def5 + - name: command:user-import-csv + color: c5def5 + - name: command:user-list + color: c5def5 + - name: command:user-list-caps + color: c5def5 + - name: command:user-meta + color: c5def5 + - name: command:user-meta-add + color: c5def5 + - name: command:user-meta-delete + color: c5def5 + - name: command:user-meta-get + color: c5def5 + - name: command:user-meta-list + color: c5def5 + - name: command:user-meta-patch + color: c5def5 + - name: command:user-meta-pluck + color: c5def5 + - name: command:user-meta-update + color: c5def5 + - name: command:user-remove-cap + color: c5def5 + - name: command:user-remove-role + color: c5def5 + - name: command:user-reset-password + color: c5def5 + - name: command:user-session + color: c5def5 + - name: command:user-session-destroy + color: c5def5 + - name: command:user-session-list + color: c5def5 + - name: command:user-set-role + color: c5def5 + - name: command:user-spam + color: c5def5 + - name: command:user-term + color: c5def5 + - name: command:user-term-add + color: c5def5 + - name: command:user-term-list + color: c5def5 + - name: command:user-term-remove + color: c5def5 + - name: command:user-term-set + color: c5def5 + - name: command:user-unspam + color: c5def5 + - name: command:user-update + color: c5def5 diff --git a/.github/workflows/check-branch-alias.yml b/.github/workflows/check-branch-alias.yml deleted file mode 100644 index 78da63710..000000000 --- a/.github/workflows/check-branch-alias.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Check Branch Alias - -on: - release: - types: [released] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - check-branch-alias: - uses: wp-cli/.github/.github/workflows/reusable-check-branch-alias.yml@main diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml deleted file mode 100644 index e9fe57761..000000000 --- a/.github/workflows/code-quality.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Code Quality Checks - -on: - pull_request: - push: - branches: - - main - - master - schedule: - - cron: '17 2 * * *' # Run every day on a seemly random time. - -jobs: - code-quality: - uses: wp-cli/.github/.github/workflows/reusable-code-quality.yml@main diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml deleted file mode 100644 index 844ffe251..000000000 --- a/.github/workflows/copilot-setup-steps.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: "Copilot Setup Steps" - -on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml - -permissions: - contents: read - -jobs: - copilot-setup-steps: - name: Setup environment - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Check existence of composer.json file - id: check_composer_file - run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Set up PHP environment - if: steps.check_composer_file.outputs.files_exists == 'true' - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: 'latest' - ini-values: zend.assertions=1, error_reporting=-1, display_errors=On - coverage: 'none' - tools: composer,cs2pr - env: - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Composer dependencies & cache dependencies - if: steps.check_composer_file.outputs.files_exists == 'true' - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - env: - COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml deleted file mode 100644 index 68334703a..000000000 --- a/.github/workflows/issue-triage.yml +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Issue and PR Triage - -'on': - issues: - types: [opened] - pull_request_target: - types: [opened] - workflow_dispatch: - inputs: - issue_number: - description: 'Issue/PR number to triage (leave empty to process all)' - required: false - type: string - -permissions: - issues: write - pull-requests: write - actions: write - contents: read - models: read - -jobs: - issue-triage: - uses: wp-cli/.github/.github/workflows/reusable-issue-triage.yml@main - with: - issue_number: >- - ${{ - (github.event_name == 'workflow_dispatch' && inputs.issue_number) || - (github.event_name == 'pull_request_target' && github.event.pull_request.number) || - (github.event_name == 'issues' && github.event.issue.number) || - '' - }} diff --git a/.github/workflows/manage-labels.yml b/.github/workflows/manage-labels.yml deleted file mode 100644 index 45711bded..000000000 --- a/.github/workflows/manage-labels.yml +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Manage Labels - -'on': - workflow_dispatch: - push: - branches: - - main - - master - paths: - - 'composer.json' - -permissions: - issues: write - contents: read - -jobs: - manage-labels: - uses: wp-cli/.github/.github/workflows/reusable-manage-labels.yml@main diff --git a/.github/workflows/regenerate-readme.yml b/.github/workflows/regenerate-readme.yml deleted file mode 100644 index 6198d6308..000000000 --- a/.github/workflows/regenerate-readme.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Regenerate README file - -on: - workflow_dispatch: - push: - branches: - - main - - master - paths-ignore: - - "features/**" - - "README.md" - -permissions: - contents: write - pull-requests: write - -jobs: - regenerate-readme: - uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml deleted file mode 100644 index bf67592d8..000000000 --- a/.github/workflows/testing.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Testing - -on: - workflow_dispatch: - pull_request: - push: - branches: - - main - - master - schedule: - - cron: '17 1 * * *' # Run every day on a seemly random time. - -jobs: - test: - uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main diff --git a/.github/workflows/welcome-new-contributors.yml b/.github/workflows/welcome-new-contributors.yml deleted file mode 100644 index bc01490b3..000000000 --- a/.github/workflows/welcome-new-contributors.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Welcome New Contributors - -on: - pull_request_target: - types: [opened] - branches: - - main - - master - -permissions: - pull-requests: write - -jobs: - welcome: - uses: wp-cli/.github/.github/workflows/reusable-welcome-new-contributors.yml@main diff --git a/.gitignore b/.gitignore index 26e4d031b..f3b908269 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,3 @@ vendor/ *.zip *.tar.gz composer.lock -*.log -phpunit.xml -phpcs.xml -.phpcs.xml -.phpunit.result.cache -.phpunit.cache diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..697bac1b4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,60 @@ +sudo: false +dist: trusty + +language: php + +notifications: + email: + on_success: never + on_failure: change + +branches: + only: + - master + +cache: + directories: + - $HOME/.composer/cache + +env: + global: + - PATH="$TRAVIS_BUILD_DIR/vendor/bin:$PATH" + - WP_CLI_BIN_DIR="$TRAVIS_BUILD_DIR/vendor/bin" + +matrix: + include: + - php: 7.2 + env: WP_VERSION=latest + - php: 7.1 + env: WP_VERSION=latest + - php: 7.0 + env: WP_VERSION=latest + - php: 5.6 + env: WP_VERSION=latest + - php: 5.6 + env: WP_VERSION=3.7.11 + - php: 5.6 + env: WP_VERSION=trunk + - php: 5.3 + dist: precise + env: WP_VERSION=latest + +before_install: + - | + # Remove Xdebug for a huge performance increase: + if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then + phpenv config-rm xdebug.ini + else + echo "xdebug.ini does not exist" + fi + +install: + - composer require wp-cli/wp-cli:dev-master + - composer install + - bash bin/install-package-tests.sh + +before_script: + - composer validate + +script: + - bash bin/test.sh diff --git a/.typos.toml b/.typos.toml deleted file mode 100644 index 965f891fc..000000000 --- a/.typos.toml +++ /dev/null @@ -1,6 +0,0 @@ -[default] -extend-ignore-re = [ - "(?Rm)^.*(#|//)\\s*spellchecker:disable-line$", - "(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on", - "(#|//)\\s*spellchecker:ignore-next-line\\n.*" -] diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1ff84f6d1..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,121 +0,0 @@ -# Instructions - -This package is part of WP-CLI, the official command line interface for WordPress. For a detailed explanation of the project structure and development workflow, please refer to the main @README.md file. - -## Best Practices for Code Contributions - -When contributing to this package, please adhere to the following guidelines: - -* **Follow Existing Conventions:** Before writing any code, analyze the existing codebase in this package to understand the coding style, naming conventions, and architectural patterns. -* **Focus on the Package's Scope:** All changes should be relevant to the functionality of the package. -* **Write Tests:** All new features and bug fixes must be accompanied by acceptance tests using Behat. You can find the existing tests in the `features/` directory. There may be PHPUnit unit tests as well in the `tests/` directory. -* **Update Documentation:** If your changes affect the user-facing functionality, please update the relevant inline code documentation. - -### Building and running - -Before submitting any changes, it is crucial to validate them by running the full suite of static code analysis and tests. To run the full suite of checks, execute the following command: `composer test`. - -This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps separately, it is highly recommended to use this single command to ensure a comprehensive validation. - -### Useful Composer Commands - -The project uses Composer to manage dependencies and run scripts. The following commands are available: - -* `composer install`: Install dependencies. -* `composer test`: Run the full test suite, including linting, code style checks, static analysis, and unit/behavior tests. -* `composer lint`: Check for syntax errors. -* `composer phpcs`: Check for code style violations. -* `composer phpcbf`: Automatically fix code style violations. -* `composer phpstan`: Run static analysis. -* `composer phpunit`: Run unit tests. -* `composer behat`: Run behavior-driven tests. - -### Coding Style - -The project follows the `WP_CLI_CS` coding standard, which is enforced by PHP_CodeSniffer. The configuration can be found in `phpcs.xml.dist`. Before submitting any code, please run `composer phpcs` to check for violations and `composer phpcbf` to automatically fix them. - -## Documentation - -The `README.md` file might be generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). In that case, changes need to be made against the corresponding part of the codebase. - -### Inline Documentation - -Only write high-value comments if at all. Avoid talking to the user through comments. - -## Testing - -The project has a comprehensive test suite that includes unit tests, behavior-driven tests, and static analysis. - -* **Unit tests** are written with PHPUnit and can be found in the `tests/` directory. The configuration is in `phpunit.xml.dist`. -* **Behavior-driven tests** are written with Behat and can be found in the `features/` directory. The configuration is in `behat.yml`. -* **Static analysis** is performed with PHPStan. - -All tests are run on GitHub Actions for every pull request. - -When writing tests, aim to follow existing patterns. Key conventions include: - -* When adding tests, first examine existing tests to understand and conform to established conventions. -* For unit tests, extend the base `WP_CLI\Tests\TestCase` test class. -* For Behat tests, only WP-CLI commands installed in `composer.json` can be run. - -### Behat Steps - -WP-CLI makes use of a Behat-based testing framework and provides a set of custom step definitions to write feature tests. - -> **Note:** If you are expecting an error output in a test, you need to use `When I try ...` instead of `When I run ...` . - -#### Given - -* `Given an empty directory` - Creates an empty directory. -* `Given /^an? (empty|non-existent) ([^\s]+) directory$/` - Creates or deletes a specific directory. -* `Given an empty cache` - Clears the WP-CLI cache directory. -* `Given /^an? ([^\s]+) (file|cache file):$/` - Creates a file with the given contents. -* `Given /^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/` - Search and replace a string in a file using regex. -* `Given /^that HTTP requests to (.*?) will respond with:$/` - Mock HTTP requests to a given URL. -* `Given WP files` - Download WordPress files without installing. -* `Given wp-config.php` - Create a wp-config.php file using `wp config create`. -* `Given a database` - Creates an empty database. -* `Given a WP install(ation)` - Installs WordPress. -* `Given a WP install(ation) in :subdir` - Installs WordPress in a given directory. -* `Given a WP install(ation) with Composer` - Installs WordPress with Composer. -* `Given a WP install(ation) with Composer and a custom vendor directory :vendor_directory` - Installs WordPress with Composer and a custom vendor directory. -* `Given /^a WP multisite (subdirectory|subdomain)?\s?(install|installation)$/` - Installs WordPress Multisite. -* `Given these installed and active plugins:` - Installs and activates one or more plugins. -* `Given a custom wp-content directory` - Configure a custom `wp-content` directory. -* `Given download:` - Download multiple files into the given destinations. -* `Given /^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/` - Store STDOUT or STDERR contents in a variable. -* `Given /^a new Phar with (?:the same version|version "([^"]+)")$/` - Build a new WP-CLI Phar file with a given version. -* `Given /^a downloaded Phar with (?:the same version|version "([^"]+)")$/` - Download a specific WP-CLI Phar version from GitHub. -* `Given /^save the (.+) file ([\'].+[^\'])? as \{(\w+)\}$/` - Stores the contents of the given file in a variable. -* `Given a misconfigured WP_CONTENT_DIR constant directory` - Modify wp-config.php to set `WP_CONTENT_DIR` to an empty string. -* `Given a dependency on current wp-cli` - Add `wp-cli/wp-cli` as a Composer dependency. -* `Given a PHP built-in web server` - Start a PHP built-in web server in the current directory. -* `Given a PHP built-in web server to serve :subdir` - Start a PHP built-in web server in the given subdirectory. - -#### When - -* ``When /^I launch in the background `([^`]+)`$/`` - Launch a given command in the background. -* ``When /^I (run|try) `([^`]+)`$/`` - Run or try a given command. -* ``When /^I (run|try) `([^`]+)` from '([^\s]+)'$/`` - Run or try a given command in a subdirectory. -* `When /^I (run|try) the previous command again$/` - Run or try the previous command again. - -#### Then - -* `Then /^the return code should( not)? be (\d+)$/` - Expect a specific exit code of the previous command. -* `Then /^(STDOUT|STDERR) should( strictly)? (be|contain|not contain):$/` - Check the contents of STDOUT or STDERR. -* `Then /^(STDOUT|STDERR) should be a number$/` - Expect STDOUT or STDERR to be a numeric value. -* `Then /^(STDOUT|STDERR) should not be a number$/` - Expect STDOUT or STDERR to not be a numeric value. -* `Then /^STDOUT should be a table containing rows:$/` - Expect STDOUT to be a table containing the given rows. -* `Then /^STDOUT should end with a table containing rows:$/` - Expect STDOUT to end with a table containing the given rows. -* `Then /^STDOUT should be JSON containing:$/` - Expect valid JSON output in STDOUT. -* `Then /^STDOUT should be a JSON array containing:$/` - Expect valid JSON array output in STDOUT. -* `Then /^STDOUT should be CSV containing:$/` - Expect STDOUT to be CSV containing certain values. -* `Then /^STDOUT should be YAML containing:$/` - Expect STDOUT to be YAML containing certain content. -* `Then /^(STDOUT|STDERR) should be empty$/` - Expect STDOUT or STDERR to be empty. -* `Then /^(STDOUT|STDERR) should not be empty$/` - Expect STDOUT or STDERR not to be empty. -* `Then /^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|<>) ([+\w.{}-]+)$/` - Expect STDOUT or STDERR to be a version string comparing to the given version. -* `Then /^the (.+) (file|directory) should( strictly)? (exist|not exist|be:|contain:|not contain):$/` - Expect a certain file or directory to (not) exist or (not) contain certain contents. -* `Then /^the contents of the (.+) file should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match file contents against a regex. -* `Then /^(STDOUT|STDERR) should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match STDOUT or STDERR against a regex. -* `Then /^an email should (be sent|not be sent)$/` - Expect an email to be sent (or not). -* `Then the HTTP status code should be :code` - Expect the HTTP status code for visiting `http://localhost:8080`. diff --git a/LICENSE b/LICENSE index b90f34773..3915c1f0d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (C) 2011-2018 WP-CLI Development Group (https://github.com/wp-cli/entity-command/contributors) +Copyright (C) 2011-2017 WP-CLI Development Group (https://github.com/wp-cli/entity-command/contributors) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 52d1a5da0..840f4de4c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ wp-cli/entity-command ===================== -Manage WordPress comments, menus, options, posts, sites, terms, and users. +Manage WordPress core entities. -[![Testing](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/entity-command/actions/workflows/testing.yml) [![Code Coverage](https://codecov.io/gh/wp-cli/entity-command/branch/main/graph/badge.svg)](https://codecov.io/gh/wp-cli/entity-command/tree/main) +[![Build Status](https://travis-ci.org/wp-cli/entity-command.svg?branch=master)](https://travis-ci.org/wp-cli/entity-command) Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support) @@ -33,38 +33,10 @@ wp comment $ wp comment delete 1337 --force Success: Deleted comment 1337. - # Trash all spam comments. + # Delete all spam comments. $ wp comment delete $(wp comment list --status=spam --format=ids) - Success: Trashed comment 264. - Success: Trashed comment 262. - - # Create a note for a block (WordPress 6.9+). - $ wp comment create --comment_post_ID=15 --comment_content="This block needs revision" --comment_author="editor" --comment_type="note" - Success: Created comment 945. - - # List notes for a specific post (WordPress 6.9+). - $ wp comment list --type=note --post_id=15 - +------------+---------------------+----------------------------------+ - | comment_ID | comment_date | comment_content | - +------------+---------------------+----------------------------------+ - | 945 | 2024-11-10 14:30:00 | This block needs revision | - +------------+---------------------+----------------------------------+ - - # Reply to a note (WordPress 6.9+). - $ wp comment create --comment_post_ID=15 --comment_content="Updated per feedback" --comment_author="editor" --comment_type="note" --comment_parent=945 - Success: Created comment 946. - - # Resolve a note by adding a comment with status meta (WordPress 6.9+). - $ wp comment create --comment_post_ID=15 --comment_content="Resolving" --comment_author="editor" --comment_type="note" --comment_parent=945 --porcelain - 947 - $ wp comment meta add 947 _wp_note_status resolved - Success: Added custom field. - - # Reopen a resolved note (WordPress 6.9+). - $ wp comment create --comment_post_ID=15 --comment_content="Reopening for further review" --comment_author="editor" --comment_type="note" --comment_parent=945 --porcelain - 948 - $ wp comment meta add 948 _wp_note_status reopen - Success: Added custom field. + Success: Deleted comment 264. + Success: Deleted comment 262. @@ -148,10 +120,6 @@ wp comment create [--=] [--porcelain] $ wp comment create --comment_post_ID=15 --comment_content="hello blog" --comment_author="wp-cli" Success: Created comment 932. - # Create a note (WordPress 6.9+). - $ wp comment create --comment_post_ID=15 --comment_content="This block needs revision" --comment_author="editor" --comment_type="note" - Success: Created comment 933. - ### wp comment delete @@ -296,9 +264,6 @@ Gets a list of comments. wp comment list [--=] [--field=] [--fields=] [--format=] ~~~ -Display comments based on all arguments supported by -[WP_Comment_Query()](https://developer.wordpress.org/reference/classes/WP_Comment_Query/__construct/). - **OPTIONS** [--=] @@ -373,41 +338,6 @@ These fields are optionally available: | 29 | 2013-03-14 11:56:08 | Jane Doe | +------------+---------------------+----------------+ - # List unapproved comments. - $ wp comment list --number=3 --status=hold --fields=ID,comment_date,comment_author - +------------+---------------------+----------------+ - | comment_ID | comment_date | comment_author | - +------------+---------------------+----------------+ - | 8 | 2023-11-10 13:13:06 | John Doe | - | 7 | 2023-11-10 13:09:55 | Mr WordPress | - | 9 | 2023-11-10 11:22:31 | Jane Doe | - +------------+---------------------+----------------+ - - # List comments marked as spam. - $ wp comment list --status=spam --fields=ID,comment_date,comment_author - +------------+---------------------+----------------+ - | comment_ID | comment_date | comment_author | - +------------+---------------------+----------------+ - | 2 | 2023-11-10 11:22:31 | Jane Doe | - +------------+---------------------+----------------+ - - # List comments in trash. - $ wp comment list --status=trash --fields=ID,comment_date,comment_author - +------------+---------------------+----------------+ - | comment_ID | comment_date | comment_author | - +------------+---------------------+----------------+ - | 3 | 2023-11-10 11:22:31 | John Doe | - +------------+---------------------+----------------+ - - # List notes for a specific post (WordPress 6.9+). - $ wp comment list --type=note --post_id=15 --fields=ID,comment_date,comment_content - +------------+---------------------+----------------------------------+ - | comment_ID | comment_date | comment_content | - +------------+---------------------+----------------------------------+ - | 10 | 2024-11-10 14:30:00 | This block needs revision | - | 11 | 2024-11-10 15:45:00 | Updated per feedback | - +------------+---------------------+----------------------------------+ - ### wp comment meta @@ -499,7 +429,7 @@ wp comment meta delete [] [] [--all] Get meta field value. ~~~ -wp comment meta get [--single] [--format=] +wp comment meta get [--format=] ~~~ **OPTIONS** @@ -510,18 +440,8 @@ wp comment meta get [--single] [--format=] The name of the meta field to get. - [--single] - Whether to return a single value. - [--format=] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- + Accepted values: table, json. Default: table @@ -530,7 +450,7 @@ wp comment meta get [--single] [--format=] List all metadata associated with an object. ~~~ -wp comment meta list [--keys=] [--fields=] [--format=] [--orderby=] [--order=] [--unserialize] +wp comment meta list [--keys=] [--fields=] [--format=] [--orderby=] [--order=] ~~~ **OPTIONS** @@ -545,16 +465,7 @@ wp comment meta list [--keys=] [--fields=] [--format= Limit the output to specific row fields. Defaults to id,meta_key,meta_value. [--format=] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - --- + Accepted values: table, csv, json, count. Default: table [--orderby=] Set orderby which field. @@ -575,9 +486,6 @@ wp comment meta list [--keys=] [--fields=] [--format= - desc --- - [--unserialize] - Unserialize meta_value output. - ### wp comment meta patch @@ -660,8 +568,6 @@ Update a meta field. wp comment meta update [] [--format=] ~~~ -**Alias:** `set` - **OPTIONS** @@ -855,538 +761,481 @@ wp comment update ... --= -### wp font +### wp menu -Manages WordPress fonts. +Lists, creates, assigns, and deletes the active theme's navigation menus. ~~~ -wp font +wp menu ~~~ -**EXAMPLES** - - # List all font collections - $ wp font collection list - - # Install a font family from a collection - $ wp font family install google-fonts inter - - # List installed font families - $ wp post list --post_type=wp_font_family - - # List installed font faces - $ wp post list --post_type=wp_font_face - - - -### wp font collection - -Manages font collections. +See the [Navigation Menus](https://developer.wordpress.org/themes/functionality/navigation-menus/) reference in the Theme Handbook. -~~~ -wp font collection -~~~ +**EXAMPLES** -Font collections are predefined sets of fonts that can be used in WordPress. -Collections are registered by WordPress core or themes and cannot be created -or deleted via the command line. + # Create a new menu + $ wp menu create "My Menu" + Success: Created menu 200. -**EXAMPLES** + # List existing menus + $ wp menu list + +---------+----------+----------+-----------+-------+ + | term_id | name | slug | locations | count | + +---------+----------+----------+-----------+-------+ + | 200 | My Menu | my-menu | | 0 | + | 177 | Top Menu | top-menu | primary | 7 | + +---------+----------+----------+-----------+-------+ - # List all font collections - $ wp font collection list - +------------------+-------------------+---------+ - | slug | name | count | - +------------------+-------------------+---------+ - | google-fonts | Google Fonts | 1500 | - +------------------+-------------------+---------+ + # Create a new menu link item + $ wp menu item add-custom my-menu Apple http://apple.com --porcelain + 1922 - # Get details about a specific font collection - $ wp font collection get google-fonts - +-------+------------------+ - | Field | Value | - +-------+------------------+ - | slug | google-fonts | - | name | Google Fonts | - +-------+------------------+ + # Assign the 'my-menu' menu to the 'primary' location + $ wp menu location assign my-menu primary + Success: Assigned location to menu. -### wp font collection get +### wp menu create -Gets details about a registered font collection. +Creates a new menu. ~~~ -wp font collection get [--field=] [--fields=] [--format=] +wp menu create [--porcelain] ~~~ **OPTIONS** - - Font collection slug. - - [--field=] - Instead of returning the whole collection, returns the value of a single field. - - [--fields=] - Limit the output to specific fields. Defaults to all fields. - - [--format=] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - --- - -**AVAILABLE FIELDS** - -These fields will be displayed by default for the specified collection: + + A descriptive name for the menu. -* slug -* name -* description -* categories + [--porcelain] + Output just the new menu id. **EXAMPLES** - # Get details of a specific collection - $ wp font collection get google-fonts - +-------+------------------+ - | Field | Value | - +-------+------------------+ - | slug | google-fonts | - | name | Google Fonts | - +-------+------------------+ - - # Get the name field only - $ wp font collection get google-fonts --field=name - Google Fonts + $ wp menu create "My Menu" + Success: Created menu 200. -### wp font collection is-registered +### wp menu delete -Checks if a font collection is registered. +Deletes one or more menus. ~~~ -wp font collection is-registered +wp menu delete ... ~~~ **OPTIONS** - - Font collection slug. + ... + The name, slug, or term ID for the menu(s). **EXAMPLES** - # Bash script for checking if a font collection is registered, with fallback. - - if wp font collection is-registered google-fonts 2>/dev/null; then - # Font collection is registered. Do something. - else - # Fallback if collection is not registered. - fi + $ wp menu delete "My Menu" + Success: 1 menu deleted. -### wp font collection list +### wp menu item -Lists registered font collections. +List, add, and delete items associated with a menu. ~~~ -wp font collection list [--field=] [--fields=] [--format=] +wp menu item ~~~ -**OPTIONS** +**EXAMPLES** - [--field=] - Prints the value of a single field for each collection. + # Add an existing post to an existing menu + $ wp menu item add-post sidebar-menu 33 --title="Custom Test Post" + Success: Menu item added. - [--fields=] - Limit the output to specific collection fields. + # Create a new menu link item + $ wp menu item add-custom sidebar-menu Apple http://apple.com + Success: Menu item added. - [--format=] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - yaml - --- + # Delete menu item + $ wp menu item delete 45 + Success: 1 menu item deleted. -**AVAILABLE FIELDS** -These fields will be displayed by default for each collection: -* slug -* name -* description -* categories -**EXAMPLES** - # List all font collections - $ wp font collection list - +------------------+-------------------+ - | slug | name | - +------------------+-------------------+ - | google-fonts | Google Fonts | - +------------------+-------------------+ +### wp menu item add-custom - # List collections in JSON format - $ wp font collection list --format=json - [{"slug":"google-fonts","name":"Google Fonts"}] +Adds a custom menu item. +~~~ +wp menu item add-custom <link> [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] +~~~ +**OPTIONS** -### wp font collection list-categories + <menu> + The name, slug, or term ID for the menu. -Lists categories in a collection. + <title> + Title for the link. -~~~ -wp font collection list-categories <slug> [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ + <link> + Target URL for the link. -**OPTIONS** + [--description=<description>] + Set a custom description for the menu item. - <slug> - Font collection slug. + [--attr-title=<attr-title>] + Set a custom title attribute for the menu item. - [--field=<field>] - Prints the value of a single field for each category. + [--target=<target>] + Set a custom link target for the menu item. - [--fields=<fields>] - Limit the output to specific category fields. + [--classes=<classes>] + Set a custom link classes for the menu item. - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - yaml - --- + [--position=<position>] + Specify the position of this menu item. -**AVAILABLE FIELDS** + [--parent-id=<parent-id>] + Make this menu item a child of another menu item. -* slug -* name + [--porcelain] + Output just the new menu item id. **EXAMPLES** - # List all categories in a collection - $ wp font collection list-categories google-fonts - +-------------+--------------+ - | slug | name | - +-------------+--------------+ - | sans-serif | Sans Serif | - | display | Display | - +-------------+--------------+ + $ wp menu item add-custom sidebar-menu Apple http://apple.com + Success: Menu item added. -### wp font collection list-families +### wp menu item add-post -Lists font families in a collection. +Adds a post as a menu item. ~~~ -wp font collection list-families <slug> [--category=<slug>] [--field=<field>] [--fields=<fields>] [--format=<format>] +wp menu item add-post <menu> <post-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] ~~~ **OPTIONS** - <slug> - Font collection slug. - - [--category=<slug>] - Filter by category slug. - - [--field=<field>] - Prints the value of a single field for each family. - - [--fields=<fields>] - Limit the output to specific family fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - yaml - --- - -**AVAILABLE FIELDS** + <menu> + The name, slug, or term ID for the menu. -* slug -* name -* fontFamily -* categories -* preview + <post-id> + Post ID to add to the menu. -**EXAMPLES** + [--title=<title>] + Set a custom title for the menu item. - # List all font families in a collection - $ wp font collection list-families google-fonts + [--link=<link>] + Set a custom url for the menu item. - # List font families in a specific category - $ wp font collection list-families google-fonts --category=sans-serif + [--description=<description>] + Set a custom description for the menu item. + [--attr-title=<attr-title>] + Set a custom title attribute for the menu item. + [--target=<target>] + Set a custom link target for the menu item. -### wp font face + [--classes=<classes>] + Set a custom link classes for the menu item. -Manages font faces. + [--position=<position>] + Specify the position of this menu item. -~~~ -wp font face -~~~ + [--parent-id=<parent-id>] + Make this menu item a child of another menu item. -To list, get, create, update or delete font faces, use `wp post` with -`--post_type=wp_font_face`. + [--porcelain] + Output just the new menu item id. **EXAMPLES** - # Install a font face for an existing family - $ wp font face install 42 --src="https://example.com/font.woff2" --font-weight=700 - Success: Created font face 43. - - # List installed font faces - $ wp post list --post_type=wp_font_face + $ wp menu item add-post sidebar-menu 33 --title="Custom Test Post" + Success: Menu item added. -### wp font face install +### wp menu item add-term -Installs a font face. +Adds a taxonomy term as a menu item. ~~~ -wp font face install <family-id> --src=<src> [--font-family=<family>] [--font-style=<style>] [--font-weight=<weight>] [--font-display=<display>] [--porcelain] +wp menu item add-term <menu> <taxonomy> <term-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] ~~~ -Creates a new font face post with the specified settings. - **OPTIONS** - <family-id> - Font family ID. - - --src=<src> - Font face source URL or file path. + <menu> + The name, slug, or term ID for the menu. - [--font-family=<family>] - CSS font-family value. + <taxonomy> + Taxonomy of the term to be added. - [--font-style=<style>] - CSS font-style value (e.g., normal, italic). - --- - default: normal - --- + <term-id> + Term ID of the term to be added. - [--font-weight=<weight>] - CSS font-weight value (e.g., 400, 700). - --- - default: 400 - --- + [--title=<title>] + Set a custom title for the menu item. + + [--link=<link>] + Set a custom url for the menu item. + + [--description=<description>] + Set a custom description for the menu item. + + [--attr-title=<attr-title>] + Set a custom title attribute for the menu item. + + [--target=<target>] + Set a custom link target for the menu item. + + [--classes=<classes>] + Set a custom link classes for the menu item. + + [--position=<position>] + Specify the position of this menu item. - [--font-display=<display>] - CSS font-display value. + [--parent-id=<parent-id>] + Make this menu item a child of another menu item. [--porcelain] - Output just the new font face ID. + Output just the new menu item id. **EXAMPLES** - # Install a font face - $ wp font face install 42 --src="https://example.com/font.woff2" --font-weight=700 --font-style=normal - Success: Created font face 43. - - # Install a font face with porcelain output - $ wp font face install 42 --src="font.woff2" --porcelain - 44 + $ wp menu item add-term sidebar-menu post_tag 24 + Success: Menu item added. -### wp font family +### wp menu item delete -Manages font families. +Deletes one or more items from a menu. ~~~ -wp font family +wp menu item delete <db-id>... ~~~ -To list, get, create, update or delete font families, use `wp post` with -`--post_type=wp_font_family`. +**OPTIONS** + + <db-id>... + Database ID for the menu item(s). **EXAMPLES** - # Install a font family from a collection - $ wp font family install google-fonts inter - Success: Installed font family "Inter" (ID: 42) with 9 font faces. - - # List installed font families - $ wp post list --post_type=wp_font_family + $ wp menu item delete 45 + Success: 1 menu item deleted. -### wp font family install +### wp menu item list -Installs a font family from a collection. +Gets a list of items associated with a menu. ~~~ -wp font family install <collection> <family> [--porcelain] +wp menu item list <menu> [--fields=<fields>] [--format=<format>] ~~~ -Retrieves a font family from a collection and creates the wp_font_family post -along with all associated font faces. - **OPTIONS** - <collection> - Font collection slug. + <menu> + The name, slug, or term ID for the menu. - <family> - Font family slug from the collection. + [--fields=<fields>] + Limit the output to specific object fields. - [--porcelain] - Output just the new font family ID. + [--format=<format>] + Render output in a particular format. + --- + default: table + options: + - table + - csv + - json + - count + - ids + - yaml + --- -**EXAMPLES** +**AVAILABLE FIELDS** - # Install a font family from a collection - $ wp font family install google-fonts inter - Success: Installed font family "Inter" (ID: 42) with 9 font faces. +These fields will be displayed by default for each menu item: - # Install and get the family ID - $ wp font family install google-fonts roboto --porcelain - 43 +* db_id +* type +* title +* link +* position +These fields are optionally available: +* menu_item_parent +* object_id +* object +* type +* type_label +* target +* attr_title +* description +* classes +* xfn -### wp menu +**EXAMPLES** -Lists, creates, assigns, and deletes the active theme's navigation menus. + $ wp menu item list main-menu + +-------+-----------+-------------+---------------------------------+----------+ + | db_id | type | title | link | position | + +-------+-----------+-------------+---------------------------------+----------+ + | 5 | custom | Home | http://example.com | 1 | + | 6 | post_type | Sample Page | http://example.com/sample-page/ | 2 | + +-------+-----------+-------------+---------------------------------+----------+ -~~~ -wp menu -~~~ -See the [Navigation Menus](https://developer.wordpress.org/themes/functionality/navigation-menus/) reference in the Theme Handbook. -**EXAMPLES** +### wp menu item update - # Create a new menu - $ wp menu create "My Menu" - Success: Created menu 200. +Updates a menu item. - # List existing menus - $ wp menu list - +---------+----------+----------+-----------+-------+ - | term_id | name | slug | locations | count | - +---------+----------+----------+-----------+-------+ - | 200 | My Menu | my-menu | | 0 | - | 177 | Top Menu | top-menu | primary | 7 | - +---------+----------+----------+-----------+-------+ +~~~ +wp menu item update <db-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] +~~~ - # Create a new menu link item - $ wp menu item add-custom my-menu Apple http://apple.com --porcelain - 1922 +**OPTIONS** - # Assign the 'my-menu' menu to the 'primary' location - $ wp menu location assign my-menu primary - Success: Assigned location primary to menu my-menu. + <db-id> + Database ID for the menu item. + [--title=<title>] + Set a custom title for the menu item. + [--link=<link>] + Set a custom url for the menu item. -### wp menu create + [--description=<description>] + Set a custom description for the menu item. -Creates a new menu. + [--attr-title=<attr-title>] + Set a custom title attribute for the menu item. -~~~ -wp menu create <menu-name> [--porcelain] -~~~ + [--target=<target>] + Set a custom link target for the menu item. -**OPTIONS** + [--classes=<classes>] + Set a custom link classes for the menu item. - <menu-name> - A descriptive name for the menu. + [--position=<position>] + Specify the position of this menu item. - [--porcelain] - Output just the new menu id. + [--parent-id=<parent-id>] + Make this menu item a child of another menu item. **EXAMPLES** - $ wp menu create "My Menu" - Success: Created menu 200. + $ wp menu item update 45 --title=WordPress --link='http://wordpress.org' --target=_blank --position=2 + Success: Menu item updated. -### wp menu delete +### wp menu list -Deletes one or more menus. +Gets a list of menus. ~~~ -wp menu delete <menu>... +wp menu list [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** - <menu>... - The name, slug, or term ID for the menu(s). + [--fields=<fields>] + Limit the output to specific object fields. + + [--format=<format>] + Render output in a particular format. + --- + default: table + options: + - table + - csv + - json + - count + - ids + - yaml + --- + +**AVAILABLE FIELDS** + +These fields will be displayed by default for each menu: + +* term_id +* name +* slug +* count + +These fields are optionally available: + +* term_group +* term_taxonomy_id +* taxonomy +* description +* parent +* locations **EXAMPLES** - $ wp menu delete "My Menu" - Deleted menu 'My Menu'. - Success: Deleted 1 of 1 menus. + $ wp menu list + +---------+----------+----------+-----------+-------+ + | term_id | name | slug | locations | count | + +---------+----------+----------+-----------+-------+ + | 200 | My Menu | my-menu | | 0 | + | 177 | Top Menu | top-menu | primary | 7 | + +---------+----------+----------+-----------+-------+ -### wp menu item +### wp menu location -List, add, and delete items associated with a menu. +Assigns, removes, and lists a menu's locations. ~~~ -wp menu item +wp menu location ~~~ **EXAMPLES** - # Add an existing post to an existing menu - $ wp menu item add-post sidebar-menu 33 --title="Custom Test Post" - Success: Menu item added. + # List available menu locations + $ wp menu location list + +----------+-------------------+ + | location | description | + +----------+-------------------+ + | primary | Primary Menu | + | social | Social Links Menu | + +----------+-------------------+ - # Create a new menu link item - $ wp menu item add-custom sidebar-menu Apple http://apple.com - Success: Menu item added. + # Assign the 'primary-menu' menu to the 'primary' location + $ wp menu location assign primary-menu primary + Success: Assigned location to menu. - # Delete menu item - $ wp menu item delete 45 - Success: Deleted 1 of 1 menu items. + # Remove the 'primary-menu' menu from the 'primary' location + $ wp menu location remove primary-menu primary + Success: Removed location from menu. -### wp menu item add-custom +### wp menu location assign -Adds a custom menu item. +Assigns a location to a menu. ~~~ -wp menu item add-custom <menu> <title> <link> [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] +wp menu location assign <menu> <location> ~~~ **OPTIONS** @@ -1394,46 +1243,64 @@ wp menu item add-custom <menu> <title> <link> [--description=<description>] [--a <menu> The name, slug, or term ID for the menu. - <title> - Title for the link. - - <link> - Target URL for the link. + <location> + Location's slug. - [--description=<description>] - Set a custom description for the menu item. +**EXAMPLES** - [--attr-title=<attr-title>] - Set a custom title attribute for the menu item. + $ wp menu location assign primary-menu primary + Success: Assigned location primary to menu primary-menu. - [--target=<target>] - Set a custom link target for the menu item. - [--classes=<classes>] - Set a custom link classes for the menu item. - [--position=<position>] - Specify the position of this menu item. +### wp menu location list - [--parent-id=<parent-id>] - Make this menu item a child of another menu item. +Lists locations for the current theme. - [--porcelain] - Output just the new menu item id. +~~~ +wp menu location list [--format=<format>] +~~~ + +**OPTIONS** + + [--format=<format>] + Render output in a particular format. + --- + default: table + options: + - table + - csv + - json + - count + - yaml + - ids + --- + +**AVAILABLE FIELDS** + +These fields will be displayed by default for each location: + +* name +* description **EXAMPLES** - $ wp menu item add-custom sidebar-menu Apple http://apple.com - Success: Menu item added. + $ wp menu location list + +----------+-------------------+ + | location | description | + +----------+-------------------+ + | primary | Primary Menu | + | social | Social Links Menu | + +----------+-------------------+ -### wp menu item add-post +### wp menu location remove -Adds a post as a menu item. +Removes a location from a menu. ~~~ -wp menu item add-post <menu> <post-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] +wp menu location remove <menu> <location> ~~~ **OPTIONS** @@ -1441,3437 +1308,932 @@ wp menu item add-post <menu> <post-id> [--title=<title>] [--link=<link>] [--desc <menu> The name, slug, or term ID for the menu. - <post-id> - Post ID to add to the menu. - - [--title=<title>] - Set a custom title for the menu item. - - [--link=<link>] - Set a custom url for the menu item. + <location> + Location's slug. - [--description=<description>] - Set a custom description for the menu item. +**EXAMPLES** - [--attr-title=<attr-title>] - Set a custom title attribute for the menu item. + $ wp menu location remove primary-menu primary + Success: Removed location from menu. - [--target=<target>] - Set a custom link target for the menu item. - [--classes=<classes>] - Set a custom link classes for the menu item. - [--position=<position>] - Specify the position of this menu item. +### wp network meta - [--parent-id=<parent-id>] - Make this menu item a child of another menu item. +Gets, adds, updates, deletes, and lists network custom fields. - [--porcelain] - Output just the new menu item id. +~~~ +wp network meta +~~~ **EXAMPLES** - $ wp menu item add-post sidebar-menu 33 --title="Custom Test Post" - Success: Menu item added. + # Get a list of super-admins + $ wp network meta get 1 site_admins + array ( + 0 => 'supervisor', + ) -### wp menu item add-term +### wp network meta add -Adds a taxonomy term as a menu item. +Add a meta field. ~~~ -wp menu item add-term <menu> <taxonomy> <term-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] [--porcelain] +wp network meta add <id> <key> [<value>] [--format=<format>] ~~~ **OPTIONS** - <menu> - The name, slug, or term ID for the menu. + <id> + The ID of the object. - <taxonomy> - Taxonomy of the term to be added. + <key> + The name of the meta field to create. - <term-id> - Term ID of the term to be added. + [<value>] + The value of the meta field. If omitted, the value is read from STDIN. - [--title=<title>] - Set a custom title for the menu item. + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- - [--link=<link>] - Set a custom url for the menu item. - [--description=<description>] - Set a custom description for the menu item. - [--attr-title=<attr-title>] - Set a custom title attribute for the menu item. +### wp network meta delete - [--target=<target>] - Set a custom link target for the menu item. +Delete a meta field. - [--classes=<classes>] - Set a custom link classes for the menu item. +~~~ +wp network meta delete <id> [<key>] [<value>] [--all] +~~~ - [--position=<position>] - Specify the position of this menu item. +**OPTIONS** - [--parent-id=<parent-id>] - Make this menu item a child of another menu item. + <id> + The ID of the object. - [--porcelain] - Output just the new menu item id. + [<key>] + The name of the meta field to delete. -**EXAMPLES** + [<value>] + The value to delete. If omitted, all rows with key will deleted. - $ wp menu item add-term sidebar-menu post_tag 24 - Success: Menu item added. + [--all] + Delete all meta for the object. -### wp menu item delete +### wp network meta get -Deletes one or more items from a menu. +Get meta field value. ~~~ -wp menu item delete <db-id>... +wp network meta get <id> <key> [--format=<format>] ~~~ **OPTIONS** - <db-id>... - Database ID for the menu item(s). + <id> + The ID of the object. -**EXAMPLES** + <key> + The name of the meta field to get. - $ wp menu item delete 45 - Success: Deleted 1 of 1 menu items. + [--format=<format>] + Accepted values: table, json. Default: table -### wp menu item get +### wp network meta list -Gets details about a menu item. +List all metadata associated with an object. ~~~ -wp menu item get <db-id> [--field=<field>] [--fields=<fields>] [--format=<format>] +wp network meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] ~~~ **OPTIONS** - <db-id> - Database ID for the menu item. + <id> + ID for the object. - [--field=<field>] - Instead of returning the whole menu item, returns the value of a single field. + [--keys=<keys>] + Limit output to metadata of specific keys. [--fields=<fields>] - Limit the output to specific fields. Defaults to db_id, type, title, link, position. + Limit the output to specific row fields. Defaults to id,meta_key,meta_value. [--format=<format>] - Render output in a particular format. + Accepted values: table, csv, json, count. Default: table + + [--orderby=<fields>] + Set orderby which field. --- - default: table + default: id options: - - table - - csv - - json - - yaml + - id + - meta_key + - meta_value --- -**AVAILABLE FIELDS** - -These fields are available: - -* db_id -* type -* title -* link -* position -* menu_item_parent -* object_id -* object -* type_label -* target -* attr_title -* description -* classes -* xfn - -**EXAMPLES** - - # Get details about a menu item with ID 45 - $ wp menu item get 45 - +-------------+----------------------------------+ - | Field | Value | - +-------------+----------------------------------+ - | db_id | 45 | - | type | custom | - | title | WordPress | - | link | https://wordpress.org | - | position | 1 | - +-------------+----------------------------------+ - - # Get a specific field from a menu item - $ wp menu item get 45 --field=title - WordPress - - # Get menu item data in JSON format - $ wp menu item get 45 --format=json - {"db_id":45,"type":"custom","title":"WordPress","link":"https://wordpress.org","position":1} + [--order=<order>] + Set ascending or descending order. + --- + default: asc + options: + - asc + - desc + --- -### wp menu item list +### wp network meta patch -Gets a list of items associated with a menu. +Update a nested value for a meta field. ~~~ -wp menu item list <menu> [--fields=<fields>] [--format=<format>] +wp network meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] ~~~ **OPTIONS** - <menu> - The name, slug, or term ID for the menu. + <action> + Patch action to perform. + --- + options: + - insert + - update + - delete + --- - [--fields=<fields>] - Limit the output to specific object fields. + <id> + The ID of the object. + + <key> + The name of the meta field to update. + + <key-path>... + The name(s) of the keys within the value to locate the value to patch. + + [<value>] + The new value. If omitted, the value is read from STDIN. [--format=<format>] - Render output in a particular format. + The serialization format for the value. --- - default: table + default: plaintext options: - - table - - csv + - plaintext - json - - count - - ids - - yaml --- -**AVAILABLE FIELDS** -These fields will be displayed by default for each menu item: -* db_id -* type -* title -* link -* position +### wp network meta pluck -These fields are optionally available: - -* menu_item_parent -* object_id -* object -* type -* type_label -* target -* attr_title -* description -* classes -* xfn - -**EXAMPLES** - - $ wp menu item list main-menu - +-------+-----------+-------------+---------------------------------+----------+ - | db_id | type | title | link | position | - +-------+-----------+-------------+---------------------------------+----------+ - | 5 | custom | Home | http://example.com | 1 | - | 6 | post_type | Sample Page | http://example.com/sample-page/ | 2 | - +-------+-----------+-------------+---------------------------------+----------+ - - - -### wp menu item update - -Updates a menu item. - -~~~ -wp menu item update <db-id> [--title=<title>] [--link=<link>] [--description=<description>] [--attr-title=<attr-title>] [--target=<target>] [--classes=<classes>] [--position=<position>] [--parent-id=<parent-id>] -~~~ - -**OPTIONS** - - <db-id> - Database ID for the menu item. - - [--title=<title>] - Set a custom title for the menu item. - - [--link=<link>] - Set a custom url for the menu item. - - [--description=<description>] - Set a custom description for the menu item. - - [--attr-title=<attr-title>] - Set a custom title attribute for the menu item. - - [--target=<target>] - Set a custom link target for the menu item. - - [--classes=<classes>] - Set a custom link classes for the menu item. - - [--position=<position>] - Specify the position of this menu item. - - [--parent-id=<parent-id>] - Make this menu item a child of another menu item. - -**EXAMPLES** - - $ wp menu item update 45 --title=WordPress --link='http://wordpress.org' --target=_blank --position=2 - Success: Menu item updated. - - - -### wp menu list - -Gets a list of menus. - -~~~ -wp menu list [--fields=<fields>] [--format=<format>] -~~~ - -**OPTIONS** - - [--fields=<fields>] - Limit the output to specific object fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - ids - - yaml - --- - -**AVAILABLE FIELDS** - -These fields will be displayed by default for each menu: - -* term_id -* name -* slug -* count - -These fields are optionally available: - -* term_group -* term_taxonomy_id -* taxonomy -* description -* parent -* locations - -**EXAMPLES** - - $ wp menu list - +---------+----------+----------+-----------+-------+ - | term_id | name | slug | locations | count | - +---------+----------+----------+-----------+-------+ - | 200 | My Menu | my-menu | | 0 | - | 177 | Top Menu | top-menu | primary | 7 | - +---------+----------+----------+-----------+-------+ - - - -### wp menu location - -Assigns, removes, and lists a menu's locations. - -~~~ -wp menu location -~~~ - -**EXAMPLES** - - # List available menu locations - $ wp menu location list - +----------+-------------------+ - | location | description | - +----------+-------------------+ - | primary | Primary Menu | - | social | Social Links Menu | - +----------+-------------------+ - - # Assign the 'primary-menu' menu to the 'primary' location - $ wp menu location assign primary-menu primary - Success: Assigned location primary to menu primary-menu. - - # Remove the 'primary-menu' menu from the 'primary' location - $ wp menu location remove primary-menu primary - Success: Removed location from menu. - - - - - -### wp menu location assign - -Assigns a location to a menu. - -~~~ -wp menu location assign <menu> <location> -~~~ - -**OPTIONS** - - <menu> - The name, slug, or term ID for the menu. - - <location> - Location's slug. - -**EXAMPLES** - - $ wp menu location assign primary-menu primary - Success: Assigned location primary to menu primary-menu. - - - -### wp menu location list - -Lists locations for the current theme. - -~~~ -wp menu location list [--format=<format>] -~~~ - -**OPTIONS** - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - yaml - - ids - --- - -**AVAILABLE FIELDS** - -These fields will be displayed by default for each location: - -* name -* description - -**EXAMPLES** - - $ wp menu location list - +----------+-------------------+ - | location | description | - +----------+-------------------+ - | primary | Primary Menu | - | social | Social Links Menu | - +----------+-------------------+ - - - -### wp menu location remove - -Removes a location from a menu. - -~~~ -wp menu location remove <menu> <location> -~~~ - -**OPTIONS** - - <menu> - The name, slug, or term ID for the menu. - - <location> - Location's slug. - -**EXAMPLES** - - $ wp menu location remove primary-menu primary - Success: Removed location from menu. - - - -### wp network - -Perform network-wide operations. - -~~~ -wp network -~~~ - -**EXAMPLES** - - # Get a list of super-admins - $ wp network meta get 1 site_admins - array ( - 0 => 'supervisor', - ) - - - -### wp network meta - -Gets, adds, updates, deletes, and lists network custom fields. - -~~~ -wp network meta -~~~ - -**EXAMPLES** - - # Get a list of super-admins - $ wp network meta get 1 site_admins - array ( - 0 => 'supervisor', - ) - - - -### wp network meta add - -Add a meta field. - -~~~ -wp network meta add <id> <key> [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to create. - - [<value>] - The value of the meta field. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp network meta delete - -Delete a meta field. - -~~~ -wp network meta delete <id> [<key>] [<value>] [--all] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - [<key>] - The name of the meta field to delete. - - [<value>] - The value to delete. If omitted, all rows with key will deleted. - - [--all] - Delete all meta for the object. - - - -### wp network meta get - -Get meta field value. - -~~~ -wp network meta get <id> <key> [--single] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to get. - - [--single] - Whether to return a single value. - - [--format=<format>] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- - - - -### wp network meta list - -List all metadata associated with an object. - -~~~ -wp network meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] [--unserialize] -~~~ - -**OPTIONS** - - <id> - ID for the object. - - [--keys=<keys>] - Limit output to metadata of specific keys. - - [--fields=<fields>] - Limit the output to specific row fields. Defaults to id,meta_key,meta_value. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - --- - - [--orderby=<fields>] - Set orderby which field. - --- - default: id - options: - - id - - meta_key - - meta_value - --- - - [--order=<order>] - Set ascending or descending order. - --- - default: asc - options: - - asc - - desc - --- - - [--unserialize] - Unserialize meta_value output. - - - -### wp network meta patch - -Update a nested value for a meta field. - -~~~ -wp network meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- - - <id> - The ID of the object. - - <key> - The name of the meta field to update. - - <key-path>... - The name(s) of the keys within the value to locate the value to patch. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp network meta pluck - -Get a nested value from a meta field. - -~~~ -wp network meta pluck <id> <key> <key-path>... [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to get. - - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. - - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml - - - -### wp network meta update - -Update a meta field. - -~~~ -wp network meta update <id> <key> [<value>] [--format=<format>] -~~~ - -**Alias:** `set` - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to update. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp option - -Retrieves and sets site options, including plugin and WordPress settings. - -~~~ -wp option -~~~ - -See the [Plugin Settings API](https://developer.wordpress.org/plugins/settings/settings-api/) and the [Theme Options](https://developer.wordpress.org/themes/customize-api/) for more information on adding customized options. - -**COMMON OPTIONS** - -These are some of the most commonly used WordPress options: - -* `siteurl` - Site URL, e.g. http://example.com -* `blogname` - Site title -* `blogdescription` - Site tagline -* `admin_email` - Administration email address -* `default_role` - Default role for new users -* `timezone_string` - Local timezone, e.g. "America/New_York" -* `home` - Home URL, e.g. http://example.com -* `blog_public` - Discourage search engines when set to 0 - -For the full list of available options, see the [Option Reference](https://developer.wordpress.org/apis/options/). - -**EXAMPLES** - - # Get site URL. - $ wp option get siteurl - http://example.com - - # Add option. - $ wp option add my_option foobar - Success: Added 'my_option' option. - - # Update option. - $ wp option update my_option '{"foo": "bar"}' --format=json - Success: Updated 'my_option' option. - - # Delete option. - $ wp option delete my_option - Success: Deleted 'my_option' option. - - - -### wp option add - -Adds a new option value. - -~~~ -wp option add <key> [<value>] [--format=<format>] [--autoload=<autoload>] -~~~ - -Errors if the option already exists. - -**OPTIONS** - - <key> - The name of the option to add. - - [<value>] - The value of the option to add. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - [--autoload=<autoload>] - Should this option be automatically loaded. - --- - options: - - 'on' - - 'off' - - 'yes' - - 'no' - --- - -**EXAMPLES** - - # Create an option by reading a JSON file. - $ wp option add my_option --format=json < config.json - Success: Added 'my_option' option. - - - -### wp option delete - -Deletes an option. - -~~~ -wp option delete <key>... -~~~ - -**OPTIONS** - - <key>... - Key for the option. - -**EXAMPLES** - - # Delete an option. - $ wp option delete my_option - Success: Deleted 'my_option' option. - - # Delete multiple options. - $ wp option delete option_one option_two option_three - Success: Deleted 'option_one' option. - Success: Deleted 'option_two' option. - Warning: Could not delete 'option_three' option. Does it exist? - - - -### wp option get - -Gets the value for an option. - -~~~ -wp option get <key> [--format=<format>] -~~~ - -**OPTIONS** - - <key> - Key for the option. - - [--format=<format>] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- - -**EXAMPLES** - - # Get option. - $ wp option get home - http://example.com - - # Get blog description. - $ wp option get blogdescription - A random blog description - - # Get blog name - $ wp option get blogname - A random blog name - - # Get admin email. - $ wp option get admin_email - someone@example.com - - # Get option in JSON format. - $ wp option get active_plugins --format=json - {"0":"dynamically-dynamic-sidebar\/dynamically-dynamic-sidebar.php","1":"monster-widget\/monster-widget.php","2":"show-current-template\/show-current-template.php","3":"theme-check\/theme-check.php","5":"wordpress-importer\/wordpress-importer.php"} - - - -### wp option list - -Lists options and their values. - -~~~ -wp option list [--search=<pattern>] [--exclude=<pattern>] [--autoload=<value>] [--transients] [--unserialize] [--field=<field>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] -~~~ - -**OPTIONS** - - [--search=<pattern>] - Use wildcards ( * and ? ) to match option name. - - [--exclude=<pattern>] - Pattern to exclude. Use wildcards ( * and ? ) to match option name. - - [--autoload=<value>] - Match only autoload options when value is on, and only not-autoload option when off. - - [--transients] - List only transients. Use `--no-transients` to ignore all transients. - - [--unserialize] - Unserialize option values in output. - - [--field=<field>] - Prints the value of a single field. - - [--fields=<fields>] - Limit the output to specific object fields. - - [--format=<format>] - The serialization format for the value. total_bytes displays the total size of matching options in bytes. - --- - default: table - options: - - table - - json - - csv - - count - - yaml - - total_bytes - --- - - [--orderby=<fields>] - Set orderby which field. - --- - default: option_id - options: - - option_id - - option_name - - option_value - --- - - [--order=<order>] - Set ascending or descending order. - --- - default: asc - options: - - asc - - desc - --- - -**AVAILABLE FIELDS** - -This field will be displayed by default for each matching option: - -* option_name -* option_value - -These fields are optionally available: - -* autoload -* size_bytes - -**EXAMPLES** - - # Get the total size of all autoload options. - $ wp option list --autoload=on --format=total_bytes - 33198 - - # Find biggest transients. - $ wp option list --search="*_transient_*" --fields=option_name,size_bytes | sort -n -k 2 | tail - option_name size_bytes - _site_transient_timeout_theme_roots 10 - _site_transient_theme_roots 76 - _site_transient_update_themes 181 - _site_transient_update_core 808 - _site_transient_update_plugins 6645 - - # List all options beginning with "i2f_". - $ wp option list --search="i2f_*" - +-------------+--------------+ - | option_name | option_value | - +-------------+--------------+ - | i2f_version | 0.1.0 | - +-------------+--------------+ - - # Delete all options beginning with "theme_mods_". - $ wp option list --search="theme_mods_*" --field=option_name | xargs -I % wp option delete % - Success: Deleted 'theme_mods_twentysixteen' option. - Success: Deleted 'theme_mods_twentyfifteen' option. - Success: Deleted 'theme_mods_twentyfourteen' option. - - - -### wp option patch - -Updates a nested value in an option. - -~~~ -wp option patch <action> <key> <key-path>... [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- - - <key> - The option name. - - <key-path>... - The name(s) of the keys within the value to locate the value to patch. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - -**EXAMPLES** - - # Add 'bar' to the 'foo' key on an option with name 'option_name' - $ wp option patch insert option_name foo bar - Success: Updated 'option_name' option. - - # Update the value of 'foo' key to 'new' on an option with name 'option_name' - $ wp option patch update option_name foo new - Success: Updated 'option_name' option. - - # Set nested value of 'bar' key to value we have in the patch file on an option with name 'option_name'. - $ wp option patch update option_name foo bar < patch - Success: Updated 'option_name' option. - - # Update the value for the key 'not-a-key' which is not exist on an option with name 'option_name'. - $ wp option patch update option_name foo not-a-key new-value - Error: No data exists for key "not-a-key" - - # Update the value for the key 'foo' without passing value on an option with name 'option_name'. - $ wp option patch update option_name foo - Error: Please provide value to update. - - # Delete the nested key 'bar' under 'foo' key on an option with name 'option_name'. - $ wp option patch delete option_name foo bar - Success: Updated 'option_name' option. - - - -### wp option pluck - -Gets a nested value from an option. - -~~~ -wp option pluck <key> <key-path>... [--format=<format>] -~~~ - -**OPTIONS** - - <key> - The option name. - - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. - - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml - --- - - - -### wp option update - -Updates an option value. - -~~~ -wp option update <key> [<value>] [--autoload=<autoload>] [--format=<format>] -~~~ - -**Alias:** `set` - -**OPTIONS** - - <key> - The name of the option to update. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--autoload=<autoload>] - Requires WP 4.2. Should this option be automatically loaded. - --- - options: - - 'on' - - 'off' - - 'yes' - - 'no' - --- - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - -**EXAMPLES** - - # Update an option by reading from a file. - $ wp option update my_option < value.txt - Success: Updated 'my_option' option. - - # Update one option on multiple sites using xargs. - $ wp site list --field=url | xargs -n1 -I {} sh -c 'wp --url={} option update my_option my_value' - Success: Updated 'my_option' option. - Success: Updated 'my_option' option. - - # Update site blog name. - $ wp option update blogname "Random blog name" - Success: Updated 'blogname' option. - - # Update site blog description. - $ wp option update blogdescription "Some random blog description" - Success: Updated 'blogdescription' option. - - # Update admin email address. - $ wp option update admin_email someone@example.com - Success: Updated 'admin_email' option. - - # Set the default role. - $ wp option update default_role author - Success: Updated 'default_role' option. - - # Set the timezone string. - $ wp option update timezone_string "America/New_York" - Success: Updated 'timezone_string' option. - - - -### wp option set-autoload - -Sets the 'autoload' value for an option. - -~~~ -wp option set-autoload <key> <autoload> -~~~ - -**OPTIONS** - - <key> - The name of the option to set 'autoload' for. - - <autoload> - Should this option be automatically loaded. - --- - options: - - 'on' - - 'off' - - 'yes' - - 'no' - --- - -**EXAMPLES** - - # Set the 'autoload' value for an option. - $ wp option set-autoload abc_options no - Success: Updated autoload value for 'abc_options' option. - - - -### wp option get-autoload - -Gets the 'autoload' value for an option. - -~~~ -wp option get-autoload <key> -~~~ - -**OPTIONS** - - <key> - The name of the option to get 'autoload' of. - -**EXAMPLES** - - # Get the 'autoload' value for an option. - $ wp option get-autoload blogname - yes - - - -### wp post - -Manages posts, content, and meta. - -~~~ -wp post -~~~ - -**EXAMPLES** - - # Create a new post. - $ wp post create --post_type=post --post_title='A sample post' - Success: Created post 123. - - # Update an existing post. - $ wp post update 123 --post_status=draft - Success: Updated post 123. - - # Delete an existing post. - $ wp post delete 123 - Success: Trashed post 123. - - - -### wp post create - -Creates a new post. - -~~~ -wp post create [--post_author=<post_author>] [--post_date=<post_date>] [--post_date_gmt=<post_date_gmt>] [--post_content=<post_content>] [--post_content_filtered=<post_content_filtered>] [--post_title=<post_title>] [--post_excerpt=<post_excerpt>] [--post_status=<post_status>] [--post_type=<post_type>] [--comment_status=<comment_status>] [--ping_status=<ping_status>] [--post_password=<post_password>] [--post_name=<post_name>] [--from-post=<post_id>] [--to_ping=<to_ping>] [--pinged=<pinged>] [--post_modified=<post_modified>] [--post_modified_gmt=<post_modified_gmt>] [--post_parent=<post_parent>] [--menu_order=<menu_order>] [--post_mime_type=<post_mime_type>] [--guid=<guid>] [--post_category=<post_category>] [--tags_input=<tags_input>] [--tax_input=<tax_input>] [--meta_input=<meta_input>] [<file>] [--<field>=<value>] [--edit] [--porcelain] -~~~ - -**OPTIONS** - - [--post_author=<post_author>] - The ID of the user who added the post. Default is the current user ID. - - [--post_date=<post_date>] - The date of the post. Default is the current time. - - [--post_date_gmt=<post_date_gmt>] - The date of the post in the GMT timezone. Default is the value of $post_date. - - [--post_content=<post_content>] - The post content. Default empty. - - [--post_content_filtered=<post_content_filtered>] - The filtered post content. Default empty. - - [--post_title=<post_title>] - The post title. Default empty. - - [--post_excerpt=<post_excerpt>] - The post excerpt. Default empty. - - [--post_status=<post_status>] - The post status. Default 'draft'. - - [--post_type=<post_type>] - The post type. Default 'post'. - - [--comment_status=<comment_status>] - Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. - - [--ping_status=<ping_status>] - Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. - - [--post_password=<post_password>] - The password to access the post. Default empty. - - [--post_name=<post_name>] - The post name. Default is the sanitized post title when creating a new post. - - [--from-post=<post_id>] - Post id of a post to be duplicated. - - [--to_ping=<to_ping>] - Space or carriage return-separated list of URLs to ping. Default empty. - - [--pinged=<pinged>] - Space or carriage return-separated list of URLs that have been pinged. Default empty. - - [--post_modified=<post_modified>] - The date when the post was last modified. Default is the current time. - - [--post_modified_gmt=<post_modified_gmt>] - The date when the post was last modified in the GMT timezone. Default is the current time. - - [--post_parent=<post_parent>] - Set this for the post it belongs to, if any. Default 0. - - [--menu_order=<menu_order>] - The order the post should be displayed in. Default 0. - - [--post_mime_type=<post_mime_type>] - The mime type of the post. Default empty. - - [--guid=<guid>] - Global Unique ID for referencing the post. Default empty. - - [--post_category=<post_category>] - Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. - - [--tags_input=<tags_input>] - Array of tag names, slugs, or IDs. Default empty. - - [--tax_input=<tax_input>] - Array of taxonomy terms keyed by their taxonomy name. Default empty. - - Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 - - [--meta_input=<meta_input>] - Array in JSON format of post meta values keyed by their post meta key. Default empty. - - [<file>] - Read post content from <file>. If this value is present, the - `--post_content` argument will be ignored. - - Passing `-` as the filename will cause post content to - be read from STDIN. - - [--<field>=<value>] - Associative args for the new post. See wp_insert_post(). - - [--edit] - Immediately open system's editor to write or edit post content. - - If content is read from a file, from STDIN, or from the `--post_content` - argument, that text will be loaded into the editor. - - [--porcelain] - Output just the new post id. - -**EXAMPLES** - - # Create post and schedule for future - $ wp post create --post_type=post --post_title='A future post' --post_status=future --post_date='2030-12-01 07:00:00' - Success: Created post 1921. - - # Create post with content from given file - $ wp post create ./post-content.txt --post_category=201,345 --post_title='Post from file' - Success: Created post 1922. - - # Create a post with multiple meta values. - $ wp post create --post_title='A post' --post_content='Just a small post.' --meta_input='{"key1":"value1","key2":"value2"}' - Success: Created post 1923. - - # Create a duplicate post from existing posts. - $ wp post create --from-post=123 --post_title='Different Title' - Success: Created post 2350. - - - -### wp post delete - -Deletes an existing post. - -~~~ -wp post delete <id>... [--force] [--defer-term-counting] -~~~ - -**OPTIONS** - - <id>... - One or more IDs of posts to delete. - - [--force] - Skip the trash bin. - - [--defer-term-counting] - Recalculate term count in batch, for a performance boost. - -**EXAMPLES** - - # Delete post skipping trash - $ wp post delete 123 --force - Success: Deleted post 123. - - # Delete multiple posts - $ wp post delete 123 456 789 - Success: Trashed post 123. - Success: Trashed post 456. - Success: Trashed post 789. - - # Delete all pages - $ wp post delete $(wp post list --post_type='page' --format=ids) - Success: Trashed post 1164. - Success: Trashed post 1186. - - # Delete all posts in the trash - $ wp post delete $(wp post list --post_status=trash --format=ids) - Success: Deleted post 1268. - Success: Deleted post 1294. - - - -### wp post edit - -Launches system editor to edit post content. - -~~~ -wp post edit <id> -~~~ - -**OPTIONS** - - <id> - The ID of the post to edit. - -**EXAMPLES** - - # Launch system editor to edit post - $ wp post edit 123 - - - -### wp post exists - -Verifies whether a post exists. - -~~~ -wp post exists <id> -~~~ - -Displays a success message if the post does exist. - -**OPTIONS** - - <id> - The ID of the post to check. - -**EXAMPLES** - - # The post exists. - $ wp post exists 1337 - Success: Post with ID 1337 exists. - $ echo $? - 0 - - # The post does not exist. - $ wp post exists 10000 - $ echo $? - 1 - - - -### wp post generate - -Generates some posts. - -~~~ -wp post generate [--count=<number>] [--post_type=<type>] [--post_status=<status>] [--post_title=<post_title>] [--post_author=<login>] [--post_date=<yyyy-mm-dd-hh-ii-ss>] [--post_date_gmt=<yyyy-mm-dd-hh-ii-ss>] [--post_content] [--max_depth=<number>] [--format=<format>] -~~~ - -Creates a specified number of new posts with dummy data. - -**OPTIONS** - - [--count=<number>] - How many posts to generate? - --- - default: 100 - --- - - [--post_type=<type>] - The type of the generated posts. - --- - default: post - --- - - [--post_status=<status>] - The status of the generated posts. - --- - default: publish - --- - - [--post_title=<post_title>] - The post title. - --- - default: - --- - - [--post_author=<login>] - The author of the generated posts. - --- - default: - --- - - [--post_date=<yyyy-mm-dd-hh-ii-ss>] - The date of the post. Default is the current time. - - [--post_date_gmt=<yyyy-mm-dd-hh-ii-ss>] - The date of the post in the GMT timezone. Default is the value of --post_date. - - [--post_content] - If set, the command reads the post_content from STDIN. - - [--max_depth=<number>] - For hierarchical post types, generate child posts down to a certain depth. - --- - default: 1 - --- - - [--format=<format>] - Render output in a particular format. - --- - default: progress - options: - - progress - - ids - --- - -**EXAMPLES** - - # Generate posts. - $ wp post generate --count=10 --post_type=page --post_date=1999-01-04 - Generating posts 100% [================================================] 0:01 / 0:04 - - # Generate posts with fetched content. - $ curl -N https://loripsum.net/api/5 | wp post generate --post_content --count=10 - % Total % Received % Xferd Average Speed Time Time Time Current - Dload Upload Total Spent Left Speed - 100 2509 100 2509 0 0 616 0 0:00:04 0:00:04 --:--:-- 616 - Generating posts 100% [================================================] 0:01 / 0:04 - - # Add meta to every generated posts. - $ wp post generate --format=ids | xargs -d ' ' -I % wp post meta add % foo bar - Success: Added custom field. - Success: Added custom field. - Success: Added custom field. - - - -### wp post get - -Gets details about a post. - -~~~ -wp post get <id> [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the post to get. - - [--field=<field>] - Instead of returning the whole post, returns the value of a single field. - - [--fields=<fields>] - Limit the output to specific fields. Defaults to all fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - --- - -**EXAMPLES** - - # Save the post content to a file - $ wp post get 123 --field=content > file.txt - - # Get the block version of a post (1 = has blocks, 0 = no blocks) - # Requires WordPress 5.0+. - $ wp post get 123 --field=block_version - 1 - - - -### wp post list - -Gets a list of posts. - -~~~ -wp post list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ - -Display posts based on all arguments supported by [WP_Query()](https://developer.wordpress.org/reference/classes/wp_query/). -Only shows post types marked as post by default. - -**OPTIONS** - - [--<field>=<value>] - One or more args to pass to WP_Query. - - [--field=<field>] - Prints the value of a single field for each post. - - [--fields=<fields>] - Limit the output to specific object fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - ids - - json - - count - - yaml - --- - -**AVAILABLE FIELDS** - -These fields will be displayed by default for each post: - -* ID -* post_title -* post_name -* post_date -* post_status - -These fields are optionally available: - -* post_author -* post_date_gmt -* post_content -* post_excerpt -* comment_status -* ping_status -* post_password -* to_ping -* pinged -* post_modified -* post_modified_gmt -* post_content_filtered -* post_parent -* guid -* menu_order -* post_type -* post_mime_type -* comment_count -* filter -* url - -**EXAMPLES** - - # List post - $ wp post list --field=ID - 568 - 829 - 1329 - 1695 - - # List posts in JSON - $ wp post list --post_type=post --posts_per_page=5 --format=json - [{"ID":1,"post_title":"Hello world!","post_name":"hello-world","post_date":"2015-06-20 09:00:10","post_status":"publish"},{"ID":1178,"post_title":"Markup: HTML Tags and Formatting","post_name":"markup-html-tags-and-formatting","post_date":"2013-01-11 20:22:19","post_status":"draft"}] - - # List all pages - $ wp post list --post_type=page --fields=post_title,post_status - +-------------+-------------+ - | post_title | post_status | - +-------------+-------------+ - | Sample Page | publish | - +-------------+-------------+ - - # List ids of all pages and posts - $ wp post list --post_type=page,post --format=ids - 15 25 34 37 198 - - # List given posts - $ wp post list --post__in=1,3 - +----+--------------+-------------+---------------------+-------------+ - | ID | post_title | post_name | post_date | post_status | - +----+--------------+-------------+---------------------+-------------+ - | 3 | Lorem Ipsum | lorem-ipsum | 2016-06-01 14:34:36 | publish | - | 1 | Hello world! | hello-world | 2016-06-01 14:31:12 | publish | - +----+--------------+-------------+---------------------+-------------+ - - # List given post by a specific author - $ wp post list --author=2 - +----+-------------------+-------------------+---------------------+-------------+ - | ID | post_title | post_name | post_date | post_status | - +----+-------------------+-------------------+---------------------+-------------+ - | 14 | New documentation | new-documentation | 2021-06-18 21:05:11 | publish | - +----+-------------------+-------------------+---------------------+-------------+ - - - -### wp post meta - -Adds, updates, deletes, and lists post custom fields. - -~~~ -wp post meta -~~~ - -**EXAMPLES** - - # Set post meta - $ wp post meta set 123 _wp_page_template about.php - Success: Updated custom field '_wp_page_template'. - - # Get post meta - $ wp post meta get 123 _wp_page_template - about.php - - # Update post meta - $ wp post meta update 123 _wp_page_template contact.php - Success: Updated custom field '_wp_page_template'. - - # Delete post meta - $ wp post meta delete 123 _wp_page_template - Success: Deleted custom field. - - - - - -### wp post meta add - -Add a meta field. - -~~~ -wp post meta add <id> <key> [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to create. - - [<value>] - The value of the meta field. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp post meta clean-duplicates - -Cleans up duplicate post meta values on a post. - -~~~ -wp post meta clean-duplicates <id> <key> -~~~ - -**OPTIONS** - - <id> - ID of the post to clean. - - <key> - Meta key to clean up. - -**EXAMPLES** - - # Delete duplicate post meta. - wp post meta clean-duplicates 1234 enclosure - Success: Cleaned up duplicate 'enclosure' meta values. - - - -### wp post meta delete - -Delete a meta field. - -~~~ -wp post meta delete <id> [<key>] [<value>] [--all] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - [<key>] - The name of the meta field to delete. - - [<value>] - The value to delete. If omitted, all rows with key will deleted. - - [--all] - Delete all meta for the object. - - - -### wp post meta get - -Get meta field value. - -~~~ -wp post meta get <id> <key> [--single] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to get. - - [--single] - Whether to return a single value. - - [--format=<format>] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- - - - -### wp post meta list - -List all metadata associated with an object. - -~~~ -wp post meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] [--unserialize] -~~~ - -**OPTIONS** - - <id> - ID for the object. - - [--keys=<keys>] - Limit output to metadata of specific keys. - - [--fields=<fields>] - Limit the output to specific row fields. Defaults to id,meta_key,meta_value. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - --- - - [--orderby=<fields>] - Set orderby which field. - --- - default: id - options: - - id - - meta_key - - meta_value - --- - - [--order=<order>] - Set ascending or descending order. - --- - default: asc - options: - - asc - - desc - --- - - [--unserialize] - Unserialize meta_value output. - - - -### wp post meta patch - -Update a nested value for a meta field. - -~~~ -wp post meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- - - <id> - The ID of the object. - - <key> - The name of the meta field to update. - - <key-path>... - The name(s) of the keys within the value to locate the value to patch. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp post meta pluck - -Get a nested value from a meta field. - -~~~ -wp post meta pluck <id> <key> <key-path>... [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to get. - - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. - - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml - - - -### wp post meta update - -Update a meta field. - -~~~ -wp post meta update <id> <key> [<value>] [--format=<format>] -~~~ - -**Alias:** `set` - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to update. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp post revision - -Manages post revisions. - -~~~ -wp post revision -~~~ - -**EXAMPLES** - - # Restore a post revision - $ wp post revision restore 123 - Success: Restored revision 123. - - # Show diff between two revisions - $ wp post revision diff 123 456 - - - - - -### wp post revision diff - -Shows the difference between two revisions. - -~~~ -wp post revision diff <from> [<to>] [--field=<field>] -~~~ - -**OPTIONS** - - <from> - The 'from' revision ID or post ID. - - [<to>] - The 'to' revision ID or post ID. If not provided, compares with the current post. - - [--field=<field>] - Compare specific field(s). Default: post_content - -**EXAMPLES** - - # Show diff between two revisions - $ wp post revision diff 123 456 - - # Show diff between a revision and the current post - $ wp post revision diff 123 - - - -### wp post revision prune - -Deletes old post revisions. - -~~~ -wp post revision prune [<post-id>...] [--latest=<limit>] [--earliest=<limit>] [--yes] -~~~ - -**OPTIONS** - - [<post-id>...] - One or more post IDs to prune revisions for. If not provided, prunes revisions for all posts. - - [--latest=<limit>] - Keep only the latest N revisions per post. Older revisions will be deleted. - - [--earliest=<limit>] - Keep only the earliest N revisions per post. Newer revisions will be deleted. - - [--yes] - Skip confirmation prompt. - -**EXAMPLES** - - # Delete all but the latest 5 revisions for post 123 - $ wp post revision prune 123 --latest=5 - Success: Deleted 3 revisions for post 123. - - # Delete all but the latest 5 revisions for all posts - $ wp post revision prune --latest=5 - Success: Deleted 150 revisions across 30 posts. - - # Delete all but the earliest 2 revisions for posts 123 and 456 - $ wp post revision prune 123 456 --earliest=2 - Success: Deleted 5 revisions for post 123. - Success: Deleted 3 revisions for post 456. - - - -### wp post revision restore - -Restores a post revision. - -~~~ -wp post revision restore <revision_id> -~~~ - -**OPTIONS** - - <revision_id> - The revision ID to restore. - -**EXAMPLES** - - # Restore a post revision - $ wp post revision restore 123 - Success: Restored revision 123. - - - -### wp post term - -Adds, updates, removes, and lists post terms. - -~~~ -wp post term -~~~ - -**EXAMPLES** - - # Set category post term `test` to the post ID 123 - $ wp post term set 123 test category - Success: Set term. - - # Set category post terms `test` and `apple` to the post ID 123 - $ wp post term set 123 test apple category - Success: Set terms. - - # List category post terms for the post ID 123 - $ wp post term list 123 category --fields=term_id,slug - +---------+-------+ - | term_id | slug | - +---------+-------+ - | 2 | apple | - | 3 | test | - +----------+------+ - - # Remove category post terms `test` and `apple` for the post ID 123 - $ wp post term remove 123 category test apple - Success: Removed terms. - - - - - -### wp post term add - -Add a term to an object. - -~~~ -wp post term add <id> <taxonomy> <term>... [--by=<field>] -~~~ - -Append the term to the existing set of terms on the object. - -**OPTIONS** - - <id> - The ID of the object. - - <taxonomy> - The name of the taxonomy type to be added. - - <term>... - The slug of the term or terms to be added. - - [--by=<field>] - Explicitly handle the term value as a slug or id. - --- - default: slug - options: - - slug - - id - --- - - - -### wp post term list - -List all terms associated with an object. - -~~~ -wp post term list <id> <taxonomy>... [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ - -**OPTIONS** - - <id> - ID for the object. - - <taxonomy>... - One or more taxonomies to list. - - [--field=<field>] - Prints the value of a single field for each term. - - [--fields=<fields>] - Limit the output to specific row fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - - ids - --- - -**AVAILABLE FIELDS** - -These fields will be displayed by default for each term: - -* term_id -* name -* slug -* taxonomy - -These fields are optionally available: - -* term_taxonomy_id -* description -* term_group -* parent -* count - - - -### wp post term remove - -Remove a term from an object. - -~~~ -wp post term remove <id> <taxonomy> [<term>...] [--by=<field>] [--all] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <taxonomy> - The name of the term's taxonomy. - - [<term>...] - The slug of the term or terms to be removed from the object. - - [--by=<field>] - Explicitly handle the term value as a slug or id. - --- - default: slug - options: - - slug - - id - --- - - [--all] - Remove all terms from the object. - - - -### wp post term set - -Set object terms. - -~~~ -wp post term set <id> <taxonomy> <term>... [--by=<field>] -~~~ - -Replaces existing terms on the object. - -**OPTIONS** - - <id> - The ID of the object. - - <taxonomy> - The name of the taxonomy type to be updated. - - <term>... - The slug of the term or terms to be updated. - - [--by=<field>] - Explicitly handle the term value as a slug or id. - --- - default: slug - options: - - slug - - id - --- - - - -### wp post update - -Updates one or more existing posts. - -~~~ -wp post update <id>... [--post_author=<post_author>] [--post_date=<post_date>] [--post_date_gmt=<post_date_gmt>] [--post_content=<post_content>] [--post_content_filtered=<post_content_filtered>] [--post_title=<post_title>] [--post_excerpt=<post_excerpt>] [--post_status=<post_status>] [--post_type=<post_type>] [--comment_status=<comment_status>] [--ping_status=<ping_status>] [--post_password=<post_password>] [--post_name=<post_name>] [--to_ping=<to_ping>] [--pinged=<pinged>] [--post_modified=<post_modified>] [--post_modified_gmt=<post_modified_gmt>] [--post_parent=<post_parent>] [--menu_order=<menu_order>] [--post_mime_type=<post_mime_type>] [--guid=<guid>] [--post_category=<post_category>] [--tags_input=<tags_input>] [--tax_input=<tax_input>] [--meta_input=<meta_input>] [<file>] --<field>=<value> [--defer-term-counting] -~~~ - -**OPTIONS** - - <id>... - One or more IDs of posts to update. - - [--post_author=<post_author>] - The ID of the user who added the post. Default is the current user ID. - - [--post_date=<post_date>] - The date of the post. Default is the current time. - - [--post_date_gmt=<post_date_gmt>] - The date of the post in the GMT timezone. Default is the value of $post_date. - - [--post_content=<post_content>] - The post content. Default empty. - - [--post_content_filtered=<post_content_filtered>] - The filtered post content. Default empty. - - [--post_title=<post_title>] - The post title. Default empty. - - [--post_excerpt=<post_excerpt>] - The post excerpt. Default empty. - - [--post_status=<post_status>] - The post status. Default 'draft'. - - [--post_type=<post_type>] - The post type. Default 'post'. - - [--comment_status=<comment_status>] - Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. - - [--ping_status=<ping_status>] - Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. - - [--post_password=<post_password>] - The password to access the post. Default empty. - - [--post_name=<post_name>] - The post name. Default is the sanitized post title when creating a new post. - - [--to_ping=<to_ping>] - Space or carriage return-separated list of URLs to ping. Default empty. - - [--pinged=<pinged>] - Space or carriage return-separated list of URLs that have been pinged. Default empty. - - [--post_modified=<post_modified>] - The date when the post was last modified. Default is the current time. - - [--post_modified_gmt=<post_modified_gmt>] - The date when the post was last modified in the GMT timezone. Default is the current time. - - [--post_parent=<post_parent>] - Set this for the post it belongs to, if any. Default 0. - - [--menu_order=<menu_order>] - The order the post should be displayed in. Default 0. - - [--post_mime_type=<post_mime_type>] - The mime type of the post. Default empty. - - [--guid=<guid>] - Global Unique ID for referencing the post. Default empty. - - [--post_category=<post_category>] - Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. - - [--tags_input=<tags_input>] - Array of tag names, slugs, or IDs. Default empty. - - [--tax_input=<tax_input>] - Array of taxonomy terms keyed by their taxonomy name. Default empty. - - Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 - - [--meta_input=<meta_input>] - Array in JSON format of post meta values keyed by their post meta key. Default empty. - - [<file>] - Read post content from <file>. If this value is present, the - `--post_content` argument will be ignored. - - Passing `-` as the filename will cause post content to - be read from STDIN. - - --<field>=<value> - One or more fields to update. See wp_insert_post(). - - [--defer-term-counting] - Recalculate term count in batch, for a performance boost. - -**EXAMPLES** - - $ wp post update 123 --post_name=something --post_status=draft - Success: Updated post 123. - - # Update a post with multiple meta values. - $ wp post update 123 --meta_input='{"key1":"value1","key2":"value2"}' - Success: Updated post 123. - - # Update multiple posts at once. - $ wp post update 123 456 --post_author=789 - Success: Updated post 123. - Success: Updated post 456. - - # Update all posts of a given post type at once. - $ wp post update $(wp post list --post_type=page --format=ids) --post_author=123 - Success: Updated post 123. - Success: Updated post 456. - - - -### wp post url-to-id - -Gets the post ID for a given URL. - -~~~ -wp post url-to-id <url> -~~~ - -**OPTIONS** - - <url> - The URL of the post to get. - -**EXAMPLES** - - # Get post ID by URL - $ wp post url-to-id https://example.com/?p=1 - 1 - - - -### wp post has-blocks - -Checks if a post contains any blocks. +Get a nested value from a meta field. ~~~ -wp post has-blocks <id> +wp network meta pluck <id> <key> <key-path>... [--format=<format>] ~~~ -Exits with return code 0 if the post contains blocks, -or return code 1 if it does not. - **OPTIONS** <id> - The ID of the post to check. - -**EXAMPLES** + The ID of the object. - # Check if post contains blocks. - $ wp post has-blocks 123 - Success: Post 123 contains blocks. + <key> + The name of the meta field to get. - # Check a classic (non-block) post. - $ wp post has-blocks 456 - Error: Post 456 does not contain blocks. + <key-path>... + The name(s) of the keys within the value to locate the value to pluck. - # Use in a shell conditional. - $ if wp post has-blocks 123 2>/dev/null; then - > echo "Post uses blocks" - > fi + [--format=<format>] + The output format of the value. + --- + default: plaintext + options: + - plaintext + - json + - yaml -### wp post has-block +### wp network meta update -Checks if a post contains a specific block type. +Update a meta field. ~~~ -wp post has-block <id> <block-name> +wp network meta update <id> <key> [<value>] [--format=<format>] ~~~ -Exits with return code 0 if the post contains the specified block, -or return code 1 if it does not. - **OPTIONS** <id> - The ID of the post to check. - - <block-name> - The block type name to check for (e.g., 'core/paragraph'). - -**EXAMPLES** - - # Check if post contains a paragraph block. - $ wp post has-block 123 core/paragraph - Success: Post 123 contains block 'core/paragraph'. - - # Check for a heading block. - $ wp post has-block 123 core/heading - Success: Post 123 contains block 'core/heading'. - - # Check for a block that doesn't exist. - $ wp post has-block 123 core/gallery - Error: Post 123 does not contain block 'core/gallery'. - - # Check for a custom block from a plugin. - $ wp post has-block 123 my-plugin/custom-block - - - -### wp post block - -Manages blocks within post content. - -~~~ -wp post block -~~~ - -Provides commands for inspecting, manipulating, and managing -Gutenberg blocks in post content. - -**EXAMPLES** - - # List all blocks in a post. - $ wp post block list 123 - +------------------+-------+ - | blockName | count | - +------------------+-------+ - | core/paragraph | 2 | - | core/heading | 1 | - +------------------+-------+ - - # Parse blocks in a post to JSON. - $ wp post block parse 123 --format=json + The ID of the object. - # Insert a paragraph block. - $ wp post block insert 123 core/paragraph --content="Hello World" + <key> + The name of the meta field to update. + [<value>] + The new value. If omitted, the value is read from STDIN. + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- -### wp post block clone +### wp option -Clones a block within a post. +Retrieves and sets site options, including plugin and WordPress settings. ~~~ -wp post block clone <id> <source-index> [--position=<position>] [--porcelain] +wp option ~~~ -Duplicates an existing block and inserts it at a specified position. - -**OPTIONS** - - <id> - The ID of the post. - - <source-index> - Index of the block to clone (0-indexed). - - [--position=<position>] - Where to insert the cloned block. Accepts 'after', 'before', 'start', 'end', or a numeric index. - --- - default: after - --- - - [--porcelain] - Output just the new block index. +See the [Plugin Settings API](https://developer.wordpress.org/plugins/settings/settings-api/) and the [Theme Options](https://developer.wordpress.org/themes/customize-api/) for more information on adding customized options. **EXAMPLES** - # Clone a block and insert immediately after it (default). - $ wp post block clone 123 2 - Success: Cloned block to index 3 in post 123. - - # Clone the first block and insert immediately before it. - $ wp post block clone 123 0 --position=before - Success: Cloned block to index 0 in post 123. - - # Clone a block and insert at the end of the post. - $ wp post block clone 123 0 --position=end - Success: Cloned block to index 5 in post 123. + # Get site URL. + $ wp option get siteurl + http://example.com - # Clone a block and insert at the start of the post. - $ wp post block clone 123 3 --position=start - Success: Cloned block to index 0 in post 123. + # Add option. + $ wp option add my_option foobar + Success: Added 'my_option' option. - # Clone and get just the new block index for scripting. - $ wp post block clone 123 1 --porcelain - 2 + # Update option. + $ wp option update my_option '{"foo": "bar"}' --format=json + Success: Updated 'my_option' option. - # Duplicate the hero section (first block) at the end for a footer. - $ wp post block clone 123 0 --position=end - Success: Cloned block to index 10 in post 123. + # Delete option. + $ wp option delete my_option + Success: Deleted 'my_option' option. -### wp post block count +### wp option add -Counts blocks across multiple posts. +Adds a new option value. ~~~ -wp post block count [<id>...] [--block=<block-name>] [--post-type=<type>] [--post-status=<status>] [--format=<format>] +wp option add <key> [<value>] [--format=<format>] [--autoload=<autoload>] ~~~ -Analyzes block usage across posts for site-wide reporting. +Errors if the option already exists. **OPTIONS** - [<id>...] - Optional post IDs. If not specified, queries all posts. - - [--block=<block-name>] - Only count specific block type. + <key> + The name of the option to add. - [--post-type=<type>] - Limit to specific post type(s). Comma-separated. - --- - default: post,page - --- + [<value>] + The value of the option to add. If ommited, the value is read from STDIN. - [--post-status=<status>] - Post status to include. + [--format=<format>] + The serialization format for the value. --- - default: publish + default: plaintext + options: + - plaintext + - json --- - [--format=<format>] - Output format. + [--autoload=<autoload>] + Should this option be automatically loaded. --- - default: table options: - - table - - json - - csv - - yaml - - count + - 'yes' + - 'no' --- **EXAMPLES** - # Count all blocks across published posts and pages. - $ wp post block count - +------------------+-------+-------+ - | blockName | count | posts | - +------------------+-------+-------+ - | core/paragraph | 1542 | 234 | - | core/heading | 523 | 198 | - | core/image | 312 | 156 | - +------------------+-------+-------+ - - # Count blocks in specific posts only. - $ wp post block count 123 456 789 - +------------------+-------+-------+ - | blockName | count | posts | - +------------------+-------+-------+ - | core/paragraph | 8 | 3 | - | core/heading | 3 | 2 | - +------------------+-------+-------+ - - # Count only paragraph blocks across the site. - $ wp post block count --block=core/paragraph --format=count - 1542 - - # Count blocks in a custom post type. - $ wp post block count --post-type=product - - # Count blocks in multiple post types. - $ wp post block count --post-type=post,page,product - - # Count blocks including drafts. - $ wp post block count --post-status=draft - - # Get count as JSON for further processing. - $ wp post block count --format=json - [{"blockName":"core/paragraph","count":1542,"posts":234}] - - # Get total number of unique block types used. - $ wp post block count --format=count - 15 + # Create an option by reading a JSON file. + $ wp option add my_option --format=json < config.json + Success: Added 'my_option' option. -### wp post block export +### wp option delete -Exports block content to a file. +Deletes an option. ~~~ -wp post block export <id> [--file=<file>] [--format=<format>] [--raw] +wp option delete <key> ~~~ -Exports blocks from a post to a file for backup or migration. - **OPTIONS** - <id> - The ID of the post to export blocks from. - - [--file=<file>] - Output file path. If not specified, outputs to STDOUT. - - [--format=<format>] - Export format. - --- - default: json - options: - - json - - yaml - - html - --- - - [--raw] - Include innerHTML in JSON/YAML output. + <key> + Key for the option. **EXAMPLES** - # Export blocks to a JSON file for backup. - $ wp post block export 123 --file=blocks.json - Success: Exported 5 blocks to blocks.json - - # Export blocks to STDOUT as JSON. - $ wp post block export 123 - { - "version": "1.0", - "generator": "wp-cli/entity-command", - "post_id": 123, - "exported_at": "2024-12-10T12:00:00+00:00", - "blocks": [...] - } - - # Export as YAML format. - $ wp post block export 123 --format=yaml - version: "1.0" - generator: wp-cli/entity-command - blocks: - - blockName: core/paragraph - attrs: [] - - # Export rendered HTML (final output, not block structure). - $ wp post block export 123 --format=html --file=content.html - Success: Exported 5 blocks to content.html - - # Export with raw innerHTML included for complete backup. - $ wp post block export 123 --raw --file=blocks-full.json - Success: Exported 5 blocks to blocks-full.json - - # Pipe export to another command. - $ wp post block export 123 | jq '.blocks[].blockName' + # Delete an option. + $ wp option delete my_option + Success: Deleted 'my_option' option. -### wp post block extract +### wp option get -Extracts data from blocks. +Gets the value for an option. ~~~ -wp post block extract <id> [--block=<block-name>] [--index=<index>] [--attr=<attr>] [--content] [--format=<format>] +wp option get <key> [--format=<format>] ~~~ -Extracts specific attribute values or content from blocks for scripting. - **OPTIONS** - <id> - The ID of the post. - - [--block=<block-name>] - Filter by block type. - - [--index=<index>] - Get from specific block index. - - [--attr=<attr>] - Extract specific attribute value. - - [--content] - Extract innerHTML content. + <key> + Key for the option. [--format=<format>] - Output format. + Get value in a particular format. --- - default: json + default: var_export options: + - var_export - json - yaml - - csv - - ids --- **EXAMPLES** - # Extract all image IDs from the post (one per line). - $ wp post block extract 123 --block=core/image --attr=id --format=ids - 456 - 789 - 1024 - - # Extract all image URLs as JSON array. - $ wp post block extract 123 --block=core/image --attr=url --format=json - ["https://example.com/img1.jpg","https://example.com/img2.jpg"] - - # Extract text content from all headings. - $ wp post block extract 123 --block=core/heading --content --format=ids - Introduction - Getting Started - Conclusion - - # Get the heading level from the first block. - $ wp post block extract 123 --index=0 --attr=level --format=ids - 2 + # Get option. + $ wp option get home + http://example.com - # Extract all heading levels as CSV. - $ wp post block extract 123 --block=core/heading --attr=level --format=csv - 2,3,3,2 + # Get blog description. + $ wp option get blogdescription + A random blog description - # Extract paragraph content as YAML. - $ wp post block extract 123 --block=core/paragraph --content --format=yaml - - "First paragraph text" - - "Second paragraph text" + # Get blog name + $ wp option get blogname + A random blog name - # Get all button URLs for link checking. - $ wp post block extract 123 --block=core/button --attr=url --format=ids - https://example.com/signup - https://example.com/learn-more + # Get admin email. + $ wp option get admin_email + someone@example.com - # Extract cover block image IDs for media audit. - $ wp post block extract 123 --block=core/cover --attr=id --format=json + # Get option in JSON format. + $ wp option get active_plugins --format=json + {"0":"dynamically-dynamic-sidebar\/dynamically-dynamic-sidebar.php","1":"monster-widget\/monster-widget.php","2":"show-current-template\/show-current-template.php","3":"theme-check\/theme-check.php","5":"wordpress-importer\/wordpress-importer.php"} -### wp post block get +### wp option list -Gets a single block by index. +Lists options and their values. ~~~ -wp post block get <id> <index> [--raw] [--format=<format>] +wp option list [--search=<pattern>] [--exclude=<pattern>] [--autoload=<value>] [--transients] [--field=<field>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] ~~~ -Retrieves the full structure of a block at the specified position. - **OPTIONS** - <id> - The ID of the post. - - <index> - The block index (0-indexed). - - [--raw] - Include innerHTML in output. - - [--format=<format>] - Render output in a particular format. - --- - default: json - options: - - json - - yaml - --- - -**EXAMPLES** - - # Get the first block in a post. - $ wp post block get 123 0 - { - "blockName": "core/paragraph", - "attrs": {}, - "innerBlocks": [] - } - - # Get the third block (index 2) with attributes. - $ wp post block get 123 2 - { - "blockName": "core/heading", - "attrs": { - "level": 2 - }, - "innerBlocks": [] - } - - # Get block as YAML format. - $ wp post block get 123 1 --format=yaml - blockName: core/image - attrs: - id: 456 - sizeSlug: large - innerBlocks: [] - - # Get block with raw HTML content included. - $ wp post block get 123 0 --raw - { - "blockName": "core/paragraph", - "attrs": {}, - "innerBlocks": [], - "innerHTML": "<p>Hello World</p>", - "innerContent": ["<p>Hello World</p>"] - } - - + [--search=<pattern>] + Use wildcards ( * and ? ) to match option name. -### wp post block import + [--exclude=<pattern>] + Pattern to exclude. Use wildcards ( * and ? ) to match option name. -Imports blocks from a file into a post. + [--autoload=<value>] + Match only autoload options when value is on, and only not-autoload option when off. -~~~ -wp post block import <id> [--file=<file>] [--position=<position>] [--replace] [--porcelain] -~~~ + [--transients] + List only transients. Use `--no-transients` to ignore all transients. -Imports blocks from a JSON or YAML file into a post's content. + [--field=<field>] + Prints the value of a single field. -**OPTIONS** + [--fields=<fields>] + Limit the output to specific object fields. - <id> - The ID of the post to import blocks into. + [--format=<format>] + The serialization format for the value. total_bytes displays the total size of matching options in bytes. + --- + default: table + options: + - table + - json + - csv + - count + - yaml + - total_bytes + --- - [--file=<file>] - Input file path. If not specified, reads from STDIN. + [--orderby=<fields>] + Set orderby which field. + --- + default: option_id + options: + - option_id + - option_name + - option_value + --- - [--position=<position>] - Where to insert imported blocks. Accepts 'start', 'end', or a numeric index. + [--order=<order>] + Set ascending or descending order. --- - default: end + default: asc + options: + - asc + - desc --- - [--replace] - Replace all existing blocks instead of appending. +**AVAILABLE FIELDS** - [--porcelain] - Output just the number of blocks imported. +This field will be displayed by default for each matching option: -**EXAMPLES** +* option_name +* option_value - # Import blocks from a JSON file, append to end of post. - $ wp post block import 123 --file=blocks.json - Success: Imported 5 blocks into post 123. +These fields are optionally available: - # Import blocks at the beginning of the post. - $ wp post block import 123 --file=blocks.json --position=start - Success: Imported 5 blocks into post 123. +* autoload +* size_bytes - # Replace all existing content with imported blocks. - $ wp post block import 123 --file=blocks.json --replace - Success: Imported 5 blocks into post 123. +**EXAMPLES** - # Import from STDIN (piped from another command). - $ cat blocks.json | wp post block import 123 - Success: Imported 5 blocks into post 123. + # Get the total size of all autoload options. + $ wp option list --autoload=on --format=total_bytes + 33198 - # Copy blocks from one post to another. - $ wp post block export 123 | wp post block import 456 - Success: Imported 5 blocks into post 456. + # Find biggest transients. + $ wp option list --search="*_transient_*" --fields=option_name,size_bytes | sort -n -k 2 | tail + option_name size_bytes + _site_transient_timeout_theme_roots 10 + _site_transient_theme_roots 76 + _site_transient_update_themes 181 + _site_transient_update_core 808 + _site_transient_update_plugins 6645 - # Import YAML format. - $ wp post block import 123 --file=blocks.yaml - Success: Imported 3 blocks into post 123. + # List all options beginning with "i2f_". + $ wp option list --search="i2f_*" + +-------------+--------------+ + | option_name | option_value | + +-------------+--------------+ + | i2f_version | 0.1.0 | + +-------------+--------------+ - # Get just the count of imported blocks for scripting. - $ wp post block import 123 --file=blocks.json --porcelain - 5 + # Delete all options beginning with "theme_mods_". + $ wp option list --search="theme_mods_*" --field=option_name | xargs -I % wp option delete % + Success: Deleted 'theme_mods_twentysixteen' option. + Success: Deleted 'theme_mods_twentyfifteen' option. + Success: Deleted 'theme_mods_twentyfourteen' option. -### wp post block insert +### wp option patch -Inserts a block into a post at a specified position. +Updates a nested value in an option. ~~~ -wp post block insert <id> <block-name> [--content=<content>] [--attrs=<attrs>] [--position=<position>] [--porcelain] +wp option patch <action> <key> <key-path>... [<value>] [--format=<format>] ~~~ -Adds a new block to the post content. By default, the block is -appended to the end of the post. - **OPTIONS** - <id> - The ID of the post to modify. - - <block-name> - The block type name (e.g., 'core/paragraph'). - - [--content=<content>] - The inner content/HTML for the block. - - [--attrs=<attrs>] - Block attributes as JSON. - - [--position=<position>] - Position to insert the block (0-indexed). Use 'start' or 'end'. + <action> + Patch action to perform. --- - default: end + options: + - insert + - update + - delete --- - [--porcelain] - Output just the post ID. - -**EXAMPLES** - - # Insert a paragraph block at the end of the post. - $ wp post block insert 123 core/paragraph --content="Hello World" - Success: Inserted block into post 123. + <key> + The option name. - # Insert a level-2 heading at the start. - $ wp post block insert 123 core/heading --content="My Title" --attrs='{"level":2}' --position=start - Success: Inserted block into post 123. + <key-path>... + The name(s) of the keys within the value to locate the value to patch. - # Insert an image block at position 2. - $ wp post block insert 123 core/image --attrs='{"id":456,"url":"https://example.com/image.jpg"}' --position=2 + [<value>] + The new value. If omitted, the value is read from STDIN. - # Insert a separator block. - $ wp post block insert 123 core/separator + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- -### wp post block list +### wp option pluck -Lists blocks in a post with counts. +Gets a nested value from an option. ~~~ -wp post block list <id> [--nested] [--format=<format>] +wp option pluck <key> <key-path>... [--format=<format>] ~~~ -Displays a summary of block types used in the post and how many -times each block type appears. - **OPTIONS** - <id> - The ID of the post to analyze. + <key> + The option name. - [--nested] - Include nested/inner blocks in the list. + <key-path>... + The name(s) of the keys within the value to locate the value to pluck. [--format=<format>] - Render output in a particular format. + The output format of the value. --- - default: table + default: plaintext options: - - table - - csv + - plaintext - json - yaml - - count --- -**EXAMPLES** - - # List blocks with counts. - $ wp post block list 123 - +------------------+-------+ - | blockName | count | - +------------------+-------+ - | core/paragraph | 5 | - | core/heading | 2 | - | core/image | 1 | - +------------------+-------+ - - # List blocks as JSON. - $ wp post block list 123 --format=json - [{"blockName":"core/paragraph","count":5}] - - # Include nested blocks (e.g., blocks inside columns or groups). - $ wp post block list 123 --nested - # Get the number of unique block types. - $ wp post block list 123 --format=count - 3 +### wp option update - -### wp post block move - -Moves a block from one position to another. +Updates an option value. ~~~ -wp post block move <id> <from-index> <to-index> [--porcelain] +wp option update <key> [<value>] [--autoload=<autoload>] [--format=<format>] ~~~ -Reorders blocks within the post by moving a block from one index to another. - **OPTIONS** - <id> - The ID of the post. + <key> + The name of the option to update. - <from-index> - Current block index (0-indexed). + [<value>] + The new value. If ommited, the value is read from STDIN. - <to-index> - Target position index (0-indexed). + [--autoload=<autoload>] + Requires WP 4.2. Should this option be automatically loaded. + --- + options: + - 'yes' + - 'no' + --- - [--porcelain] - Output just the post ID. + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- **EXAMPLES** - # Move the first block to the third position. - $ wp post block move 123 0 2 - Success: Moved block from index 0 to index 2 in post 123. + # Update an option by reading from a file. + $ wp option update my_option < value.txt + Success: Updated 'my_option' option. + + # Update one option on multiple sites using xargs. + $ wp site list --field=url | xargs -n1 -I {} sh -c 'wp --url={} option update my_option my_value' + Success: Updated 'my_option' option. + Success: Updated 'my_option' option. + + # Update site blog name. + $ wp option update blogname "Random blog name" + Success: Updated 'blogname' option. + + # Update site blog description. + $ wp option update blogdescription "Some random blog description" + Success: Updated 'blogdescription' option. - # Move the last block (index 4) to the beginning. - $ wp post block move 123 4 0 - Success: Moved block from index 4 to index 0 in post 123. + # Update admin email address. + $ wp option update admin_email someone@example.com + Success: Updated 'admin_email' option. - # Move a heading block from position 3 to position 1. - $ wp post block move 123 3 1 - Success: Moved block from index 3 to index 1 in post 123. + # Set the default role. + $ wp option update default_role author + Success: Updated 'default_role' option. - # Move block and get post ID for scripting. - $ wp post block move 123 2 0 --porcelain - 123 + # Set the timezone string. + $ wp option update timezone_string "America/New_York" + Success: Updated 'timezone_string' option. -### wp post block parse +### wp post -Parses and displays the block structure of a post. +Manages posts, content, and meta. ~~~ -wp post block parse <id> [--raw] [--format=<format>] +wp post ~~~ -Outputs the parsed block structure as JSON or YAML. By default, -innerHTML is stripped from the output for readability. +**EXAMPLES** -**OPTIONS** + # Create a new post. + $ wp post create --post_type=post --post_title='A sample post' + Success: Created post 123. - <id> - The ID of the post to parse. + # Update an existing post. + $ wp post update 123 --post_status=draft + Success: Updated post 123. - [--raw] - Include raw innerHTML in output. + # Delete an existing post. + $ wp post delete 123 + Success: Trashed post 123. - [--format=<format>] - Render output in a particular format. - --- - default: json - options: - - json - - yaml - --- -**EXAMPLES** - # Parse blocks to JSON. - $ wp post block parse 123 - [ - { - "blockName": "core/paragraph", - "attrs": {} - } - ] +### wp post create - # Parse blocks to YAML format. - $ wp post block parse 123 --format=yaml - - - blockName: core/paragraph - attrs: { } +Creates a new post. - # Parse blocks including raw HTML content. - $ wp post block parse 123 --raw +~~~ +wp post create [--post_author=<post_author>] [--post_date=<post_date>] [--post_date_gmt=<post_date_gmt>] [--post_content=<post_content>] [--post_content_filtered=<post_content_filtered>] [--post_title=<post_title>] [--post_excerpt=<post_excerpt>] [--post_status=<post_status>] [--post_type=<post_type>] [--comment_status=<comment_status>] [--ping_status=<ping_status>] [--post_password=<post_password>] [--post_name=<post_name>] [--to_ping=<to_ping>] [--pinged=<pinged>] [--post_modified=<post_modified>] [--post_modified_gmt=<post_modified_gmt>] [--post_parent=<post_parent>] [--menu_order=<menu_order>] [--post_mime_type=<post_mime_type>] [--guid=<guid>] [--post_category=<post_category>] [--tags_input=<tags_input>] [--tax_input=<tax_input>] [--meta_input=<meta_input>] [<file>] [--<field>=<value>] [--edit] [--porcelain] +~~~ +**OPTIONS** + [--post_author=<post_author>] + The ID of the user who added the post. Default is the current user ID. -### wp post block remove + [--post_date=<post_date>] + The date of the post. Default is the current time. -Removes blocks from a post by name or index. + [--post_date_gmt=<post_date_gmt>] + The date of the post in the GMT timezone. Default is the value of $post_date. -~~~ -wp post block remove <id> [<block-name>] [--index=<index>] [--all] [--porcelain] -~~~ + [--post_content=<post_content>] + The post content. Default empty. -Removes one or more blocks from the post content. Blocks can be -removed by their type name or by their position index. + [--post_content_filtered=<post_content_filtered>] + The filtered post content. Default empty. -**OPTIONS** + [--post_title=<post_title>] + The post title. Default empty. - <id> - The ID of the post to modify. + [--post_excerpt=<post_excerpt>] + The post excerpt. Default empty. - [<block-name>] - The block type name to remove (e.g., 'core/paragraph'). + [--post_status=<post_status>] + The post status. Default 'draft'. - [--index=<index>] - Remove block at specific index (0-indexed). Can be comma-separated for multiple indices. + [--post_type=<post_type>] + The post type. Default 'post'. - [--all] - Remove all blocks of the specified type. + [--comment_status=<comment_status>] + Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. - [--porcelain] - Output just the number of blocks removed. + [--ping_status=<ping_status>] + Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. -**EXAMPLES** + [--post_password=<post_password>] + The password to access the post. Default empty. - # Remove the first block (index 0). - $ wp post block remove 123 --index=0 - Success: Removed 1 block from post 123. + [--post_name=<post_name>] + The post name. Default is the sanitized post title when creating a new post. - # Remove the first paragraph block found. - $ wp post block remove 123 core/paragraph - Success: Removed 1 block from post 123. + [--to_ping=<to_ping>] + Space or carriage return-separated list of URLs to ping. Default empty. - # Remove all paragraph blocks. - $ wp post block remove 123 core/paragraph --all - Success: Removed 5 blocks from post 123. + [--pinged=<pinged>] + Space or carriage return-separated list of URLs that have been pinged. Default empty. - # Remove blocks at multiple indices. - $ wp post block remove 123 --index=0,2,4 - Success: Removed 3 blocks from post 123. + [--post_modified=<post_modified>] + The date when the post was last modified. Default is the current time. - # Remove all image blocks and get count. - $ wp post block remove 123 core/image --all --porcelain - 2 + [--post_modified_gmt=<post_modified_gmt>] + The date when the post was last modified in the GMT timezone. Default is the current time. + [--post_parent=<post_parent>] + Set this for the post it belongs to, if any. Default 0. + [--menu_order=<menu_order>] + The order the post should be displayed in. Default 0. -### wp post block replace + [--post_mime_type=<post_mime_type>] + The mime type of the post. Default empty. -Replaces blocks in a post. + [--guid=<guid>] + Global Unique ID for referencing the post. Default empty. -~~~ -wp post block replace <id> <old-block-name> <new-block-name> [--attrs=<attrs>] [--content=<content>] [--all] [--porcelain] -~~~ + [--post_category=<post_category>] + Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. -Replaces blocks of one type with blocks of another type. Can also -be used to update block attributes without changing the block type. + [--tags_input=<tags_input>] + Array of tag names, slugs, or IDs. Default empty. -**OPTIONS** + [--tax_input=<tax_input>] + Array of taxonomy terms keyed by their taxonomy name. Default empty. - <id> - The ID of the post to modify. + [--meta_input=<meta_input>] + Array in JSON format of post meta values keyed by their post meta key. Default empty. - <old-block-name> - The block type name to replace. + [<file>] + Read post content from <file>. If this value is present, the + `--post_content` argument will be ignored. - <new-block-name> - The new block type name. + Passing `-` as the filename will cause post content to + be read from STDIN. - [--attrs=<attrs>] - New block attributes as JSON. + [--<field>=<value>] + Associative args for the new post. See wp_insert_post(). - [--content=<content>] - New block content. Use '{content}' to preserve original content. + [--edit] + Immediately open system's editor to write or edit post content. - [--all] - Replace all matching blocks. By default, only the first match is replaced. + If content is read from a file, from STDIN, or from the `--post_content` + argument, that text will be loaded into the editor. [--porcelain] - Output just the number of blocks replaced. + Output just the new post id. -**EXAMPLES** - # Replace the first paragraph block with a heading. - $ wp post block replace 123 core/paragraph core/heading - Success: Replaced 1 block in post 123. +**EXAMPLES** - # Replace all paragraphs with preformatted blocks, keeping content. - $ wp post block replace 123 core/paragraph core/preformatted --content='{content}' --all - Success: Replaced 3 blocks in post 123. + # Create post and schedule for future + $ wp post create --post_type=page --post_title='A future post' --post_status=future --post_date='2020-12-01 07:00:00' + Success: Created post 1921. - # Change all h2 headings to h3. - $ wp post block replace 123 core/heading core/heading --attrs='{"level":3}' --all + # Create post with content from given file + $ wp post create ./post-content.txt --post_category=201,345 --post_title='Post from file' + Success: Created post 1922. - # Replace and get count for scripting. - $ wp post block replace 123 core/quote core/pullquote --all --porcelain - 2 + # Create a post with multiple meta values. + $ wp post create --post_title='A post' --post_content='Just a small post.' --meta_input='{"key1":"value1","key2":"value2"} + Success: Created post 1923. -### wp post block render +### wp post delete -Renders blocks from a post to HTML. +Deletes an existing post. ~~~ -wp post block render <id> [--block=<block-name>] +wp post delete <id>... [--force] [--defer-term-counting] ~~~ -Outputs the rendered HTML of blocks in a post. This uses WordPress's -block rendering system to produce the final HTML output. - **OPTIONS** - <id> - The ID of the post to render. + <id>... + One or more IDs of posts to delete. + + [--force] + Skip the trash bin. - [--block=<block-name>] - Only render blocks of this type. + [--defer-term-counting] + Recalculate term count in batch, for a performance boost. **EXAMPLES** - # Render all blocks to HTML. - $ wp post block render 123 - <p>Hello World</p> - <h2>My Heading</h2> + # Delete post skipping trash + $ wp post delete 123 --force + Success: Deleted post 123. - # Render only paragraph blocks. - $ wp post block render 123 --block=core/paragraph - <p>Hello World</p> + # Delete all pages + $ wp post delete $(wp post list --post_type='page' --format=ids) + Success: Trashed post 1164. + Success: Trashed post 1186. - # Render only heading blocks. - $ wp post block render 123 --block=core/heading + # Delete all posts in the trash + $ wp post delete $(wp post list --post_status=trash --format=ids) + Success: Deleted post 1268. + Success: Deleted post 1294. -### wp post block update +### wp post edit -Updates a block's attributes or content by index. +Launches system editor to edit post content. ~~~ -wp post block update <id> <index> [--attrs=<attrs>] [--content=<content>] [--replace-attrs] [--porcelain] +wp post edit <id> ~~~ -Modifies a specific block without changing its type. For blocks where -attributes are reflected in HTML (like heading levels), the HTML is -automatically updated to match the new attributes. - **OPTIONS** <id> - The ID of the post. - - <index> - The block index to update (0-indexed). + The ID of the post to edit. - [--attrs=<attrs>] - Block attributes as JSON. Merges with existing attributes by default. +**EXAMPLES** - [--content=<content>] - New innerHTML content for the block. + # Launch system editor to edit post + $ wp post edit 123 - [--replace-attrs] - Replace all attributes instead of merging. - [--porcelain] - Output just the post ID. -**EXAMPLES** +### wp post generate - # Change a heading from h2 to h3. - $ wp post block update 123 0 --attrs='{"level":3}' - Success: Updated block at index 0 in post 123. +Generates some posts. - # Add alignment to an existing paragraph (merges with existing attrs). - $ wp post block update 123 1 --attrs='{"align":"center"}' - Success: Updated block at index 1 in post 123. +~~~ +wp post generate [--count=<number>] [--post_type=<type>] [--post_status=<status>] [--post_title=<post_title>] [--post_author=<login>] [--post_date=<yyyy-mm-dd-hh-ii-ss>] [--post_content] [--max_depth=<number>] [--format=<format>] +~~~ - # Update the text content of a paragraph block. - $ wp post block update 123 2 --content="<p>Updated paragraph text</p>" - Success: Updated block at index 2 in post 123. +Creates a specified number of new posts with dummy data. - # Update both attributes and content at once. - $ wp post block update 123 0 --attrs='{"level":2}' --content="<h2>New Heading</h2>" - Success: Updated block at index 0 in post 123. +**OPTIONS** - # Replace all attributes instead of merging (removes existing attrs). - $ wp post block update 123 0 --attrs='{"level":4}' --replace-attrs - Success: Updated block at index 0 in post 123. + [--count=<number>] + How many posts to generate? + --- + default: 100 + --- - # Get just the post ID for scripting. - $ wp post block update 123 0 --attrs='{"level":2}' --porcelain - 123 + [--post_type=<type>] + The type of the generated posts. + --- + default: post + --- - # Use custom HTML sync logic via the wp_cli_post_block_update_html filter. - # Use WP_CLI::add_wp_hook() in a file loaded with --require. - $ wp post block update 123 0 --attrs='{"url":"https://example.com"}' --require=my-sync-filters.php - Success: Updated block at index 0 in post 123. + [--post_status=<status>] + The status of the generated posts. + --- + default: publish + --- + [--post_title=<post_title>] + The post title. + --- + default: + --- + [--post_author=<login>] + The author of the generated posts. + --- + default: + --- -### wp post-type + [--post_date=<yyyy-mm-dd-hh-ii-ss>] + The date of the generated posts. Default: current date -Retrieves details on the site's registered post types. + [--post_content] + If set, the command reads the post_content from STDIN. -~~~ -wp post-type -~~~ + [--max_depth=<number>] + For hierarchical post types, generate child posts down to a certain depth. + --- + default: 1 + --- -Get information on WordPress' built-in and the site's [custom post types](https://developer.wordpress.org/plugins/post-types/). + [--format=<format>] + Render output in a particular format. + --- + default: progress + options: + - progress + - ids + --- **EXAMPLES** - # Get details about a post type - $ wp post-type get page --fields=name,label,hierarchical --format=json - {"name":"page","label":"Pages","hierarchical":true} + # Generate posts. + $ wp post generate --count=10 --post_type=page --post_date=1999-01-04 + Generating posts 100% [================================================] 0:01 / 0:04 - # List post types with 'post' capability type - $ wp post-type list --capability_type=post --fields=name,public - +---------------+--------+ - | name | public | - +---------------+--------+ - | post | 1 | - | attachment | 1 | - | revision | | - | nav_menu_item | | - +---------------+--------+ + # Generate posts with fetched content. + $ curl http://loripsum.net/api/5 | wp post generate --post_content --count=10 + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 100 2509 100 2509 0 0 616 0 0:00:04 0:00:04 --:--:-- 616 + Generating posts 100% [================================================] 0:01 / 0:04 + # Add meta to every generated posts. + $ wp post generate --format=ids | xargs -d ' ' -I % wp post meta add % foo bar + Success: Added custom field. + Success: Added custom field. + Success: Added custom field. -### wp post-type get -Gets details about a registered post type. +### wp post get + +Gets details about a post. ~~~ -wp post-type get <post-type> [--field=<field>] [--fields=<fields>] [--format=<format>] +wp post get <id> [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** - <post-type> - Post type slug + <id> + The ID of the post to get. [--field=<field>] - Instead of returning the whole taxonomy, returns the value of a single field. + Instead of returning the whole post, returns the value of a single field. [--fields=<fields>] Limit the output to specific fields. Defaults to all fields. @@ -4887,50 +2249,31 @@ wp post-type get <post-type> [--field=<field>] [--fields=<fields>] [--format=<fo - yaml --- -**AVAILABLE FIELDS** - -These fields will be displayed by default for the specified post type: - -* name -* label -* description -* hierarchical -* public -* capability_type -* labels -* cap -* supports - -These fields are optionally available: - -* count - **EXAMPLES** - # Get details about the 'page' post type. - $ wp post-type get page --fields=name,label,hierarchical --format=json - {"name":"page","label":"Pages","hierarchical":true} + # Save the post content to a file + $ wp post get 123 --field=content > file.txt -### wp post-type list +### wp post list -Lists registered post types. +Gets a list of posts. ~~~ -wp post-type list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] +wp post list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** [--<field>=<value>] - Filter by one or more fields (see get_post_types() first parameter for a list of available fields). + One or more args to pass to WP_Query. [--field=<field>] - Prints the value of a single field for each post type. + Prints the value of a single field for each post. [--fields=<fields>] - Limit the output to specific post type fields. + Limit the output to specific object fields. [--format=<format>] Render output in a particular format. @@ -4939,6 +2282,7 @@ wp post-type list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--f options: - table - csv + - ids - json - count - yaml @@ -4946,633 +2290,670 @@ wp post-type list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--f **AVAILABLE FIELDS** -These fields will be displayed by default for each post type: +These fields will be displayed by default for each post: -* name -* label -* description -* hierarchical -* public -* capability_type +* ID +* post_title +* post_name +* post_date +* post_status These fields are optionally available: -* count +* post_author +* post_date_gmt +* post_content +* post_excerpt +* comment_status +* ping_status +* post_password +* to_ping +* pinged +* post_modified +* post_modified_gmt +* post_content_filtered +* post_parent +* guid +* menu_order +* post_type +* post_mime_type +* comment_count +* filter +* url **EXAMPLES** - # List registered post types - $ wp post-type list --format=csv - name,label,description,hierarchical,public,capability_type - post,Posts,,,1,post - page,Pages,,1,1,page - attachment,Media,,,1,post - revision,Revisions,,,,post - nav_menu_item,"Navigation Menu Items",,,,post + # List post + $ wp post list --field=ID + 568 + 829 + 1329 + 1695 - # List post types with 'post' capability type - $ wp post-type list --capability_type=post --fields=name,public - +---------------+--------+ - | name | public | - +---------------+--------+ - | post | 1 | - | attachment | 1 | - | revision | | - | nav_menu_item | | - +---------------+--------+ + # List posts in JSON + $ wp post list --post_type=post --posts_per_page=5 --format=json + [{"ID":1,"post_title":"Hello world!","post_name":"hello-world","post_date":"2015-06-20 09:00:10","post_status":"publish"},{"ID":1178,"post_title":"Markup: HTML Tags and Formatting","post_name":"markup-html-tags-and-formatting","post_date":"2013-01-11 20:22:19","post_status":"draft"}] + + # List all pages + $ wp post list --post_type=page --fields=post_title,post_status + +-------------+-------------+ + | post_title | post_status | + +-------------+-------------+ + | Sample Page | publish | + +-------------+-------------+ + + # List ids of all pages and posts + $ wp post list --post_type=page,post --format=ids + 15 25 34 37 198 + + # List given posts + $ wp post list --post__in=1,3 + +----+--------------+-------------+---------------------+-------------+ + | ID | post_title | post_name | post_date | post_status | + +----+--------------+-------------+---------------------+-------------+ + | 3 | Lorem Ipsum | lorem-ipsum | 2016-06-01 14:34:36 | publish | + | 1 | Hello world! | hello-world | 2016-06-01 14:31:12 | publish | + +----+--------------+-------------+---------------------+-------------+ -### wp site +### wp post meta -Creates, deletes, empties, moderates, and lists one or more sites on a multisite installation. +Adds, updates, deletes, and lists post custom fields. ~~~ -wp site +wp post meta ~~~ **EXAMPLES** - # Create site - $ wp site create --slug=example - Success: Site 3 created: www.example.com/example/ + # Set post meta + $ wp post meta set 123 _wp_page_template about.php + Success: Updated custom field '_wp_page_template'. - # Output a simple list of site URLs - $ wp site list --field=url - http://www.example.com/ - http://www.example.com/subdir/ + # Get post meta + $ wp post meta get 123 _wp_page_template + about.php - # Delete site - $ wp site delete 123 - Are you sure you want to delete the 'http://www.example.com/example' site? [y/n] y - Success: The site at 'http://www.example.com/example' was deleted. + # Update post meta + $ wp post meta update 123 _wp_page_template contact.php + Success: Updated custom field '_wp_page_template'. + + # Delete post meta + $ wp post meta delete 123 _wp_page_template + Success: Deleted custom field. -### wp site activate -Activates one or more sites. + +### wp post meta add + +Add a meta field. ~~~ -wp site activate [<id>...] [--slug=<slug>] +wp post meta add <id> <key> [<value>] [--format=<format>] ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to activate. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be activated. Subdomain on subdomain installs, directory on subdirectory installs. + <id> + The ID of the object. -**EXAMPLES** + <key> + The name of the meta field to create. - $ wp site activate 123 - Success: Site 123 activated. + [<value>] + The value of the meta field. If omitted, the value is read from STDIN. - $ wp site activate --slug=demo - Success: Site 123 marked as activated. + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- -### wp site archive +### wp post meta delete -Archives one or more sites. +Delete a meta field. ~~~ -wp site archive [<id>...] [--slug=<slug>] +wp post meta delete <id> [<key>] [<value>] [--all] ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to archive. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to archive. Subdomain on subdomain installs, directory on subdirectory installs. + <id> + The ID of the object. -**EXAMPLES** + [<key>] + The name of the meta field to delete. - $ wp site archive 123 - Success: Site 123 archived. + [<value>] + The value to delete. If omitted, all rows with key will deleted. - $ wp site archive --slug=demo - Success: Site 123 archived. + [--all] + Delete all meta for the object. -### wp site create +### wp post meta get -Creates a site in a multisite installation. +Get meta field value. ~~~ -wp site create [--slug=<slug>] [--site-url=<url>] [--title=<title>] [--email=<email>] [--network_id=<network-id>] [--private] [--porcelain] +wp post meta get <id> <key> [--format=<format>] ~~~ **OPTIONS** - [--slug=<slug>] - Path for the new site. Subdomain on subdomain installs, directory on subdirectory installs. - Required if --site-url is not provided. + <id> + The ID of the object. - [--site-url=<url>] - Full URL for the new site. Use this to specify a custom domain instead of the auto-generated one. - For subdomain installs, this allows you to use a different base domain (e.g., 'http://site.example.com' instead of 'http://site.main.example.com'). - For subdirectory installs, this allows you to use a different path. - If provided, --slug is optional and will be derived from the URL. If both --slug and --site-url are provided, --slug will be used as the base for internal operations (like user creation), while the domain/path from --site-url will be used for the actual site URL. + <key> + The name of the meta field to get. - [--title=<title>] - Title of the new site. Default: prettified slug. + [--format=<format>] + Accepted values: table, json. Default: table - [--email=<email>] - Email for admin user. User will be created if none exists. Assignment to super admin if not included. - [--network_id=<network-id>] - Network to associate new site with. Defaults to current network (typically 1). - [--private] - If set, the new site will be non-public (not indexed) +### wp post meta list - [--porcelain] - If set, only the site id will be output on success. +List all metadata associated with an object. -**EXAMPLES** +~~~ +wp post meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] +~~~ - # Create a site with auto-generated domain - $ wp site create --slug=example - Success: Site 3 created: http://www.example.com/example/ +**OPTIONS** + + <id> + ID for the object. + + [--keys=<keys>] + Limit output to metadata of specific keys. + + [--fields=<fields>] + Limit the output to specific row fields. Defaults to id,meta_key,meta_value. + + [--format=<format>] + Accepted values: table, csv, json, count. Default: table - # Create a site with a custom domain (subdomain multisite) - $ wp site create --site-url=http://site.example.com - Success: Site 4 created: http://site.example.com/ + [--orderby=<fields>] + Set orderby which field. + --- + default: id + options: + - id + - meta_key + - meta_value + --- - # Create a site with a custom subdirectory (subdirectory multisite) - $ wp site create --site-url=http://example.com/custom/path/ - Success: Site 5 created: http://example.com/custom/path/ + [--order=<order>] + Set ascending or descending order. + --- + default: asc + options: + - asc + - desc + --- -### wp site generate +### wp post meta patch -Generate some sites. +Update a nested value for a meta field. ~~~ -wp site generate [--count=<number>] [--slug=<slug>] [--email=<email>] [--network_id=<network-id>] [--private] [--format=<format>] +wp post meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] ~~~ -Creates a specified number of new sites. - **OPTIONS** - [--count=<number>] - How many sites to generates? + <action> + Patch action to perform. --- - default: 100 + options: + - insert + - update + - delete --- - [--slug=<slug>] - Path for the new site. Subdomain on subdomain installs, directory on subdirectory installs. + <id> + The ID of the object. - [--email=<email>] - Email for admin user. User will be created if none exists. Assignment to super admin if not included. + <key> + The name of the meta field to update. - [--network_id=<network-id>] - Network to associate new site with. Defaults to current network (typically 1). + <key-path>... + The name(s) of the keys within the value to locate the value to patch. - [--private] - If set, the new site will be non-public (not indexed) + [<value>] + The new value. If omitted, the value is read from STDIN. [--format=<format>] - Render output in a particular format. + The serialization format for the value. --- - default: progress + default: plaintext options: - - progress - - ids + - plaintext + - json --- -**EXAMPLES** - - # Generate 10 sites. - $ wp site generate --count=10 - Generating sites 100% [================================================] 0:01 / 0:04 - -### wp site deactivate +### wp post meta pluck -Deactivates one or more sites. +Get a nested value from a meta field. ~~~ -wp site deactivate [<id>...] [--slug=<slug>] +wp post meta pluck <id> <key> <key-path>... [--format=<format>] ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to deactivate. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be deactivated. Subdomain on subdomain installs, directory on subdirectory installs. + <id> + The ID of the object. -**EXAMPLES** + <key> + The name of the meta field to get. - $ wp site deactivate 123 - Success: Site 123 deactivated. + <key-path>... + The name(s) of the keys within the value to locate the value to pluck. - $ wp site deactivate --slug=demo - Success: Site 123 deactivated. + [--format=<format>] + The output format of the value. + --- + default: plaintext + options: + - plaintext + - json + - yaml -### wp site delete +### wp post meta update -Deletes a site in a multisite installation. +Update a meta field. ~~~ -wp site delete [<site-id>] [--slug=<slug>] [--yes] [--keep-tables] [--delete-tables-with-prefix] +wp post meta update <id> <key> [<value>] [--format=<format>] ~~~ **OPTIONS** - [<site-id>] - The id of the site to delete. If not provided, you must set the --slug parameter. + <id> + The ID of the object. - [--slug=<slug>] - Path of the site to be deleted. Subdomain on subdomain installs, directory on subdirectory installs. + <key> + The name of the meta field to update. - [--yes] - Answer yes to the confirmation message. + [<value>] + The new value. If omitted, the value is read from STDIN. - [--keep-tables] - Delete the blog from the list, but don't drop its tables. + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- - [--delete-tables-with-prefix] - Delete all tables with the site's database table prefix after deleting the site. -**EXAMPLES** - $ wp site delete 123 - Are you sure you want to delete the http://www.example.com/example site? [y/n] y - Success: The site at 'http://www.example.com/example' was deleted. +### wp post term +Adds, updates, removes, and lists post terms. +~~~ +wp post term +~~~ -### wp site empty +**EXAMPLES** -Empties a site of its content (posts, comments, terms, links, and meta). + # Set post terms + $ wp post term set 123 test category + Success: Set terms. -~~~ -wp site empty [--uploads] [--yes] -~~~ -Truncates posts, comments, terms, and links tables to empty a site of its -content. Doesn't affect site configuration (options) or users. -Flushes the object cache after emptying the site to ensure stale data -is not served. On a Multisite installation, this will flush the cache -for all sites. -To also empty custom database tables, you'll need to hook into command -execution: -``` -WP_CLI::add_hook( 'after_invoke:site empty', function(){ - global $wpdb; - foreach( array( 'p2p', 'p2pmeta' ) as $table ) { - $table = $wpdb->$table; - $wpdb->query( "TRUNCATE $table" ); - } -}); -``` +### wp post term add + +Add a term to an object. + +~~~ +wp post term add <id> <taxonomy> <term>... [--by=<field>] +~~~ -**OPTIONS** +Append the term to the existing set of terms on the object. - [--uploads] - Also delete *all* files in the site's uploads directory. + <id> + The ID of the object. - [--yes] - Proceed to empty the site without a confirmation prompt. + <taxonomy> + The name of the taxonomy type to be added. -**EXAMPLES** + <term>... + The slug of the term or terms to be added. - $ wp site empty - Are you sure you want to empty the site at http://www.example.com of all posts, links, comments, and terms? [y/n] y - Success: The site at 'http://www.example.com' was emptied. + [--by=<field>] + Explicitly handle the term value as a slug or id. + --- + options: + - slug + - id + --- -### wp site get +### wp post term list -Gets details about a site in a multisite installation. +List all terms associated with an object. ~~~ -wp site get <site> [--field=<field>] [--fields=<fields>] [--format=<format>] +wp post term list <id> <taxonomy>... [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ -**OPTIONS** + <id> + ID for the object. - <site> - Site ID or URL of the site to get. For subdirectory sites, use the full URL (e.g., http://example.com/subdir/). + <taxonomy>... + One or more taxonomies to list. [--field=<field>] - Instead of returning the whole site, returns the value of a single field. + Prints the value of a single field for each term. [--fields=<fields>] - Limit the output to specific fields. Defaults to all fields. + Limit the output to specific row fields. [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - --- + Accepted values: table, csv, json, count, ids. Default: table **AVAILABLE FIELDS** -These fields will be displayed by default for the site: +These fields will be displayed by default for each term: -* blog_id -* url -* last_updated -* registered +* term_id +* name +* slug +* taxonomy These fields are optionally available: -* site_id -* domain -* path -* public -* archived -* mature -* spam -* deleted -* lang_id +* term_taxonomy_id +* description +* term_group +* parent +* count -**EXAMPLES** - # Get site by ID - $ wp site get 1 - +---------+-------------------------+---------------------+---------------------+ - | blog_id | url | last_updated | registered | - +---------+-------------------------+---------------------+---------------------+ - | 1 | http://example.com/ | 2025-01-01 12:00:00 | 2025-01-01 12:00:00 | - +---------+-------------------------+---------------------+---------------------+ - # Get site URL by site ID - $ wp site get 1 --field=url - http://example.com/ +### wp post term remove - # Get site ID by URL - $ wp site get http://example.com/subdir/ --field=blog_id - 2 +Remove a term from an object. - # Delete a site by URL - $ wp site delete $(wp site get http://example.com/subdir/ --field=blog_id) --yes - Success: The site at 'http://example.com/subdir/' was deleted. +~~~ +wp post term remove <id> <taxonomy> [<term>...] [--by=<field>] [--all] +~~~ +**OPTIONS** + <id> + The ID of the object. -### wp site list + <taxonomy> + The name of the term's taxonomy. -Lists all sites in a multisite installation. + [<term>...] + The name of the term or terms to be removed from the object. -~~~ -wp site list [--network=<id>] [--<field>=<value>] [--site__in=<value>] [--site_user=<value>] [--site-path=<path>] [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ + [--by=<field>] + Explicitly handle the term value as a slug or id. + --- + options: + - slug + - id + --- -**OPTIONS** + [--all] + Remove all terms from the object. - [--network=<id>] - The network to which the sites belong. - [--<field>=<value>] - Filter by one or more fields (see "Available Fields" section). However, - 'url' isn't an available filter, as it comes from 'home' in wp_options. - Note: '--path' conflicts with the global parameter of the same name; use - '--site-path' to filter by path instead. - [--site__in=<value>] - Only list the sites with these blog_id values (comma-separated). +### wp post term set - [--site_user=<value>] - Only list the sites with this user. +Set object terms. - [--site-path=<path>] - Filter by path. Avoids conflict with the global `--path` parameter. +~~~ +wp post term set <id> <taxonomy> <term>... [--by=<field>] +~~~ - [--field=<field>] - Prints the value of a single field for each site. +Replaces existing terms on the object. - [--fields=<fields>] - Comma-separated list of fields to show. + <id> + The ID of the object. - [--format=<format>] - Render output in a particular format. + <taxonomy> + The name of the taxonomy type to be updated. + + <term>... + The slug of the term or terms to be updated. + + [--by=<field>] + Explicitly handle the term value as a slug or id. --- - default: table options: - - table - - csv - - count - - ids - - json - - yaml + - slug + - id --- -**AVAILABLE FIELDS** - -These fields will be displayed by default for each site: -* blog_id -* url -* last_updated -* registered -These fields are optionally available: +### wp post update -* site_id -* domain -* path -* public -* archived -* mature -* spam -* deleted -* lang_id +Updates one or more existing posts. -**EXAMPLES** +~~~ +wp post update <id>... [--post_author=<post_author>] [--post_date=<post_date>] [--post_date_gmt=<post_date_gmt>] [--post_content=<post_content>] [--post_content_filtered=<post_content_filtered>] [--post_title=<post_title>] [--post_excerpt=<post_excerpt>] [--post_status=<post_status>] [--post_type=<post_type>] [--comment_status=<comment_status>] [--ping_status=<ping_status>] [--post_password=<post_password>] [--post_name=<post_name>] [--to_ping=<to_ping>] [--pinged=<pinged>] [--post_modified=<post_modified>] [--post_modified_gmt=<post_modified_gmt>] [--post_parent=<post_parent>] [--menu_order=<menu_order>] [--post_mime_type=<post_mime_type>] [--guid=<guid>] [--post_category=<post_category>] [--tags_input=<tags_input>] [--tax_input=<tax_input>] [--meta_input=<meta_input>] [<file>] --<field>=<value> [--defer-term-counting] +~~~ - # Output a simple list of site URLs - $ wp site list --field=url - http://www.example.com/ - http://www.example.com/subdir/ +**OPTIONS** + <id>... + One or more IDs of posts to update. + [--post_author=<post_author>] + The ID of the user who added the post. Default is the current user ID. -### wp site mature + [--post_date=<post_date>] + The date of the post. Default is the current time. -Sets one or more sites as mature. + [--post_date_gmt=<post_date_gmt>] + The date of the post in the GMT timezone. Default is the value of $post_date. -~~~ -wp site mature [<id>...] [--slug=<slug>] -~~~ + [--post_content=<post_content>] + The post content. Default empty. -**OPTIONS** + [--post_content_filtered=<post_content_filtered>] + The filtered post content. Default empty. - [<id>...] - One or more IDs of sites to set as mature. If not provided, you must set the --slug parameter. + [--post_title=<post_title>] + The post title. Default empty. - [--slug=<slug>] - Path of the site to be set as mature. Subdomain on subdomain installs, directory on subdirectory installs. + [--post_excerpt=<post_excerpt>] + The post excerpt. Default empty. -**EXAMPLES** + [--post_status=<post_status>] + The post status. Default 'draft'. - $ wp site mature 123 - Success: Site 123 marked as mature. + [--post_type=<post_type>] + The post type. Default 'post'. - $ wp site mature --slug=demo - Success: Site 123 marked as mature. + [--comment_status=<comment_status>] + Whether the post can accept comments. Accepts 'open' or 'closed'. Default is the value of 'default_comment_status' option. + [--ping_status=<ping_status>] + Whether the post can accept pings. Accepts 'open' or 'closed'. Default is the value of 'default_ping_status' option. + [--post_password=<post_password>] + The password to access the post. Default empty. -### wp site meta + [--post_name=<post_name>] + The post name. Default is the sanitized post title when creating a new post. -Adds, updates, deletes, and lists site custom fields. + [--to_ping=<to_ping>] + Space or carriage return-separated list of URLs to ping. Default empty. -~~~ -wp site meta -~~~ + [--pinged=<pinged>] + Space or carriage return-separated list of URLs that have been pinged. Default empty. -**EXAMPLES** + [--post_modified=<post_modified>] + The date when the post was last modified. Default is the current time. - # Set site meta - $ wp site meta set 123 bio "Mary is a WordPress developer." - Success: Updated custom field 'bio'. + [--post_modified_gmt=<post_modified_gmt>] + The date when the post was last modified in the GMT timezone. Default is the current time. - # Get site meta - $ wp site meta get 123 bio - Mary is a WordPress developer. + [--post_parent=<post_parent>] + Set this for the post it belongs to, if any. Default 0. - # Update site meta - $ wp site meta update 123 bio "Mary is an awesome WordPress developer." - Success: Updated custom field 'bio'. + [--menu_order=<menu_order>] + The order the post should be displayed in. Default 0. - # Delete site meta - $ wp site meta delete 123 bio - Success: Deleted custom field. + [--post_mime_type=<post_mime_type>] + The mime type of the post. Default empty. + [--guid=<guid>] + Global Unique ID for referencing the post. Default empty. + [--post_category=<post_category>] + Array of category names, slugs, or IDs. Defaults to value of the 'default_category' option. + [--tags_input=<tags_input>] + Array of tag names, slugs, or IDs. Default empty. + [--tax_input=<tax_input>] + Array of taxonomy terms keyed by their taxonomy name. Default empty. -### wp site meta add + [--meta_input=<meta_input>] + Array in JSON format of post meta values keyed by their post meta key. Default empty. -Add a meta field. + [<file>] + Read post content from <file>. If this value is present, the + `--post_content` argument will be ignored. -~~~ -wp site meta add <id> <key> [<value>] [--format=<format>] -~~~ + Passing `-` as the filename will cause post content to + be read from STDIN. -**OPTIONS** + --<field>=<value> + One or more fields to update. See wp_insert_post(). - <id> - The ID of the object. + [--defer-term-counting] + Recalculate term count in batch, for a performance boost. - <key> - The name of the meta field to create. +**EXAMPLES** - [<value>] - The value of the meta field. If omitted, the value is read from STDIN. + $ wp post update 123 --post_name=something --post_status=draft + Success: Updated post 123. - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + # Update a post with multiple meta values. + $ wp post update 123 --meta_input='{"key1":"value1","key2":"value2"} + Success: Updated post 123. -### wp site meta delete +### wp post-type -Delete a meta field. +Retrieves details on the site's registered post types. ~~~ -wp site meta delete <id> [<key>] [<value>] [--all] +wp post-type ~~~ -**OPTIONS** - - <id> - The ID of the object. +Get information on WordPress' built-in and the site's [custom post types](https://developer.wordpress.org/plugins/post-types/). - [<key>] - The name of the meta field to delete. +**EXAMPLES** - [<value>] - The value to delete. If omitted, all rows with key will deleted. + # Get details about a post type + $ wp post-type get page --fields=name,label,hierarchical --format=json + {"name":"page","label":"Pages","hierarchical":true} - [--all] - Delete all meta for the object. + # List post types with 'post' capability type + $ wp post-type list --capability_type=post --fields=name,public + +---------------+--------+ + | name | public | + +---------------+--------+ + | post | 1 | + | attachment | 1 | + | revision | | + | nav_menu_item | | + +---------------+--------+ -### wp site meta get +### wp post-type get -Get meta field value. +Gets details about a registered post type. ~~~ -wp site meta get <id> <key> [--single] [--format=<format>] +wp post-type get <post-type> [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** - <id> - The ID of the object. + <post-type> + Post type slug - <key> - The name of the meta field to get. + [--field=<field>] + Instead of returning the whole taxonomy, returns the value of a single field. - [--single] - Whether to return a single value. + [--fields=<fields>] + Limit the output to specific fields. Defaults to all fields. [--format=<format>] - Get value in a particular format. + Render output in a particular format. --- - default: var_export + default: table options: - - var_export + - table + - csv - json - yaml --- +**EXAMPLES** + + # Get details about the 'page' post type. + $ wp post-type get page --fields=name,label,hierarchical --format=json + {"name":"page","label":"Pages","hierarchical":true} -### wp site meta list -List all metadata associated with an object. +### wp post-type list + +Lists registered post types. ~~~ -wp site meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] [--unserialize] +wp post-type list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** - <id> - ID for the object. + [--<field>=<value>] + Filter by one or more fields (see get_post_types() first parameter for a list of available fields). - [--keys=<keys>] - Limit output to metadata of specific keys. + [--field=<field>] + Prints the value of a single field for each post type. [--fields=<fields>] - Limit the output to specific row fields. Defaults to id,meta_key,meta_value. + Limit the output to specific post type fields. [--format=<format>] Render output in a particular format. @@ -5582,410 +2963,357 @@ wp site meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [ - table - csv - json - - yaml - count + - yaml --- - [--orderby=<fields>] - Set orderby which field. - --- - default: id - options: - - id - - meta_key - - meta_value - --- +**AVAILABLE FIELDS** - [--order=<order>] - Set ascending or descending order. - --- - default: asc - options: - - asc - - desc - --- +These fields will be displayed by default for each term: - [--unserialize] - Unserialize meta_value output. +* name +* label +* description +* hierarchical +* public +* capability_type +There are no optionally available fields. +**EXAMPLES** -### wp site meta patch + # List registered post types + $ wp post-type list --format=csv + name,label,description,hierarchical,public,capability_type + post,Posts,,,1,post + page,Pages,,1,1,page + attachment,Media,,,1,post + revision,Revisions,,,,post + nav_menu_item,"Navigation Menu Items",,,,post -Update a nested value for a meta field. + # List post types with 'post' capability type + $ wp post-type list --capability_type=post --fields=name,public + +---------------+--------+ + | name | public | + +---------------+--------+ + | post | 1 | + | attachment | 1 | + | revision | | + | nav_menu_item | | + +---------------+--------+ -~~~ -wp site meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] -~~~ -**OPTIONS** - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- +### wp site - <id> - The ID of the object. +Creates, deletes, empties, moderates, and lists one or more sites on a multisite installation. - <key> - The name of the meta field to update. +~~~ +wp site +~~~ - <key-path>... - The name(s) of the keys within the value to locate the value to patch. +**EXAMPLES** - [<value>] - The new value. If omitted, the value is read from STDIN. + # Create site + $ wp site create --slug=example + Success: Site 3 created: www.example.com/example/ - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + # Output a simple list of site URLs + $ wp site list --field=url + http://www.example.com/ + http://www.example.com/subdir/ + + # Delete site + $ wp site delete 123 + Are you sure you want to delete the 'http://www.example.com/example' site? [y/n] y + Success: The site at 'http://www.example.com/example' was deleted. -### wp site meta pluck +### wp site activate -Get a nested value from a meta field. +Activates one or more sites. ~~~ -wp site meta pluck <id> <key> <key-path>... [--format=<format>] +wp site activate <id>... ~~~ **OPTIONS** - <id> - The ID of the object. - - <key> - The name of the meta field to get. + <id>... + One or more IDs of sites to activate. - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. +**EXAMPLES** - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml + $ wp site activate 123 + Success: Site 123 activated. -### wp site meta update +### wp site archive -Update a meta field. +Archives one or more sites. ~~~ -wp site meta update <id> <key> [<value>] [--format=<format>] +wp site archive <id>... ~~~ -**Alias:** `set` - **OPTIONS** - <id> - The ID of the object. - - <key> - The name of the meta field to update. + <id>... + One or more IDs of sites to archive. - [<value>] - The new value. If omitted, the value is read from STDIN. +**EXAMPLES** - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + $ wp site archive 123 + Success: Site 123 archived. -### wp site option +### wp site create -Adds, updates, deletes, and lists site options in a multisite installation. +Creates a site in a multisite installation. ~~~ -wp site option +wp site create --slug=<slug> [--title=<title>] [--email=<email>] [--network_id=<network-id>] [--private] [--porcelain] ~~~ -**EXAMPLES** - - # Get site registration - $ wp site option get registration - none - - # Add site option - $ wp site option add my_option foobar - Success: Added 'my_option' site option. - - # Update site option - $ wp site option update my_option '{"foo": "bar"}' --format=json - Success: Updated 'my_option' site option. +**OPTIONS** - # Delete site option - $ wp site option delete my_option - Success: Deleted 'my_option' site option. + --slug=<slug> + Path for the new site. Subdomain on subdomain installs, directory on subdirectory installs. + [--title=<title>] + Title of the new site. Default: prettified slug. + [--email=<email>] + Email for Admin user. User will be created if none exists. Assignement to Super Admin if not included. + [--network_id=<network-id>] + Network to associate new site with. Defaults to current network (typically 1). + [--private] + If set, the new site will be non-public (not indexed) -### wp site option add + [--porcelain] + If set, only the site id will be output on success. -Adds a site option. +**EXAMPLES** -~~~ -wp site option add <key> [<value>] [--format=<format>] -~~~ + $ wp site create --slug=example + Success: Site 3 created: http://www.example.com/example/ -**OPTIONS** - <key> - The name of the site option to add. - [<value>] - The value of the site option to add. If omitted, the value is read from STDIN. +### wp site deactivate - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- +Deactivates one or more sites. + +~~~ +wp site deactivate <id>... +~~~ + +**OPTIONS** + + <id>... + One or more IDs of sites to deactivate. **EXAMPLES** - # Create a site option by reading a JSON file - $ wp site option add my_option --format=json < config.json - Success: Added 'my_option' site option. + $ wp site deactivate 123 + Success: Site 123 deactivated. -### wp site option delete +### wp site delete -Deletes a site option. +Deletes a site in a multisite installation. ~~~ -wp site option delete <key> +wp site delete [<site-id>] [--slug=<slug>] [--yes] [--keep-tables] ~~~ **OPTIONS** - <key> - Key for the site option. + [<site-id>] + The id of the site to delete. If not provided, you must set the --slug parameter. + + [--slug=<slug>] + Path of the blog to be deleted. Subdomain on subdomain installs, directory on subdirectory installs. + + [--yes] + Answer yes to the confirmation message. + + [--keep-tables] + Delete the blog from the list, but don't drop it's tables. **EXAMPLES** - $ wp site option delete my_option - Success: Deleted 'my_option' site option. + $ wp site delete 123 + Are you sure you want to delete the http://www.example.com/example site? [y/n] y + Success: The site at 'http://www.example.com/example' was deleted. -### wp site option get +### wp site empty -Gets a site option. +Empties a site of its content (posts, comments, terms, and meta). ~~~ -wp site option get <key> [--format=<format>] +wp site empty [--uploads] [--yes] ~~~ +Truncates posts, comments, and terms tables to empty a site of its +content. Doesn't affect site configuration (options) or users. + +If running a persistent object cache, make sure to flush the cache +after emptying the site, as the cache values will be invalid otherwise. + +To also empty custom database tables, you'll need to hook into command +execution: + +``` +WP_CLI::add_hook( 'after_invoke:site empty', function(){ + global $wpdb; + foreach( array( 'p2p', 'p2pmeta' ) as $table ) { + $table = $wpdb->$table; + $wpdb->query( "TRUNCATE $table" ); + } +}); +``` + **OPTIONS** - <key> - Key for the site option. + [--uploads] + Also delete *all* files in the site's uploads directory. - [--format=<format>] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- + [--yes] + Proceed to empty the site without a confirmation prompt. **EXAMPLES** - # Get site upload filetypes - $ wp site option get upload_filetypes - jpg jpeg png gif mov avi mpg + $ wp site empty + Are you sure you want to empty the site at http://www.example.com of all posts, comments, and terms? [y/n] y + Success: The site at 'http://www.example.com' was emptied. -### wp site option list +### wp site list -Lists site options. +Lists all sites in a multisite installation. ~~~ -wp site option list [--search=<pattern>] [--site_id=<id>] [--field=<field>] [--fields=<fields>] [--format=<format>] +wp site list [--network=<id>] [--<field>=<value>] [--site__in=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ **OPTIONS** - [--search=<pattern>] - Use wildcards ( * and ? ) to match option name. + [--network=<id>] + The network to which the sites belong. + + [--<field>=<value>] + Filter by one or more fields (see "Available Fields" section). However, + 'url' isn't an available filter, because it's created from domain + path. - [--site_id=<id>] - Limit options to those of a particular site id. + [--site__in=<value>] + Only list the sites with these blog_id values (comma-separated). [--field=<field>] - Prints the value of a single field. + Prints the value of a single field for each site. [--fields=<fields>] - Limit the output to specific object fields. + Comma-separated list of fields to show. [--format=<format>] - The serialization format for the value. total_bytes displays the total size of matching options in bytes. + Render output in a particular format. --- default: table options: - table - - json - csv - count + - ids + - json - yaml - - total_bytes --- **AVAILABLE FIELDS** -This field will be displayed by default for each matching option: +These fields will be displayed by default for each site: -* meta_key -* meta_value +* blog_id +* url +* last_updated +* registered These fields are optionally available: -* meta_id * site_id -* size_bytes +* domain +* path +* public +* archived +* mature +* spam +* deleted +* lang_id **EXAMPLES** - # List all site options beginning with "i2f_" - $ wp site option list --search="i2f_*" - +-------------+--------------+ - | meta_key | meta_value | - +-------------+--------------+ - | i2f_version | 0.1.0 | - +-------------+--------------+ - - - -### wp site option patch - -Updates a nested value in an option. - -~~~ -wp site option patch <action> <key> <key-path>... [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- - - <key> - The option name. - - <key-path>... - The name(s) of the keys within the value to locate the value to patch. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + # Output a simple list of site URLs + $ wp site list --field=url + http://www.example.com/ + http://www.example.com/subdir/ -### wp site option pluck +### wp site mature -Gets a nested value from an option. +Sets one or more sites as mature. ~~~ -wp site option pluck <key> <key-path>... [--format=<format>] +wp site mature <id>... ~~~ **OPTIONS** - <key> - The option name. + <id>... + One or more IDs of sites to set as mature. - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. +**EXAMPLES** - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml + $ wp site mature 123 + Success: Site 123 marked as mature. -### wp site option update +### wp site option -Updates a site option. +Adds, updates, deletes, and lists site options in a multisite install. ~~~ -wp site option update <key> [<value>] [--format=<format>] +wp site option ~~~ -**Alias:** `set` +**EXAMPLES** -**OPTIONS** + # Get site registration + $ wp site option get registration + none - <key> - The name of the site option to update. + # Add site option + $ wp site option add my_option foobar + Success: Added 'my_option' site option. - [<value>] - The new value. If omitted, the value is read from STDIN. + # Update site option + $ wp site option update my_option '{"foo": "bar"}' --format=json + Success: Updated 'my_option' site option. - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + # Delete site option + $ wp site option delete my_option + Success: Deleted 'my_option' site option. -**EXAMPLES** - # Update a site option by reading from a file - $ wp site option update my_option < value.txt - Success: Updated 'my_option' site option. @@ -5994,25 +3322,19 @@ wp site option update <key> [<value>] [--format=<format>] Sets one or more sites as private. ~~~ -wp site private [<id>...] [--slug=<slug>] +wp site private <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to set as private. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be set as private. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to set as private. **EXAMPLES** $ wp site private 123 Success: Site 123 marked as private. - $ wp site private --slug=demo - Success: Site 123 marked as private. - ### wp site public @@ -6020,25 +3342,19 @@ wp site private [<id>...] [--slug=<slug>] Sets one or more sites as public. ~~~ -wp site public [<id>...] [--slug=<slug>] +wp site public <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to set as public. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be set as public. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to set as public. **EXAMPLES** $ wp site public 123 Success: Site 123 marked as public. - $ wp site public --slug=demo - Success: Site 123 marked as public. - ### wp site spam @@ -6046,16 +3362,13 @@ wp site public [<id>...] [--slug=<slug>] Marks one or more sites as spam. ~~~ -wp site spam [<id>...] [--slug=<slug>] +wp site spam <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to be marked as spam. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be marked as spam. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to be marked as spam. **EXAMPLES** @@ -6069,25 +3382,19 @@ wp site spam [<id>...] [--slug=<slug>] Unarchives one or more sites. ~~~ -wp site unarchive [<id>...] [--slug=<slug>] +wp site unarchive <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to unarchive. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to unarchive. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to unarchive. **EXAMPLES** $ wp site unarchive 123 Success: Site 123 unarchived. - $ wp site unarchive --slug=demo - Success: Site 123 unarchived. - ### wp site unmature @@ -6095,23 +3402,17 @@ wp site unarchive [<id>...] [--slug=<slug>] Sets one or more sites as immature. ~~~ -wp site unmature [<id>...] [--slug=<slug>] +wp site unmature <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to set as unmature. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be set as unmature. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to set as unmature. **EXAMPLES** - $ wp site unmature 123 - Success: Site 123 marked as unmature. - - $ wp site unmature --slug=demo + $ wp site general 123 Success: Site 123 marked as unmature. @@ -6121,16 +3422,13 @@ wp site unmature [<id>...] [--slug=<slug>] Removes one or more sites from spam. ~~~ -wp site unspam [<id>...] [--slug=<slug>] +wp site unspam <id>... ~~~ **OPTIONS** - [<id>...] - One or more IDs of sites to remove from spam. If not provided, you must set the --slug parameter. - - [--slug=<slug>] - Path of the site to be removed from spam. Subdomain on subdomain installs, directory on subdirectory installs. + <id>... + One or more IDs of sites to remove from spam. **EXAMPLES** @@ -6197,24 +3495,6 @@ wp taxonomy get <taxonomy> [--field=<field>] [--fields=<fields>] [--format=<form - yaml --- -**AVAILABLE FIELDS** - -These fields will be displayed by default for the specified taxonomy: - -* name -* label -* description -* object_type -* show_tagcloud -* hierarchical -* public -* labels -* cap - -These fields are optionally available: - -* count - **EXAMPLES** # Get details of `category` taxonomy. @@ -6271,14 +3551,10 @@ These fields will be displayed by default for each term: * name * label * description -* object_type -* show_tagcloud -* hierarchical * public +* hierarchical -These fields are optionally available: - -* count +There are no optionally available fields. **EXAMPLES** @@ -6311,7 +3587,7 @@ Manages taxonomy terms and term meta, with create, delete, and list commands. wp term ~~~ -See reference for [taxonomies and their terms](https://wordpress.org/documentation/article/taxonomies). +See reference for [taxonomies and their terms](https://codex.wordpress.org/Taxonomies). **EXAMPLES** @@ -6340,11 +3616,6 @@ See reference for [taxonomies and their terms](https://wordpress.org/documentati Success: Updated category term count Success: Updated post_tag term count - # Prune terms with 0 or 1 published posts - $ wp term prune post_tag - Deleted post_tag 15. - Success: Pruned 1 of 5 terms. - ### wp term create @@ -6584,7 +3855,6 @@ These fields will be displayed by default for each term: These fields are optionally available: -* term_group * url **EXAMPLES** @@ -6699,7 +3969,7 @@ wp term meta delete <id> [<key>] [<value>] [--all] Get meta field value. ~~~ -wp term meta get <id> <key> [--single] [--format=<format>] +wp term meta get <id> <key> [--format=<format>] ~~~ **OPTIONS** @@ -6710,18 +3980,8 @@ wp term meta get <id> <key> [--single] [--format=<format>] <key> The name of the meta field to get. - [--single] - Whether to return a single value. - [--format=<format>] - Get value in a particular format. - --- - default: var_export - options: - - var_export - - json - - yaml - --- + Accepted values: table, json. Default: table @@ -6730,7 +3990,7 @@ wp term meta get <id> <key> [--single] [--format=<format>] List all metadata associated with an object. ~~~ -wp term meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] [--unserialize] +wp term meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] ~~~ **OPTIONS** @@ -6744,18 +4004,9 @@ wp term meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [ [--fields=<fields>] Limit the output to specific row fields. Defaults to id,meta_key,meta_value. - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - --- - + [--format=<format>] + Accepted values: table, csv, json, count. Default: table + [--orderby=<fields>] Set orderby which field. --- @@ -6775,9 +4026,6 @@ wp term meta list <id> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [ - desc --- - [--unserialize] - Unserialize meta_value output. - ### wp term meta patch @@ -6860,8 +4108,6 @@ Update a meta field. wp term meta update <id> <key> [<value>] [--format=<format>] ~~~ -**Alias:** `set` - **OPTIONS** <id> @@ -6884,45 +4130,6 @@ wp term meta update <id> <key> [<value>] [--format=<format>] -### wp term migrate - -Migrate a term of a taxonomy to another taxonomy. - -~~~ -wp term migrate <term> [--by=<field>] [--from=<taxonomy>] [--to=<taxonomy>] -~~~ - -**OPTIONS** - - <term> - Slug or ID of the term to migrate. - - [--by=<field>] - Explicitly handle the term value as a slug or id. - --- - default: id - options: - - slug - - id - --- - - [--from=<taxonomy>] - Taxonomy slug of the term to migrate. - - [--to=<taxonomy>] - Taxonomy slug to migrate to. - -**EXAMPLES** - - # Migrate a category's term (video) to tag taxonomy. - $ wp term migrate 9190 --from=category --to=post_tag - Term 'video' assigned to post 1155. - Term 'video' migrated. - Old instance of term 'video' removed from its original taxonomy. - Success: Migrated the term 'video' from taxonomy 'category' to taxonomy 'post_tag' for 1 post. - - - ### wp term recount Recalculates number of posts assigned to each term. @@ -6960,47 +4167,6 @@ to bring the count back to the correct value. -### wp term prune - -Removes terms with 0 or 1 published posts from one or more taxonomies. - -~~~ -wp term prune <taxonomy>... [--dry-run] -~~~ - -Useful for cleaning up large sites with many unused or barely-used terms. -The term count is based on the number of published posts assigned to each -term. - -**OPTIONS** - - <taxonomy>... - One or more taxonomies to prune. - - [--dry-run] - Preview the terms to be pruned, without actually deleting them. - -**EXAMPLES** - - # Prune post tags with 0 or 1 published posts. - $ wp term prune post_tag - Deleted post_tag 15. - Success: Pruned 1 of 5 terms. - - # Dry run to preview which terms would be pruned. - $ wp term prune post_tag --dry-run - Would delete post_tag 15. - Success: 1 post_tag term would be pruned. - - # Prune multiple taxonomies at once. - $ wp term prune category post_tag - Deleted category 8. - Success: Pruned 1 of 3 terms. - Deleted post_tag 15. - Success: Pruned 1 of 5 terms. - - - ### wp term update Updates an existing term. @@ -7058,8 +4224,7 @@ Manages users, along with their roles, capabilities, and meta. wp user ~~~ -See references for [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities) -and [WP User class](https://developer.wordpress.org/reference/classes/wp_user). +See references for [Roles and Capabilities](https://codex.wordpress.org/Roles_and_Capabilities) and [WP User class](https://codex.wordpress.org/Class_Reference/WP_User). **EXAMPLES** @@ -7078,7 +4243,7 @@ and [WP User class](https://developer.wordpress.org/reference/classes/wp_user). # Delete user 123 and reassign posts to user 567 $ wp user delete 123 --reassign=567 - Success: Removed user 123 from http://example.com. + Success: Removed user 123 from http://example.com @@ -7095,400 +4260,41 @@ wp user add-cap <user> <cap> <user> User ID, user email, or user login. - <cap> - The capability to add. - -**EXAMPLES** - - # Add a capability for a user - $ wp user add-cap john create_premium_item - Success: Added 'create_premium_item' capability for john (16). - - # Add a capability for a user - $ wp user add-cap 15 edit_product - Success: Added 'edit_product' capability for johndoe (15). - - - -### wp user add-role - -Adds a role for a user. - -~~~ -wp user add-role <user> [<role>...] -~~~ - -**OPTIONS** - - <user> - User ID, user email, or user login. - - [<role>...] - Add the specified role(s) to the user. - -**EXAMPLES** - - $ wp user add-role 12 author - Success: Added 'author' role for johndoe (12). - - $ wp user add-role 12 author editor - Success: Added 'author', 'editor' roles for johndoe (12). - - - -### wp user application-password - -Creates, updates, deletes, lists and retrieves application passwords. - -~~~ -wp user application-password -~~~ - -**EXAMPLES** - - # List user application passwords and only show app name and password hash - $ wp user application-password list 123 --fields=name,password - +--------+------------------------------------+ - | name | password | - +--------+------------------------------------+ - | myapp | $P$BVGeou1CUot114YohIemgpwxQCzb8O/ | - +--------+------------------------------------+ - - # Get a specific application password and only show app name and created timestamp - $ wp user application-password get 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --fields=name,created - +--------+------------+ - | name | created | - +--------+------------+ - | myapp | 1638395611 | - +--------+------------+ - - # Create user application password - $ wp user application-password create 123 myapp - Success: Created application password. - Password: ZG1bxdxdzjTwhsY8vK8l1C65 - - # Only print the password without any chrome - $ wp user application-password create 123 myapp --porcelain - ZG1bxdxdzjTwhsY8vK8l1C65 - - # Update an existing application password - $ wp user application-password update 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --name=newappname - Success: Updated application password. - - # Delete an existing application password - $ wp user application-password delete 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - Success: Deleted 1 of 1 application password. - - # Check if an application password for a given application exists - $ wp user application-password exists 123 myapp - $ echo $? - 1 - - # Bash script for checking whether an application password exists and creating one if not - if ! wp user application-password exists 123 myapp; then - PASSWORD=$(wp user application-password create 123 myapp --porcelain) - fi - - - - - -### wp user application-password create - -Creates a new application password. - -~~~ -wp user application-password create <user> <app-name> [--app-id=<app-id>] [--porcelain] -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to create a new application password for. - - <app-name> - Unique name of the application to create an application password for. - - [--app-id=<app-id>] - Application ID to attribute to the application password. - - [--porcelain] - Output just the new password. - -**EXAMPLES** - - # Create user application password - $ wp user application-password create 123 myapp - Success: Created application password. - Password: ZG1bxdxdzjTwhsY8vK8l1C65 - - # Only print the password without any chrome - $ wp user application-password create 123 myapp --porcelain - ZG1bxdxdzjTwhsY8vK8l1C65 - - # Create user application with a custom application ID for internal tracking - $ wp user application-password create 123 myapp --app-id=42 --porcelain - ZG1bxdxdzjTwhsY8vK8l1C65 - - - -### wp user application-password delete - -Delete an existing application password. - -~~~ -wp user application-password delete <user> [<uuid>...] [--all] -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to delete the application password for. - - [<uuid>...] - Comma-separated list of UUIDs of the application passwords to delete. - - [--all] - Delete all of the user's application password. - -**EXAMPLES** - - # Delete an existing application password - $ wp user application-password delete 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - Success: Deleted 1 of 1 application password. - - # Delete all of the user's application passwords - $ wp user application-password delete 123 --all - Success: Deleted all application passwords. - - - -### wp user application-password exists - -Checks whether an application password for a given application exists. - -~~~ -wp user application-password exists <user> <app-name> -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to check the existence of an application password for. - - <app-name> - Name of the application to check the existence of an application password for. - -**EXAMPLES** - - # Check if an application password for a given application exists - $ wp user application-password exists 123 myapp - $ echo $? - 1 - - # Bash script for checking whether an application password exists and creating one if not - if ! wp user application-password exists 123 myapp; then - PASSWORD=$(wp user application-password create 123 myapp --porcelain) - fi - - - -### wp user application-password get - -Gets a specific application password. - -~~~ -wp user application-password get <user> <uuid> [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to get the application password for. - - <uuid> - The universally unique ID of the application password. - - [--field=<field>] - Prints the value of a single field for the application password. - - [--fields=<fields>] - Limit the output to specific fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - --- - -**EXAMPLES** - - # Get a specific application password and only show app name and created timestamp - $ wp user application-password get 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --fields=name,created - +--------+------------+ - | name | created | - +--------+------------+ - | myapp | 1638395611 | - +--------+------------+ - - - -### wp user application-password list - -Lists all application passwords associated with a user. - -~~~ -wp user application-password list <user> [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to get application passwords for. - - [--<field>=<value>] - Filter the list by a specific field. - - [--field=<field>] - Prints the value of a single field for each application password. - - [--fields=<fields>] - Limit the output to specific fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - count - - yaml - - ids - --- - - [--orderby=<fields>] - Set orderby which field. - --- - default: created - options: - - uuid - - app_id - - name - - password - - created - - last_used - - last_ip - --- - - [--order=<order>] - Set ascending or descending order. - --- - default: desc - options: - - asc - - desc - --- - -**EXAMPLES** - - # List user application passwords and only show app name and password hash - $ wp user application-password list 123 --fields=name,password - +--------+------------------------------------+ - | name | password | - +--------+------------------------------------+ - | myapp | $P$BVGeou1CUot114YohIemgpwxQCzb8O/ | - +--------+------------------------------------+ - - - -### wp user application-password record-usage - -Record usage of an application password. - -~~~ -wp user application-password record-usage <user> <uuid> -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to update the application password for. - - <uuid> - The universally unique ID of the application password. - -**EXAMPLES** - - # Record usage of an application password - $ wp user application-password record-usage 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - Success: Recorded application password usage. - - - -### wp user application-password update - -Updates an existing application password. - -~~~ -wp user application-password update <user> <uuid> [--<field>=<value>] -~~~ - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to update the application password for. - - <uuid> - The universally unique ID of the application password. - - [--<field>=<value>] - Update the <field> with a new <value>. Currently supported fields: name. + <cap> + The capability to add. **EXAMPLES** - # Update an existing application password - $ wp user application-password update 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --name=newappname - Success: Updated application password. + # Add a capability for a user + $ wp user add-cap john create_premium_item + Success: Added 'create_premium_item' capability for john (16). + + # Add a capability for a user + $ wp user add-cap 15 edit_product + Success: Added 'edit_product' capability for johndoe (15). -### wp user check-password +### wp user add-role -Checks if a user's password is valid or not. +Adds a role for a user. ~~~ -wp user check-password <user> <user_pass> [--escape-chars] +wp user add-role <user> <role> ~~~ **OPTIONS** <user> - The user login, user email or user ID of the user to check credentials for. - - <user_pass> - A string that contains the plain text password for the user. + User ID, user email, or user login. - [--escape-chars] - Escape password with `wp_slash()` to mimic the same behavior as `wp-login.php`. + <role> + Add the specified role to the user. **EXAMPLES** - # Check whether given credentials are valid; exit status 0 if valid, otherwise 1 - $ wp user check-password admin adminpass - $ echo $? - 1 - - # Bash script for checking whether given credentials are valid or not - if ! $(wp user check-password admin adminpass); then - notify-send "Invalid Credentials"; - fi + $ wp user add-role 12 author + Success: Added 'author' role for johndoe (12). @@ -7497,7 +4303,7 @@ wp user check-password <user> <user_pass> [--escape-chars] Creates a new user. ~~~ -wp user create <user-login> <user-email> [--role=<role>] [--user_pass=<password>] [--user_registered=<yyyy-mm-dd-hh-ii-ss>] [--display_name=<name>] [--user_nicename=<nice_name>] [--user_url=<url>] [--nickname=<nickname>] [--first_name=<first_name>] [--last_name=<last_name>] [--description=<description>] [--rich_editing=<rich_editing>] [--send-email] [--porcelain] +wp user create <user-login> <user-email> [--role=<role>] [--user_pass=<password>] [--user_registered=<yyyy-mm-dd-hh-ii-ss>] [--display_name=<name>] [--user_nicename=<nice_name>] [--user_url=<url>] [--user_email=<email>] [--nickname=<nickname>] [--first_name=<first_name>] [--last_name=<last_name>] [--description=<description>] [--rich_editing=<rich_editing>] [--send-email] [--porcelain] ~~~ **OPTIONS** @@ -7527,6 +4333,9 @@ wp user create <user-login> <user-email> [--role=<role>] [--user_pass=<password> [--user_url=<url>] A string containing the user's URL for the user's web site. + [--user_email=<email>] + A string containing the user's email address. + [--nickname=<nickname>] The user's nickname, defaults to the user's username. @@ -7591,45 +4400,12 @@ make sure to reassign their posts prior to deleting the user. # Delete user 123 and reassign posts to user 567 $ wp user delete 123 --reassign=567 - Success: Removed user 123 from http://example.com. + Success: Removed user 123 from http://example.com # Delete all contributors and reassign their posts to user 2 $ wp user delete $(wp user list --role=contributor --field=ID) --reassign=2 - Success: Removed user 813 from http://example.com. - Success: Removed user 578 from http://example.com. - - # Delete all contributors in batches of 100 (avoid error: argument list too long: wp) - $ wp user delete $(wp user list --role=contributor --field=ID | head -n 100) - - - -### wp user exists - -Verifies whether a user exists. - -~~~ -wp user exists <id> -~~~ - -Displays a success message if the user does exist. - -**OPTIONS** - - <id> - The ID of the user to check. - -**EXAMPLES** - - # The user exists. - $ wp user exists 1337 - Success: User with ID 1337 exists. - $ echo $? - 0 - - # The user does not exist. - $ wp user exists 10000 - $ echo $? - 1 + Success: Removed user 813 from http://example.com + Success: Removed user 578 from http://example.com @@ -7740,9 +4516,9 @@ the user is updated unless the `--skip-update` flag is used. # Import users from local CSV file $ wp user import-csv /path/to/users.csv - Success: bobjones created. - Success: newuser1 created. - Success: existinguser created. + Success: bobjones created + Success: newuser1 created + Success: existinguser created # Import users from remote CSV file $ wp user import-csv http://example.com/users.csv @@ -7776,7 +4552,7 @@ Display WordPress users based on all arguments supported by Control output by one or more arguments of WP_User_Query(). [--network] - List all users in the network for multisite. Roles are not included when using this flag, as users can have different roles on different sites in a multisite network. + List all users in the network for multisite. [--field=<field>] Prints the value of a single field for each user. @@ -7848,7 +4624,7 @@ These fields are optionally available: Lists all capabilities for a user. ~~~ -wp user list-caps <user> [--format=<format>] [--origin=<origin>] [--exclude-role-names] +wp user list-caps <user> [--format=<format>] ~~~ **OPTIONS** @@ -7869,19 +4645,6 @@ wp user list-caps <user> [--format=<format>] [--origin=<origin>] [--exclude-role - yaml --- - [--origin=<origin>] - Render output in a particular format. - --- - default: all - options: - - all - - user - - role - --- - - [--exclude-role-names] - Exclude capabilities that match role names from output. - **EXAMPLES** $ wp user list-caps 21 @@ -7946,13 +4709,7 @@ wp user meta add <user> <key> <value> [--format=<format>] The new metadata value. [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- + The serialization format for the value. Default is plaintext. **EXAMPLES** @@ -8022,10 +4779,6 @@ wp user meta get <user> <key> [--format=<format>] $ wp user meta get 123 bio Mary is an WordPress developer. - # Get the primary site of a user (for multisite) - $ wp user meta get 2 primary_blog - 3 - ### wp user meta list @@ -8033,7 +4786,7 @@ wp user meta get <user> <key> [--format=<format>] Lists all metadata associated with a user. ~~~ -wp user meta list <user> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] [--unserialize] +wp user meta list <user> [--keys=<keys>] [--fields=<fields>] [--format=<format>] [--orderby=<fields>] [--order=<order>] ~~~ **OPTIONS** @@ -8078,415 +4831,119 @@ wp user meta list <user> [--keys=<keys>] [--fields=<fields>] [--format=<format>] - desc --- - [--unserialize] - Unserialize meta_value output. - **EXAMPLES** # List user meta $ wp user meta list 123 --keys=nickname,description,wp_capabilities +---------+-----------------+--------------------------------+ - | user_id | meta_key | meta_value | - +---------+-----------------+--------------------------------+ - | 123 | nickname | supervisor | - | 123 | description | Mary is a WordPress developer. | - | 123 | wp_capabilities | {"administrator":true} | - +---------+-----------------+--------------------------------+ - - - -### wp user meta patch - -Update a nested value for a meta field. - -~~~ -wp user meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] -~~~ - -**OPTIONS** - - <action> - Patch action to perform. - --- - options: - - insert - - update - - delete - --- - - <id> - The ID of the object. - - <key> - The name of the meta field to update. - - <key-path>... - The name(s) of the keys within the value to locate the value to patch. - - [<value>] - The new value. If omitted, the value is read from STDIN. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - - - -### wp user meta pluck - -Get a nested value from a meta field. - -~~~ -wp user meta pluck <id> <key> <key-path>... [--format=<format>] -~~~ - -**OPTIONS** - - <id> - The ID of the object. - - <key> - The name of the meta field to get. - - <key-path>... - The name(s) of the keys within the value to locate the value to pluck. - - [--format=<format>] - The output format of the value. - --- - default: plaintext - options: - - plaintext - - json - - yaml - - - -### wp user meta update - -Updates a meta field. - -~~~ -wp user meta update <user> <key> <value> [--format=<format>] -~~~ - -**Alias:** `set` - -**OPTIONS** - - <user> - The user login, user email, or user ID of the user to update metadata for. - - <key> - The metadata key. - - <value> - The new metadata value. - - [--format=<format>] - The serialization format for the value. - --- - default: plaintext - options: - - plaintext - - json - --- - -**EXAMPLES** - - # Update user meta - $ wp user meta update 123 bio "Mary is an awesome WordPress developer." - Success: Updated custom field 'bio'. - - - -### wp user privacy-request - -Manages user privacy requests (GDPR personal data export and erasure). - -~~~ -wp user privacy-request -~~~ - -**EXAMPLES** - - # List all privacy requests. - $ wp user privacy-request list - +----+-------------------+----------------------+-------------------+--------------------+ - | ID | user_email | action_name | status | created_timestamp | - +----+-------------------+----------------------+-------------------+--------------------+ - | 1 | bob@example.com | export_personal_data | request-pending | 1713779524 | - +----+-------------------+----------------------+-------------------+--------------------+ - - # Create a new data export request. - $ wp user privacy-request create bob@example.com export_personal_data - Success: Created privacy request 1. - - # Erase personal data for request 1. - $ wp user privacy-request erase 1 - Success: Erased personal data for request 1. - - # Export personal data for request 1. - $ wp user privacy-request export 1 - Success: Exported personal data to: /var/www/html/wp-content/uploads/wp-personal-data-exports/wp-personal-data-export-bob-example-com-1.zip - - # Mark request 1 as complete. - $ wp user privacy-request complete 1 - Success: Completed 1 of 1 privacy requests. - - # Delete request 1. - $ wp user privacy-request delete 1 - Success: Deleted 1 of 1 privacy requests. - - - - - -### wp user privacy-request complete - -Marks one or more privacy requests as completed. - -~~~ -wp user privacy-request complete <request-id>... -~~~ - -**OPTIONS** - - <request-id>... - One or more IDs of the privacy requests to complete. - -**EXAMPLES** - - # Mark request 1 as completed. - $ wp user privacy-request complete 1 - Privacy request 1 completed. - Success: Completed 1 of 1 privacy requests. - - # Mark multiple requests as completed. - $ wp user privacy-request complete 1 2 - Privacy request 1 completed. - Privacy request 2 completed. - Success: Completed 2 of 2 privacy requests. - - - -### wp user privacy-request create - -Creates a privacy request for a user. - -~~~ -wp user privacy-request create <email> <action-type> [--status=<status>] [--send-email] [--porcelain] -~~~ - -**OPTIONS** - - <email> - The email address of the user to create the request for. - - <action-type> - The type of personal data request. - --- - options: - - export_personal_data - - remove_personal_data - --- - - [--status=<status>] - The initial status of the request. - --- - default: pending - options: - - pending - - confirmed - --- - - [--send-email] - If set, sends a confirmation email to the user. - - [--porcelain] - Output just the new request ID. - -**EXAMPLES** - - # Create a new data export request with pending status. - $ wp user privacy-request create bob@example.com export_personal_data - Success: Created privacy request 1. - - # Create a confirmed data erasure request. - $ wp user privacy-request create bob@example.com remove_personal_data --status=confirmed - Success: Created privacy request 2. - - # Get just the new request ID. - $ wp user privacy-request create bob@example.com export_personal_data --porcelain - 3 - - - -### wp user privacy-request delete - -Deletes one or more privacy requests. - -~~~ -wp user privacy-request delete <request-id>... -~~~ - -**OPTIONS** - - <request-id>... - One or more IDs of the privacy requests to delete. - -**EXAMPLES** - - # Delete privacy request 1. - $ wp user privacy-request delete 1 - Privacy request 1 deleted. - Success: Deleted 1 of 1 privacy requests. - - # Delete multiple privacy requests. - $ wp user privacy-request delete 1 2 3 - Privacy request 1 deleted. - Privacy request 2 deleted. - Privacy request 3 deleted. - Success: Deleted 3 of 3 privacy requests. - - - -### wp user privacy-request erase - -Erases personal data for a given privacy request. - -~~~ -wp user privacy-request erase <request-id> -~~~ - -Runs all registered data erasers for the email address associated with the -request, then marks the request as completed. - -**OPTIONS** - - <request-id> - The ID of the remove_personal_data privacy request to process. - -**EXAMPLES** - - # Erase personal data for request 1. - $ wp user privacy-request erase 1 - Success: Erased personal data for request 1. + | user_id | meta_key | meta_value | + +---------+-----------------+--------------------------------+ + | 123 | nickname | supervisor | + | 123 | description | Mary is a WordPress developer. | + | 123 | wp_capabilities | {"administrator":true} | + +---------+-----------------+--------------------------------+ -### wp user privacy-request export +### wp user meta patch -Exports personal data for a given privacy request. +Update a nested value for a meta field. ~~~ -wp user privacy-request export <request-id> +wp user meta patch <action> <id> <key> <key-path>... [<value>] [--format=<format>] ~~~ -Runs all registered data exporters for the email address associated with -the request, generates a ZIP file containing the data, then marks the -request as completed. - **OPTIONS** - <request-id> - The ID of the export_personal_data privacy request to process. + <action> + Patch action to perform. + --- + options: + - insert + - update + - delete + --- + + <id> + The ID of the object. -**EXAMPLES** + <key> + The name of the meta field to update. - # Export personal data for request 1. - $ wp user privacy-request export 1 - Success: Exported personal data to: /var/www/html/wp-content/uploads/wp-personal-data-exports/wp-personal-data-export-bob-example-com-1.zip + <key-path>... + The name(s) of the keys within the value to locate the value to patch. + + [<value>] + The new value. If omitted, the value is read from STDIN. + + [--format=<format>] + The serialization format for the value. + --- + default: plaintext + options: + - plaintext + - json + --- -### wp user privacy-request list +### wp user meta pluck -Lists privacy requests. +Get a nested value from a meta field. ~~~ -wp user privacy-request list [--action-type=<action-type>] [--status=<status>] [--field=<field>] [--fields=<fields>] [--format=<format>] +wp user meta pluck <id> <key> <key-path>... [--format=<format>] ~~~ **OPTIONS** - [--action-type=<action-type>] - Filter the list by action type. - --- - options: - - export_personal_data - - remove_personal_data - --- - - [--status=<status>] - Filter the list by request status. - --- - options: - - request-pending - - request-confirmed - - request-failed - - request-completed - --- + <id> + The ID of the object. - [--field=<field>] - Prints the value of a single field for each request. + <key> + The name of the meta field to get. - [--fields=<fields>] - Limit the output to specific object fields. + <key-path>... + The name(s) of the keys within the value to locate the value to pluck. [--format=<format>] - Render output in a particular format. + The output format of the value. --- - default: table + default: plaintext options: - - table - - csv - - ids + - plaintext - json - - count - yaml - --- -**AVAILABLE FIELDS** -These fields will be displayed by default for each request: -* ID -* user_email -* action_name -* status -* created_timestamp +### wp user meta update -These fields are optionally available: +Updates a meta field. -* user_id -* confirmed_timestamp -* completed_timestamp +~~~ +wp user meta update <user> <key> <value> [--format=<format>] +~~~ -**EXAMPLES** +**OPTIONS** + + <user> + The user login, user email, or user ID of the user to update metadata for. + + <key> + The metadata key. - # List all privacy requests. - $ wp user privacy-request list - +----+-------------------+----------------------+-------------------+--------------------+ - | ID | user_email | action_name | status | created_timestamp | - +----+-------------------+----------------------+-------------------+--------------------+ - | 1 | bob@example.com | export_personal_data | request-pending | 1713779524 | - +----+-------------------+----------------------+-------------------+--------------------+ + <value> + The new metadata value. - # List only export requests. - $ wp user privacy-request list --action-type=export_personal_data + [--format=<format>] + The serialization format for the value. Default is plaintext. - # List only completed requests. - $ wp user privacy-request list --status=request-completed +**EXAMPLES** - # List request IDs only. - $ wp user privacy-request list --format=ids - 1 2 + # Update user meta + $ wp user meta update 123 bio "Mary is an awesome WordPress developer." + Success: Updated custom field 'bio'. @@ -8495,7 +4952,7 @@ These fields are optionally available: Removes a user's capability. ~~~ -wp user remove-cap <user> <cap> [--force] +wp user remove-cap <user> <cap> ~~~ **OPTIONS** @@ -8506,9 +4963,6 @@ wp user remove-cap <user> <cap> [--force] <cap> The capability to be removed. - [--force] - Forcefully remove a capability. - **EXAMPLES** $ wp user remove-cap 11 publish_newsletters @@ -8520,9 +4974,6 @@ wp user remove-cap <user> <cap> [--force] $ wp user remove-cap 11 nonexistent_cap Error: No such 'nonexistent_cap' cap for supervisor (11). - $ wp user remove-cap 11 publish_newsletters --force - Success: Removed 'publish_newsletters' cap for supervisor (11). - ### wp user remove-role @@ -8530,7 +4981,7 @@ wp user remove-cap <user> <cap> [--force] Removes a user's role. ~~~ -wp user remove-role <user> [<role>...] +wp user remove-role <user> [<role>] ~~~ **OPTIONS** @@ -8538,26 +4989,13 @@ wp user remove-role <user> [<role>...] <user> User ID, user email, or user login. - [<role>...] - Remove the specified role(s) from the user. If not passed, all roles are - removed from the user; on multisite, this removes the user from the current - site/blog. + [<role>] + A specific role to remove. **EXAMPLES** $ wp user remove-role 12 author - Success: Removed 'author' role from johndoe (12). - - $ wp user remove-role 12 author editor - Success: Removed 'author', 'editor' roles from johndoe (12). - - # On single-site: removes all roles from the user - $ wp user remove-role 12 - Success: Removed all roles from johndoe (12) on http://example.com. - - # On multisite: removes the user from the current site/blog - $ wp user remove-role 12 - Success: Removed johndoe (12) from http://example.com. + Success: Removed 'author' role for johndoe (12). @@ -8566,7 +5004,7 @@ wp user remove-role <user> [<role>...] Resets the password for one or more users. ~~~ -wp user reset-password <user>... [--skip-email] [--show-password] [--porcelain] +wp user reset-password <user>... [--skip-email] ~~~ **OPTIONS** @@ -8577,41 +5015,13 @@ wp user reset-password <user>... [--skip-email] [--show-password] [--porcelain] [--skip-email] Don't send an email notification to the affected user(s). - [--show-password] - Show the new password(s). - - [--porcelain] - Output only the new password(s). - **EXAMPLES** # Reset the password for two users and send them the change email. $ wp user reset-password admin editor Reset password for admin. Reset password for editor. - Success: Passwords reset for 2 users. - - # Reset and display the password. - $ wp user reset-password editor --show-password - Reset password for editor. - Password: N6hAau0fXZMN#rLCIirdEGOh - Success: Password reset for 1 user. - - # Reset the password for one user, displaying only the new password, and not sending the change email. - $ wp user reset-password admin --skip-email --porcelain - yV6BP*!d70wg - - # Reset password for all users. - $ wp user reset-password $(wp user list --format=ids) - Reset password for admin. - Reset password for editor. - Reset password for subscriber. - Success: Passwords reset for 3 users. - - # Reset password for all users with a particular role. - $ wp user reset-password $(wp user list --format=ids --role=administrator) - Reset password for admin. - Success: Password reset for 1 user. + Success: Passwords reset. @@ -8759,217 +5169,24 @@ wp user set-role <user> [<role>] -### wp user signup - -Manages signups on a multisite installation. - -~~~ -wp user signup -~~~ - -**EXAMPLES** - - # List signups. - $ wp user signup list - +-----------+------------+---------------------+---------------------+--------+------------------+ - | signup_id | user_login | user_email | registered | active | activation_key | - +-----------+------------+---------------------+---------------------+--------+------------------+ - | 1 | bobuser | bobuser@example.com | 2024-03-13 05:46:53 | 1 | 7320b2f009266618 | - | 2 | johndoe | johndoe@example.com | 2024-03-13 06:24:44 | 0 | 9068d859186cd0b5 | - +-----------+------------+---------------------+---------------------+--------+------------------+ - - # Activate signup. - $ wp user signup activate 2 - Signup 2 activated. Password: bZFSGsfzb9xs - Success: Activated 1 of 1 signups. - - # Delete signup. - $ wp user signup delete 3 - Signup 3 deleted. - Success: Deleted 1 of 1 signups. - - - - - -### wp user signup activate - -Activates one or more signups. - -~~~ -wp user signup activate <signup>... -~~~ - -**OPTIONS** - - <signup>... - The signup ID, user login, user email, or activation key of the signup(s) to activate. - -**EXAMPLES** - - # Activate signup. - $ wp user signup activate 2 - Signup 2 activated. Password: bZFSGsfzb9xs - Success: Activated 1 of 1 signups. - - - -### wp user signup delete - -Deletes one or more signups. - -~~~ -wp user signup delete [<signup>...] [--all] -~~~ - -**OPTIONS** - - [<signup>...] - The signup ID, user login, user email, or activation key of the signup(s) to delete. - - [--all] - If set, all signups will be deleted. - -**EXAMPLES** - - # Delete signup. - $ wp user signup delete 3 - Signup 3 deleted. - Success: Deleted 1 of 1 signups. - - - -### wp user signup get - -Gets details about a signup. - -~~~ -wp user signup get <signup> [--field=<field>] [--fields=<fields>] [--format=<format>] -~~~ - -**OPTIONS** - - <signup> - The signup ID, user login, user email, or activation key. - - [--field=<field>] - Instead of returning the whole signup, returns the value of a single field. - - [--fields=<fields>] - Limit the output to specific fields. Defaults to all fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - --- - -**EXAMPLES** - - # Get signup. - $ wp user signup get 1 --field=user_login - bobuser - - # Get signup and export to JSON file. - $ wp user signup get bobuser --format=json > bobuser.json - - - -### wp user signup list - -Lists signups. - -~~~ -wp user signup list [--<field>=<value>] [--field=<field>] [--fields=<fields>] [--format=<format>] [--per_page=<per_page>] -~~~ - -**OPTIONS** - - [--<field>=<value>] - Filter the list by a specific field. - - [--field=<field>] - Prints the value of a single field for each signup. - - [--fields=<fields>] - Limit the output to specific object fields. - - [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - ids - - json - - count - - yaml - --- - - [--per_page=<per_page>] - Limits the signups to the given number. Defaults to none. - -**AVAILABLE FIELDS** - -These fields will be displayed by default for each signup: - -* signup_id -* user_login -* user_email -* registered -* active -* activation_key - -These fields are optionally available: - -* domain -* path -* title -* activated -* meta - -**EXAMPLES** - - # List signup IDs. - $ wp user signup list --field=signup_id - 1 - - # List all signups. - $ wp user signup list - +-----------+------------+---------------------+---------------------+--------+------------------+ - | signup_id | user_login | user_email | registered | active | activation_key | - +-----------+------------+---------------------+---------------------+--------+------------------+ - | 1 | bobuser | bobuser@example.com | 2024-03-13 05:46:53 | 1 | 7320b2f009266618 | - | 2 | johndoe | johndoe@example.com | 2024-03-13 06:24:44 | 0 | 9068d859186cd0b5 | - +-----------+------------+---------------------+---------------------+--------+------------------+ - - - ### wp user spam -Marks one or more users as spam on multisite. +Marks one or more users as spam. ~~~ -wp user spam <user>... +wp user spam <id>... ~~~ **OPTIONS** - <user>... - The user login, user email, or user ID of the user(s) to mark as spam. + <id>... + One or more IDs of users to mark as spam. **EXAMPLES** - # Mark user as spam. $ wp user spam 123 User 123 marked as spam. - Success: Spammed 1 of 1 users. + Success: Spamed 1 of 1 users. @@ -9001,8 +5218,6 @@ wp user term add <id> <taxonomy> <term>... [--by=<field>] Append the term to the existing set of terms on the object. -**OPTIONS** - <id> The ID of the object. @@ -9015,7 +5230,6 @@ Append the term to the existing set of terms on the object. [--by=<field>] Explicitly handle the term value as a slug or id. --- - default: slug options: - slug - id @@ -9031,8 +5245,6 @@ List all terms associated with an object. wp user term list <id> <taxonomy>... [--field=<field>] [--fields=<fields>] [--format=<format>] ~~~ -**OPTIONS** - <id> ID for the object. @@ -9046,17 +5258,7 @@ wp user term list <id> <taxonomy>... [--field=<field>] [--fields=<fields>] [--fo Limit the output to specific row fields. [--format=<format>] - Render output in a particular format. - --- - default: table - options: - - table - - csv - - json - - yaml - - count - - ids - --- + Accepted values: table, csv, json, count, ids. Default: table **AVAILABLE FIELDS** @@ -9094,12 +5296,11 @@ wp user term remove <id> <taxonomy> [<term>...] [--by=<field>] [--all] The name of the term's taxonomy. [<term>...] - The slug of the term or terms to be removed from the object. + The name of the term or terms to be removed from the object. [--by=<field>] Explicitly handle the term value as a slug or id. --- - default: slug options: - slug - id @@ -9120,8 +5321,6 @@ wp user term set <id> <taxonomy> <term>... [--by=<field>] Replaces existing terms on the object. -**OPTIONS** - <id> The ID of the object. @@ -9134,7 +5333,6 @@ Replaces existing terms on the object. [--by=<field>] Explicitly handle the term value as a slug or id. --- - default: slug options: - slug - id @@ -9144,20 +5342,19 @@ Replaces existing terms on the object. ### wp user unspam -Removes one or more users from spam on multisite. +Removes one or more users from spam. ~~~ -wp user unspam <user>... +wp user unspam <id>... ~~~ **OPTIONS** - <user>... - The user login, user email, or user ID of the user(s) to remove from spam. + <id>... + One or more IDs of users to remove from spam. **EXAMPLES** - # Remove user from spam. $ wp user unspam 123 User 123 removed from spam. Success: Unspamed 1 of 1 users. @@ -9169,7 +5366,7 @@ wp user unspam <user>... Updates an existing user. ~~~ -wp user update <user>... [--user_pass=<password>] [--user_nicename=<nice_name>] [--user_url=<url>] [--user_email=<email>] [--display_name=<display_name>] [--nickname=<nickname>] [--first_name=<first_name>] [--last_name=<last_name>] [--description=<description>] [--rich_editing=<rich_editing>] [--user_registered=<yyyy-mm-dd-hh-ii-ss>] [--role=<role>] --<field>=<value> [--skip-email] +wp user update <user>... [--user_pass=<password>] [--user_nicename=<nice_name>] [--user_url=<url>] [--user_email=<email>] [--display_name=<display_name>] [--nickname=<nickname>] [--first_name=<first_name>] [--last_name=<last_name>] [--description=<description>] [--rich_editing=<rich_editing>] [--user_registered=<yyyy-mm-dd-hh-ii-ss>] [--role=<role>] --<field>=<value> ~~~ **OPTIONS** @@ -9216,9 +5413,6 @@ wp user update <user>... [--user_pass=<password>] [--user_nicename=<nice_name>] --<field>=<value> One or more fields to update. For accepted fields, see wp_update_user(). - [--skip-email] - Don't send an email notification to the user. - **EXAMPLES** # Update user @@ -9255,13 +5449,9 @@ Want to contribute a new feature? Please first [open a new issue](https://github Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally. -### License - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - ## Support -GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support +Github issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support *This README.md is generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). To suggest changes, please submit a pull request against the corresponding part of the codebase.* diff --git a/behat.yml b/behat.yml deleted file mode 100644 index d6ee86224..000000000 --- a/behat.yml +++ /dev/null @@ -1,7 +0,0 @@ -default: - suites: - default: - contexts: - - WP_CLI\Tests\Context\FeatureContext - paths: - - features diff --git a/bin/install-package-tests.sh b/bin/install-package-tests.sh new file mode 100755 index 000000000..d672d9a50 --- /dev/null +++ b/bin/install-package-tests.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -ex + +install_db() { + mysql -e 'CREATE DATABASE IF NOT EXISTS wp_cli_test;' -uroot + mysql -e 'GRANT ALL PRIVILEGES ON wp_cli_test.* TO "wp_cli_test"@"localhost" IDENTIFIED BY "password1"' -uroot + mysql -e 'GRANT ALL PRIVILEGES ON wp_cli_test_scaffold.* TO "wp_cli_test"@"localhost" IDENTIFIED BY "password1"' -uroot +} + +install_db diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 000000000..bd3ae6e91 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -ex + +# Run the unit tests, if they exist +if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ] +then + phpunit +fi + +# Run the functional tests +BEHAT_TAGS=$(php utils/behat-tags.php) +behat --format progress $BEHAT_TAGS --strict diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index b9042b3a0..000000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -ignore: - - "src/Compat/**/*" diff --git a/composer.json b/composer.json index e9367775f..8d47e94c7 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,11 @@ { "name": "wp-cli/entity-command", + "description": "Manage WordPress core entities.", "type": "wp-cli-package", - "description": "Manage WordPress comments, menus, options, posts, sites, terms, and users.", "homepage": "https://github.com/wp-cli/entity-command", + "support": { + "issues": "https://github.com/wp-cli/entity-command/issues" + }, "license": "MIT", "authors": [ { @@ -11,30 +14,24 @@ "homepage": "https://runcommand.io" } ], - "require": { - "wp-cli/wp-cli": "^2.13" + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "": "src/", + "WP_CLI\\": "src/WP_CLI" + }, + "files": [ "entity-command.php" ] }, + "require": {}, "require-dev": { - "wp-cli/cache-command": "^1 || ^2", - "wp-cli/db-command": "^1.3 || ^2", - "wp-cli/extension-command": "^1.2 || ^2", - "wp-cli/media-command": "^1.1 || ^2", - "wp-cli/super-admin-command": "^1 || ^2", - "wp-cli/wp-cli-tests": "^5" - }, - "config": { - "process-timeout": 7200, - "sort-packages": true, - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true, - "johnpbloch/wordpress-core-installer": true, - "phpstan/extension-installer": true - }, - "lock": false + "behat/behat": "~2.5", + "wp-cli/wp-cli": "^1.5", + "phpunit/phpunit": "^4.8" }, "extra": { "branch-alias": { - "dev-main": "2.x-dev" + "dev-master": "1.x-dev" }, "bundled": true, "commands": [ @@ -63,17 +60,6 @@ "comment unspam", "comment untrash", "comment update", - "font", - "font collection", - "font collection get", - "font collection is-registered", - "font collection list", - "font collection list-categories", - "font collection list-families", - "font face", - "font face install", - "font family", - "font family install", "menu", "menu create", "menu delete", @@ -82,7 +68,6 @@ "menu item add-post", "menu item add-term", "menu item delete", - "menu item get", "menu item list", "menu item update", "menu list", @@ -90,7 +75,6 @@ "menu location assign", "menu location list", "menu location remove", - "network", "network meta", "network meta add", "network meta delete", @@ -107,53 +91,27 @@ "option patch", "option pluck", "option update", - "option set-autoload", - "option get-autoload", "post", "post create", "post delete", "post edit", - "post exists", "post generate", "post get", "post list", "post meta", "post meta add", - "post meta clean-duplicates", "post meta delete", "post meta get", "post meta list", "post meta patch", "post meta pluck", "post meta update", - "post revision", - "post revision diff", - "post revision prune", - "post revision restore", "post term", "post term add", "post term list", "post term remove", "post term set", "post update", - "post url-to-id", - "post has-blocks", - "post has-block", - "post block", - "post block clone", - "post block count", - "post block export", - "post block extract", - "post block get", - "post block import", - "post block insert", - "post block list", - "post block move", - "post block parse", - "post block remove", - "post block replace", - "post block render", - "post block update", "post-type", "post-type get", "post-type list", @@ -161,29 +119,12 @@ "site activate", "site archive", "site create", - "site generate", "site deactivate", "site delete", "site empty", - "site get", "site list", "site mature", - "site meta", - "site meta add", - "site meta delete", - "site meta get", - "site meta list", - "site meta patch", - "site meta pluck", - "site meta update", "site option", - "site option add", - "site option delete", - "site option get", - "site option list", - "site option patch", - "site option pluck", - "site option update", "site private", "site public", "site spam", @@ -207,25 +148,13 @@ "term meta patch", "term meta pluck", "term meta update", - "term migrate", "term recount", - "term prune", "term update", "user", "user add-cap", "user add-role", - "user application-password", - "user application-password create", - "user application-password delete", - "user application-password exists", - "user application-password get", - "user application-password list", - "user application-password record-usage", - "user application-password update", - "user check-password", "user create", "user delete", - "user exists", "user generate", "user get", "user import-csv", @@ -239,13 +168,6 @@ "user meta patch", "user meta pluck", "user meta update", - "user privacy-request", - "user privacy-request complete", - "user privacy-request create", - "user privacy-request delete", - "user privacy-request erase", - "user privacy-request export", - "user privacy-request list", "user remove-cap", "user remove-role", "user reset-password", @@ -253,11 +175,6 @@ "user session destroy", "user session list", "user set-role", - "user signup", - "user signup activate", - "user signup delete", - "user signup get", - "user signup list", "user spam", "user term", "user term add", @@ -267,40 +184,5 @@ "user unspam", "user update" ] - }, - "autoload": { - "classmap": [ - "src/" - ], - "exclude-from-classmap": [ - "src/Compat/WP_HTML_Span.php", - "src/Compat/WP_Block_Processor.php", - "src/Compat/polyfills.php" - ], - "files": [ - "entity-command.php" - ] - }, - "minimum-stability": "dev", - "prefer-stable": true, - "scripts": { - "behat": "run-behat-tests", - "behat-rerun": "rerun-behat-tests", - "lint": "run-linter-tests", - "phpcs": "run-phpcs-tests", - "phpstan": "run-phpstan-tests", - "phpunit": "run-php-unit-tests", - "phpcbf": "run-phpcbf-cleanup", - "prepare-tests": "install-package-tests", - "test": [ - "@lint", - "@phpcs", - "@phpstan", - "@phpunit", - "@behat" - ] - }, - "support": { - "issues": "https://github.com/wp-cli/entity-command/issues" } } diff --git a/entity-command.php b/entity-command.php index 52bc0a4cd..95a35c85e 100644 --- a/entity-command.php +++ b/entity-command.php @@ -1,142 +1,60 @@ <?php -use WP_CLI\Utils; - if ( ! class_exists( 'WP_CLI' ) ) { return; } -$wpcli_entity_autoloader = __DIR__ . '/vendor/autoload.php'; -if ( file_exists( $wpcli_entity_autoloader ) ) { - require_once $wpcli_entity_autoloader; +$autoload = dirname( __FILE__ ) . '/vendor/autoload.php'; +if ( file_exists( $autoload ) ) { + require_once $autoload; } -// Load the BlockProcessorLoader class (but don't call load() yet). -// The polyfills will be loaded on-demand by Block_Processor_Helper -// when needed, ensuring WordPress classes take precedence if available. -require_once __DIR__ . '/src/Compat/BlockProcessorLoader.php'; - WP_CLI::add_command( 'comment', 'Comment_Command' ); WP_CLI::add_command( 'comment meta', 'Comment_Meta_Command' ); WP_CLI::add_command( 'menu', 'Menu_Command' ); WP_CLI::add_command( 'menu item', 'Menu_Item_Command' ); WP_CLI::add_command( 'menu location', 'Menu_Location_Command' ); -WP_CLI::add_command( - 'network meta', - 'Network_Meta_Command', - array( - 'before_invoke' => function () { - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - }, - ) -); +WP_CLI::add_command( 'network meta', 'Network_Meta_Command', array( + 'before_invoke' => function () { + if ( !is_multisite() ) { + WP_CLI::error( 'This is not a multisite install.' ); + } + } +) ); WP_CLI::add_command( 'option', 'Option_Command' ); WP_CLI::add_command( 'post', 'Post_Command' ); -WP_CLI::add_command( - 'post block', - 'Post_Block_Command', - array( - 'before_invoke' => function () { - if ( Utils\wp_version_compare( '5.0', '<' ) ) { - WP_CLI::error( 'Requires WordPress 5.0 or greater.' ); - } - }, - ) -); WP_CLI::add_command( 'post meta', 'Post_Meta_Command' ); -WP_CLI::add_command( 'post revision', 'Post_Revision_Command' ); WP_CLI::add_command( 'post term', 'Post_Term_Command' ); WP_CLI::add_command( 'post-type', 'Post_Type_Command' ); WP_CLI::add_command( 'site', 'Site_Command' ); -WP_CLI::add_command( - 'site meta', - 'Site_Meta_Command', - array( - 'before_invoke' => function () { - /** - * @var \wpdb $wpdb - */ - global $wpdb; - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - if ( ! function_exists( 'is_site_meta_supported' ) || ! is_site_meta_supported() ) { - WP_CLI::error( sprintf( 'The %s table is not installed. Please run the network database upgrade.', $wpdb->blogmeta ) ); - } - }, - ) -); -WP_CLI::add_command( - 'site option', - 'Site_Option_Command', - array( - 'before_invoke' => function () { - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - }, - ) -); +WP_CLI::add_command( 'site option', 'Site_Option_Command', array( + 'before_invoke' => function() { + if ( !is_multisite() ) { + WP_CLI::error( 'This is not a multisite installation.' ); + } + } +) ); WP_CLI::add_command( 'taxonomy', 'Taxonomy_Command' ); WP_CLI::add_command( 'term', 'Term_Command' ); -WP_CLI::add_command( 'term meta', 'Term_Meta_Command' ); -WP_CLI::add_command( 'user', 'User_Command' ); -WP_CLI::add_command( - 'user application-password', - 'User_Application_Password_Command', - array( - 'before_invoke' => function () { - if ( Utils\wp_version_compare( '5.6', '<' ) ) { - WP_CLI::error( 'Requires WordPress 5.6 or greater.' ); - } - }, - ) +WP_CLI::add_command( 'term meta', 'Term_Meta_Command', array( + 'before_invoke' => function() { + if ( \WP_CLI\Utils\wp_version_compare( '4.4', '<' ) ) { + WP_CLI::error( "Requires WordPress 4.4 or greater." ); + } + }) ); +WP_CLI::add_command( 'user', 'User_Command' ); WP_CLI::add_command( 'user meta', 'User_Meta_Command' ); -WP_CLI::add_command( - 'user privacy-request', - 'User_Privacy_Request_Command', - array( - 'before_invoke' => function () { - if ( Utils\wp_version_compare( '4.9.6', '<' ) ) { - WP_CLI::error( 'Requires WordPress 4.9.6 or greater.' ); - } - }, - ) +WP_CLI::add_command( 'user session', 'User_Session_Command', array( + 'before_invoke' => function() { + if ( \WP_CLI\Utils\wp_version_compare( '4.0', '<' ) ) { + WP_CLI::error( "Requires WordPress 4.0 or greater." ); + } + }) ); -WP_CLI::add_command( 'user session', 'User_Session_Command' ); + WP_CLI::add_command( 'user term', 'User_Term_Command' ); if ( class_exists( 'WP_CLI\Dispatcher\CommandNamespace' ) ) { WP_CLI::add_command( 'network', 'Network_Namespace' ); } - -WP_CLI::add_command( - 'user signup', - 'Signup_Command', - array( - 'before_invoke' => function () { - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - }, - ) -); - -if ( class_exists( 'WP_CLI\Dispatcher\CommandNamespace' ) ) { - WP_CLI::add_command( 'font', 'Font_Namespace' ); -} - -$wpcli_entity_font_version_check = array( - 'before_invoke' => function () { - if ( Utils\wp_version_compare( '6.5', '<' ) ) { - WP_CLI::error( 'Requires WordPress 6.5 or greater.' ); - } - }, -); - -WP_CLI::add_command( 'font collection', 'Font_Collection_Command', $wpcli_entity_font_version_check ); -WP_CLI::add_command( 'font family', 'Font_Family_Command', $wpcli_entity_font_version_check ); -WP_CLI::add_command( 'font face', 'Font_Face_Command', $wpcli_entity_font_version_check ); diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 000000000..6f88ed1bb --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,942 @@ +<?php + +use Behat\Behat\Context\ClosuredContextInterface, + Behat\Behat\Context\TranslatedContextInterface, + Behat\Behat\Context\BehatContext, + Behat\Behat\Event\SuiteEvent; + +use \WP_CLI\Process; +use \WP_CLI\Utils; + +// Inside a community package +if ( file_exists( __DIR__ . '/utils.php' ) ) { + require_once __DIR__ . '/utils.php'; + require_once __DIR__ . '/Process.php'; + require_once __DIR__ . '/ProcessRun.php'; + $project_composer = dirname( dirname( dirname( __FILE__ ) ) ) . '/composer.json'; + if ( file_exists( $project_composer ) ) { + $composer = json_decode( file_get_contents( $project_composer ) ); + if ( ! empty( $composer->autoload->files ) ) { + $contents = 'require:' . PHP_EOL; + foreach( $composer->autoload->files as $file ) { + $contents .= ' - ' . dirname( dirname( dirname( __FILE__ ) ) ) . '/' . $file . PHP_EOL; + } + @mkdir( sys_get_temp_dir() . '/wp-cli-package-test/' ); + $project_config = sys_get_temp_dir() . '/wp-cli-package-test/config.yml'; + file_put_contents( $project_config, $contents ); + putenv( 'WP_CLI_CONFIG_PATH=' . $project_config ); + } + } +// Inside WP-CLI +} else { + require_once __DIR__ . '/../../php/utils.php'; + require_once __DIR__ . '/../../php/WP_CLI/Process.php'; + require_once __DIR__ . '/../../php/WP_CLI/ProcessRun.php'; + if ( file_exists( __DIR__ . '/../../vendor/autoload.php' ) ) { + require_once __DIR__ . '/../../vendor/autoload.php'; + } else if ( file_exists( __DIR__ . '/../../../../autoload.php' ) ) { + require_once __DIR__ . '/../../../../autoload.php'; + } +} + +/** + * Features context. + */ +class FeatureContext extends BehatContext implements ClosuredContextInterface { + + /** + * The current working directory for scenarios that have a "Given a WP installation" or "Given an empty directory" step. Variable RUN_DIR. Lives until the end of the scenario. + */ + private static $run_dir; + + /** + * Where WordPress core is downloaded to for caching, and which is copied to RUN_DIR during a "Given a WP installation" step. Lives until manually deleted. + */ + private static $cache_dir; + + /** + * The directory that holds the install cache, and which is copied to RUN_DIR during a "Given a WP installation" step. Recreated on each suite run. + */ + private static $install_cache_dir; + + /** + * The directory that the WP-CLI cache (WP_CLI_CACHE_DIR, normally "$HOME/.wp-cli/cache") is set to on a "Given an empty cache" step. + * Variable SUITE_CACHE_DIR. Lives until the end of the scenario (or until another "Given an empty cache" step within the scenario). + */ + private static $suite_cache_dir; + + /** + * Where the current WP-CLI source repository is copied to for Composer-based tests with a "Given a dependency on current wp-cli" step. + * Variable COMPOSER_LOCAL_REPOSITORY. Lives until the end of the suite. + */ + private static $composer_local_repository; + + /** + * The test database settings. All but `dbname` can be set via environment variables. The database is dropped at the start of each scenario and created on a "Given a WP installation" step. + */ + private static $db_settings = array( + 'dbname' => 'wp_cli_test', + 'dbuser' => 'wp_cli_test', + 'dbpass' => 'password1', + 'dbhost' => '127.0.0.1', + ); + + /** + * Array of background process ids started by the current scenario. Used to terminate them at the end of the scenario. + */ + private $running_procs = array(); + + /** + * Array of variables available as {VARIABLE_NAME}. Some are always set: CORE_CONFIG_SETTINGS, SRC_DIR, CACHE_DIR, WP_VERSION-version-latest. + * Some are step-dependent: RUN_DIR, SUITE_CACHE_DIR, COMPOSER_LOCAL_REPOSITORY, PHAR_PATH. One is set on use: INVOKE_WP_CLI_WITH_PHP_ARGS-args. + * Scenarios can define their own variables using "Given save" steps. Variables are reset for each scenario. + */ + public $variables = array(); + + /** + * The current feature file and scenario line number as '<file>.<line>'. Used in RUN_DIR and SUITE_CACHE_DIR directory names. Set at the start of each scenario. + */ + private static $temp_dir_infix; + + /** + * Settings and variables for WP_CLI_TEST_LOG_RUN_TIMES run time logging. + */ + private static $log_run_times; // Whether to log run times - WP_CLI_TEST_LOG_RUN_TIMES env var. Set on `@BeforeScenario'. + private static $suite_start_time; // When the suite started, set on `@BeforeScenario'. + private static $output_to; // Where to output log - stdout|error_log. Set on `@BeforeSuite`. + private static $num_top_processes; // Number of processes/methods to output by longest run times. Set on `@BeforeSuite`. + private static $num_top_scenarios; // Number of scenarios to output by longest run times. Set on `@BeforeSuite`. + + private static $scenario_run_times = array(); // Scenario run times (top `self::$num_top_scenarios` only). + private static $scenario_count = 0; // Scenario count, incremented on `@AfterScenario`. + private static $proc_method_run_times = array(); // Array of run time info for proc methods, keyed by method name and arg, each a 2-element array containing run time and run count. + + /** + * Get the environment variables required for launched `wp` processes + */ + private static function get_process_env_variables() { + // Ensure we're using the expected `wp` binary + $bin_dir = getenv( 'WP_CLI_BIN_DIR' ) ?: realpath( __DIR__ . '/../../bin' ); + $vendor_dir = realpath( __DIR__ . '/../../vendor/bin' ); + $path_separator = Utils\is_windows() ? ';' : ':'; + $env = array( + 'PATH' => $bin_dir . $path_separator . $vendor_dir . $path_separator . getenv( 'PATH' ), + 'BEHAT_RUN' => 1, + 'HOME' => sys_get_temp_dir() . '/wp-cli-home', + ); + if ( $config_path = getenv( 'WP_CLI_CONFIG_PATH' ) ) { + $env['WP_CLI_CONFIG_PATH'] = $config_path; + } + if ( $term = getenv( 'TERM' ) ) { + $env['TERM'] = $term; + } + if ( $php_args = getenv( 'WP_CLI_PHP_ARGS' ) ) { + $env['WP_CLI_PHP_ARGS'] = $php_args; + } + if ( $php_used = getenv( 'WP_CLI_PHP_USED' ) ) { + $env['WP_CLI_PHP_USED'] = $php_used; + } + if ( $php = getenv( 'WP_CLI_PHP' ) ) { + $env['WP_CLI_PHP'] = $php; + } + if ( $travis_build_dir = getenv( 'TRAVIS_BUILD_DIR' ) ) { + $env['TRAVIS_BUILD_DIR'] = $travis_build_dir; + } + if ( $github_token = getenv( 'GITHUB_TOKEN' ) ) { + $env['GITHUB_TOKEN'] = $github_token; + } + return $env; + } + + /** + * We cache the results of `wp core download` to improve test performance. + * Ideally, we'd cache at the HTTP layer for more reliable tests. + */ + private static function cache_wp_files() { + $wp_version_suffix = ( $wp_version = getenv( 'WP_VERSION' ) ) ? "-$wp_version" : ''; + self::$cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix; + + if ( is_readable( self::$cache_dir . '/wp-config-sample.php' ) ) + return; + + $cmd = Utils\esc_cmd( 'wp core download --force --path=%s', self::$cache_dir ); + if ( $wp_version ) { + $cmd .= Utils\esc_cmd( ' --version=%s', $wp_version ); + } + Process::create( $cmd, null, self::get_process_env_variables() )->run_check(); + } + + /** + * @BeforeSuite + */ + public static function prepare( SuiteEvent $event ) { + // Test performance statistics - useful for detecting slow tests. + if ( self::$log_run_times = getenv( 'WP_CLI_TEST_LOG_RUN_TIMES' ) ) { + self::log_run_times_before_suite( $event ); + } + + $result = Process::create( 'wp cli info', null, self::get_process_env_variables() )->run_check(); + echo PHP_EOL; + echo $result->stdout; + echo PHP_EOL; + self::cache_wp_files(); + $result = Process::create( Utils\esc_cmd( 'wp core version --path=%s', self::$cache_dir ) , null, self::get_process_env_variables() )->run_check(); + echo 'WordPress ' . $result->stdout; + echo PHP_EOL; + + // Remove install cache if any (not setting the static var). + $wp_version_suffix = ( $wp_version = getenv( 'WP_VERSION' ) ) ? "-$wp_version" : ''; + $install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; + if ( file_exists( $install_cache_dir ) ) { + self::remove_dir( $install_cache_dir ); + } + } + + /** + * @AfterSuite + */ + public static function afterSuite( SuiteEvent $event ) { + if ( self::$composer_local_repository ) { + self::remove_dir( self::$composer_local_repository ); + self::$composer_local_repository = null; + } + + if ( self::$log_run_times ) { + self::log_run_times_after_suite( $event ); + } + } + + /** + * @BeforeScenario + */ + public function beforeScenario( $event ) { + if ( self::$log_run_times ) { + self::log_run_times_before_scenario( $event ); + } + + $this->variables['SRC_DIR'] = realpath( __DIR__ . '/../..' ); + + // Used in the names of the RUN_DIR and SUITE_CACHE_DIR directories. + self::$temp_dir_infix = null; + if ( $file = self::get_event_file( $event, $line ) ) { + self::$temp_dir_infix = basename( $file ) . '.' . $line; + } + } + + /** + * @AfterScenario + */ + public function afterScenario( $event ) { + + if ( self::$run_dir ) { + // remove altered WP install, unless there's an error + if ( $event->getResult() < 4 ) { + self::remove_dir( self::$run_dir ); + } + self::$run_dir = null; + } + + // Remove WP-CLI package directory if any. Set to `wp package path` by package-command and scaffold-package-command features, and by cli-info.feature. + if ( isset( $this->variables['PACKAGE_PATH'] ) ) { + self::remove_dir( $this->variables['PACKAGE_PATH'] ); + } + + // Remove SUITE_CACHE_DIR if any. + if ( self::$suite_cache_dir ) { + self::remove_dir( self::$suite_cache_dir ); + self::$suite_cache_dir = null; + } + + // Remove any background processes. + foreach ( $this->running_procs as $proc ) { + $status = proc_get_status( $proc ); + self::terminate_proc( $status['pid'] ); + } + + if ( self::$log_run_times ) { + self::log_run_times_after_scenario( $event ); + } + } + + /** + * Terminate a process and any of its children. + */ + private static function terminate_proc( $master_pid ) { + + $output = `ps -o ppid,pid,command | grep $master_pid`; + + foreach ( explode( PHP_EOL, $output ) as $line ) { + if ( preg_match( '/^\s*(\d+)\s+(\d+)/', $line, $matches ) ) { + $parent = $matches[1]; + $child = $matches[2]; + + if ( $parent == $master_pid ) { + self::terminate_proc( $child ); + } + } + } + + if ( ! posix_kill( (int) $master_pid, 9 ) ) { + $errno = posix_get_last_error(); + // Ignore "No such process" error as that's what we want. + if ( 3 /*ESRCH*/ !== $errno ) { + throw new RuntimeException( posix_strerror( $errno ) ); + } + } + } + + /** + * Create a temporary WP_CLI_CACHE_DIR. Exposed as SUITE_CACHE_DIR in "Given an empty cache" step. + */ + public static function create_cache_dir() { + if ( self::$suite_cache_dir ) { + self::remove_dir( self::$suite_cache_dir ); + } + self::$suite_cache_dir = sys_get_temp_dir() . '/' . uniqid( 'wp-cli-test-suite-cache-' . self::$temp_dir_infix . '-', TRUE ); + mkdir( self::$suite_cache_dir ); + return self::$suite_cache_dir; + } + + /** + * Initializes context. + * Every scenario gets its own context object. + * + * @param array $parameters context parameters (set them up through behat.yml) + */ + public function __construct( array $parameters ) { + if ( getenv( 'WP_CLI_TEST_DBUSER' ) ) { + self::$db_settings['dbuser'] = getenv( 'WP_CLI_TEST_DBUSER' ); + } + + if ( false !== getenv( 'WP_CLI_TEST_DBPASS' ) ) { + self::$db_settings['dbpass'] = getenv( 'WP_CLI_TEST_DBPASS' ); + } + + if ( getenv( 'WP_CLI_TEST_DBHOST' ) ) { + self::$db_settings['dbhost'] = getenv( 'WP_CLI_TEST_DBHOST' ); + } + + $this->drop_db(); + $this->set_cache_dir(); + $this->variables['CORE_CONFIG_SETTINGS'] = Utils\assoc_args_to_str( self::$db_settings ); + } + + public function getStepDefinitionResources() { + return glob( __DIR__ . '/../steps/*.php' ); + } + + public function getHookDefinitionResources() { + return array(); + } + + /** + * Replace standard {VARIABLE_NAME} variables and the special {INVOKE_WP_CLI_WITH_PHP_ARGS-args} and {WP_VERSION-version-latest} variables. + * Note that standard variable names can only contain uppercase letters, digits and underscores and cannot begin with a digit. + */ + public function replace_variables( $str ) { + if ( false !== strpos( $str, '{INVOKE_WP_CLI_WITH_PHP_ARGS-' ) ) { + $str = $this->replace_invoke_wp_cli_with_php_args( $str ); + } + $str = preg_replace_callback( '/\{([A-Z_][A-Z_0-9]*)\}/', array( $this, 'replace_var' ), $str ); + if ( false !== strpos( $str, '{WP_VERSION-' ) ) { + $str = $this->replace_wp_versions( $str ); + } + return $str; + } + + /** + * Substitute {INVOKE_WP_CLI_WITH_PHP_ARGS-args} variables. + */ + private function replace_invoke_wp_cli_with_php_args( $str ) { + static $phar_path = null, $shell_path = null; + + if ( null === $phar_path ) { + $phar_path = false; + $phar_begin = '#!/usr/bin/env php'; + $phar_begin_len = strlen( $phar_begin ); + if ( ( $bin_dir = getenv( 'WP_CLI_BIN_DIR' ) ) && file_exists( $bin_dir . '/wp' ) && $phar_begin === file_get_contents( $bin_dir . '/wp', false, null, 0, $phar_begin_len ) ) { + $phar_path = $bin_dir . '/wp'; + } else { + $src_dir = dirname( dirname( __DIR__ ) ); + $bin_path = $src_dir . '/bin/wp'; + $vendor_bin_path = $src_dir . '/vendor/bin/wp'; + if ( file_exists( $bin_path ) && is_executable( $bin_path ) ) { + $shell_path = $bin_path; + } elseif ( file_exists( $vendor_bin_path ) && is_executable( $vendor_bin_path ) ) { + $shell_path = $vendor_bin_path; + } else { + $shell_path = 'wp'; + } + } + } + + $str = preg_replace_callback( '/{INVOKE_WP_CLI_WITH_PHP_ARGS-([^}]*)}/', function ( $matches ) use ( $phar_path, $shell_path ) { + return $phar_path ? "php {$matches[1]} {$phar_path}" : ( 'WP_CLI_PHP_ARGS=' . escapeshellarg( $matches[1] ) . ' ' . $shell_path ); + }, $str ); + + return $str; + } + + /** + * Replace variables callback. + */ + private function replace_var( $matches ) { + $cmd = $matches[0]; + + foreach ( array_slice( $matches, 1 ) as $key ) { + $cmd = str_replace( '{' . $key . '}', $this->variables[ $key ], $cmd ); + } + + return $cmd; + } + + /** + * Substitute {WP_VERSION-version-latest} variables. + */ + private function replace_wp_versions( $str ) { + static $wp_versions = null; + if ( null === $wp_versions ) { + $wp_versions = array(); + + $response = Requests::get( 'https://api.wordpress.org/core/version-check/1.7/', null, array( 'timeout' => 30 ) ); + if ( 200 === $response->status_code && ( $body = json_decode( $response->body ) ) && is_object( $body ) && isset( $body->offers ) && is_array( $body->offers ) ) { + // Latest version alias. + $wp_versions["{WP_VERSION-latest}"] = count( $body->offers ) ? $body->offers[0]->version : ''; + foreach ( $body->offers as $offer ) { + $sub_ver = preg_replace( '/(^[0-9]+\.[0-9]+)\.[0-9]+$/', '$1', $offer->version ); + $sub_ver_key = "{WP_VERSION-{$sub_ver}-latest}"; + + $main_ver = preg_replace( '/(^[0-9]+)\.[0-9]+$/', '$1', $sub_ver ); + $main_ver_key = "{WP_VERSION-{$main_ver}-latest}"; + + if ( ! isset( $wp_versions[ $main_ver_key ] ) ) { + $wp_versions[ $main_ver_key ] = $offer->version; + } + if ( ! isset( $wp_versions[ $sub_ver_key ] ) ) { + $wp_versions[ $sub_ver_key ] = $offer->version; + } + } + } + } + return strtr( $str, $wp_versions ); + } + + /** + * Get the file and line number for the current behat event. + */ + private static function get_event_file( $event, &$line ) { + if ( method_exists( $event, 'getScenario' ) ) { + $scenario_feature = $event->getScenario(); + } elseif ( method_exists( $event, 'getFeature' ) ) { + $scenario_feature = $event->getFeature(); + } elseif ( method_exists( $event, 'getOutline' ) ) { + $scenario_feature = $event->getOutline(); + } else { + return null; + } + $line = $scenario_feature->getLine(); + return $scenario_feature->getFile(); + } + + /** + * Create the RUN_DIR directory, unless already set for this scenario. + */ + public function create_run_dir() { + if ( !isset( $this->variables['RUN_DIR'] ) ) { + self::$run_dir = $this->variables['RUN_DIR'] = sys_get_temp_dir() . '/' . uniqid( 'wp-cli-test-run-' . self::$temp_dir_infix . '-', TRUE ); + mkdir( $this->variables['RUN_DIR'] ); + } + } + + public function build_phar( $version = 'same' ) { + $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . '/' . uniqid( "wp-cli-build-", TRUE ) . '.phar'; + + // Test running against a package installed as a WP-CLI dependency + // WP-CLI installed as a project dependency + $make_phar_path = __DIR__ . '/../../../../../utils/make-phar.php'; + if ( ! file_exists( $make_phar_path ) ) { + // Test running against WP-CLI proper + $make_phar_path = __DIR__ . '/../../utils/make-phar.php'; + if ( ! file_exists( $make_phar_path ) ) { + // WP-CLI as a dependency of this project + $make_phar_path = __DIR__ . '/../../vendor/wp-cli/wp-cli/utils/make-phar.php'; + } + } + + $this->proc( Utils\esc_cmd( + 'php -dphar.readonly=0 %1$s %2$s --version=%3$s && chmod +x %2$s', + $make_phar_path, + $this->variables['PHAR_PATH'], + $version + ) )->run_check(); + } + + public function download_phar( $version = 'same' ) { + if ( 'same' === $version ) { + $version = WP_CLI_VERSION; + } + + $download_url = sprintf( + 'https://github.com/wp-cli/wp-cli/releases/download/v%1$s/wp-cli-%1$s.phar', + $version + ); + + $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . '/' + . uniqid( 'wp-cli-download-', true ) + . '.phar'; + + Process::create( Utils\esc_cmd( + 'curl -sSfL %1$s > %2$s && chmod +x %2$s', + $download_url, + $this->variables['PHAR_PATH'] + ) )->run_check(); + } + + /** + * CACHE_DIR is a cache for downloaded test data such as images. Lives until manually deleted. + */ + private function set_cache_dir() { + $path = sys_get_temp_dir() . '/wp-cli-test-cache'; + if ( ! file_exists( $path ) ) { + mkdir( $path ); + } + $this->variables['CACHE_DIR'] = $path; + } + + /** + * Run a MySQL command with `$db_settings`. + * + * @param string $sql_cmd Command to run. + * @param array $assoc_args Optional. Associative array of options. Default empty. + * @param bool $add_database Optional. Whether to add dbname to the $sql_cmd. Default false. + */ + private static function run_sql( $sql_cmd, $assoc_args = array(), $add_database = false ) { + $default_assoc_args = array( + 'host' => self::$db_settings['dbhost'], + 'user' => self::$db_settings['dbuser'], + 'pass' => self::$db_settings['dbpass'], + ); + if ( $add_database ) { + $sql_cmd .= ' ' . escapeshellarg( self::$db_settings['dbname'] ); + } + $start_time = microtime( true ); + Utils\run_mysql_command( $sql_cmd, array_merge( $assoc_args, $default_assoc_args ) ); + if ( self::$log_run_times ) { + self::log_proc_method_run_time( 'run_sql ' . $sql_cmd, $start_time ); + } + } + + public function create_db() { + $dbname = self::$db_settings['dbname']; + self::run_sql( 'mysql --no-defaults', array( 'execute' => "CREATE DATABASE IF NOT EXISTS $dbname" ) ); + } + + public function drop_db() { + $dbname = self::$db_settings['dbname']; + self::run_sql( 'mysql --no-defaults', array( 'execute' => "DROP DATABASE IF EXISTS $dbname" ) ); + } + + public function proc( $command, $assoc_args = array(), $path = '' ) { + if ( !empty( $assoc_args ) ) + $command .= Utils\assoc_args_to_str( $assoc_args ); + + $env = self::get_process_env_variables(); + if ( isset( $this->variables['SUITE_CACHE_DIR'] ) ) { + $env['WP_CLI_CACHE_DIR'] = $this->variables['SUITE_CACHE_DIR']; + } + + if ( isset( $this->variables['RUN_DIR'] ) ) { + $cwd = "{$this->variables['RUN_DIR']}/{$path}"; + } else { + $cwd = null; + } + + return Process::create( $command, $cwd, $env ); + } + + /** + * Start a background process. Will automatically be closed when the tests finish. + */ + public function background_proc( $cmd ) { + $descriptors = array( + 0 => STDIN, + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + $proc = proc_open( $cmd, $descriptors, $pipes, $this->variables['RUN_DIR'], self::get_process_env_variables() ); + + sleep(1); + + $status = proc_get_status( $proc ); + + if ( !$status['running'] ) { + $stderr = is_resource( $pipes[2] ) ? ( ': ' . stream_get_contents( $pipes[2] ) ) : ''; + throw new RuntimeException( sprintf( "Failed to start background process '%s'%s.", $cmd, $stderr ) ); + } else { + $this->running_procs[] = $proc; + } + } + + public function move_files( $src, $dest ) { + rename( $this->variables['RUN_DIR'] . "/$src", $this->variables['RUN_DIR'] . "/$dest" ); + } + + /** + * Remove a directory (recursive). + */ + public static function remove_dir( $dir ) { + Process::create( Utils\esc_cmd( 'rm -rf %s', $dir ) )->run_check(); + } + + /** + * Copy a directory (recursive). Destination directory must exist. + */ + public static function copy_dir( $src_dir, $dest_dir ) { + Process::create( Utils\esc_cmd( "cp -r %s/* %s", $src_dir, $dest_dir ) )->run_check(); + } + + public function add_line_to_wp_config( &$wp_config_code, $line ) { + $token = "/* That's all, stop editing!"; + + $wp_config_code = str_replace( $token, "$line\n\n$token", $wp_config_code ); + } + + public function download_wp( $subdir = '' ) { + $dest_dir = $this->variables['RUN_DIR'] . "/$subdir"; + + if ( $subdir ) { + mkdir( $dest_dir ); + } + + self::copy_dir( self::$cache_dir, $dest_dir ); + + // disable emailing + mkdir( $dest_dir . '/wp-content/mu-plugins' ); + copy( __DIR__ . '/../extra/no-mail.php', $dest_dir . '/wp-content/mu-plugins/no-mail.php' ); + } + + public function create_config( $subdir = '', $extra_php = false ) { + $params = self::$db_settings; + + // Replaces all characters that are not alphanumeric or an underscore into an underscore. + $params['dbprefix'] = $subdir ? preg_replace( '#[^a-zA-Z\_0-9]#', '_', $subdir ) : 'wp_'; + + $params['skip-salts'] = true; + + if( false !== $extra_php ) { + $params['extra-php'] = $extra_php; + } + + $config_cache_path = ''; + if ( self::$install_cache_dir ) { + $config_cache_path = self::$install_cache_dir . '/config_' . md5( implode( ':', $params ) . ':subdir=' . $subdir ); + $run_dir = '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR']; + } + + if ( $config_cache_path && file_exists( $config_cache_path ) ) { + copy( $config_cache_path, $run_dir . '/wp-config.php' ); + } else { + $this->proc( 'wp config create', $params, $subdir )->run_check(); + if ( $config_cache_path && file_exists( $run_dir . '/wp-config.php' ) ) { + copy( $run_dir . '/wp-config.php', $config_cache_path ); + } + } + } + + public function install_wp( $subdir = '' ) { + $wp_version_suffix = ( $wp_version = getenv( 'WP_VERSION' ) ) ? "-$wp_version" : ''; + self::$install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; + if ( ! file_exists( self::$install_cache_dir ) ) { + mkdir( self::$install_cache_dir ); + } + + $subdir = $this->replace_variables( $subdir ); + + $this->create_db(); + $this->create_run_dir(); + $this->download_wp( $subdir ); + $this->create_config( $subdir ); + + $install_args = array( + 'url' => 'http://example.com', + 'title' => 'WP CLI Site', + 'admin_user' => 'admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'password1', + 'skip-email' => true, + ); + + $install_cache_path = ''; + if ( self::$install_cache_dir ) { + $install_cache_path = self::$install_cache_dir . '/install_' . md5( implode( ':', $install_args ) . ':subdir=' . $subdir ); + $run_dir = '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR']; + } + + if ( $install_cache_path && file_exists( $install_cache_path ) ) { + self::copy_dir( $install_cache_path, $run_dir ); + self::run_sql( 'mysql --no-defaults', array( 'execute' => "source {$install_cache_path}.sql" ), true /*add_database*/ ); + } else { + $this->proc( 'wp core install', $install_args, $subdir )->run_check(); + if ( $install_cache_path ) { + mkdir( $install_cache_path ); + self::dir_diff_copy( $run_dir, self::$cache_dir, $install_cache_path ); + self::run_sql( 'mysqldump --no-defaults', array( 'result-file' => "{$install_cache_path}.sql" ), true /*add_database*/ ); + } + } + } + + public function install_wp_with_composer( $vendor_directory = 'vendor' ) { + $this->create_run_dir(); + $this->create_db(); + + $yml_path = $this->variables['RUN_DIR'] . "/wp-cli.yml"; + file_put_contents( $yml_path, 'path: wordpress' ); + + $this->composer_command( 'init --name="wp-cli/composer-test" --type="project" --no-interaction' ); + $this->composer_command( 'config vendor-dir ' . $vendor_directory ); + $this->composer_command( 'require johnpbloch/wordpress --optimize-autoloader --no-interaction' ); + + $config_extra_php = "require_once dirname(__DIR__) . '/" . $vendor_directory . "/autoload.php';"; + $this->create_config( 'wordpress', $config_extra_php ); + + $install_args = array( + 'url' => 'http://localhost:8080', + 'title' => 'WP CLI Site with both WordPress and wp-cli as Composer dependencies', + 'admin_user' => 'admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'password1', + 'skip-email' => true, + ); + + $this->proc( 'wp core install', $install_args )->run_check(); + } + + public function composer_add_wp_cli_local_repository() { + if ( ! self::$composer_local_repository ) { + self::$composer_local_repository = sys_get_temp_dir() . '/' . uniqid( "wp-cli-composer-local-", TRUE ); + mkdir( self::$composer_local_repository ); + + $env = self::get_process_env_variables(); + $src = isset( $env['TRAVIS_BUILD_DIR'] ) ? $env['TRAVIS_BUILD_DIR'] : realpath( __DIR__ . '/../../' ); + + self::copy_dir( $src, self::$composer_local_repository . '/' ); + self::remove_dir( self::$composer_local_repository . '/.git' ); + self::remove_dir( self::$composer_local_repository . '/vendor' ); + } + $dest = self::$composer_local_repository . '/'; + $this->composer_command( "config repositories.wp-cli '{\"type\": \"path\", \"url\": \"$dest\", \"options\": {\"symlink\": false}}'" ); + $this->variables['COMPOSER_LOCAL_REPOSITORY'] = self::$composer_local_repository; + } + + public function composer_require_current_wp_cli() { + $this->composer_add_wp_cli_local_repository(); + $this->composer_command( 'require wp-cli/wp-cli:dev-master --optimize-autoloader --no-interaction' ); + } + + public function start_php_server( $subdir = '' ) { + $dir = $this->variables['RUN_DIR'] . '/'; + if ( $subdir ) { + $dir .= trim( $subdir, '/' ) . '/'; + } + $cmd = Utils\esc_cmd( '%s -S %s -t %s -c %s %s', + Utils\get_php_binary(), + 'localhost:8080', + $dir, + get_cfg_var( 'cfg_file_path' ), + $this->variables['RUN_DIR'] . '/vendor/wp-cli/server-command/router.php' + ); + $this->background_proc( $cmd ); + } + + private function composer_command($cmd) { + if ( !isset( $this->variables['COMPOSER_PATH'] ) ) { + $this->variables['COMPOSER_PATH'] = exec('which composer'); + } + $this->proc( $this->variables['COMPOSER_PATH'] . ' ' . $cmd )->run_check(); + } + + /** + * Initialize run time logging. + */ + private static function log_run_times_before_suite( $event ) { + self::$suite_start_time = microtime( true ); + + Process::$log_run_times = true; + + $travis = getenv( 'TRAVIS' ); + + // Default output settings. + self::$output_to = 'stdout'; + self::$num_top_processes = $travis ? 10 : 40; + self::$num_top_scenarios = $travis ? 10 : 20; + + // Allow setting of above with "WP_CLI_TEST_LOG_RUN_TIMES=<output_to>[,<num_top_processes>][,<num_top_scenarios>]" formatted env var. + if ( preg_match( '/^(stdout|error_log)?(,[0-9]+)?(,[0-9]+)?$/i', self::$log_run_times, $matches ) ) { + if ( isset( $matches[1] ) ) { + self::$output_to = strtolower( $matches[1] ); + } + if ( isset( $matches[2] ) ) { + self::$num_top_processes = max( (int) substr( $matches[2], 1 ), 1 ); + } + if ( isset( $matches[3] ) ) { + self::$num_top_scenarios = max( (int) substr( $matches[3], 1 ), 1 ); + } + } + } + + /** + * Record the start time of the scenario into the `$scenario_run_times` array. + */ + private static function log_run_times_before_scenario( $event ) { + if ( $scenario_key = self::get_scenario_key( $event ) ) { + self::$scenario_run_times[ $scenario_key ] = -microtime( true ); + } + } + + /** + * Save the run time of the scenario into the `$scenario_run_times` array. Only the top `self::$num_top_scenarios` are kept. + */ + private static function log_run_times_after_scenario( $event ) { + if ( $scenario_key = self::get_scenario_key( $event ) ) { + self::$scenario_run_times[ $scenario_key ] += microtime( true ); + self::$scenario_count++; + if ( count( self::$scenario_run_times ) > self::$num_top_scenarios ) { + arsort( self::$scenario_run_times ); + array_pop( self::$scenario_run_times ); + } + } + } + + /** + * Copy files in updated directory that are not in source directory to copy directory. ("Incremental backup".) + * Note: does not deal with changed files (ie does not compare file contents for changes), for speed reasons. + * + * @param string $upd_dir The directory to search looking for files/directories not in `$src_dir`. + * @param string $src_dir The directory to be compared to `$upd_dir`. + * @param string $cop_dir Where to copy any files/directories in `$upd_dir` but not in `$src_dir` to. + */ + private static function dir_diff_copy( $upd_dir, $src_dir, $cop_dir ) { + if ( false === ( $files = scandir( $upd_dir ) ) ) { + $error = error_get_last(); + throw new \RuntimeException( sprintf( "Failed to open updated directory '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_dir, $error['message'] ) ); + } + foreach ( array_diff( $files, array( '.', '..' ) ) as $file ) { + $upd_file = $upd_dir . '/' . $file; + $src_file = $src_dir . '/' . $file; + $cop_file = $cop_dir . '/' . $file; + if ( ! file_exists( $src_file ) ) { + if ( is_dir( $upd_file ) ) { + if ( ! file_exists( $cop_file ) && ! mkdir( $cop_file, 0777, true /*recursive*/ ) ) { + $error = error_get_last(); + throw new \RuntimeException( sprintf( "Failed to create copy directory '%s': %s. " . __FILE__ . ':' . __LINE__, $cop_file, $error['message'] ) ); + } + self::copy_dir( $upd_file, $cop_file ); + } else { + if ( ! copy( $upd_file, $cop_file ) ) { + $error = error_get_last(); + throw new \RuntimeException( sprintf( "Failed to copy '%s' to '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_file, $cop_file, $error['message'] ) ); + } + } + } elseif ( is_dir( $upd_file ) ) { + self::dir_diff_copy( $upd_file, $src_file, $cop_file ); + } + } + } + + /** + * Get the scenario key used for `$scenario_run_times` array. + * Format "<grandparent-dir> <feature-file>:<line-number>", eg "core-command core-update.feature:221". + */ + private static function get_scenario_key( $event ) { + $scenario_key = ''; + if ( $file = self::get_event_file( $event, $line ) ) { + $scenario_grandparent = Utils\basename( dirname( dirname( $file ) ) ); + $scenario_key = $scenario_grandparent . ' ' . Utils\basename( $file ) . ':' . $line; + } + return $scenario_key; + } + + /** + * Print out stats on the run times of processes and scenarios. + */ + private static function log_run_times_after_suite( $event ) { + + $suite = ''; + if ( self::$scenario_run_times ) { + // Grandparent directory is first part of key. + $keys = array_keys( self::$scenario_run_times ); + $suite = substr( $keys[0], 0, strpos( $keys[0], ' ' ) ); + } + + $run_from = Utils\basename( dirname( dirname( __DIR__ ) ) ); + + // Format same as Behat, if have minutes. + $fmt = function ( $time ) { + $mins = floor( $time / 60 ); + return round( $time, 3 ) . ( $mins ? ( ' (' . $mins . 'm' . round( $time - ( $mins * 60 ), 3 ) . 's)' ) : '' ); + }; + + $time = microtime( true ) - self::$suite_start_time; + + $log = PHP_EOL . str_repeat( '(', 80 ) . PHP_EOL; + + // Process and proc method run times. + $run_times = array_merge( Process::$run_times, self::$proc_method_run_times ); + + list( $ptime, $calls ) = array_reduce( $run_times, function ( $carry, $item ) { + return array( $carry[0] + $item[0], $carry[1] + $item[1] ); + }, array( 0, 0 ) ); + + $overhead = $time - $ptime; + $pct = round( ( $overhead / $time ) * 100 ); + $unique = count( $run_times ); + + $log .= sprintf( + PHP_EOL . "Total process run time %s (tests %s, overhead %.3f %d%%), calls %d (%d unique) for '%s' run from '%s'" . PHP_EOL, + $fmt( $ptime ), $fmt( $time ), $overhead, $pct, $calls, $unique, $suite, $run_from + ); + + uasort( $run_times, function ( $a, $b ) { + return $a[0] === $b[0] ? 0 : ( $a[0] < $b[0] ? 1 : -1 ); // Reverse sort. + } ); + + $tops = array_slice( $run_times, 0, self::$num_top_processes, true ); + + $log .= PHP_EOL . "Top " . self::$num_top_processes . " process run times for '$suite'"; + $log .= PHP_EOL . implode( PHP_EOL, array_map( function ( $k, $v, $i ) { + return sprintf( ' %3d. %7.3f %3d %s', $i + 1, round( $v[0], 3 ), $v[1], $k ); + }, array_keys( $tops ), $tops, array_keys( array_keys( $tops ) ) ) ) . PHP_EOL; + + // Scenario run times. + arsort( self::$scenario_run_times ); + + $tops = array_slice( self::$scenario_run_times, 0, self::$num_top_scenarios, true ); + + $log .= PHP_EOL . "Top " . self::$num_top_scenarios . " (of " . self::$scenario_count . ") scenario run times for '$suite'"; + $log .= PHP_EOL . implode( PHP_EOL, array_map( function ( $k, $v, $i ) { + return sprintf( ' %3d. %7.3f %s', $i + 1, round( $v, 3 ), substr( $k, strpos( $k, ' ' ) + 1 ) ); + }, array_keys( $tops ), $tops, array_keys( array_keys( $tops ) ) ) ) . PHP_EOL; + + $log .= PHP_EOL . str_repeat( ')', 80 ); + + if ( 'error_log' === self::$output_to ) { + error_log( $log ); + } else { + echo PHP_EOL . $log; + } + } + + /** + * Log the run time of a proc method (one that doesn't use Process but does (use a function that does) a `proc_open()`). + */ + private static function log_proc_method_run_time( $key, $start_time ) { + $run_time = microtime( true ) - $start_time; + if ( ! isset( self::$proc_method_run_times[ $key ] ) ) { + self::$proc_method_run_times[ $key ] = array( 0, 0 ); + } + self::$proc_method_run_times[ $key ][0] += $run_time; + self::$proc_method_run_times[ $key ][1]++; + } + +} diff --git a/features/bootstrap/Process.php b/features/bootstrap/Process.php new file mode 100644 index 000000000..584f679b9 --- /dev/null +++ b/features/bootstrap/Process.php @@ -0,0 +1,136 @@ +<?php + +namespace WP_CLI; + +use WP_CLI\Utils; + +/** + * Run a system process, and learn what happened. + */ +class Process { + /** + * @var string The full command to execute by the system. + */ + private $command; + + /** + * @var string|null The path of the working directory for the process or NULL if not specified (defaults to current working directory). + */ + private $cwd; + + /** + * @var array Environment variables to set when running the command. + */ + private $env; + + /** + * @var array Descriptor spec for `proc_open()`. + */ + private static $descriptors = array( + 0 => STDIN, + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + /** + * @var bool Whether to log run time info or not. + */ + public static $log_run_times = false; + + /** + * @var array Array of process run time info, keyed by process command, each a 2-element array containing run time and run count. + */ + public static $run_times = array(); + + /** + * @param string $command Command to execute. + * @param string $cwd Directory to execute the command in. + * @param array $env Environment variables to set when running the command. + * + * @return Process + */ + public static function create( $command, $cwd = null, $env = array() ) { + $proc = new self; + + $proc->command = $command; + $proc->cwd = $cwd; + $proc->env = $env; + + return $proc; + } + + private function __construct() {} + + /** + * Run the command. + * + * @return ProcessRun + */ + public function run() { + $start_time = microtime( true ); + + $proc = Utils\proc_open_compat( $this->command, self::$descriptors, $pipes, $this->cwd, $this->env ); + + $stdout = stream_get_contents( $pipes[1] ); + fclose( $pipes[1] ); + + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[2] ); + + $return_code = proc_close( $proc ); + + $run_time = microtime( true ) - $start_time; + + if ( self::$log_run_times ) { + if ( ! isset( self::$run_times[ $this->command ] ) ) { + self::$run_times[ $this->command ] = array( 0, 0 ); + } + self::$run_times[ $this->command ][0] += $run_time; + self::$run_times[ $this->command ][1]++; + } + + return new ProcessRun( + array( + 'stdout' => $stdout, + 'stderr' => $stderr, + 'return_code' => $return_code, + 'command' => $this->command, + 'cwd' => $this->cwd, + 'env' => $this->env, + 'run_time' => $run_time, + ) + ); + } + + /** + * Run the command, but throw an Exception on error. + * + * @return ProcessRun + */ + public function run_check() { + $r = $this->run(); + + // $r->STDERR is incorrect, but kept incorrect for backwards-compat + if ( $r->return_code || ! empty( $r->STDERR ) ) { + throw new \RuntimeException( $r ); + } + + return $r; + } + + /** + * Run the command, but throw an Exception on error. + * Same as `run_check()` above, but checks the correct stderr. + * + * @return ProcessRun + */ + public function run_check_stderr() { + $r = $this->run(); + + if ( $r->return_code || ! empty( $r->stderr ) ) { + throw new \RuntimeException( $r ); + } + + return $r; + } +} diff --git a/features/bootstrap/ProcessRun.php b/features/bootstrap/ProcessRun.php new file mode 100644 index 000000000..96b4c80b6 --- /dev/null +++ b/features/bootstrap/ProcessRun.php @@ -0,0 +1,68 @@ +<?php + +namespace WP_CLI; + +/** + * Results of an executed command. + */ +class ProcessRun { + /** + * @var string The full command executed by the system. + */ + public $command; + + /** + * @var string Captured output from the process' STDOUT. + */ + public $stdout; + + /** + * @var string Captured output from the process' STDERR. + */ + public $stderr; + + /** + * @var string|null The path of the working directory for the process or NULL if not specified (defaults to current working directory). + */ + public $cwd; + + /** + * @var array Environment variables set for this process. + */ + public $env; + + /** + * @var int Exit code of the process. + */ + public $return_code; + + /** + * @var float The run time of the process. + */ + public $run_time; + + /** + * @var array $props Properties of executed command. + */ + public function __construct( $props ) { + foreach ( $props as $key => $value ) { + $this->$key = $value; + } + } + + /** + * Return properties of executed command as a string. + * + * @return string + */ + public function __toString() { + $out = "$ $this->command\n"; + $out .= "$this->stdout\n$this->stderr"; + $out .= "cwd: $this->cwd\n"; + $out .= "run time: $this->run_time\n"; + $out .= "exit status: $this->return_code"; + + return $out; + } + +} diff --git a/features/bootstrap/support.php b/features/bootstrap/support.php new file mode 100644 index 000000000..6aa17c6c3 --- /dev/null +++ b/features/bootstrap/support.php @@ -0,0 +1,200 @@ +<?php + +// Utility functions used by Behat steps + +function assertRegExp( $regex, $actual ) { + if ( ! preg_match( $regex, $actual ) ) { + throw new Exception( "Actual value: " . var_export( $actual, true ) ); + } +} + +function assertEquals( $expected, $actual ) { + if ( $expected != $actual ) { + throw new Exception( "Actual value: " . var_export( $actual, true ) ); + } +} + +function assertNotEquals( $expected, $actual ) { + if ( $expected == $actual ) { + throw new Exception( "Actual value: " . var_export( $actual, true ) ); + } +} + +function assertNumeric( $actual ) { + if ( !is_numeric( $actual ) ) { + throw new Exception( "Actual value: " . var_export( $actual, true ) ); + } +} + +function assertNotNumeric( $actual ) { + if ( is_numeric( $actual ) ) { + throw new Exception( "Actual value: " . var_export( $actual, true ) ); + } +} + +function checkString( $output, $expected, $action, $message = false ) { + switch ( $action ) { + + case 'be': + $r = $expected === rtrim( $output, "\n" ); + break; + + case 'contain': + $r = false !== strpos( $output, $expected ); + break; + + case 'not contain': + $r = false === strpos( $output, $expected ); + break; + + default: + throw new Behat\Behat\Exception\PendingException(); + } + + if ( !$r ) { + if ( false === $message ) + $message = $output; + throw new Exception( $message ); + } +} + +function compareTables( $expected_rows, $actual_rows, $output ) { + // the first row is the header and must be present + if ( $expected_rows[0] != $actual_rows[0] ) { + throw new \Exception( $output ); + } + + unset( $actual_rows[0] ); + unset( $expected_rows[0] ); + + $missing_rows = array_diff( $expected_rows, $actual_rows ); + if ( !empty( $missing_rows ) ) { + throw new \Exception( $output ); + } +} + +function compareContents( $expected, $actual ) { + if ( gettype( $expected ) != gettype( $actual ) ) { + return false; + } + + if ( is_object( $expected ) ) { + foreach ( get_object_vars( $expected ) as $name => $value ) { + if ( ! compareContents( $value, $actual->$name ) ) + return false; + } + } else if ( is_array( $expected ) ) { + foreach ( $expected as $key => $value ) { + if ( ! compareContents( $value, $actual[$key] ) ) + return false; + } + } else { + return $expected === $actual; + } + + return true; +} + +/** + * Compare two strings containing JSON to ensure that @a $actualJson contains at + * least what the JSON string @a $expectedJson contains. + * + * @return whether or not @a $actualJson contains @a $expectedJson + * @retval true @a $actualJson contains @a $expectedJson + * @retval false @a $actualJson does not contain @a $expectedJson + * + * @param[in] $actualJson the JSON string to be tested + * @param[in] $expectedJson the expected JSON string + * + * Examples: + * expected: {'a':1,'array':[1,3,5]} + * + * 1 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: true + * + * 2 ) + * actual: {'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * element 'a' is missing from the root object + * + * 3 ) + * actual: {'a':0,'b':2,'c':3,'array':[1,2,3,4,5]} + * return: false + * the value of element 'a' is not 1 + * + * 4 ) + * actual: {'a':1,'b':2,'c':3,'array':[1,2,4,5]} + * return: false + * the contents of 'array' does not include 3 + */ +function checkThatJsonStringContainsJsonString( $actualJson, $expectedJson ) { + $actualValue = json_decode( $actualJson ); + $expectedValue = json_decode( $expectedJson ); + + if ( !$actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + +/** + * Compare two strings to confirm $actualCSV contains $expectedCSV + * Both strings are expected to have headers for their CSVs. + * $actualCSV must match all data rows in $expectedCSV + * + * @param string A CSV string + * @param array A nested array of values + * @return bool Whether $actualCSV contains $expectedCSV + */ +function checkThatCsvStringContainsValues( $actualCSV, $expectedCSV ) { + $actualCSV = array_map( 'str_getcsv', explode( PHP_EOL, $actualCSV ) ); + + if ( empty( $actualCSV ) ) + return false; + + // Each sample must have headers + $actualHeaders = array_values( array_shift( $actualCSV ) ); + $expectedHeaders = array_values( array_shift( $expectedCSV ) ); + + // Each expectedCSV must exist somewhere in actualCSV in the proper column + $expectedResult = 0; + foreach ( $expectedCSV as $expected_row ) { + $expected_row = array_combine( $expectedHeaders, $expected_row ); + foreach ( $actualCSV as $actual_row ) { + + if ( count( $actualHeaders ) != count( $actual_row ) ) + continue; + + $actual_row = array_intersect_key( array_combine( $actualHeaders, $actual_row ), $expected_row ); + if ( $actual_row == $expected_row ) + $expectedResult++; + } + } + + return $expectedResult >= count( $expectedCSV ); +} + +/** + * Compare two strings containing YAML to ensure that @a $actualYaml contains at + * least what the YAML string @a $expectedYaml contains. + * + * @return whether or not @a $actualYaml contains @a $expectedJson + * @retval true @a $actualYaml contains @a $expectedJson + * @retval false @a $actualYaml does not contain @a $expectedJson + * + * @param[in] $actualYaml the YAML string to be tested + * @param[in] $expectedYaml the expected YAML string + */ +function checkThatYamlStringContainsYamlString( $actualYaml, $expectedYaml ) { + $actualValue = Mustangostang\Spyc::YAMLLoad( $actualYaml ); + $expectedValue = Mustangostang\Spyc::YAMLLoad( $expectedYaml ); + + if ( !$actualValue ) { + return false; + } + + return compareContents( $expectedValue, $actualValue ); +} + diff --git a/features/bootstrap/utils.php b/features/bootstrap/utils.php new file mode 100644 index 000000000..26b3dbbed --- /dev/null +++ b/features/bootstrap/utils.php @@ -0,0 +1,1453 @@ +<?php + +// Utilities that do NOT depend on WordPress code. + +namespace WP_CLI\Utils; + +use \Composer\Semver\Comparator; +use \Composer\Semver\Semver; +use \WP_CLI; +use \WP_CLI\Dispatcher; +use \WP_CLI\Iterators\Transform; + +const PHAR_STREAM_PREFIX = 'phar://'; + +function inside_phar() { + return 0 === strpos( WP_CLI_ROOT, PHAR_STREAM_PREFIX ); +} + +// Files that need to be read by external programs have to be extracted from the Phar archive. +function extract_from_phar( $path ) { + if ( ! inside_phar() ) { + return $path; + } + + $fname = basename( $path ); + + $tmp_path = get_temp_dir() . "wp-cli-$fname"; + + copy( $path, $tmp_path ); + + register_shutdown_function( + function() use ( $tmp_path ) { + if ( file_exists( $tmp_path ) ) { + unlink( $tmp_path ); + } + } + ); + + return $tmp_path; +} + +function load_dependencies() { + if ( inside_phar() ) { + if ( file_exists( WP_CLI_ROOT . '/vendor/autoload.php' ) ) { + require WP_CLI_ROOT . '/vendor/autoload.php'; + } elseif ( file_exists( dirname( dirname( WP_CLI_ROOT ) ) . '/autoload.php' ) ) { + require dirname( dirname( WP_CLI_ROOT ) ) . '/autoload.php'; + } + return; + } + + $has_autoload = false; + + foreach ( get_vendor_paths() as $vendor_path ) { + if ( file_exists( $vendor_path . '/autoload.php' ) ) { + require $vendor_path . '/autoload.php'; + $has_autoload = true; + break; + } + } + + if ( ! $has_autoload ) { + fwrite( STDERR, "Internal error: Can't find Composer autoloader.\nTry running: composer install\n" ); + exit( 3 ); + } +} + +function get_vendor_paths() { + $vendor_paths = array( + WP_CLI_ROOT . '/../../../vendor', // part of a larger project / installed via Composer (preferred) + WP_CLI_ROOT . '/vendor', // top-level project / installed as Git clone + ); + $maybe_composer_json = WP_CLI_ROOT . '/../../../composer.json'; + if ( file_exists( $maybe_composer_json ) && is_readable( $maybe_composer_json ) ) { + $composer = json_decode( file_get_contents( $maybe_composer_json ) ); + if ( ! empty( $composer->config ) && ! empty( $composer->config->{'vendor-dir'} ) ) { + array_unshift( $vendor_paths, WP_CLI_ROOT . '/../../../' . $composer->config->{'vendor-dir'} ); + } + } + return $vendor_paths; +} + +// Using require() directly inside a class grants access to private methods to the loaded code +function load_file( $path ) { + require_once $path; +} + +function load_command( $name ) { + $path = WP_CLI_ROOT . "/php/commands/$name.php"; + + if ( is_readable( $path ) ) { + include_once $path; + } +} + +/** + * Like array_map(), except it returns a new iterator, instead of a modified array. + * + * Example: + * + * $arr = array('Football', 'Socker'); + * + * $it = iterator_map($arr, 'strtolower', function($val) { + * return str_replace('foo', 'bar', $val); + * }); + * + * foreach ( $it as $val ) { + * var_dump($val); + * } + * + * @param array|object Either a plain array or another iterator + * @param callback The function to apply to an element + * @return object An iterator that applies the given callback(s) + */ +function iterator_map( $it, $fn ) { + if ( is_array( $it ) ) { + $it = new \ArrayIterator( $it ); + } + + if ( ! method_exists( $it, 'add_transform' ) ) { + $it = new Transform( $it ); + } + + foreach ( array_slice( func_get_args(), 1 ) as $fn ) { + $it->add_transform( $fn ); + } + + return $it; +} + +/** + * Search for file by walking up the directory tree until the first file is found or until $stop_check($dir) returns true + * @param string|array The files (or file) to search for + * @param string|null The directory to start searching from; defaults to CWD + * @param callable Function which is passed the current dir each time a directory level is traversed + * @return null|string Null if the file was not found + */ +function find_file_upward( $files, $dir = null, $stop_check = null ) { + $files = (array) $files; + if ( is_null( $dir ) ) { + $dir = getcwd(); + } + while ( is_readable( $dir ) ) { + // Stop walking up when the supplied callable returns true being passed the $dir + if ( is_callable( $stop_check ) && call_user_func( $stop_check, $dir ) ) { + return null; + } + + foreach ( $files as $file ) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + if ( file_exists( $path ) ) { + return $path; + } + } + + $parent_dir = dirname( $dir ); + if ( empty( $parent_dir ) || $parent_dir === $dir ) { + break; + } + $dir = $parent_dir; + } + return null; +} + +function is_path_absolute( $path ) { + // Windows + if ( isset( $path[1] ) && ':' === $path[1] ) { + return true; + } + + return '/' === $path[0]; +} + +/** + * Composes positional arguments into a command string. + * + * @param array + * @return string + */ +function args_to_str( $args ) { + return ' ' . implode( ' ', array_map( 'escapeshellarg', $args ) ); +} + +/** + * Composes associative arguments into a command string. + * + * @param array + * @return string + */ +function assoc_args_to_str( $assoc_args ) { + $str = ''; + + foreach ( $assoc_args as $key => $value ) { + if ( true === $value ) { + $str .= " --$key"; + } elseif ( is_array( $value ) ) { + foreach ( $value as $_ => $v ) { + $str .= assoc_args_to_str( + array( + $key => $v, + ) + ); + } + } else { + $str .= " --$key=" . escapeshellarg( $value ); + } + } + + return $str; +} + +/** + * Given a template string and an arbitrary number of arguments, + * returns the final command, with the parameters escaped. + */ +function esc_cmd( $cmd ) { + if ( func_num_args() < 2 ) { + trigger_error( 'esc_cmd() requires at least two arguments.', E_USER_WARNING ); + } + + $args = func_get_args(); + + $cmd = array_shift( $args ); + + return vsprintf( $cmd, array_map( 'escapeshellarg', $args ) ); +} + +function locate_wp_config() { + static $path; + + if ( null === $path ) { + $path = false; + + if ( file_exists( ABSPATH . 'wp-config.php' ) ) { + $path = ABSPATH . 'wp-config.php'; + } elseif ( file_exists( ABSPATH . '../wp-config.php' ) && ! file_exists( ABSPATH . '/../wp-settings.php' ) ) { + $path = ABSPATH . '../wp-config.php'; + } + + if ( $path ) { + $path = realpath( $path ); + } + } + + return $path; +} + +function wp_version_compare( $since, $operator ) { + $wp_version = str_replace( '-src', '', $GLOBALS['wp_version'] ); + $since = str_replace( '-src', '', $since ); + return version_compare( $wp_version, $since, $operator ); +} + +/** + * Render a collection of items as an ASCII table, JSON, CSV, YAML, list of ids, or count. + * + * Given a collection of items with a consistent data structure: + * + * ``` + * $items = array( + * array( + * 'key' => 'foo', + * 'value' => 'bar', + * ) + * ); + * ``` + * + * Render `$items` as an ASCII table: + * + * ``` + * WP_CLI\Utils\format_items( 'table', $items, array( 'key', 'value' ) ); + * + * # +-----+-------+ + * # | key | value | + * # +-----+-------+ + * # | foo | bar | + * # +-----+-------+ + * ``` + * + * Or render `$items` as YAML: + * + * ``` + * WP_CLI\Utils\format_items( 'yaml', $items, array( 'key', 'value' ) ); + * + * # --- + * # - + * # key: foo + * # value: bar + * ``` + * + * @access public + * @category Output + * + * @param string $format Format to use: 'table', 'json', 'csv', 'yaml', 'ids', 'count' + * @param array $items An array of items to output. + * @param array|string $fields Named fields for each item of data. Can be array or comma-separated list. + * @return null + */ +function format_items( $format, $items, $fields ) { + $assoc_args = compact( 'format', 'fields' ); + $formatter = new \WP_CLI\Formatter( $assoc_args ); + $formatter->display_items( $items ); +} + +/** + * Write data as CSV to a given file. + * + * @access public + * + * @param resource $fd File descriptor + * @param array $rows Array of rows to output + * @param array $headers List of CSV columns (optional) + */ +function write_csv( $fd, $rows, $headers = array() ) { + if ( ! empty( $headers ) ) { + fputcsv( $fd, $headers ); + } + + foreach ( $rows as $row ) { + if ( ! empty( $headers ) ) { + $row = pick_fields( $row, $headers ); + } + + fputcsv( $fd, array_values( $row ) ); + } +} + +/** + * Pick fields from an associative array or object. + * + * @param array|object Associative array or object to pick fields from + * @param array List of fields to pick + * @return array + */ +function pick_fields( $item, $fields ) { + $item = (object) $item; + + $values = array(); + + foreach ( $fields as $field ) { + $values[ $field ] = isset( $item->$field ) ? $item->$field : null; + } + + return $values; +} + +/** + * Launch system's $EDITOR for the user to edit some text. + * + * @access public + * @category Input + * + * @param string $content Some form of text to edit (e.g. post content) + * @return string|bool Edited text, if file is saved from editor; false, if no change to file. + */ +function launch_editor_for_input( $input, $filename = 'WP-CLI' ) { + + check_proc_available( 'launch_editor_for_input' ); + + $tmpdir = get_temp_dir(); + + do { + $tmpfile = basename( $filename ); + $tmpfile = preg_replace( '|\.[^.]*$|', '', $tmpfile ); + $tmpfile .= '-' . substr( md5( mt_rand() ), 0, 6 ); + $tmpfile = $tmpdir . $tmpfile . '.tmp'; + $fp = fopen( $tmpfile, 'xb' ); + if ( ! $fp && is_writable( $tmpdir ) && file_exists( $tmpfile ) ) { + $tmpfile = ''; + continue; + } + if ( $fp ) { + fclose( $fp ); + } + } while ( ! $tmpfile ); + + if ( ! $tmpfile ) { + \WP_CLI::error( 'Error creating temporary file.' ); + } + + $output = ''; + file_put_contents( $tmpfile, $input ); + + $editor = getenv( 'EDITOR' ); + if ( ! $editor ) { + $editor = is_windows() ? 'notepad' : 'vi'; + } + + $descriptorspec = array( STDIN, STDOUT, STDERR ); + $process = proc_open_compat( "$editor " . escapeshellarg( $tmpfile ), $descriptorspec, $pipes ); + $r = proc_close( $process ); + if ( $r ) { + exit( $r ); + } + + $output = file_get_contents( $tmpfile ); + + unlink( $tmpfile ); + + if ( $output === $input ) { + return false; + } + + return $output; +} + +/** + * @param string MySQL host string, as defined in wp-config.php + * @return array + */ +function mysql_host_to_cli_args( $raw_host ) { + $assoc_args = array(); + + $host_parts = explode( ':', $raw_host ); + if ( count( $host_parts ) == 2 ) { + list( $assoc_args['host'], $extra ) = $host_parts; + $extra = trim( $extra ); + if ( is_numeric( $extra ) ) { + $assoc_args['port'] = (int) $extra; + $assoc_args['protocol'] = 'tcp'; + } elseif ( '' !== $extra ) { + $assoc_args['socket'] = $extra; + } + } else { + $assoc_args['host'] = $raw_host; + } + + return $assoc_args; +} + +function run_mysql_command( $cmd, $assoc_args, $descriptors = null ) { + check_proc_available( 'run_mysql_command' ); + + if ( ! $descriptors ) { + $descriptors = array( STDIN, STDOUT, STDERR ); + } + + if ( isset( $assoc_args['host'] ) ) { + //@codingStandardsIgnoreStart + $assoc_args = array_merge( $assoc_args, mysql_host_to_cli_args( $assoc_args['host'] ) ); + //@codingStandardsIgnoreEnd + } + + $pass = $assoc_args['pass']; + unset( $assoc_args['pass'] ); + + $old_pass = getenv( 'MYSQL_PWD' ); + putenv( 'MYSQL_PWD=' . $pass ); + + $final_cmd = force_env_on_nix_systems( $cmd ) . assoc_args_to_str( $assoc_args ); + + $proc = proc_open_compat( $final_cmd, $descriptors, $pipes ); + if ( ! $proc ) { + exit( 1 ); + } + + $r = proc_close( $proc ); + + putenv( 'MYSQL_PWD=' . $old_pass ); + + if ( $r ) { + exit( $r ); + } +} + +/** + * Render PHP or other types of files using Mustache templates. + * + * IMPORTANT: Automatic HTML escaping is disabled! + */ +function mustache_render( $template_name, $data = array() ) { + if ( ! file_exists( $template_name ) ) { + $template_name = WP_CLI_ROOT . "/templates/$template_name"; + } + + $template = file_get_contents( $template_name ); + + $m = new \Mustache_Engine( + array( + 'escape' => function ( $val ) { + return $val; }, + ) + ); + + return $m->render( $template, $data ); +} + +/** + * Create a progress bar to display percent completion of a given operation. + * + * Progress bar is written to STDOUT, and disabled when command is piped. Progress + * advances with `$progress->tick()`, and completes with `$progress->finish()`. + * Process bar also indicates elapsed time and expected total time. + * + * ``` + * # `wp user generate` ticks progress bar each time a new user is created. + * # + * # $ wp user generate --count=500 + * # Generating users 22 % [=======> ] 0:05 / 0:23 + * + * $progress = \WP_CLI\Utils\make_progress_bar( 'Generating users', $count ); + * for ( $i = 0; $i < $count; $i++ ) { + * // uses wp_insert_user() to insert the user + * $progress->tick(); + * } + * $progress->finish(); + * ``` + * + * @access public + * @category Output + * + * @param string $message Text to display before the progress bar. + * @param integer $count Total number of ticks to be performed. + * @param int $interval Optional. The interval in milliseconds between updates. Default 100. + * @return cli\progress\Bar|WP_CLI\NoOp + */ +function make_progress_bar( $message, $count, $interval = 100 ) { + if ( \cli\Shell::isPiped() ) { + return new \WP_CLI\NoOp; + } + + return new \cli\progress\Bar( $message, $count, $interval ); +} + +function parse_url( $url ) { + $url_parts = \parse_url( $url ); + + if ( ! isset( $url_parts['scheme'] ) ) { + $url_parts = parse_url( 'http://' . $url ); + } + + return $url_parts; +} + +/** + * Check if we're running in a Windows environment (cmd.exe). + * + * @return bool + */ +function is_windows() { + return false !== ( $test_is_windows = getenv( 'WP_CLI_TEST_IS_WINDOWS' ) ) ? (bool) $test_is_windows : strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN'; +} + +/** + * Replace magic constants in some PHP source code. + * + * @param string $source The PHP code to manipulate. + * @param string $path The path to use instead of the magic constants + */ +function replace_path_consts( $source, $path ) { + $replacements = array( + '__FILE__' => "'$path'", + '__DIR__' => "'" . dirname( $path ) . "'", + ); + + $old = array_keys( $replacements ); + $new = array_values( $replacements ); + + return str_replace( $old, $new, $source ); +} + +/** + * Make a HTTP request to a remote URL. + * + * Wraps the Requests HTTP library to ensure every request includes a cert. + * + * ``` + * # `wp core download` verifies the hash for a downloaded WordPress archive + * + * $md5_response = Utils\http_request( 'GET', $download_url . '.md5' ); + * if ( 20 != substr( $md5_response->status_code, 0, 2 ) ) { + * WP_CLI::error( "Couldn't access md5 hash for release (HTTP code {$response->status_code})" ); + * } + * ``` + * + * @access public + * + * @param string $method HTTP method (GET, POST, DELETE, etc.) + * @param string $url URL to make the HTTP request to. + * @param array $headers Add specific headers to the request. + * @param array $options + * @return object + */ +function http_request( $method, $url, $data = null, $headers = array(), $options = array() ) { + + $cert_path = '/rmccue/requests/library/Requests/Transport/cacert.pem'; + $halt_on_error = ! isset( $options['halt_on_error'] ) || (bool) $options['halt_on_error']; + if ( inside_phar() ) { + // cURL can't read Phar archives + $options['verify'] = extract_from_phar( + WP_CLI_VENDOR_DIR . $cert_path + ); + } else { + foreach ( get_vendor_paths() as $vendor_path ) { + if ( file_exists( $vendor_path . $cert_path ) ) { + $options['verify'] = $vendor_path . $cert_path; + break; + } + } + if ( empty( $options['verify'] ) ) { + $error_msg = 'Cannot find SSL certificate.'; + if ( $halt_on_error ) { + WP_CLI::error( $error_msg ); + } + throw new \RuntimeException( $error_msg ); + } + } + + try { + return \Requests::request( $url, $headers, $data, $method, $options ); + } catch ( \Requests_Exception $ex ) { + // CURLE_SSL_CACERT_BADFILE only defined for PHP >= 7. + if ( 'curlerror' !== $ex->getType() || ! in_array( curl_errno( $ex->getData() ), array( CURLE_SSL_CONNECT_ERROR, CURLE_SSL_CERTPROBLEM, 77 /*CURLE_SSL_CACERT_BADFILE*/ ), true ) ) { + $error_msg = sprintf( "Failed to get url '%s': %s.", $url, $ex->getMessage() ); + if ( $halt_on_error ) { + WP_CLI::error( $error_msg ); + } + throw new \RuntimeException( $error_msg, null, $ex ); + } + // Handle SSL certificate issues gracefully + \WP_CLI::warning( sprintf( "Re-trying without verify after failing to get verified url '%s' %s.", $url, $ex->getMessage() ) ); + $options['verify'] = false; + try { + return \Requests::request( $url, $headers, $data, $method, $options ); + } catch ( \Requests_Exception $ex ) { + $error_msg = sprintf( "Failed to get non-verified url '%s' %s.", $url, $ex->getMessage() ); + if ( $halt_on_error ) { + WP_CLI::error( $error_msg ); + } + throw new \RuntimeException( $error_msg, null, $ex ); + } + } +} + +/** + * Increments a version string using the "x.y.z-pre" format + * + * Can increment the major, minor or patch number by one + * If $new_version == "same" the version string is not changed + * If $new_version is not a known keyword, it will be used as the new version string directly + * + * @param string $current_version + * @param string $new_version + * @return string + */ +function increment_version( $current_version, $new_version ) { + // split version assuming the format is x.y.z-pre + $current_version = explode( '-', $current_version, 2 ); + $current_version[0] = explode( '.', $current_version[0] ); + + switch ( $new_version ) { + case 'same': + // do nothing + break; + + case 'patch': + $current_version[0][2]++; + + $current_version = array( $current_version[0] ); // drop possible pre-release info + break; + + case 'minor': + $current_version[0][1]++; + $current_version[0][2] = 0; + + $current_version = array( $current_version[0] ); // drop possible pre-release info + break; + + case 'major': + $current_version[0][0]++; + $current_version[0][1] = 0; + $current_version[0][2] = 0; + + $current_version = array( $current_version[0] ); // drop possible pre-release info + break; + + default: // not a keyword + $current_version = array( array( $new_version ) ); + break; + } + + // reconstruct version string + $current_version[0] = implode( '.', $current_version[0] ); + $current_version = implode( '-', $current_version ); + + return $current_version; +} + +/** + * Compare two version strings to get the named semantic version. + * + * @access public + * + * @param string $new_version + * @param string $original_version + * @return string $name 'major', 'minor', 'patch' + */ +function get_named_sem_ver( $new_version, $original_version ) { + + if ( ! Comparator::greaterThan( $new_version, $original_version ) ) { + return ''; + } + + $parts = explode( '-', $original_version ); + $bits = explode( '.', $parts[0] ); + $major = $bits[0]; + if ( isset( $bits[1] ) ) { + $minor = $bits[1]; + } + if ( isset( $bits[2] ) ) { + $patch = $bits[2]; + } + + if ( ! is_null( $minor ) && Semver::satisfies( $new_version, "{$major}.{$minor}.x" ) ) { + return 'patch'; + } + + if ( Semver::satisfies( $new_version, "{$major}.x.x" ) ) { + return 'minor'; + } + + return 'major'; +} + +/** + * Return the flag value or, if it's not set, the $default value. + * + * Because flags can be negated (e.g. --no-quiet to negate --quiet), this + * function provides a safer alternative to using + * `isset( $assoc_args['quiet'] )` or similar. + * + * @access public + * @category Input + * + * @param array $assoc_args Arguments array. + * @param string $flag Flag to get the value. + * @param mixed $default Default value for the flag. Default: NULL + * @return mixed + */ +function get_flag_value( $assoc_args, $flag, $default = null ) { + return isset( $assoc_args[ $flag ] ) ? $assoc_args[ $flag ] : $default; +} + +/** + * Get the home directory. + * + * @access public + * @category System + * + * @return string + */ +function get_home_dir() { + $home = getenv( 'HOME' ); + if ( ! $home ) { + // In Windows $HOME may not be defined + $home = getenv( 'HOMEDRIVE' ) . getenv( 'HOMEPATH' ); + } + + return rtrim( $home, '/\\' ); +} + +/** + * Appends a trailing slash. + * + * @access public + * @category System + * + * @param string $string What to add the trailing slash to. + * @return string String with trailing slash added. + */ +function trailingslashit( $string ) { + return rtrim( $string, '/\\' ) . '/'; +} + +/** + * Convert Windows EOLs to *nix. + * + * @param string $str String to convert. + * @return string String with carriage return / newline pairs reduced to newlines. + */ +function normalize_eols( $str ) { + return str_replace( "\r\n", "\n", $str ); +} + +/** + * Get the system's temp directory. Warns user if it isn't writable. + * + * @access public + * @category System + * + * @return string + */ +function get_temp_dir() { + static $temp = ''; + + if ( $temp ) { + return $temp; + } + + // `sys_get_temp_dir()` introduced PHP 5.2.1. Will always return something. + $temp = trailingslashit( sys_get_temp_dir() ); + + if ( ! is_writable( $temp ) ) { + \WP_CLI::warning( "Temp directory isn't writable: {$temp}" ); + } + + return $temp; +} + +/** + * Parse a SSH url for its host, port, and path. + * + * Similar to parse_url(), but adds support for defined SSH aliases. + * + * ``` + * host OR host/path/to/wordpress OR host:port/path/to/wordpress + * ``` + * + * @access public + * + * @return mixed + */ +function parse_ssh_url( $url, $component = -1 ) { + preg_match( '#^((docker|docker\-compose|ssh|vagrant):)?(([^@:]+)@)?([^:/~]+)(:([\d]*))?((/|~)(.+))?$#', $url, $matches ); + $bits = array(); + foreach ( array( + 2 => 'scheme', + 4 => 'user', + 5 => 'host', + 7 => 'port', + 8 => 'path', + ) as $i => $key ) { + if ( ! empty( $matches[ $i ] ) ) { + $bits[ $key ] = $matches[ $i ]; + } + } + + // Find the hostname from `vagrant ssh-config` automatically. + if ( preg_match( '/^vagrant:?/', $url ) ) { + if ( 'vagrant' === $bits['host'] && empty( $bits['scheme'] ) ) { + $ssh_config = shell_exec( 'vagrant ssh-config 2>/dev/null' ); + if ( preg_match( '/Host\s(.+)/', $ssh_config, $matches ) ) { + $bits['scheme'] = 'vagrant'; + $bits['host'] = $matches[1]; + } + } + } + + switch ( $component ) { + case PHP_URL_SCHEME: + return isset( $bits['scheme'] ) ? $bits['scheme'] : null; + case PHP_URL_USER: + return isset( $bits['user'] ) ? $bits['user'] : null; + case PHP_URL_HOST: + return isset( $bits['host'] ) ? $bits['host'] : null; + case PHP_URL_PATH: + return isset( $bits['path'] ) ? $bits['path'] : null; + case PHP_URL_PORT: + return isset( $bits['port'] ) ? $bits['port'] : null; + default: + return $bits; + } +} + +/** + * Report the results of the same operation against multiple resources. + * + * @access public + * @category Input + * + * @param string $noun Resource being affected (e.g. plugin) + * @param string $verb Type of action happening to the noun (e.g. activate) + * @param integer $total Total number of resource being affected. + * @param integer $successes Number of successful operations. + * @param integer $failures Number of failures. + * @param null|integer $skips Optional. Number of skipped operations. Default null (don't show skips). + */ +function report_batch_operation_results( $noun, $verb, $total, $successes, $failures, $skips = null ) { + $plural_noun = $noun . 's'; + $past_tense_verb = past_tense_verb( $verb ); + $past_tense_verb_upper = ucfirst( $past_tense_verb ); + if ( $failures ) { + $failed_skipped_message = null === $skips ? '' : " ({$failures} failed" . ( $skips ? ", {$skips} skipped" : '' ) . ')'; + if ( $successes ) { + WP_CLI::error( "Only {$past_tense_verb} {$successes} of {$total} {$plural_noun}{$failed_skipped_message}." ); + } else { + WP_CLI::error( "No {$plural_noun} {$past_tense_verb}{$failed_skipped_message}." ); + } + } else { + $skipped_message = $skips ? " ({$skips} skipped)" : ''; + if ( $successes || $skips ) { + WP_CLI::success( "{$past_tense_verb_upper} {$successes} of {$total} {$plural_noun}{$skipped_message}." ); + } else { + $message = $total > 1 ? ucfirst( $plural_noun ) : ucfirst( $noun ); + WP_CLI::success( "{$message} already {$past_tense_verb}." ); + } + } +} + +/** + * Parse a string of command line arguments into an $argv-esqe variable. + * + * @access public + * @category Input + * + * @param string $arguments + * @return array + */ +function parse_str_to_argv( $arguments ) { + preg_match_all( '/(?<=^|\s)([\'"]?)(.+?)(?<!\\\\)\1(?=$|\s)/', $arguments, $matches ); + $argv = isset( $matches[0] ) ? $matches[0] : array(); + $argv = array_map( + function( $arg ) { + foreach ( array( '"', "'" ) as $char ) { + if ( substr( $arg, 0, 1 ) === $char && substr( $arg, -1 ) === $char ) { + $arg = substr( $arg, 1, -1 ); + break; + } + } + return $arg; + }, $argv + ); + return $argv; +} + +/** + * Locale-independent version of basename() + * + * @access public + * + * @param string $path + * @param string $suffix + * @return string + */ +function basename( $path, $suffix = '' ) { + return urldecode( \basename( str_replace( array( '%2F', '%5C' ), '/', urlencode( $path ) ), $suffix ) ); +} + +/** + * Checks whether the output of the current script is a TTY or a pipe / redirect + * + * Returns true if STDOUT output is being redirected to a pipe or a file; false is + * output is being sent directly to the terminal. + * + * If an env variable SHELL_PIPE exists, returned result depends it's + * value. Strings like 1, 0, yes, no, that validate to booleans are accepted. + * + * To enable ASCII formatting even when shell is piped, use the + * ENV variable SHELL_PIPE=0 + * + * @access public + * + * @return bool + */ +// @codingStandardsIgnoreLine +function isPiped() { + $shellPipe = getenv( 'SHELL_PIPE' ); + + if ( false !== $shellPipe ) { + return filter_var( $shellPipe, FILTER_VALIDATE_BOOLEAN ); + } + + return (function_exists( 'posix_isatty' ) && ! posix_isatty( STDOUT )); +} + +/** + * Expand within paths to their matching paths. + * + * Has no effect on paths which do not use glob patterns. + * + * @param string|array $paths Single path as a string, or an array of paths. + * @param int $flags Optional. Flags to pass to glob. Defaults to GLOB_BRACE. + * + * @return array Expanded paths. + */ +function expand_globs( $paths, $flags = 'default' ) { + // Compatibility for systems without GLOB_BRACE. + $glob_func = 'glob'; + if ( 'default' === $flags ) { + if ( ! defined( 'GLOB_BRACE' ) || getenv( 'WP_CLI_TEST_EXPAND_GLOBS_NO_GLOB_BRACE' ) ) { + $glob_func = 'WP_CLI\Utils\glob_brace'; + } else { + $flags = GLOB_BRACE; + } + } + + $expanded = array(); + + foreach ( (array) $paths as $path ) { + $matching = array( $path ); + + if ( preg_match( '/[' . preg_quote( '*?[]{}!', '/' ) . ']/', $path ) ) { + $matching = $glob_func( $path, $flags ) ?: array(); + } + $expanded = array_merge( $expanded, $matching ); + } + + return array_values( array_unique( $expanded ) ); +} + +/** + * Simulate a `glob()` with the `GLOB_BRACE` flag set. For systems (eg Alpine Linux) built against a libc library (eg https://www.musl-libc.org/) that lacks it. + * Copied and adapted from Zend Framework's `Glob::fallbackGlob()` and Glob::nextBraceSub()`. + * + * Zend Framework (http://framework.zend.com/) + * + * @link http://github.com/zendframework/zf2 for the canonical source repository + * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com) + * @license http://framework.zend.com/license/new-bsd New BSD License + * + * @param string $pattern Filename pattern. + * @param void $dummy_flags Not used. + * + * @return array Array of paths. + */ +function glob_brace( $pattern, $dummy_flags = null ) { + + static $next_brace_sub; + if ( ! $next_brace_sub ) { + // Find the end of the subpattern in a brace expression. + $next_brace_sub = function ( $pattern, $current ) { + $length = strlen( $pattern ); + $depth = 0; + + while ( $current < $length ) { + if ( '\\' === $pattern[ $current ] ) { + if ( ++$current === $length ) { + break; + } + $current++; + } else { + if ( ( '}' === $pattern[ $current ] && 0 === $depth-- ) || ( ',' === $pattern[ $current ] && 0 === $depth ) ) { + break; + } + + if ( '{' === $pattern[ $current++ ] ) { + $depth++; + } + } + } + + return $current < $length ? $current : null; + }; + } + + $length = strlen( $pattern ); + + // Find first opening brace. + for ( $begin = 0; $begin < $length; $begin++ ) { + if ( '\\' === $pattern[ $begin ] ) { + $begin++; + } elseif ( '{' === $pattern[ $begin ] ) { + break; + } + } + + // Find comma or matching closing brace. + if ( null === ( $next = $next_brace_sub( $pattern, $begin + 1 ) ) ) { + return glob( $pattern ); + } + + $rest = $next; + + // Point `$rest` to matching closing brace. + while ( '}' !== $pattern[ $rest ] ) { + if ( null === ( $rest = $next_brace_sub( $pattern, $rest + 1 ) ) ) { + return glob( $pattern ); + } + } + + $paths = array(); + $p = $begin + 1; + + // For each comma-separated subpattern. + do { + $subpattern = substr( $pattern, 0, $begin ) + . substr( $pattern, $p, $next - $p ) + . substr( $pattern, $rest + 1 ); + + if ( $result = glob_brace( $subpattern ) ) { + $paths = array_merge( $paths, $result ); + } + + if ( '}' === $pattern[ $next ] ) { + break; + } + + $p = $next + 1; + $next = $next_brace_sub( $pattern, $p ); + } while ( null !== $next ); + + return array_values( array_unique( $paths ) ); +} + +/** + * Get the closest suggestion for a mis-typed target term amongst a list of + * options. + * + * Uses the Levenshtein algorithm to calculate the relative "distance" between + * terms. + * + * If the "distance" to the closest term is higher than the threshold, an empty + * string is returned. + * + * @param string $target Target term to get a suggestion for. + * @param array $options Array with possible options. + * @param int $threshold Threshold above which to return an empty string. + * + * @return string + */ +function get_suggestion( $target, array $options, $threshold = 2 ) { + + $suggestion_map = array( + 'add' => 'create', + 'check' => 'check-update', + 'capability' => 'cap', + 'clear' => 'flush', + 'decrement' => 'decr', + 'del' => 'delete', + 'directory' => 'dir', + 'exec' => 'eval', + 'exec-file' => 'eval-file', + 'increment' => 'incr', + 'language' => 'locale', + 'lang' => 'locale', + 'new' => 'create', + 'number' => 'count', + 'remove' => 'delete', + 'regen' => 'regenerate', + 'rep' => 'replace', + 'repl' => 'replace', + 'trash' => 'delete', + 'v' => 'version', + ); + + if ( array_key_exists( $target, $suggestion_map ) && in_array( $suggestion_map[ $target ], $options, true ) ) { + return $suggestion_map[ $target ]; + } + + if ( empty( $options ) ) { + return ''; + } + foreach ( $options as $option ) { + $distance = levenshtein( $option, $target ); + $levenshtein[ $option ] = $distance; + } + + // Sort known command strings by distance to user entry. + asort( $levenshtein ); + + // Fetch the closest command string. + reset( $levenshtein ); + $suggestion = key( $levenshtein ); + + // Only return a suggestion if below a given threshold. + return $levenshtein[ $suggestion ] <= $threshold && $suggestion !== $target + ? (string) $suggestion + : ''; +} + +/** + * Get a Phar-safe version of a path. + * + * For paths inside a Phar, this strips the outer filesystem's location to + * reduce the path to what it needs to be within the Phar archive. + * + * Use the __FILE__ or __DIR__ constants as a starting point. + * + * @param string $path An absolute path that might be within a Phar. + * + * @return string A Phar-safe version of the path. + */ +function phar_safe_path( $path ) { + + if ( ! inside_phar() ) { + return $path; + } + + return str_replace( + PHAR_STREAM_PREFIX . WP_CLI_PHAR_PATH . '/', + PHAR_STREAM_PREFIX, + $path + ); +} + +/** + * Check whether a given Command object is part of the bundled set of + * commands. + * + * This function accepts both a fully qualified class name as a string as + * well as an object that extends `WP_CLI\Dispatcher\CompositeCommand`. + * + * @param \WP_CLI\Dispatcher\CompositeCommand|string $command + * + * @return bool + */ +function is_bundled_command( $command ) { + static $classes; + + if ( null === $classes ) { + $classes = array(); + $class_map = WP_CLI_VENDOR_DIR . '/composer/autoload_commands_classmap.php'; + if ( file_exists( WP_CLI_VENDOR_DIR . '/composer/' ) ) { + $classes = include $class_map; + } + } + + if ( is_object( $command ) ) { + $command = get_class( $command ); + } + + return is_string( $command ) + ? array_key_exists( $command, $classes ) + : false; +} + +/** + * Maybe prefix command string with "/usr/bin/env". + * Removes (if there) if Windows, adds (if not there) if not. + * + * @param string $command + * + * @return string + */ +function force_env_on_nix_systems( $command ) { + $env_prefix = '/usr/bin/env '; + $env_prefix_len = strlen( $env_prefix ); + if ( is_windows() ) { + if ( 0 === strncmp( $command, $env_prefix, $env_prefix_len ) ) { + $command = substr( $command, $env_prefix_len ); + } + } else { + if ( 0 !== strncmp( $command, $env_prefix, $env_prefix_len ) ) { + $command = $env_prefix . $command; + } + } + return $command; +} + +/** + * Check that `proc_open()` and `proc_close()` haven't been disabled. + * + * @param string $context Optional. If set will appear in error message. Default null. + * @param bool $return Optional. If set will return false rather than error out. Default false. + * + * @return bool + */ +function check_proc_available( $context = null, $return = false ) { + if ( ! function_exists( 'proc_open' ) || ! function_exists( 'proc_close' ) ) { + if ( $return ) { + return false; + } + $msg = 'The PHP functions `proc_open()` and/or `proc_close()` are disabled. Please check your PHP ini directive `disable_functions` or suhosin settings.'; + if ( $context ) { + WP_CLI::error( sprintf( "Cannot do '%s': %s", $context, $msg ) ); + } else { + WP_CLI::error( $msg ); + } + } + return true; +} + +/** + * Returns past tense of verb, with limited accuracy. Only regular verbs catered for, apart from "reset". + * + * @param string $verb Verb to return past tense of. + * + * @return string + */ +function past_tense_verb( $verb ) { + static $irregular = array( + 'reset' => 'reset', + ); + if ( isset( $irregular[ $verb ] ) ) { + return $irregular[ $verb ]; + } + $last = substr( $verb, -1 ); + if ( 'e' === $last ) { + $verb = substr( $verb, 0, -1 ); + } elseif ( 'y' === $last && ! preg_match( '/[aeiou]y$/', $verb ) ) { + $verb = substr( $verb, 0, -1 ) . 'i'; + } elseif ( preg_match( '/^[^aeiou]*[aeiou][^aeiouhwxy]$/', $verb ) ) { + // Rule of thumb that most (all?) one-voweled regular verbs ending in vowel + consonant (excluding "h", "w", "x", "y") double their final consonant - misses many cases (eg "submit"). + $verb .= $last; + } + return $verb . 'ed'; +} + +/** + * Get the path to the PHP binary used when executing WP-CLI. + * + * Environment values permit specific binaries to be indicated. + * + * @access public + * @category System + * + * @return string + */ +function get_php_binary() { + if ( $wp_cli_php_used = getenv( 'WP_CLI_PHP_USED' ) ) { + return $wp_cli_php_used; + } + + if ( $wp_cli_php = getenv( 'WP_CLI_PHP' ) ) { + return $wp_cli_php; + } + + // Available since PHP 5.4. + if ( defined( 'PHP_BINARY' ) ) { + return PHP_BINARY; + } + + // @codingStandardsIgnoreLine + if ( @is_executable( PHP_BINDIR . '/php' ) ) { + return PHP_BINDIR . '/php'; + } + + // @codingStandardsIgnoreLine + if ( is_windows() && @is_executable( PHP_BINDIR . '/php.exe' ) ) { + return PHP_BINDIR . '/php.exe'; + } + + return 'php'; +} + +/** + * Windows compatible `proc_open()`. + * Works around bug in PHP, and also deals with *nix-like `ENV_VAR=blah cmd` environment variable prefixes. + * + * @access public + * + * @param string $command Command to execute. + * @param array $descriptorspec Indexed array of descriptor numbers and their values. + * @param array &$pipes Indexed array of file pointers that correspond to PHP's end of any pipes that are created. + * @param string $cwd Initial working directory for the command. + * @param array $env Array of environment variables. + * @param array $other_options Array of additional options (Windows only). + * + * @return string Command stripped of any environment variable settings. + */ +function proc_open_compat( $cmd, $descriptorspec, &$pipes, $cwd = null, $env = null, $other_options = null ) { + if ( is_windows() ) { + // Need to encompass the whole command in double quotes - PHP bug https://bugs.php.net/bug.php?id=49139 + $cmd = '"' . _proc_open_compat_win_env( $cmd, $env ) . '"'; + } + return proc_open( $cmd, $descriptorspec, $pipes, $cwd, $env, $other_options ); +} + +/** + * For use by `proc_open_compat()` only. Separated out for ease of testing. Windows only. + * Turns *nix-like `ENV_VAR=blah command` environment variable prefixes into stripped `cmd` with prefixed environment variables added to passed in environment array. + * + * @access private + * + * @param string $command Command to execute. + * @param array &$env Array of existing environment variables. Will be modified if any settings in command. + * + * @return string Command stripped of any environment variable settings. + */ +function _proc_open_compat_win_env( $cmd, &$env ) { + if ( false !== strpos( $cmd, '=' ) ) { + while ( preg_match( '/^([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|[^ ]*) /', $cmd, $matches ) ) { + $cmd = substr( $cmd, strlen( $matches[0] ) ); + if ( null === $env ) { + $env = array(); + } + $env[ $matches[1] ] = isset( $matches[2][0] ) && '"' === $matches[2][0] ? substr( $matches[2], 1, -1 ) : $matches[2]; + } + } + return $cmd; +} + +/** + * First half of escaping for LIKE special characters % and _ before preparing for MySQL. + * + * Use this only before wpdb::prepare() or esc_sql(). Reversing the order is very bad for security. + * + * Copied from core "wp-includes/wp-db.php". Avoids dependency on WP 4.4 wpdb. + * + * @access public + * + * @param string $text The raw text to be escaped. The input typed by the user should have no + * extra or deleted slashes. + * @return string Text in the form of a LIKE phrase. The output is not SQL safe. Call $wpdb::prepare() + * or real_escape next. + */ +function esc_like( $text ) { + return addcslashes( $text, '_%\\' ); +} + +/** + * Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names. + * See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html + * + * @param string|array $idents A single identifier or an array of identifiers. + * @return string|array An escaped string if given a string, or an array of escaped strings if given an array of strings. + */ +function esc_sql_ident( $idents ) { + $backtick = function ( $v ) { + // Escape any backticks in the identifier by doubling. + return '`' . str_replace( '`', '``', $v ) . '`'; + }; + if ( is_string( $idents ) ) { + return $backtick( $idents ); + } + return array_map( $backtick, $idents ); +} + +/** + * Check whether a given string is a valid JSON representation. + * + * @param string $argument String to evaluate. + * @param bool $ignore_scalars Optional. Whether to ignore scalar values. + * Defaults to true. + * + * @return bool Whether the provided string is a valid JSON representation. + */ +function is_json( $argument, $ignore_scalars = true ) { + if ( ! is_string( $argument ) || '' === $argument ) { + return false; + } + + if ( $ignore_scalars && ! in_array( $argument[0], array( '{', '[' ), true ) ) { + return false; + } + + json_decode( $argument, $assoc = true ); + + return json_last_error() === JSON_ERROR_NONE; +} + +/** + * Parse known shell arrays included in the $assoc_args array. + * + * @param array $assoc_args Associative array of arguments. + * @param array $array_arguments Array of argument keys that should receive an + * array through the shell. + * + * @return array + */ +function parse_shell_arrays( $assoc_args, $array_arguments ) { + if ( empty( $assoc_args ) || empty( $array_arguments ) ) { + return $assoc_args; + } + + foreach ( $array_arguments as $key ) { + if ( array_key_exists( $key, $assoc_args ) && is_json( $assoc_args[ $key ] ) ) { + $assoc_args[ $key ] = json_decode( $assoc_args[ $key ], $assoc = true ); + } + } + + return $assoc_args; +} diff --git a/features/comment-meta.feature b/features/comment-meta.feature index 06d0f0fed..b1fd8831b 100644 --- a/features/comment-meta.feature +++ b/features/comment-meta.feature @@ -56,27 +56,3 @@ Feature: Manage comment custom fields """ -- hi """ - - Scenario: List comment meta - Given a WP install - - When I run `wp comment meta add 1 apple banana` - And I run `wp comment meta add 1 apple banana` - Then STDOUT should not be empty - - When I run `wp comment meta set 1 banana '["apple", "apple"]' --format=json` - Then STDOUT should not be empty - - When I run `wp comment meta list 1` - Then STDOUT should be a table containing rows: - | comment_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - - When I run `wp comment meta list 1 --unserialize` - Then STDOUT should be a table containing rows: - | comment_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | ["apple","apple"] | diff --git a/features/comment-notes.feature b/features/comment-notes.feature deleted file mode 100644 index f17032e5c..000000000 --- a/features/comment-notes.feature +++ /dev/null @@ -1,162 +0,0 @@ -Feature: Manage WordPress notes - - Background: - Given a WP install - - @require-wp-6.9 - Scenario: Create and list notes - When I run `wp comment create --comment_post_ID=1 --comment_content='This is a note about the block' --comment_author='Editor' --comment_type='note' --porcelain` - Then STDOUT should be a number - And save STDOUT as {NOTE_ID} - - When I run `wp comment get {NOTE_ID} --field=comment_type` - Then STDOUT should be: - """ - note - """ - - When I run `wp comment list --type=note --post_id=1 --format=ids` - Then STDOUT should be: - """ - {NOTE_ID} - """ - - When I run `wp comment list --type=note --post_id=1 --fields=comment_ID,comment_type,comment_content` - Then STDOUT should be a table containing rows: - | comment_ID | comment_type | comment_content | - | {NOTE_ID} | note | This is a note about the block | - - @require-wp-6.9 - Scenario: Notes are not shown by default in comment list - When I run `wp comment create --comment_post_ID=1 --comment_content='Regular comment' --comment_author='User' --porcelain` - Then save STDOUT as {COMMENT_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='This is a note' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE_ID} - - When I run `wp comment list --post_id=1 --format=ids` - Then STDOUT should contain: - """ - {COMMENT_ID} - """ - And STDOUT should not contain: - """ - {NOTE_ID} - """ - - When I run `wp comment list --type=note --post_id=1 --format=ids` - Then STDOUT should be: - """ - {NOTE_ID} - """ - - @require-wp-6.9 - Scenario: Reply to a note - When I run `wp comment create --comment_post_ID=1 --comment_content='Initial note' --comment_author='Editor1' --comment_type='note' --porcelain` - Then save STDOUT as {PARENT_NOTE_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='Reply to note' --comment_author='Editor2' --comment_type='note' --comment_parent={PARENT_NOTE_ID} --porcelain` - Then save STDOUT as {REPLY_NOTE_ID} - - When I run `wp comment get {REPLY_NOTE_ID} --field=comment_parent` - Then STDOUT should be: - """ - {PARENT_NOTE_ID} - """ - - When I run `wp comment list --type=note --post_id=1 --format=count` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-6.9 - Scenario: Resolve a note - When I run `wp comment create --comment_post_ID=1 --comment_content='Note to be resolved' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='Resolving' --comment_author='Editor' --comment_type='note' --comment_parent={NOTE_ID} --porcelain` - Then save STDOUT as {RESOLVE_NOTE_ID} - - When I run `wp comment meta add {RESOLVE_NOTE_ID} _wp_note_status resolved` - Then STDOUT should contain: - """ - Success: Added custom field. - """ - - When I run `wp comment meta get {RESOLVE_NOTE_ID} _wp_note_status` - Then STDOUT should be: - """ - resolved - """ - - @require-wp-6.9 - Scenario: Reopen a resolved note - When I run `wp comment create --comment_post_ID=1 --comment_content='Note to resolve and reopen' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='Resolving' --comment_author='Editor' --comment_type='note' --comment_parent={NOTE_ID} --porcelain` - Then save STDOUT as {RESOLVE_NOTE_ID} - - When I run `wp comment meta add {RESOLVE_NOTE_ID} _wp_note_status resolved` - Then STDOUT should contain: - """ - Success: Added custom field. - """ - - When I run `wp comment create --comment_post_ID=1 --comment_content='Reopening' --comment_author='Editor' --comment_type='note' --comment_parent={NOTE_ID} --porcelain` - Then save STDOUT as {REOPEN_NOTE_ID} - - When I run `wp comment meta add {REOPEN_NOTE_ID} _wp_note_status reopen` - Then STDOUT should contain: - """ - Success: Added custom field. - """ - - When I run `wp comment meta get {REOPEN_NOTE_ID} _wp_note_status` - Then STDOUT should be: - """ - reopen - """ - - @require-wp-6.9 - Scenario: List notes with comment meta - When I run `wp comment create --comment_post_ID=1 --comment_content='First note' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE1_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='Resolved note' --comment_author='Editor' --comment_type='note' --comment_parent={NOTE1_ID} --porcelain` - Then save STDOUT as {NOTE2_ID} - - When I run `wp comment meta add {NOTE2_ID} _wp_note_status resolved` - Then STDOUT should contain: - """ - Success: Added custom field. - """ - - When I run `wp comment meta list {NOTE2_ID} --keys=_wp_note_status` - Then STDOUT should be a table containing rows: - | comment_id | meta_key | meta_value | - | {NOTE2_ID} | _wp_note_status | resolved | - - @require-wp-6.9 - Scenario: Get notes for multiple posts - When I run `wp post create --post_title='Post 2' --porcelain` - Then save STDOUT as {POST2_ID} - - When I run `wp comment create --comment_post_ID=1 --comment_content='Note on post 1' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE1_ID} - - When I run `wp comment create --comment_post_ID={POST2_ID} --comment_content='Note on post 2' --comment_author='Editor' --comment_type='note' --porcelain` - Then save STDOUT as {NOTE2_ID} - - When I run `wp comment list --type=note --post_id=1 --format=ids` - Then STDOUT should be: - """ - {NOTE1_ID} - """ - - When I run `wp comment list --type=note --post_id={POST2_ID} --format=ids` - Then STDOUT should be: - """ - {NOTE2_ID} - """ diff --git a/features/comment-recount.feature b/features/comment-recount.feature index c9216150f..7208b97bd 100644 --- a/features/comment-recount.feature +++ b/features/comment-recount.feature @@ -11,14 +11,7 @@ Feature: Recount comments on a post 3 """ - Given a recount-comments.php file: - """ - <?php - global $wpdb; - $wpdb->update( $wpdb->posts, array( "comment_count" => 1 ), array( "ID" => 1 ) ); - clean_post_cache( 1 ); - """ - When I run `wp eval-file recount-comments.php` + When I run `wp eval 'global $wpdb; $wpdb->update( $wpdb->posts, array( "comment_count" => 1 ), array( "ID" => 1 ) );'` And I run `wp post get 1 --field=comment_count` Then STDOUT should be: """ @@ -30,9 +23,3 @@ Feature: Recount comments on a post """ Updated post 1 comment count to 3. """ - - When I try `wp comment recount 99999999` - Then STDERR should be: - """ - Warning: Post 99999999 doesn't exist. - """ diff --git a/features/comment.feature b/features/comment.feature index 066df5476..e09c3feb2 100644 --- a/features/comment.feature +++ b/features/comment.feature @@ -70,16 +70,6 @@ Feature: Manage WordPress comments """ And the return code should be 1 - Scenario: Updating an invalid comment should return an error - Given a WP install - - When I try `wp comment update 22 --comment_author=Foo` - Then the return code should be 1 - And STDERR should contain: - """ - Warning: Could not update comment. - """ - Scenario: Get details about an existing comment When I run `wp comment get 1` Then STDOUT should be a table containing rows: @@ -115,12 +105,6 @@ Feature: Manage WordPress comments #comment-1 """ - When I run `wp comment get 1 --field=url` - Then STDOUT should contain: - """ - #comment-1 - """ - Scenario: List the URLs of comments When I run `wp comment create --comment_post_ID=1 --porcelain` Then save STDOUT as {COMMENT_ID} @@ -128,15 +112,15 @@ Feature: Manage WordPress comments When I run `wp comment url 1 {COMMENT_ID}` Then STDOUT should be: """ - https://example.com/?p=1#comment-1 - https://example.com/?p=1#comment-{COMMENT_ID} + http://example.com/?p=1#comment-1 + http://example.com/?p=1#comment-{COMMENT_ID} """ When I run `wp comment url {COMMENT_ID} 1` Then STDOUT should be: """ - https://example.com/?p=1#comment-{COMMENT_ID} - https://example.com/?p=1#comment-1 + http://example.com/?p=1#comment-{COMMENT_ID} + http://example.com/?p=1#comment-1 """ Scenario: Count comments @@ -168,12 +152,11 @@ Feature: Manage WordPress comments total_comments: 1 """ - @require-mysql Scenario: Approving/unapproving comments Given I run `wp comment create --comment_post_ID=1 --comment_approved=0 --porcelain` And save STDOUT as {COMMENT_ID} - # With site url set. + # With site url set. When I run `wp comment approve {COMMENT_ID} --url=www.example.com` Then STDOUT should be: """ @@ -181,7 +164,7 @@ Feature: Manage WordPress comments """ When I try the previous command again - Then STDERR should contain: + Then STDERR should be: """ Error: Could not update comment status """ @@ -206,67 +189,7 @@ Feature: Manage WordPress comments 0 """ - # Without site url set. - When I try `wp comment approve {COMMENT_ID}` - Then STDOUT should be: - """ - Success: Approved comment {COMMENT_ID}. - """ - And STDERR should be: - """ - Warning: Site url not set - defaulting to 'example.com'. Any notification emails sent to post author may appear to come from 'example.com'. - """ - And the return code should be 0 - - When I try `wp comment unapprove {COMMENT_ID}` - Then STDOUT should be: - """ - Success: Unapproved comment {COMMENT_ID}. - """ - And STDERR should be: - """ - Warning: Site url not set - defaulting to 'example.com'. Any notification emails sent to post author may appear to come from 'example.com'. - """ - And the return code should be 0 - - # Approving an approved comment works in SQLite - @require-sqlite - Scenario: Approving/unapproving comments - Given I run `wp comment create --comment_post_ID=1 --comment_approved=0 --porcelain` - And save STDOUT as {COMMENT_ID} - - # With site url set. - When I run `wp comment approve {COMMENT_ID} --url=www.example.com` - Then STDOUT should be: - """ - Success: Approved comment {COMMENT_ID}. - """ - - When I try the previous command again - Then STDOUT should be: - """ - Success: Approved comment {COMMENT_ID}. - """ - - When I run `wp comment get --field=comment_approved {COMMENT_ID}` - Then STDOUT should be: - """ - 1 - """ - - When I run `wp comment unapprove {COMMENT_ID} --url=www.example.com` - Then STDOUT should be: - """ - Success: Unapproved comment {COMMENT_ID}. - """ - - When I run `wp comment get --field=comment_approved {COMMENT_ID}` - Then STDOUT should be: - """ - 0 - """ - - # Without site url set. + # Without site url set. When I try `wp comment approve {COMMENT_ID}` Then STDOUT should be: """ @@ -289,14 +212,13 @@ Feature: Manage WordPress comments """ And the return code should be 0 - @skip-windows Scenario: Approving/unapproving comments with multidigit comment ID Given I run `wp comment delete $(wp comment list --field=ID)` And I run `wp comment generate --count=10 --quiet` And I run `wp comment create --comment_post_ID=1 --porcelain` And save STDOUT as {COMMENT_ID} - # With site url set. + # With site url set. When I run `wp comment unapprove {COMMENT_ID} --url=www.example.com` Then STDOUT should be: """ @@ -321,7 +243,7 @@ Feature: Manage WordPress comments 11 """ - # Without site url set. + # Without site url set. When I try `wp comment unapprove {COMMENT_ID}` Then STDOUT should be: """ @@ -344,18 +266,17 @@ Feature: Manage WordPress comments """ And the return code should be 0 - @skip-windows Scenario: Spam/unspam comments with multidigit comment ID Given I run `wp comment delete $(wp comment list --field=ID)` And I run `wp comment generate --count=10 --quiet` And I run `wp comment create --comment_post_ID=1 --porcelain` And save STDOUT as {COMMENT_ID} - # With site url set. + # With site url set. When I run `wp comment spam {COMMENT_ID}` Then STDOUT should be: """ - Success: Marked comment {COMMENT_ID} as spam. + Success: Marked as spam comment {COMMENT_ID}. """ When I run `wp comment list --format=count --status=spam` @@ -376,11 +297,11 @@ Feature: Manage WordPress comments 0 """ - # Without site url set. + # Without site url set. When I run `wp comment spam {COMMENT_ID}` Then STDOUT should be: """ - Success: Marked comment {COMMENT_ID} as spam. + Success: Marked as spam comment {COMMENT_ID}. """ When I try `wp comment unspam {COMMENT_ID}` @@ -394,14 +315,13 @@ Feature: Manage WordPress comments """ And the return code should be 0 - @skip-windows Scenario: Trash/untrash comments with multidigit comment ID Given I run `wp comment delete $(wp comment list --field=ID) --force` And I run `wp comment generate --count=10 --quiet` And I run `wp comment create --comment_post_ID=1 --porcelain` And save STDOUT as {COMMENT_ID} - # With site url set. + # With site url set. When I run `wp comment trash {COMMENT_ID}` Then STDOUT should be: """ @@ -426,7 +346,7 @@ Feature: Manage WordPress comments 0 """ - # Without site url set. + # Without site url set. When I run `wp comment trash {COMMENT_ID}` Then STDOUT should be: """ diff --git a/features/extra/no-mail.php b/features/extra/no-mail.php new file mode 100644 index 000000000..de7a42272 --- /dev/null +++ b/features/extra/no-mail.php @@ -0,0 +1,7 @@ +<?php + +function wp_mail( $to ) { + // Log for testing purposes + WP_CLI::log( "WP-CLI test suite: Sent email to {$to}." ); +} + diff --git a/features/font-collection.feature b/features/font-collection.feature deleted file mode 100644 index 38acd953f..000000000 --- a/features/font-collection.feature +++ /dev/null @@ -1,82 +0,0 @@ -Feature: Manage WordPress font collections - - Background: - Given a WP install - - @require-wp-6.5 - Scenario: Listing font collections - When I try `wp font collection list` - Then STDOUT should be a table containing rows: - | slug | name | description | categories | - | google-fonts | Google Fonts | Install from Google Fonts. Fonts are copied to and served from your site. | Sans Serif (sans-serif), Display (display), Serif (serif), Handwriting (handwriting), Monospace (monospace) | - - @require-wp-6.5 - Scenario: Getting a non-existent font collection - When I try `wp font collection get nonexistent-collection` - Then the return code should be 1 - And STDERR should contain: - """ - doesn't exist - """ - - @require-wp-6.5 - Scenario: Checking whether a font collection is registered - When I try `wp font collection is-registered nonexistent-collection` - Then the return code should be 1 - - When I run `wp font collection is-registered google-fonts` - Then the return code should be 0 - - @require-wp-6.5 - Scenario: Listing font families in a collection - When I run `wp font collection list-families google-fonts --format=count` - Then STDOUT should be a number - - @require-wp-6.5 - Scenario: Listing font families in a collection with fields - When I run `wp font collection list-families google-fonts --fields=slug,name --format=csv` - Then STDOUT should contain: - """ - slug,name - """ - - @require-wp-6.5 - Scenario: Filtering font families by category - When I run `wp font collection list-families google-fonts --category=sans-serif --format=count` - Then STDOUT should be a number - - @require-wp-6.5 - Scenario: Listing categories in a collection - When I run `wp font collection list-categories google-fonts --format=csv` - Then STDOUT should contain: - """ - slug,name - """ - - @require-wp-6.5 - Scenario: Getting a non-existent collection for list-families - When I try `wp font collection list-families nonexistent-collection` - Then the return code should be 1 - And STDERR should contain: - """ - doesn't exist - """ - - @require-wp-6.5 - Scenario: Getting a non-existent collection for list-categories - When I try `wp font collection list-categories nonexistent-collection` - Then the return code should be 1 - And STDERR should contain: - """ - doesn't exist - """ - - @less-than-wp-6.5 - Scenario: Font collection commands fail on WordPress < 6.5 - Given a WP install - When I try `wp font collection list` - Then the return code should be 1 - And STDERR should contain: - """ - Requires WordPress 6.5 or greater - """ diff --git a/features/font-face.feature b/features/font-face.feature deleted file mode 100644 index 51ab88b3d..000000000 --- a/features/font-face.feature +++ /dev/null @@ -1,68 +0,0 @@ -Feature: Manage WordPress font faces - - Background: - Given a WP install - - @require-wp-6.5 - Scenario: Installing a font face - Given I run `wp post create --post_type=wp_font_family --post_title="Test Family" --post_status=publish --porcelain` - And save STDOUT as {FONT_FAMILY_ID} - - When I run `wp font face install {FONT_FAMILY_ID} --src="https://example.com/font.woff2" --porcelain` - Then STDOUT should be a number - And save STDOUT as {FONT_FACE_ID} - - When I run `wp post get {FONT_FACE_ID} --field=post_parent` - Then STDOUT should be: - """ - {FONT_FAMILY_ID} - """ - - @require-wp-6.5 - Scenario: Installing a font face with custom properties - Given I run `wp post create --post_type=wp_font_family --post_title="Test Family" --post_status=publish --porcelain` - And save STDOUT as {FONT_FAMILY_ID} - - When I run `wp font face install {FONT_FAMILY_ID} --src="font.woff2" --font-weight=700 --font-style=italic --porcelain` - Then STDOUT should be a number - And save STDOUT as {FONT_FACE_ID} - - When I run `wp post get {FONT_FACE_ID} --field=post_title` - Then STDOUT should contain: - """ - 700 - """ - And STDOUT should contain: - """ - italic - """ - - @require-wp-6.5 - Scenario: Installing a font face with invalid parent - When I try `wp font face install 999999 --src="font.woff2"` - Then the return code should be 1 - And STDERR should contain: - """ - doesn't exist - """ - - @require-wp-6.5 - Scenario: Installing a font face without required src parameter - Given I run `wp post create --post_type=wp_font_family --post_title="Test Family" --post_status=publish --porcelain` - And save STDOUT as {FONT_FAMILY_ID} - - When I try `wp font face install {FONT_FAMILY_ID}` - Then the return code should be 1 - And STDERR should contain: - """ - missing --src parameter - """ - - @less-than-wp-6.5 - Scenario: Font face install commands fail on WordPress < 6.5 - When I try `wp font face install 1 --src=test.woff2` - Then the return code should be 1 - And STDERR should contain: - """ - Requires WordPress 6.5 or greater - """ diff --git a/features/font-family.feature b/features/font-family.feature deleted file mode 100644 index 4a4799cba..000000000 --- a/features/font-family.feature +++ /dev/null @@ -1,46 +0,0 @@ -Feature: Manage WordPress font families - - Background: - Given a WP install - - @require-wp-6.5 - Scenario: Installing a font family from a collection - When I run `wp font family install google-fonts "roboto" --porcelain` - Then STDOUT should be a number - And save STDOUT as {FONT_FAMILY_ID} - - When I run `wp post get {FONT_FAMILY_ID} --field=post_title` - Then STDOUT should contain: - """ - Roboto - """ - - When I run `wp post list --post_type=wp_font_face --post_parent={FONT_FAMILY_ID} --format=count` - Then STDOUT should be a number - - @require-wp-6.5 - Scenario: Installing a font family from a non-existent collection - When I try `wp font family install nonexistent-collection roboto` - Then the return code should be 1 - And STDERR should contain: - """ - doesn't exist - """ - - @require-wp-6.5 - Scenario: Installing a non-existent font family from a collection - When I try `wp font family install google-fonts nonexistent-family` - Then the return code should be 1 - And STDERR should contain: - """ - not found - """ - - @less-than-wp-6.5 - Scenario: Font family install commands fail on WordPress < 6.5 - When I try `wp font family install google-fonts roboto` - Then the return code should be 1 - And STDERR should contain: - """ - Requires WordPress 6.5 or greater - """ diff --git a/features/menu-item.feature b/features/menu-item.feature index 5d73bf0ae..2507896bb 100644 --- a/features/menu-item.feature +++ b/features/menu-item.feature @@ -34,10 +34,10 @@ Feature: Manage WordPress menu items When I run `wp menu item add-term sidebar-menu post_tag {TERM_ID} --porcelain` Then save STDOUT as {TERM_ITEM_ID} - When I run `wp menu item add-custom sidebar-menu Apple https://apple.com --parent-id={POST_ITEM_ID} --porcelain` + When I run `wp menu item add-custom sidebar-menu Apple http://apple.com --parent-id={POST_ITEM_ID} --porcelain` Then save STDOUT as {CUSTOM_ITEM_ID} - When I run `wp menu item update {CUSTOM_ITEM_ID} --title=WordPress --link='https://wordpress.org' --target=_blank --position=2` + When I run `wp menu item update {CUSTOM_ITEM_ID} --title=WordPress --link='http://wordpress.org' --target=_blank --position=2` Then STDOUT should be: """ Success: Menu item updated. @@ -51,10 +51,10 @@ Feature: Manage WordPress menu items When I run `wp menu item list sidebar-menu --fields=type,title,description,position,link,menu_item_parent` Then STDOUT should be a table containing rows: - | type | title | description | position | link | menu_item_parent | - | post_type | Custom Test Post | Washington Apples | 1 | {POST_LINK} | 0 | - | custom | WordPress | | 2 | https://wordpress.org | {POST_ITEM_ID} | - | taxonomy | Test term | | 3 | {TERM_LINK} | 0 | + | type | title | description | position | link | menu_item_parent | + | post_type | Custom Test Post | Washington Apples | 1 | {POST_LINK} | 0 | + | custom | WordPress | | 2 | http://wordpress.org | {POST_ITEM_ID} | + | taxonomy | Test term | | 3 | {TERM_LINK} | 0 | When I run `wp menu item list sidebar-menu --format=ids` Then STDOUT should not be empty @@ -65,7 +65,7 @@ Feature: Manage WordPress menu items Success: Deleted 1 of 1 menu items. """ And I run `wp menu item list sidebar-menu --format=count` - And STDOUT should be: + Then STDOUT should be: """ 2 """ @@ -76,7 +76,7 @@ Feature: Manage WordPress menu items Success: Deleted 2 of 2 menu items. """ And I run `wp menu item list sidebar-menu --format=count` - And STDOUT should be: + Then STDOUT should be: """ 0 """ @@ -86,13 +86,13 @@ Feature: Manage WordPress menu items When I run `wp menu create "Grandparent Test"` Then STDOUT should not be empty - When I run `wp menu item add-custom grandparent-test Grandparent https://example.com/grandparent --porcelain` + When I run `wp menu item add-custom grandparent-test Grandparent http://example.com/grandparent --porcelain` Then save STDOUT as {GRANDPARENT_ID} - When I run `wp menu item add-custom grandparent-test Parent https://example.com/parent --porcelain --parent-id={GRANDPARENT_ID}` + When I run `wp menu item add-custom grandparent-test Parent http://example.com/parent --porcelain --parent-id={GRANDPARENT_ID}` Then save STDOUT as {PARENT_ID} - When I run `wp menu item add-custom grandparent-test Child https://example.com/child --porcelain --parent-id={PARENT_ID}` + When I run `wp menu item add-custom grandparent-test Child http://example.com/child --porcelain --parent-id={PARENT_ID}` Then save STDOUT as {CHILD_ID} When I run `wp menu item list grandparent-test --fields=title,db_id,menu_item_parent` @@ -103,8 +103,8 @@ Feature: Manage WordPress menu items | Child | {CHILD_ID} | {PARENT_ID} | When I run `wp menu item delete {PARENT_ID}` - And I run `wp cache flush` - And I run `wp menu item list grandparent-test --fields=title,db_id,menu_item_parent` + + When I run `wp menu item list grandparent-test --fields=title,db_id,menu_item_parent` Then STDOUT should be a table containing rows: | title | db_id | menu_item_parent | | Grandparent | {GRANDPARENT_ID} | 0 | @@ -122,7 +122,7 @@ Feature: Manage WordPress menu items """ And the return code should be 1 - When I run `wp menu item add-custom sidebar-menu Apple https://apple.com --porcelain` + When I run `wp menu item add-custom sidebar-menu Apple http://apple.com --porcelain` Then save STDOUT as {CUSTOM_ITEM_ID} When I try `wp menu item delete {CUSTOM_ITEM_ID} 99999999` @@ -132,194 +132,3 @@ Feature: Manage WordPress menu items Error: Only deleted 1 of 2 menu items. """ And the return code should be 1 - - Scenario: Menu order is recalculated on insertion - When I run `wp menu create "Sidebar Menu"` - Then STDOUT should not be empty - - When I run `wp menu item add-custom sidebar-menu First https://first.com --porcelain` - Then save STDOUT as {ITEM_ID_1} - - When I run `wp menu item add-custom sidebar-menu Second https://second.com --porcelain` - Then save STDOUT as {ITEM_ID_2} - - When I run `wp menu item add-custom sidebar-menu Third https://third.com --porcelain` - Then save STDOUT as {ITEM_ID_3} - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | First | 1 | https://first.com | - | custom | Second | 2 | https://second.com | - | custom | Third | 3 | https://third.com | - - When I run `wp menu item add-custom sidebar-menu Fourth https://fourth.com --position=2 --porcelain` - Then save STDOUT as {ITEM_ID_4} - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | First | 1 | https://first.com | - | custom | Fourth | 2 | https://fourth.com | - | custom | Second | 3 | https://second.com | - | custom | Third | 4 | https://third.com | - - Scenario: Menu order is recalculated on deletion - When I run `wp menu create "Sidebar Menu"` - Then STDOUT should not be empty - - When I run `wp menu item add-custom sidebar-menu First https://first.com --porcelain` - Then save STDOUT as {ITEM_ID_1} - - When I run `wp menu item add-custom sidebar-menu Second https://second.com --porcelain` - Then save STDOUT as {ITEM_ID_2} - - When I run `wp menu item add-custom sidebar-menu Third https://third.com --porcelain` - Then save STDOUT as {ITEM_ID_3} - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | First | 1 | https://first.com | - | custom | Second | 2 | https://second.com | - | custom | Third | 3 | https://third.com | - - When I run `wp menu item delete {ITEM_ID_2}` - Then STDOUT should be: - """ - Success: Deleted 1 of 1 menu items. - """ - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | First | 1 | https://first.com | - | custom | Third | 2 | https://third.com | - - Scenario: Menu order is recalculated on update - When I run `wp menu create "Sidebar Menu"` - Then STDOUT should not be empty - - When I run `wp menu item add-custom sidebar-menu Alpha https://alpha.com --porcelain` - Then save STDOUT as {ITEM_ID_1} - - When I run `wp menu item add-custom sidebar-menu Beta https://beta.com --porcelain` - Then save STDOUT as {ITEM_ID_2} - - When I run `wp menu item add-custom sidebar-menu Gamma https://gamma.com --porcelain` - Then save STDOUT as {ITEM_ID_3} - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | Alpha | 1 | https://alpha.com | - | custom | Beta | 2 | https://beta.com | - | custom | Gamma | 3 | https://gamma.com | - - When I run `wp menu item update {ITEM_ID_3} --position=1` - Then STDOUT should be: - """ - Success: Menu item updated. - """ - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | Gamma | 1 | https://gamma.com | - | custom | Alpha | 2 | https://alpha.com | - | custom | Beta | 3 | https://beta.com | - - When I run `wp menu item update {ITEM_ID_1} --position=3` - Then STDOUT should be: - """ - Success: Menu item updated. - """ - - When I run `wp menu item list sidebar-menu --fields=type,title,position,link` - Then STDOUT should be a table containing rows: - | type | title | position | link | - | custom | Gamma | 1 | https://gamma.com | - | custom | Beta | 2 | https://beta.com | - | custom | Alpha | 3 | https://alpha.com | - - Scenario: Get menu item details - When I run `wp menu create "Sidebar Menu"` - Then STDOUT should not be empty - - When I run `wp menu item add-custom sidebar-menu Apple https://apple.com --porcelain` - Then save STDOUT as {ITEM_ID} - - When I run `wp menu item get {ITEM_ID}` - Then STDOUT should be a table containing rows: - | Field | Value | - | db_id | {ITEM_ID} | - | type | custom | - | title | Apple | - | link | https://apple.com | - | position | 1 | - - When I run `wp menu item get {ITEM_ID} --format=json` - Then STDOUT should be JSON containing: - """ - { - "db_id": {ITEM_ID}, - "type": "custom", - "title": "Apple", - "link": "https://apple.com" - } - """ - - When I run `wp menu item get {ITEM_ID} --field=title` - Then STDOUT should be: - """ - Apple - """ - - When I run `wp menu item get {ITEM_ID} --fields=db_id,title,type --format=csv` - Then STDOUT should be CSV containing: - | Field | Value | - | db_id | {ITEM_ID} | - | title | Apple | - | type | custom | - - When I try `wp menu item get 99999999` - Then STDERR should be: - """ - Error: Invalid menu item. - """ - And the return code should be 1 - - When I run `wp post create --post_title='Test Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp menu item get {POST_ID}` - Then STDERR should be: - """ - Error: Invalid menu item. - """ - And the return code should be 1 - - Scenario: Add a post type archive as a menu item - - When I run `wp menu create "Archive Menu"` - Then STDOUT should not be empty - - When I run `wp menu item add-post-type-archive archive-menu post --porcelain` - Then STDOUT should be a number - And save STDOUT as {ITEM_ID} - - When I run `wp menu item list archive-menu --fields=db_id,type,object` - Then STDOUT should be a table containing rows: - | db_id | type | object | - | {ITEM_ID} | post_type_archive | post | - - When I run `wp menu item get {ITEM_ID} --field=link` - Then STDOUT should not be empty - - When I try `wp menu item add-post-type-archive archive-menu invalidposttype` - Then STDERR should be: - """ - Error: Invalid post type. - """ - And the return code should be 1 - diff --git a/features/menu-location.feature b/features/menu-location.feature index 1568eafd7..74c0155d9 100644 --- a/features/menu-location.feature +++ b/features/menu-location.feature @@ -2,11 +2,11 @@ Feature: Manage WordPress menu locations Background: Given a WP install - And I run `wp theme delete --all --force` - And I run `wp theme install twentytwelve --activate` Scenario: Assign / remove location from a menu - When I run `wp menu location list` + + When I run `wp theme install p2 --activate` + And I run `wp menu location list` Then STDOUT should be a table containing rows: | location | description | | primary | Primary Menu | diff --git a/features/network-meta.feature b/features/network-meta.feature index 275de1cd7..528a3416c 100644 --- a/features/network-meta.feature +++ b/features/network-meta.feature @@ -13,72 +13,6 @@ Feature: Manage network-wide custom fields. Then STDOUT should be empty And STDERR should contain: """ - This is not a multisite install + This is not a multisite install. """ And the return code should be 1 - - Scenario: Network meta is actually network options - Given a WP multisite install - - When I run `wp eval 'update_network_option( 1, "mykey", "123" );'` - And I run `wp eval 'echo get_network_option( 1, "mykey" );'` - Then STDOUT should be: - """ - 123 - """ - - When I run `wp network meta update 1 mykey 456` - Then STDOUT should be: - """ - Success: Updated custom field 'mykey'. - """ - - When I run `wp network meta get 1 mykey` - Then STDOUT should be: - """ - 456 - """ - - When I run `wp eval 'echo get_network_option( 1, "mykey" );'` - Then STDOUT should be: - """ - 456 - """ - - @require-object-cache - Scenario: Object cache correctly handles network meta updates - Given a WP multisite install - - When I run `wp eval 'update_network_option( 1, "objkey", "123" );'` - - And I run `wp network meta get 1 objkey` - Then STDOUT should be: - """ - 123 - """ - - When I run `wp eval 'update_network_option( 1, "objkey", "456" );'` - - And I run `wp network meta get 1 objkey` - Then STDOUT should be: - """ - 456 - """ - - When I run `wp network meta update 1 objkey 789` - Then STDOUT should be: - """ - Success: Updated custom field 'objkey'. - """ - - When I run `wp network meta get 1 objkey` - Then STDOUT should be: - """ - 789 - """ - - When I run `wp eval 'echo get_network_option( 1, "objkey" );'` - Then STDOUT should be: - """ - 789 - """ \ No newline at end of file diff --git a/features/option-get-autoload.feature b/features/option-get-autoload.feature deleted file mode 100644 index 30a3c0d1b..000000000 --- a/features/option-get-autoload.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: Get 'autoload' value for an option - - Scenario: Option doesn't exist - Given a WP install - - When I try `wp option get-autoload foo` - Then STDERR should be: - """ - Error: Could not get 'foo' option. Does it exist? - """ - @less-than-wp-6.6 - Scenario: Displays 'autoload' value - Given a WP install - - When I run `wp option add foo bar` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - yes - """ - @require-wp-6.6 - Scenario: Displays 'autoload' value - Given a WP install - - When I run `wp option add foo bar` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - on - """ diff --git a/features/option-list.feature b/features/option-list.feature index 38bd86925..fd0d11129 100644 --- a/features/option-list.feature +++ b/features/option-list.feature @@ -1,6 +1,5 @@ Feature: List WordPress options - @skip-object-cache Scenario: Using the `--transients` flag Given a WP install And I run `wp transient set wp_transient_flag wp_transient_flag` @@ -33,32 +32,6 @@ Feature: List WordPress options siteurl """ - @skip-object-cache - Scenario: Using the `--autoload=on` flag excludes transients by default - Given a WP install - And I run `wp option add sample_autoload_option 'sample_autoload_option' --autoload=yes` - And I run `wp transient set sample_autoload_transient 'sample_autoload_transient'` - - When I run `wp option list --autoload=on` - Then STDOUT should contain: - """ - sample_autoload_option - """ - And STDOUT should not contain: - """ - sample_autoload_transient - """ - - When I run `wp option list --transients --autoload=on` - Then STDOUT should contain: - """ - sample_autoload_transient - """ - And STDOUT should not contain: - """ - sample_autoload_option - """ - Scenario: List option with exclude pattern Given a WP install @@ -162,66 +135,3 @@ Feature: List WordPress options siteurl """ - Scenario: Using the `--unserialize` flag - Given a WP install - - When I run `wp option add --format=json sample_test_field_one '{"value": 1}'` - And I run `wp option list --search="sample_test_field_*" --format=yaml --unserialize` - Then STDOUT should be: - """ - --- - - - option_name: sample_test_field_one - option_value: - value: 1 - """ - - Scenario: Using the `--autoload=on` flag - Given a WP install - And I run `wp option add sample_autoload_one 'sample_value_one' --autoload=yes` - And I run `wp option add sample_autoload_two 'sample_value_two' --autoload=no` - And I run `wp option add sample_autoload_three 'sample_value_three' --autoload=on` - And I run `wp option add sample_autoload_four 'sample_value_four' --autoload=off` - - When I run `wp option list --autoload=on` - Then STDOUT should not contain: - """ - sample_value_two - """ - And STDOUT should not contain: - """ - sample_value_four - """ - And STDOUT should contain: - """ - sample_value_one - """ - And STDOUT should contain: - """ - sample_value_three - """ - - Scenario: Using the `--autoload=off` flag - Given a WP install - And I run `wp option add sample_autoload_one 'sample_value_one' --autoload=yes` - And I run `wp option add sample_autoload_two 'sample_value_two' --autoload=no` - And I run `wp option add sample_autoload_three 'sample_value_three' --autoload=on` - And I run `wp option add sample_autoload_four 'sample_value_four' --autoload=off` - - When I run `wp option list --autoload=off` - Then STDOUT should not contain: - """ - sample_value_one - """ - And STDOUT should not contain: - """ - sample_value_three - """ - And STDOUT should contain: - """ - sample_value_two - """ - And STDOUT should contain: - """ - sample_value_four - """ diff --git a/features/option-pluck-patch.feature b/features/option-pluck-patch.feature index 05b9c4026..fa790e406 100644 --- a/features/option-pluck-patch.feature +++ b/features/option-pluck-patch.feature @@ -269,57 +269,3 @@ Feature: Option commands have pluck and patch. """ [ "new", "bar" ] """ - - @patch @pluck - Scenario: An object value can be updated - Given a WP install - And a setup.php file: - """ - <?php - $option = new stdClass; - $option->test_mode = 0; - $ret = update_option( 'wp_cli_test', $option ); - """ - And I run `wp eval-file setup.php` - - When I run `wp option pluck wp_cli_test test_mode` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp option patch update wp_cli_test test_mode 1` - Then STDOUT should be: - """ - Success: Updated 'wp_cli_test' option. - """ - - When I run `wp option pluck wp_cli_test test_mode` - Then STDOUT should be: - """ - 1 - """ - - @patch - Scenario: When we don't pass all necessary arguments - Given a WP install - And an input.json file: - """ - { - "foo": "bar" - } - """ - And I run `wp option update option_name --format=json < input.json` - - When I try `wp option patch update option_name foo` - Then STDERR should contain: - """ - Please provide value to update. - """ - And the return code should be 1 - - When I run `wp option patch update option_name foo 0` - Then STDOUT should be: - """ - Success: Updated 'option_name' option. - """ diff --git a/features/option-set-autoload.feature b/features/option-set-autoload.feature deleted file mode 100644 index 8e893d4da..000000000 --- a/features/option-set-autoload.feature +++ /dev/null @@ -1,93 +0,0 @@ -Feature: Set 'autoload' value for an option - - Scenario: Option doesn't exist - Given a WP install - - When I try `wp option set-autoload foo yes` - Then STDERR should be: - """ - Error: Could not get 'foo' option. Does it exist? - """ - - Scenario: Invalid 'autoload' value provided - Given a WP install - - When I run `wp option add foo bar` - Then STDOUT should contain: - """ - Success: - """ - - When I try `wp option set-autoload foo invalid` - Then STDERR should be: - """ - Error: Invalid value specified for positional arg. - """ - - @less-than-wp-6.6 - Scenario: Successfully updates autoload value - Given a WP install - - When I run `wp option add foo bar` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - yes - """ - - When I run `wp option set-autoload foo no` - Then STDOUT should be: - """ - Success: Updated autoload value for 'foo' option. - """ - - When I run the previous command again - Then STDOUT should be: - """ - Success: Autoload value passed for 'foo' option is unchanged. - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - no - """ - - @require-wp-6.6 - Scenario: Successfully updates autoload value - Given a WP install - - When I run `wp option add foo bar` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - on - """ - - When I run `wp option set-autoload foo off` - Then STDOUT should be: - """ - Success: Updated autoload value for 'foo' option. - """ - - When I run the previous command again - Then STDOUT should be: - """ - Success: Autoload value passed for 'foo' option is unchanged. - """ - - When I run `wp option get-autoload foo` - Then STDOUT should be: - """ - off - """ diff --git a/features/option.feature b/features/option.feature index 212f82d4d..235bb0e37 100644 --- a/features/option.feature +++ b/features/option.feature @@ -42,7 +42,7 @@ Feature: Manage WordPress options When I run `wp option list` Then STDOUT should contain: """ - home https://example.com + home http://example.com """ When I run `wp option add auto_opt --autoload=no 'bar'` @@ -57,21 +57,6 @@ Feature: Manage WordPress options When I run `wp option delete str_opt` Then STDOUT should not be empty - When I run `wp option add option_one "ONE"` - And I run `wp option add option_two "TWO"` - Then STDOUT should not be empty - - When I try `wp option delete option_one option_two option_three` - Then STDOUT should be: - """ - Success: Deleted 'option_one' option. - Success: Deleted 'option_two' option. - """ - And STDERR should be: - """ - Warning: Could not delete 'option_three' option. Does it exist? - """ - When I run `wp option list` Then STDOUT should not contain: """ @@ -106,6 +91,7 @@ Feature: Manage WordPress options 0 """ + # JSON values When I run `wp option set json_opt '[ 1, 2 ]' --format=json` Then STDOUT should not be empty @@ -119,6 +105,7 @@ Feature: Manage WordPress options [1,2] """ + # Reading from files Given a value.json file: """ @@ -138,7 +125,6 @@ Feature: Manage WordPress options """ @require-wp-4.2 - @less-than-wp-6.6 Scenario: Update autoload value for custom option Given a WP install And I run `wp option add hello world --autoload=no` @@ -159,29 +145,7 @@ Feature: Manage WordPress options | option_name | option_value | autoload | | hello | island | yes | - @require-wp-6.6 - Scenario: Update autoload value for custom option - Given a WP install - And I run `wp option add hello world --autoload=off` - - When I run `wp option update hello universe` - Then STDOUT should not be empty - - When I run `wp option list --search='hello' --fields=option_name,option_value,autoload` - Then STDOUT should be a table containing rows: - | option_name | option_value | autoload | - | hello | universe | off | - - When I run `wp option update hello island --autoload=on` - Then STDOUT should not be empty - - When I run `wp option list --search='hello' --fields=option_name,option_value,autoload` - Then STDOUT should be a table containing rows: - | option_name | option_value | autoload | - | hello | island | on | - @require-wp-4.2 - @less-than-wp-6.6 Scenario: Managed autoloaded options Given a WP install @@ -263,31 +227,31 @@ Feature: Manage WordPress options When I try `wp option list --search='auto_opt' --autoload` Then STDOUT should not be empty - And STDERR should be: + And STDERR should be: """ Warning: --autoload parameter needs a value """ And the return code should be 0 - When I try `wp option list --search='auto_opt' --autoload=nope` + When I try `wp option list --search='auto_opt' --autoload=no` Then STDOUT should be empty - And STDERR should contain: + And STDERR should be: """ - Error: Value of '--autoload' should be + Error: Value of '--autoload' should be on or off. """ And the return code should be 1 When I try `wp option add str_opt_foo 'bar' --autoload` Then STDOUT should not be empty - And STDERR should be: + And STDERR should be: """ Warning: --autoload parameter needs a value """ And the return code should be 0 - When I try `wp option add str_opt_foo 'bar' --autoload=bad` + When I try `wp option add str_opt_foo 'bar' --autoload=off` Then STDOUT should be empty - And STDERR should contain: + And STDERR should contain: """ Error: Parameter errors: """ diff --git a/features/post-block.feature b/features/post-block.feature deleted file mode 100644 index fa3345c1e..000000000 --- a/features/post-block.feature +++ /dev/null @@ -1,1920 +0,0 @@ -Feature: Manage blocks in post content - - @require-wp-5.0 - Scenario: Check if a post has blocks - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post has-blocks {POST_ID}` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains blocks. - """ - - When I run `wp post create --post_title="Classic Post" --post_content="<p>Hello classic</p>" --porcelain` - Then save STDOUT as {CLASSIC_ID} - - When I try `wp post has-blocks {CLASSIC_ID}` - Then STDERR should contain: - """ - Error: Post {CLASSIC_ID} does not contain blocks. - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Check if a post has a specific block - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post has-block {POST_ID} core/paragraph` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/paragraph'. - """ - - When I run `wp post has-block {POST_ID} core/heading` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/heading'. - """ - - When I try `wp post has-block {POST_ID} core/image` - Then STDERR should contain: - """ - Error: Post {POST_ID} does not contain block 'core/image'. - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Parse blocks in a post - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph {\"align\":\"center\"} --><p>Hello</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "blockName": "core/paragraph" - """ - And STDOUT should contain: - """ - "align": "center" - """ - - When I run `wp post block parse {POST_ID} --format=yaml` - Then STDOUT should contain: - """ - blockName: core/paragraph - """ - - When I run `wp post block parse {POST_ID} --raw` - Then STDOUT should contain: - """ - innerHTML - """ - - @require-wp-5.0 - Scenario: List blocks in a post - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>One</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Two</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/paragraph | 2 | - | core/heading | 1 | - - When I run `wp post block list {POST_ID} --format=json` - Then STDOUT should be JSON containing: - """ - [{"blockName":"core/paragraph","count":2}] - """ - - When I run `wp post block list {POST_ID} --format=count` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-5.0 - Scenario: List nested blocks - Given a WP install - When I run `wp post create --post_title="Nested Blocks" --post_content="<!-- wp:group --><!-- wp:paragraph --><p>Nested</p><!-- /wp:paragraph --><!-- /wp:group -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/group | 1 | - And STDOUT should not contain: - """ - core/paragraph - """ - - When I run `wp post block list {POST_ID} --nested` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/group | 1 | - | core/paragraph | 1 | - - @require-wp-5.0 - Scenario: Render blocks to HTML - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block render {POST_ID}` - # In WordPress 7.0+ paragraph blocks are rendered with a class name. - # See https://github.com/WordPress/gutenberg/pull/71207. - Then STDOUT should contain: - """ - <p - """ - And STDOUT should contain: - """ - >Hello World</p> - """ - And STDOUT should contain: - """ - <h2 - """ - And STDOUT should contain: - """ - Title</h2> - """ - - When I run `wp post block render {POST_ID} --block=core/paragraph` - Then STDOUT should contain: - """ - <p - """ - And STDOUT should contain: - """ - >Hello World</p> - """ - And STDOUT should not contain: - """ - Title</h2> - """ - - @require-wp-5.0 - Scenario: Insert a block into a post - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/paragraph --content="Added at end"` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Added at end - """ - - When I run `wp post block insert {POST_ID} core/heading --content="Title" --position=start` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/paragraph | 2 | - | core/heading | 1 | - - @require-wp-5.0 - Scenario: Insert a block with attributes - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/heading --content="Title" --attrs='{"level":3}'` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 3 - """ - - @require-wp-5.0 - Scenario: Remove a block by index - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} --index=1` - Then STDOUT should contain: - """ - Success: Removed 1 block from post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - First - """ - And STDOUT should contain: - """ - Third - """ - And STDOUT should not contain: - """ - Second - """ - - @require-wp-5.0 - Scenario: Remove multiple blocks by indices - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} --index=0,2` - Then STDOUT should contain: - """ - Success: Removed 2 blocks from post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Second - """ - And STDOUT should not contain: - """ - First - """ - And STDOUT should not contain: - """ - Third - """ - - @require-wp-5.0 - Scenario: Remove blocks by name - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Para 1</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Heading</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Para 2</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} core/paragraph` - Then STDOUT should contain: - """ - Success: Removed 1 block from post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/paragraph | 1 | - | core/heading | 1 | - - @require-wp-5.0 - Scenario: Remove all blocks of a type - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Para 1</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Heading</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Para 2</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} core/paragraph --all` - Then STDOUT should contain: - """ - Success: Removed 2 blocks from post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/heading | 1 | - And STDOUT should not contain: - """ - core/paragraph - """ - - @require-wp-5.0 - Scenario: Replace blocks - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Content</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block replace {POST_ID} core/paragraph core/heading` - Then STDOUT should contain: - """ - Success: Replaced 1 block in post {POST_ID}. - """ - - When I run `wp post has-block {POST_ID} core/heading` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/heading'. - """ - - When I try `wp post has-block {POST_ID} core/paragraph` - Then the return code should be 1 - - @require-wp-5.0 - Scenario: Replace all blocks of a type - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Para 1</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Para 2</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block replace {POST_ID} core/paragraph core/verse --all` - Then STDOUT should contain: - """ - Success: Replaced 2 blocks in post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/verse | 2 | - And STDOUT should not contain: - """ - core/paragraph - """ - - @require-wp-5.0 - Scenario: Replace block with new attributes - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:heading {\"level\":2} --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block replace {POST_ID} core/heading core/heading --attrs='{"level":4}'` - Then STDOUT should contain: - """ - Success: Replaced 1 block in post {POST_ID}. - """ - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 4 - """ - - @require-wp-5.0 - Scenario: Error handling for invalid post - Given a WP install - - When I try `wp post has-blocks 999999` - Then STDERR should contain: - """ - Could not find the post - """ - And the return code should be 1 - - When I try `wp post block list 999999` - Then STDERR should contain: - """ - Could not find the post - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Error handling for remove without block name or index - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block remove {POST_ID}` - Then STDERR should contain: - """ - Error: You must specify either a block name or --index. - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Porcelain output for insert - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/paragraph --content="New" --porcelain` - Then STDOUT should be: - """ - {POST_ID} - """ - - @require-wp-5.0 - Scenario: Porcelain output for remove - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} --index=0 --porcelain` - Then STDOUT should be: - """ - 1 - """ - - @require-wp-5.0 - Scenario: Porcelain output for replace - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block replace {POST_ID} core/paragraph core/heading --porcelain` - Then STDOUT should be: - """ - 1 - """ - - @require-wp-5.0 - Scenario: Get a block by index - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph {\"align\":\"center\"} --><p>First</p><!-- /wp:paragraph --><!-- wp:heading {\"level\":2} --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block get {POST_ID} 0` - Then STDOUT should contain: - """ - "blockName": "core/paragraph" - """ - And STDOUT should contain: - """ - "align": "center" - """ - - When I run `wp post block get {POST_ID} 1` - Then STDOUT should contain: - """ - "blockName": "core/heading" - """ - And STDOUT should contain: - """ - "level": 2 - """ - - When I run `wp post block get {POST_ID} 0 --format=yaml` - Then STDOUT should contain: - """ - blockName: core/paragraph - """ - - When I run `wp post block get {POST_ID} 0 --raw` - Then STDOUT should contain: - """ - innerHTML - """ - - @require-wp-5.0 - Scenario: Error on invalid block index - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block get {POST_ID} 5` - Then STDERR should contain: - """ - Invalid index: 5 - """ - And the return code should be 1 - - When I try `wp post block get {POST_ID} -1` - Then STDERR should contain: - """ - Invalid index: -1 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Update block attributes - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:heading {\"level\":2} --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"level":3}'` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 3 - """ - - @require-wp-5.0 - Scenario: Update heading level syncs HTML tag - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:heading {\"level\":2} --><h2>Original Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"level":4}'` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - # Verify the attribute was updated - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 4 - """ - - # Verify the HTML tag was updated to match - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - <h4>Original Title</h4> - """ - And STDOUT should not contain: - """ - <h2> - """ - - @require-wp-5.0 - Scenario: Update list ordered attribute syncs HTML tag - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:list --><ul><li>Item 1</li><li>Item 2</li></ul><!-- /wp:list -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"ordered":true}'` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - # Verify the HTML tag was updated from ul to ol - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - <ol> - """ - And STDOUT should contain: - """ - </ol> - """ - And STDOUT should not contain: - """ - <ul> - """ - - @require-wp-5.0 - Scenario: Update block with custom HTML sync filter via --require - Given a WP install - And a custom-sync-filter.php file: - """ - <?php - WP_CLI::add_wp_hook( 'wp_cli_post_block_update_html', function( $block, $new_attrs, $block_name ) { - if ( 'core/paragraph' === $block_name && isset( $new_attrs['customClass'] ) ) { - $block['innerHTML'] = preg_replace( - '/<p([^>]*)>/', - '<p class="' . esc_attr( $new_attrs['customClass'] ) . '"$1>', - $block['innerHTML'] - ); - $block['innerContent'] = [ $block['innerHTML'] ]; - } - return $block; - }, 10, 3 ); - """ - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"customClass":"my-custom-class"}' --require=custom-sync-filter.php` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - <p class="my-custom-class">Hello World</p> - """ - - @require-wp-5.0 - Scenario: Update block content - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Old text</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --content="<p>New text</p>"` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - New text - """ - And STDOUT should not contain: - """ - Old text - """ - - @require-wp-5.0 - Scenario: Update block with replace-attrs flag - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:heading {\"level\":2,\"align\":\"center\"} --><h2 class=\"has-text-align-center\">Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"level":4}' --replace-attrs` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 4 - """ - And STDOUT should not contain: - """ - "align" - """ - - @require-wp-5.0 - Scenario: Error when no attrs or content provided for update - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block update {POST_ID} 0` - Then STDERR should contain: - """ - You must specify either --attrs or --content. - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Porcelain output for update - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --content="<p>New</p>" --porcelain` - Then STDOUT should be: - """ - {POST_ID} - """ - - @require-wp-5.0 - Scenario: Move block forward - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block move {POST_ID} 0 2` - Then STDOUT should contain: - """ - Success: Moved block from index 0 to index 2 in post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /Second.*First/s - - @require-wp-5.0 - Scenario: Move block backward - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block move {POST_ID} 2 0` - Then STDOUT should contain: - """ - Success: Moved block from index 2 to index 0 in post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /Third.*First/s - - @require-wp-5.0 - Scenario: Move block same index warning - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block move {POST_ID} 0 0` - Then STDERR should contain: - """ - Source and destination indices are the same. - """ - And the return code should be 0 - - @require-wp-5.0 - Scenario: Error on invalid move indices - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block move {POST_ID} 5 0` - Then STDERR should contain: - """ - Invalid from-index: 5 - """ - And the return code should be 1 - - When I try `wp post block move {POST_ID} 0 10` - Then STDERR should contain: - """ - Invalid to-index: 10 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Porcelain output for move - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block move {POST_ID} 0 1 --porcelain` - Then STDOUT should be: - """ - {POST_ID} - """ - - @require-wp-5.0 - Scenario: Export blocks to STDOUT as JSON - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID}` - Then STDOUT should contain: - """ - "version": "1.0" - """ - And STDOUT should contain: - """ - "generator": "wp-cli/entity-command" - """ - And STDOUT should contain: - """ - "blockName": "core/paragraph" - """ - - @require-wp-5.0 - Scenario: Export blocks as YAML - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID} --format=yaml` - Then STDOUT should contain: - """ - version: - """ - And STDOUT should contain: - """ - generator: wp-cli/entity-command - """ - And STDOUT should contain: - """ - blockName: core/paragraph - """ - - @require-wp-5.0 - Scenario: Export blocks as HTML - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID} --format=html` - # In WordPress 7.0+ paragraph blocks are rendered with a class name. - # See https://github.com/WordPress/gutenberg/pull/71207. - Then STDOUT should contain: - """ - <p - """ - And STDOUT should contain: - """ - >Hello World</p> - """ - - @require-wp-5.0 - Scenario: Export blocks to file - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID} --file=blocks-export.json` - Then STDOUT should contain: - """ - Success: Exported 1 block to blocks-export.json - """ - And the blocks-export.json file should contain: - """ - "blockName": "core/paragraph" - """ - - @require-wp-5.0 - Scenario: Import blocks from file - Given a WP install - And a blocks-import.json file: - """ - { - "version": "1.0", - "blocks": [ - {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "<p>Imported</p>", "innerContent": ["<p>Imported</p>"]} - ] - } - """ - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:heading --><h2>Original</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=blocks-import.json` - Then STDOUT should contain: - """ - Success: Imported 1 block into post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/heading | 1 | - | core/paragraph | 1 | - - @require-wp-5.0 - Scenario: Import blocks at start - Given a WP install - And a blocks-import.json file: - """ - { - "blocks": [ - {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "<p>First</p>", "innerContent": ["<p>First</p>"]} - ] - } - """ - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=blocks-import.json --position=start` - Then STDOUT should contain: - """ - Success: Imported 1 block into post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /First.*Second/s - - @require-wp-5.0 - Scenario: Import blocks with replace - Given a WP install - And a blocks-import.json file: - """ - { - "blocks": [ - {"blockName": "core/heading", "attrs": {"level": 2}, "innerBlocks": [], "innerHTML": "<h2>New Content</h2>", "innerContent": ["<h2>New Content</h2>"]} - ] - } - """ - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Old</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=blocks-import.json --replace` - Then STDOUT should contain: - """ - Success: Imported 1 block into post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/heading | 1 | - And STDOUT should not contain: - """ - core/paragraph - """ - - @require-wp-5.0 - Scenario: Import error on missing file - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block import {POST_ID} --file=nonexistent.json` - Then STDERR should contain: - """ - File not found: nonexistent.json - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Porcelain output for import - Given a WP install - And a blocks-import.json file: - """ - { - "blocks": [ - {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "<p>One</p>", "innerContent": ["<p>One</p>"]}, - {"blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "<p>Two</p>", "innerContent": ["<p>Two</p>"]} - ] - } - """ - When I run `wp post create --post_title="Block Post" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=blocks-import.json --porcelain` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-5.0 - Scenario: Count blocks across posts - Given a WP install - When I run `wp post create --post_title="Post 1" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Test2</p><!-- /wp:paragraph -->" --post_status=publish --porcelain` - Then save STDOUT as {POST_1} - - When I run `wp post create --post_title="Post 2" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --post_status=publish --porcelain` - Then save STDOUT as {POST_2} - - When I run `wp post block count {POST_1} {POST_2}` - Then STDOUT should be a table containing rows: - | blockName | count | posts | - | core/paragraph | 3 | 2 | - | core/heading | 1 | 1 | - - @require-wp-5.0 - Scenario: Count specific block type - Given a WP install - When I run `wp post create --post_title="Post 1" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Test2</p><!-- /wp:paragraph -->" --post_status=publish --porcelain` - Then save STDOUT as {POST_1} - - When I run `wp post block count {POST_1} --block=core/paragraph --format=count` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-5.0 - Scenario: Count unique block types - Given a WP install - When I run `wp post create --post_title="Post 1" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --post_status=publish --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block count {POST_ID} --format=count` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-5.0 - Scenario: Clone block with default position (after) - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block clone {POST_ID} 0` - Then STDOUT should contain: - """ - Success: Cloned block to index 1 in post {POST_ID}. - """ - - When I run `wp post block list {POST_ID}` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/paragraph | 3 | - - @require-wp-5.0 - Scenario: Clone block to end - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block clone {POST_ID} 0 --position=end` - Then STDOUT should contain: - """ - Success: Cloned block to index 2 in post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /First.*Title.*First/s - - @require-wp-5.0 - Scenario: Clone block to start - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block clone {POST_ID} 1 --position=start` - Then STDOUT should contain: - """ - Success: Cloned block to index 0 in post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /Title.*First.*Title/s - - @require-wp-5.0 - Scenario: Porcelain output for clone - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block clone {POST_ID} 0 --porcelain` - Then STDOUT should be: - """ - 1 - """ - - @require-wp-5.0 - Scenario: Error on invalid clone index - Given a WP install - When I run `wp post create --post_title="Block Post" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block clone {POST_ID} 5` - Then STDERR should contain: - """ - Invalid source-index: 5 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Extract attribute values - Given a WP install - And a block-content.txt file: - """ - <!-- wp:heading {"level":2} --><h2>Title 1</h2><!-- /wp:heading --><!-- wp:heading {"level":3} --><h3>Title 2</h3><!-- /wp:heading --> - """ - When I run `wp post create block-content.txt --post_title='Block Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=ids` - Then STDOUT should contain: - """ - 2 - """ - And STDOUT should contain: - """ - 3 - """ - - @require-wp-5.0 - Scenario: Extract attribute from specific index - Given a WP install - And a block-content.txt file: - """ - <!-- wp:heading {"level":2} --><h2>Title</h2><!-- /wp:heading --> - """ - When I run `wp post create block-content.txt --post_title='Block Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block extract {POST_ID} --index=0 --attr=level --format=ids` - Then STDOUT should be: - """ - 2 - """ - - @require-wp-5.0 - Scenario: Extract content from blocks - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content.txt --post_title='Block Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block extract {POST_ID} --block=core/paragraph --content --format=ids` - Then STDOUT should contain: - """ - Hello World - """ - - @require-wp-5.0 - Scenario: Extract error when no attr or content specified - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content.txt --post_title='Block Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block extract {POST_ID}` - Then STDERR should contain: - """ - You must specify either --attr or --content. - """ - And the return code should be 1 - - # ============================================================================ - # Phase 3: Extended Test Coverage - P0 (Critical) Tests - # ============================================================================ - - @require-wp-5.0 - Scenario: Check for nested block inside group - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group --><!-- wp:paragraph --><p>Nested para</p><!-- /wp:paragraph --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Nested' --porcelain` - Then save STDOUT as {POST_ID} - - # Should find the nested paragraph - When I run `wp post has-block {POST_ID} core/paragraph` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/paragraph'. - """ - - # Should also find the container - When I run `wp post has-block {POST_ID} core/group` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/group'. - """ - - @require-wp-5.0 - Scenario: Partial block name does not match - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - # "core/para" should NOT match "core/paragraph" - When I try `wp post has-block {POST_ID} core/para` - Then STDERR should contain: - """ - does not contain block 'core/para' - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Parse post with classic content (no blocks) - Given a WP install - And a block-content.txt file: - """ - <p>Just HTML, no blocks</p> - """ - When I run `wp post create block-content.txt --post_title='Classic' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "blockName": null - """ - - @require-wp-5.0 - Scenario: Render dynamic block - Given a WP install - And a block-content.txt file: - """ - <!-- wp:archives /--> - """ - When I run `wp post create block-content.txt --post_title='Dynamic' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block render {POST_ID}` - # Dynamic blocks render at runtime - output depends on site content - Then STDOUT should not be empty - - @require-wp-5.0 - Scenario: Insert block at specific numeric position - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/paragraph --content="Second" --position=1` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /First.*Second.*Third/s - - @require-wp-5.0 - Scenario: Remove all blocks from post - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Only block</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} --index=0` - Then STDOUT should contain: - """ - Success: Removed 1 block from post {POST_ID}. - """ - - When I run `wp post block list {POST_ID} --format=count` - Then STDOUT should be: - """ - 0 - """ - - @require-wp-5.0 - Scenario: Replace when no matches found - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block replace {POST_ID} core/image core/heading` - Then STDERR should contain: - """ - No blocks of type 'core/image' were found - """ - And the return code should be 0 - - @require-wp-5.0 - Scenario: Update with invalid attrs JSON - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block update {POST_ID} 0 --attrs='{not valid json'` - Then STDERR should contain: - """ - Invalid JSON - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Import invalid JSON file - Given a WP install - And a bad-import.json file: - """ - {not valid json - """ - When I run `wp post create --post_title="Test" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block import {POST_ID} --file=bad-import.json` - Then STDERR should contain: - """ - Invalid block structure - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Count blocks filtered by post type - Given a WP install - And a block-content-1.txt file: - """ - <!-- wp:paragraph --><p>Post</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content-1.txt --post_title='Post' --post_type=post --post_status=publish --porcelain` - Then save STDOUT as {POST_ID} - - And a block-content-2.txt file: - """ - <!-- wp:heading --><h2>Page</h2><!-- /wp:heading --> - """ - When I run `wp post create block-content-2.txt --post_title='Page' --post_type=page --post_status=publish --porcelain` - Then save STDOUT as {PAGE_ID} - - When I run `wp post block count {POST_ID} --post-type=post` - Then STDOUT should be a table containing rows: - | blockName | count | posts | - | core/paragraph | 1 | 1 | - - When I run `wp post block count {PAGE_ID} --post-type=page` - Then STDOUT should be a table containing rows: - | blockName | count | posts | - | core/heading | 1 | 1 | - - @require-wp-5.0 - Scenario: Count blocks filtered by post status - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Pub</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content.txt --post_title='Published' --post_status=publish --porcelain` - Then save STDOUT as {PUB_ID} - - When I run `wp post create --post_title="Draft" --post_content="<!-- wp:heading --><h2>Draft</h2><!-- /wp:heading -->" --post_status=draft --porcelain` - Then save STDOUT as {DRAFT_ID} - - When I run `wp post block count {DRAFT_ID} --post-status=draft` - Then STDOUT should be a table containing rows: - | blockName | count | posts | - | core/heading | 1 | 1 | - - # ============================================================================ - # Phase 3: Extended Test Coverage - P1 (High) Tests - # ============================================================================ - - @require-wp-5.0 - Scenario: Post with mixed block and freeform content - Given a WP install - # Content with blocks and freeform text in between - When I run `wp post create --post_title="Mixed" --post_content="<!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph --><p>Some freeform text</p><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post has-blocks {POST_ID}` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains blocks. - """ - - @require-wp-5.0 - Scenario: Empty post has no blocks - Given a WP install - When I run `wp post create --post_title="Empty" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post has-blocks {POST_ID}` - Then STDERR should contain: - """ - does not contain blocks - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Parse deeply nested blocks - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group --><!-- wp:columns --><!-- wp:column --><!-- wp:group --><!-- wp:paragraph --><p>Deep</p><!-- /wp:paragraph --><!-- /wp:group --><!-- /wp:column --><!-- /wp:columns --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Deep' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "blockName": "core/group" - """ - And STDOUT should contain: - """ - "blockName": "core/columns" - """ - And STDOUT should contain: - """ - "blockName": "core/paragraph" - """ - - @require-wp-5.0 - Scenario: List blocks on post with no blocks - Given a WP install - When I run `wp post create --post_title="Classic" --post_content="<p>No blocks</p>" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block list {POST_ID} --format=count` - Then STDOUT should be: - """ - 0 - """ - - @require-wp-5.0 - Scenario: Render nested blocks - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group {"className":"test-group"} --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Nested' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block render {POST_ID}` - Then STDOUT should contain: - """ - Inner</p> - """ - - @require-wp-5.0 - Scenario: Insert self-closing block - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Before</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/separator` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post has-block {POST_ID} core/separator` - Then STDOUT should contain: - """ - Success: Post {POST_ID} contains block 'core/separator'. - """ - - @require-wp-5.0 - Scenario: Insert block into empty post - Given a WP install - When I run `wp post create --post_title="Empty" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block insert {POST_ID} core/paragraph --content="First block"` - Then STDOUT should contain: - """ - Success: Inserted block into post {POST_ID}. - """ - - When I run `wp post block list {POST_ID} --format=count` - Then STDOUT should be: - """ - 1 - """ - - @require-wp-5.0 - Scenario: Remove with out of bounds index - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block remove {POST_ID} --index=100` - Then STDERR should contain: - """ - Invalid index: 100 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Remove with negative index - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block remove {POST_ID} --index=-1` - Then STDERR should contain: - """ - Invalid index: -1 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Remove container block removes children - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:group --><!-- wp:paragraph --><p>Nested</p><!-- /wp:paragraph --><!-- /wp:group -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block remove {POST_ID} --index=0` - Then STDOUT should contain: - """ - Success: Removed 1 block from post {POST_ID}. - """ - - When I run `wp post block list {POST_ID} --format=count` - Then STDOUT should be: - """ - 0 - """ - - @require-wp-5.0 - Scenario: Remove block by index - Given a WP install - When I run `wp post create --post_title="Three Blocks" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - # Index 1 should be "Second" block - When I run `wp post block remove {POST_ID} --index=1` - Then STDOUT should contain: - """ - Success: Removed 1 block from post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - First - """ - And STDOUT should contain: - """ - Third - """ - And STDOUT should not contain: - """ - Second - """ - - @require-wp-5.0 - Scenario: Remove multiple blocks by indices - Given a WP install - When I run `wp post create --post_title="Three Blocks" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - # Indices 0 and 2 should be "First" and "Third" - When I run `wp post block remove {POST_ID} --index=0,2` - Then STDOUT should contain: - """ - Success: Removed 2 blocks from post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Second - """ - And STDOUT should not contain: - """ - First - """ - And STDOUT should not contain: - """ - Third - """ - - @require-wp-5.0 - Scenario: Replace block preserves content - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Keep this text</p><!-- /wp:paragraph --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block replace {POST_ID} core/paragraph core/verse` - Then STDOUT should contain: - """ - Success: Replaced 1 block in post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Keep this text - """ - - @require-wp-5.0 - Scenario: Get nested block shows inner blocks - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block get {POST_ID} 0` - Then STDOUT should contain: - """ - "blockName": "core/group" - """ - And STDOUT should contain: - """ - "innerBlocks" - """ - - @require-wp-5.0 - Scenario: Update both attrs and content - Given a WP install - And a block-content.txt file: - """ - <!-- wp:heading {"level":2} --><h2>Old Title</h2><!-- /wp:heading --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block update {POST_ID} 0 --attrs='{"level":3}' --content="<h3>New Title</h3>"` - Then STDOUT should contain: - """ - Success: Updated block at index 0 in post {POST_ID}. - """ - - When I run `wp post block parse {POST_ID}` - Then STDOUT should contain: - """ - "level": 3 - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - New Title - """ - - @require-wp-5.0 - Scenario: Move in single block post - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Only</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block move {POST_ID} 0 1` - Then STDERR should contain: - """ - Invalid to-index: 1 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Export with --raw includes innerHTML - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test content</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID} --raw` - Then STDOUT should contain: - """ - "innerHTML" - """ - And STDOUT should contain: - """ - <p>Test content</p> - """ - - @require-wp-5.0 - Scenario: Export post with no blocks - Given a WP install - When I run `wp post create --post_title="Classic" --post_content="<p>No blocks</p>" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block export {POST_ID}` - Then STDOUT should contain: - """ - "blocks": - """ - - @require-wp-5.0 - Scenario: Import at specific numeric position - Given a WP install - And a blocks-import-pos.json file: - """ - {"blocks":[{"blockName":"core/heading","attrs":{"level":2},"innerBlocks":[],"innerHTML":"<h2>Middle</h2>","innerContent":["<h2>Middle</h2>"]}]} - """ - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Last</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=blocks-import-pos.json --position=1` - Then STDOUT should contain: - """ - Success: Imported 1 block into post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /First.*Middle.*Last/s - - @require-wp-5.0 - Scenario: Clone block to specific numeric position - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Third</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - # Clone first block to position 2 (between Second and Third) - When I run `wp post block clone {POST_ID} 0 --position=2` - Then STDOUT should contain: - """ - Success: Cloned block to index 2 in post {POST_ID}. - """ - - When I run `wp post block render {POST_ID}` - Then STDOUT should match /First.*Second.*First.*Third/s - - @require-wp-5.0 - Scenario: Clone nested block preserves children - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block clone {POST_ID} 0` - Then STDOUT should contain: - """ - Success: Cloned block to index 1 in post {POST_ID}. - """ - - When I run `wp post block list {POST_ID} --nested` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/group | 2 | - | core/paragraph | 2 | - - @require-wp-5.0 - Scenario: Extract non-existent attribute - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block extract {POST_ID} --block=core/paragraph --attr=nonexistent --format=ids` - Then STDERR should contain: - """ - No values found - """ - - @require-wp-5.0 - Scenario: Extract from non-existent block type - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block extract {POST_ID} --block=core/image --attr=id --format=ids` - Then STDERR should contain: - """ - No matching blocks - """ - - # ============================================================================ - # Phase 3: Extended Test Coverage - P2 (Medium) Tests - # ============================================================================ - - @require-wp-5.0 - Scenario: Parse empty post content - Given a WP install - When I run `wp post create --post_title="Empty" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block parse {POST_ID}` - Then STDOUT should be: - """ - [] - """ - - @require-wp-5.0 - Scenario: List blocks in CSV and YAML formats - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block list {POST_ID} --format=csv` - Then STDOUT should contain: - """ - blockName,count - """ - And STDOUT should contain: - """ - core/paragraph,1 - """ - - When I run `wp post block list {POST_ID} --format=yaml` - Then STDOUT should contain: - """ - blockName: core/paragraph - """ - - @require-wp-5.0 - Scenario: List with --nested counts all nesting levels - Given a WP install - And a block-content.txt file: - """ - <!-- wp:group --><!-- wp:group --><!-- wp:paragraph --><p>Deep</p><!-- /wp:paragraph --><!-- /wp:group --><!-- /wp:group --> - """ - When I run `wp post create block-content.txt --post_title='Deep' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block list {POST_ID} --nested` - Then STDOUT should be a table containing rows: - | blockName | count | - | core/group | 2 | - | core/paragraph | 1 | - - @require-wp-5.0 - Scenario: Render unknown block type - Given a WP install - When I run `wp post create --post_title="Unknown" --post_content="<!-- wp:fake/nonexistent --><p>Content</p><!-- /wp:fake/nonexistent -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block render {POST_ID}` - # Unknown blocks render their innerHTML as-is - Then STDOUT should contain: - """ - <p>Content</p> - """ - - @require-wp-5.0 - Scenario: Insert with invalid attrs JSON - Given a WP install - When I run `wp post create --post_title="Test" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block insert {POST_ID} core/heading --attrs='{invalid json'` - Then STDERR should contain: - """ - Invalid JSON - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Replace with invalid attrs JSON - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block replace {POST_ID} core/paragraph core/heading --attrs='{broken'` - Then STDERR should contain: - """ - Invalid JSON - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Move with negative indices - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Second</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post block move {POST_ID} -1 0` - Then STDERR should contain: - """ - Invalid from-index: -1 - """ - And the return code should be 1 - - @require-wp-5.0 - Scenario: Count blocks in various formats - Given a WP install - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->" --post_status=publish --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block count {POST_ID} --format=json` - Then STDOUT should be JSON containing: - """ - [{"blockName":"core/paragraph","count":1,"posts":1}] - """ - - When I run `wp post block count {POST_ID} --format=csv` - Then STDOUT should contain: - """ - blockName,count,posts - """ - - When I run `wp post block count {POST_ID} --format=yaml` - Then STDOUT should contain: - """ - blockName: core/paragraph - """ - - @require-wp-5.0 - Scenario: Import empty blocks array - Given a WP install - And a empty-blocks.json file: - """ - {"version":"1.0","blocks":[]} - """ - When I run `wp post create --post_title="Test" --post_content="<!-- wp:paragraph --><p>Existing</p><!-- /wp:paragraph -->" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID} --file=empty-blocks.json` - Then STDOUT should contain: - """ - Success: Imported 0 blocks into post {POST_ID}. - """ - - @require-wp-5.0 - Scenario: Extract attribute in various formats - Given a WP install - And a block-content.txt file: - """ - <!-- wp:heading {"level":2} --><h2>One</h2><!-- /wp:heading --><!-- wp:heading {"level":3} --><h3>Two</h3><!-- /wp:heading --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=json` - Then STDOUT should be JSON containing: - """ - [2,3] - """ - - When I run `wp post block extract {POST_ID} --block=core/heading --attr=level --format=csv` - Then STDOUT should contain: - """ - 2,3 - """ - - @require-wp-5.0 - Scenario: Extract with both block and index filters - Given a WP install - And a block-content.txt file: - """ - <!-- wp:paragraph --><p>Para</p><!-- /wp:paragraph --><!-- wp:heading {"level":2} --><h2>Title</h2><!-- /wp:heading --> - """ - When I run `wp post create block-content.txt --post_title='Test' --porcelain` - Then save STDOUT as {POST_ID} - - # --index=1 is the heading, --block filter should match - When I run `wp post block extract {POST_ID} --index=1 --block=core/heading --attr=level --format=ids` - Then STDOUT should be: - """ - 2 - """ - - # ============================================================================ - # Phase 3: STDIN Import Test (requires wp-cli-tests update) - # ============================================================================ - - @require-wp-5.0 @broken - Scenario: Import blocks from STDIN - Given a WP install - And a blocks-stdin.json file: - """ - {"blocks":[{"blockName":"core/paragraph","attrs":{},"innerBlocks":[],"innerHTML":"<p>From STDIN</p>","innerContent":["<p>From STDIN</p>"]}]} - """ - When I run `wp post create --post_title="STDIN Test" --post_content="" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post block import {POST_ID}` with STDIN from 'blocks-stdin.json' - Then STDOUT should contain: - """ - Success: Imported 1 block into post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - From STDIN - """ diff --git a/features/post-create-duplicate.feature b/features/post-create-duplicate.feature deleted file mode 100644 index bfc9f3bc2..000000000 --- a/features/post-create-duplicate.feature +++ /dev/null @@ -1,81 +0,0 @@ -Feature: Create Duplicate WordPress post from existing posts. - - Background: - Given a WP install - - Scenario: Generate duplicate post. - When I run `wp term create category "Test Category" --porcelain` - Then save STDOUT as {TERM_ID} - - When I run `wp term create post_tag "Test Tag" --porcelain` - Then save STDOUT as {TAG_ID} - - When I run `wp post create --post_title='Test Duplicate Post' --post_category={TERM_ID} --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post term add {POST_ID} post_tag {TAG_ID} --by=id` - Then STDOUT should contain: - """ - Success: Added term. - """ - - When I run `wp post create --from-post={POST_ID} --porcelain` - Then STDOUT should be a number - And save STDOUT as {DUPLICATE_POST_ID} - - When I run `wp post get {DUPLICATE_POST_ID} --field=title` - Then STDOUT should be: - """ - Test Duplicate Post - """ - - When I run `wp post term list {DUPLICATE_POST_ID} category --field=term_id` - Then STDOUT should be: - """ - {TERM_ID} - """ - - When I run `wp post term list {DUPLICATE_POST_ID} post_tag --field=term_id` - Then STDOUT should be: - """ - {TAG_ID} - """ - - @require-wp-4.4 - Scenario: Generate duplicate post with post metadata. - When I run `wp post create --post_title='Test Post' --meta_input='{"key1":"value1","key2":"value2"}' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post create --from-post={POST_ID} --porcelain` - Then save STDOUT as {DUPLICATE_POST_ID} - - When I run `wp post meta list {DUPLICATE_POST_ID} --format=table` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | {DUPLICATE_POST_ID} | key1 | value1 | - | {DUPLICATE_POST_ID} | key2 | value2 | - - Scenario: Generate duplicate page. - When I run `wp post create --post_type="page" --post_title="Test Page" --post_content="Page Content" --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post create --from-post={POST_ID} --post_title="Duplicate Page" --porcelain` - Then save STDOUT as {DUPLICATE_POST_ID} - - When I run `wp post list --post_type='page' --fields="title, content, type"` - Then STDOUT should be a table containing rows: - | post_title | post_content | post_type | - | Test Page | Page Content | page | - | Duplicate Page | Page Content | page | - - Scenario: Change type of duplicate post. - When I run `wp post create --post_title='Test Post' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post create --from-post={POST_ID} --post_type=page --porcelain` - Then save STDOUT as {DUPLICATE_POST_ID} - - When I run `wp post get {DUPLICATE_POST_ID} --fields=type` - Then STDOUT should be a table containing rows: - | Field | Value | - | post_type | page | diff --git a/features/post-generate.feature b/features/post-generate.feature index f07347e3d..3c5e5dd5f 100644 --- a/features/post-generate.feature +++ b/features/post-generate.feature @@ -12,14 +12,6 @@ Feature: Generate new WordPress posts """ And STDERR should be empty - @broken - Scenario: Using --post-content requires STDIN input - When I try `wp post generate --count=1 --post_content` - Then STDERR should contain: - """ - Error: The parameter `post_content` reads from STDIN. - """ - Scenario: Generating posts by a specific author When I run `wp user create dummyuser dummy@example.com --porcelain` @@ -52,147 +44,19 @@ Feature: Generate new WordPress posts Scenario: Generating post and outputting title and name When I run `wp post generate --count=3 --post_title=Howdy!` - And I run `wp post list --field=post_title --posts_per_page=4 --orderby=ID --order=asc` + And I run `wp post list --field=post_title --posts_per_page=3` Then STDOUT should contain: """ - Hello world! Howdy! Howdy! 2 Howdy! 3 """ And STDERR should be empty - - When I run `wp post list --field=post_name --posts_per_page=4 --orderby=ID --order=asc` + And I run `wp post list --field=post_name --posts_per_page=3` Then STDOUT should contain: """ - hello-world howdy howdy-2 howdy-3 """ And STDERR should be empty - - Scenario: Generating posts with post_date argument without time - When I run `wp post generate --count=1 --post_date="2018-07-01"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-01 00:00:00 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-01 00:00:00 - """ - - Scenario: Generating posts with post_date argument with time - When I run `wp post generate --count=1 --post_date="2018-07-02 02:21:05"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-02 02:21:05 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-02 02:21:05 - """ - - Scenario: Generating posts with post_date_gmt argument without time - When I run `wp post generate --count=1 --post_date_gmt="2018-07-03"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-03 00:00:00 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-03 00:00:00 - """ - - Scenario: Generating posts with post_date_gmt argument with time - When I run `wp post generate --count=1 --post_date_gmt="2018-07-04 12:34:56"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-04 12:34:56 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-04 12:34:56 - """ - - Scenario: Generating posts with post_date argument with hyphenated time - When I run `wp post generate --count=1 --post_date="2018-07-05-17:17:17"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-05 17:17:17 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-05 17:17:17 - """ - - Scenario: Generating posts with post_date_gmt argument with hyphenated time - When I run `wp post generate --count=1 --post_date_gmt="2018-07-06-12:12:12"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 2018-07-06 12:12:12 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2018-07-06 12:12:12 - """ - - Scenario: Generating posts with different post_date & post_date_gmt argument without time - When I run `wp post generate --count=1 --post_date="1999-12-31" --post_date_gmt="2000-01-01"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 1999-12-31 00:00:00 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2000-01-01 00:00:00 - """ - - Scenario: Generating posts with different post_date & post_date_gmt argument with time - When I run `wp post generate --count=1 --post_date="1999-12-31 11:11:00" --post_date_gmt="2000-01-01 02:11:00"` - And I run `wp post list --field=post_date` - Then STDOUT should contain: - """ - 1999-12-31 11:11:00 - """ - - When I run `wp post list --field=post_date_gmt` - Then STDOUT should contain: - """ - 2000-01-01 02:11:00 - """ - - Scenario: Generating posts when the site timezone is ahead of UTC - When I run `wp option update timezone_string "Europe/Helsinki"` - And I run `wp post delete 1 --force` - And I run `wp post list --field=post_status` - Then STDOUT should be empty - - When I run `wp post generate --count=1` - And I run `wp post list --field=post_status` - Then STDOUT should be: - """ - publish - """ diff --git a/features/post-meta-clean-duplicates.feature b/features/post-meta-clean-duplicates.feature deleted file mode 100644 index 7730a7802..000000000 --- a/features/post-meta-clean-duplicates.feature +++ /dev/null @@ -1,63 +0,0 @@ -Feature: Clean up duplicate post meta values - - Scenario: Clean up duplicate post meta values. - Given a WP install - And a session_no file: - """ - n - """ - And a session_yes file: - """ - y - """ - - When I run `wp post meta add 1 foo bar` - Then STDOUT should be: - """ - Success: Added custom field. - """ - - When I run the previous command again - Then the return code should be 0 - - When I run the previous command again - Then the return code should be 0 - - When I run `wp post meta list 1 --keys=foo` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | foo | bar | - | 1 | foo | bar | - | 1 | foo | bar | - - When I run `wp post meta clean-duplicates 1 foo < session_no` - # Check for contains only, as the string contains a trailing space. - Then STDOUT should contain: - """ - Are you sure you want to delete 2 duplicate meta values and keep 1 valid meta value? [y/n] - """ - - When I run `wp post meta list 1 --keys=foo --format=count` - Then STDOUT should be: - """ - 3 - """ - - When I run `wp post meta clean-duplicates 1 foo < session_yes` - Then STDOUT should contain: - """ - Cleaned up duplicate 'foo' meta values. - """ - - When I try the previous command again - Then STDOUT should contain: - """ - Success: Nothing to clean up: found 1 valid meta value and 0 duplicates. - """ - - When I try `wp post meta clean-duplicates 1 food` - Then STDERR should be: - """ - Error: No meta values found for 'food'. - """ - And the return code should be 1 diff --git a/features/post-meta.feature b/features/post-meta.feature index 34f2e9585..e738914c2 100644 --- a/features/post-meta.feature +++ b/features/post-meta.feature @@ -1,7 +1,5 @@ Feature: Manage post custom fields - # Windows likes to include the quotes of the `"via STDIN"` string. - @skip-windows Scenario: Postmeta CRUD Given a WP install @@ -36,7 +34,7 @@ Feature: Manage post custom fields ["1","2"] """ - When I run `echo "via STDIN" | wp post-meta set 1 foo` + When I run `echo 'via STDIN' | wp post-meta set 1 foo` And I run `wp post-meta get 1 foo` Then STDOUT should be: """ @@ -61,87 +59,45 @@ Feature: Manage post custom fields When I run `wp post meta list 1` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - - When I run `wp post meta list 1 --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | ["apple","apple"] | + | post_id | meta_key | meta_value | + | 1 | apple | banana | + | 1 | apple | banana | + | 1 | banana | ["apple","apple"] | When I run `wp post meta list 1 --orderby=id --order=desc` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - | 1 | apple | banana | - | 1 | apple | banana | - - When I run `wp post meta list 1 --orderby=id --order=desc --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | ["apple","apple"] | - | 1 | apple | banana | - | 1 | apple | banana | + | post_id | meta_key | meta_value | + | 1 | banana | ["apple","apple"] | + | 1 | apple | banana | + | 1 | apple | banana | When I run `wp post meta list 1 --orderby=meta_key --order=asc` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - - When I run `wp post meta list 1 --orderby=meta_key --order=asc --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | ["apple","apple"] | + | post_id | meta_key | meta_value | + | 1 | apple | banana | + | 1 | apple | banana | + | 1 | banana | ["apple","apple"] | When I run `wp post meta list 1 --orderby=meta_key --order=desc` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - | 1 | apple | banana | - | 1 | apple | banana | - - When I run `wp post meta list 1 --orderby=meta_key --order=desc --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | ["apple","apple"] | - | 1 | apple | banana | - | 1 | apple | banana | + | post_id | meta_key | meta_value | + | 1 | banana | ["apple","apple"] | + | 1 | apple | banana | + | 1 | apple | banana | When I run `wp post meta list 1 --orderby=meta_value --order=asc` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - - When I run `wp post meta list 1 --orderby=meta_value --order=asc --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | apple | banana | - | 1 | apple | banana | - | 1 | banana | ["apple","apple"] | + | post_id | meta_key | meta_value | + | 1 | apple | banana | + | 1 | apple | banana | + | 1 | banana | ["apple","apple"] | When I run `wp post meta list 1 --orderby=meta_value --order=desc` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | a:2:{i:0;s:5:"apple";i:1;s:5:"apple";} | - | 1 | apple | banana | - | 1 | apple | banana | - - When I run `wp post meta list 1 --orderby=meta_value --order=desc --unserialize` - Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | banana | ["apple","apple"] | - | 1 | apple | banana | - | 1 | apple | banana | + | post_id | meta_key | meta_value | + | 1 | banana | ["apple","apple"] | + | 1 | apple | banana | + | 1 | apple | banana | Scenario: Delete all post meta Given a WP install @@ -188,8 +144,8 @@ Feature: Manage post custom fields When I run `wp post meta list 1` Then STDOUT should be a table containing rows: - | post_id | meta_key | meta_value | - | 1 | foo | | + | post_id | meta_key | meta_value | + | 1 | foo | | Scenario: Make sure WordPress receives the slashed data it expects in meta fields Given a WP install @@ -221,39 +177,6 @@ Feature: Manage post custom fields My\New\Meta """ - Scenario: List post meta with or without single flag - Given a WP install - - When I run `wp post meta add 1 apple banana` - And I run `wp post meta add 1 apple mango` - Then STDOUT should not be empty - - When I run `wp post meta get 1 apple` - Then STDOUT should be: - """ - banana - """ - - When I run `wp post meta get 1 apple --single` - Then STDOUT should be: - """ - banana - """ - - When I run `wp post meta get 1 apple --no-single` - Then STDOUT should be: - """ - array ( - 0 => 'banana', - 1 => 'mango', - ) - """ - When I run `wp post meta get 1 apple --no-single --format=json` - Then STDOUT should be: - """ - ["banana","mango"] - """ - @pluck Scenario: Nested values can be retrieved. Given a WP install @@ -357,7 +280,7 @@ Feature: Manage post custom fields Then STDOUT should be JSON containing: """ { - "foo": "baz" + "foo": "baz" } """ diff --git a/features/post-revision.feature b/features/post-revision.feature deleted file mode 100644 index 594d6a7ee..000000000 --- a/features/post-revision.feature +++ /dev/null @@ -1,256 +0,0 @@ -Feature: Manage WordPress post revisions - - Background: - Given a WP install - - # Creating a published post doesn't create an initial revision, - # so we update it twice here and restore the middle version. - # See https://github.com/wp-cli/entity-command/issues/564. - Scenario: Restore a post revision - When I run `wp post create --post_title='Original Post' --post_content='Original content' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_content='Updated content'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=ids` - Then STDOUT should not be empty - And save STDOUT as {REVISION_ID} - - When I run `wp post update {POST_ID} --post_content='Another one'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Another one - """ - - When I run `wp post revision restore {REVISION_ID}` - Then STDOUT should contain: - """ - Success: Restored revision - """ - - When I run `wp post get {POST_ID} --field=post_content` - Then STDOUT should contain: - """ - Updated content - """ - - Scenario: Restore invalid revision should fail - When I try `wp post revision restore 99999` - Then STDERR should contain: - """ - Error: Invalid revision ID - """ - And the return code should be 1 - - Scenario: Show diff between two revisions - When I run `wp post create --post_title='Test Post' --post_content='First version' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_content='Second version'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post update {POST_ID} --post_title='New Title' --post_content='Third version'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` - Then STDOUT should not be empty - And save STDOUT as {REVISION_IDS} - - When I run `echo "{REVISION_IDS}" | awk '{print $1}'` - Then save STDOUT as {REVISION_ID_1} - - When I run `echo "{REVISION_IDS}" | awk '{print $2}'` - Then save STDOUT as {REVISION_ID_2} - - When I run `wp post revision diff {REVISION_ID_1} {REVISION_ID_2}` - Then STDOUT should contain: - """ - - Second version - + Third version - """ - And STDOUT should contain: - """ - --- Test Post - """ - And STDOUT should contain: - """ - +++ New Title - """ - - Scenario: Show diff between revision and current post - When I run `wp post create --post_title='Diff Test' --post_content='Original text' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_content='Modified text'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` - Then STDOUT should not be empty - And save STDOUT as {REVISION_ID} - - When I run `wp post revision diff {REVISION_ID}` - Then STDOUT should contain: - """ - Success: No difference found. - """ - - Scenario: Diff with invalid revision should fail - When I try `wp post revision diff 99999` - Then STDERR should contain: - """ - Error: Invalid 'from' ID - """ - And the return code should be 1 - - Scenario: Diff between two invalid revisions should fail - When I try `wp post revision diff 99998 99999` - Then STDERR should contain: - """ - Error: Invalid 'from' ID - """ - And the return code should be 1 - - Scenario: Diff with specific field - When I run `wp post create --post_title='Field Test' --post_content='Some content' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_title='Modified Field Test'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --fields=ID --format=ids --orderby=ID --order=ASC` - Then STDOUT should not be empty - And save STDOUT as {REVISION_ID} - - When I run `wp post revision diff {REVISION_ID} --field=post_title` - Then the return code should be 0 - - Scenario: Prune revisions keeping latest N - When I run `wp post create --post_title='Prune Test' --post_content='Version 1' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_content='Version 2'` - And I run `wp post update {POST_ID} --post_content='Version 3'` - And I run `wp post update {POST_ID} --post_content='Version 4'` - And I run `wp post update {POST_ID} --post_content='Version 5'` - - # The initial post does not create a revision. - # See https://core.trac.wordpress.org/ticket/30854 - And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count` - Then STDOUT should be: - """ - 4 - """ - - When I run `wp post revision prune {POST_ID} --latest=2 --yes` - Then STDOUT should contain: - """ - Success: Deleted 2 revisions for post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count` - Then STDOUT should be: - """ - 2 - """ - - Scenario: Prune revisions keeping earliest N - When I run `wp post create --post_title='Prune Earliest Test' --post_content='Version 1' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_content='Version 2'` - And I run `wp post update {POST_ID} --post_content='Version 3'` - And I run `wp post update {POST_ID} --post_content='Version 4'` - - # The initial post does not create a revision. - # See https://core.trac.wordpress.org/ticket/30854 - And I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count` - Then STDOUT should be: - """ - 3 - """ - - When I run `wp post revision prune {POST_ID} --earliest=2 --yes` - Then STDOUT should contain: - """ - Success: Deleted 1 revision for post {POST_ID}. - """ - - When I run `wp post list --post_type=revision --post_parent={POST_ID} --format=count` - Then STDOUT should be: - """ - 2 - """ - - Scenario: Prune revisions for all posts - When I run `wp post create --post_title='Post 1' --post_content='Content 1' --porcelain` - Then save STDOUT as {POST_ID_1} - - When I run `wp post update {POST_ID_1} --post_content='Update 1'` - And I run `wp post update {POST_ID_1} --post_content='Update 2'` - And I run `wp post update {POST_ID_1} --post_content='Update 3'` - - And I run `wp post create --post_title='Post 2' --post_content='Content 2' --porcelain` - Then save STDOUT as {POST_ID_2} - - When I run `wp post update {POST_ID_2} --post_content='Update 1'` - And I run `wp post update {POST_ID_2} --post_content='Update 2'` - - And I run `wp post revision prune --latest=1 --yes` - Then STDOUT should contain: - """ - Success: Deleted - """ - And STDOUT should contain: - """ - revisions across - """ - - Scenario: Prune with no flags should fail - When I run `wp post create --post_title='Test' --post_content='Content' --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post revision prune {POST_ID}` - Then STDERR should contain: - """ - Error: Please specify either --latest or --earliest flag. - """ - And the return code should be 1 - - Scenario: Prune with both flags should fail - When I run `wp post create --post_title='Test' --post_content='Content' --porcelain` - Then save STDOUT as {POST_ID} - - When I try `wp post revision prune {POST_ID} --latest=5 --earliest=5` - Then STDERR should contain: - """ - Error: Cannot specify both --latest and --earliest flags. - """ - And the return code should be 1 diff --git a/features/post-type.feature b/features/post-type.feature index 6369b5c9f..0a73beef5 100644 --- a/features/post-type.feature +++ b/features/post-type.feature @@ -6,17 +6,9 @@ Feature: Manage WordPress post types Scenario: Listing post types When I run `wp post-type list --format=csv` Then STDOUT should be CSV containing: - | name | label | description | hierarchical | public | capability_type | - | post | Posts | | false | true | post | - | page | Pages | | true | true | page | - - @require-wp-5.0 - Scenario: Listing post types with count - When I run `wp post-type list --fields=name,count --format=csv` - Then STDOUT should be CSV containing: - | name | count | - | post | 1 | - | page | 2 | + | name | label | description | hierarchical | public | capability_type | + | post | Posts | | | 1 | post | + | page | Pages | | 1 | 1 | page | Scenario: Get a post type When I try `wp post-type get invalid-post-type` @@ -31,19 +23,3 @@ Feature: Manage WordPress post types | Field | Value | | name | page | | label | Pages | - And STDOUT should contain: - """ - supports - """ - And STDOUT should contain: - """ - "title":true - """ - - @require-wp-5.0 - Scenario: Get a post type with count - When I try `wp post-type get page --fields=name,count` - Then STDOUT should be a table containing rows: - | Field | Value | - | name | page | - | count | 2 | diff --git a/features/post-url-to-id.feature b/features/post-url-to-id.feature deleted file mode 100644 index cb9ddf9ed..000000000 --- a/features/post-url-to-id.feature +++ /dev/null @@ -1,24 +0,0 @@ -Feature: Get the post ID for a given URL - - Background: - Given a WP install - - Scenario: Get the post ID for a given URL - When I run `wp post get 1 --field=url` - Then STDOUT should be: - """ - https://example.com/?p=1 - """ - And save STDOUT as {POST_URL} - - When I run `wp post url-to-id {POST_URL}` - Then STDOUT should contain: - """ - 1 - """ - - When I try `wp post url-to-id 'https://example.com/?p=404'` - Then STDERR should contain: - """ - Could not get post with url https://example.com/?p=404. - """ \ No newline at end of file diff --git a/features/post.feature b/features/post.feature index f72cbd652..ff510fe74 100644 --- a/features/post.feature +++ b/features/post.feature @@ -8,21 +8,6 @@ Feature: Manage WordPress posts Then STDOUT should be a number And save STDOUT as {POST_ID} - When I run `wp post create --post_title='Test post' --post_type="test" --porcelain` - Then STDOUT should be a number - And save STDOUT as {CUSTOM_POST_ID} - - When I run `wp post exists {CUSTOM_POST_ID}` - Then STDOUT should be: - """ - Success: Post with ID {CUSTOM_POST_ID} exists. - """ - And the return code should be 0 - - When I try `wp post exists 1000` - Then STDOUT should be empty - And the return code should be 1 - When I run `wp post update {POST_ID} --post_title='Updated post'` Then STDOUT should be: """ @@ -41,65 +26,14 @@ Feature: Manage WordPress posts Success: Deleted post {POST_ID}. """ - When I run `wp post delete {CUSTOM_POST_ID}` - Then STDOUT should be: - """ - Success: Trashed post {CUSTOM_POST_ID}. - """ - - When I run the previous command again - Then STDOUT should be: - """ - Success: Deleted post {CUSTOM_POST_ID}. - """ - - Scenario: Force-deleting a custom post type post skips trash - When I run `wp post create --post_title='Test CPT post' --post_type='book' --porcelain` - Then STDOUT should be a number - And save STDOUT as {BOOK_POST_ID} - - When I run `wp post delete {BOOK_POST_ID} --force` - Then STDOUT should be: - """ - Success: Deleted post {BOOK_POST_ID}. - """ - When I try the previous command again Then the return code should be 1 - Scenario: Deleting already trashed custom post type posts - When I run `wp post create --post_title='Test CPT post' --post_type='book' --porcelain` - Then STDOUT should be a number - And save STDOUT as {BOOK_POST_ID} - - When I run `wp post update {BOOK_POST_ID} --post_status='trash'` - Then STDOUT should be: - """ - Success: Updated post {BOOK_POST_ID}. - """ - - When I run `wp post delete {BOOK_POST_ID}` - Then STDOUT should be: - """ - Success: Deleted post {BOOK_POST_ID}. - """ - - Scenario: Updating an invalid post should exit with an error - Given a WP install - - When I try `wp post update 22 --post_title=Foo` - Then the return code should be 1 - And STDERR should contain: - """ - Warning: Invalid post ID. - """ - Scenario: Setting post categories When I run `wp term create category "First Category" --porcelain` - Then save STDOUT as {TERM_ID} - - When I run `wp term create category "Second Category" --porcelain` - Then save STDOUT as {SECOND_TERM_ID} + And save STDOUT as {TERM_ID} + And I run `wp term create category "Second Category" --porcelain` + And save STDOUT as {SECOND_TERM_ID} When I run `wp post create --post_title="Test category" --post_category="First Category" --porcelain` Then STDOUT should be a number @@ -224,7 +158,7 @@ Feature: Manage WordPress posts """ And a create-post.sh file: """ - cat content.html | wp post create --post_title="Test post" --post_excerpt="A multiline + cat content.html | wp post create --post_title='Test post' --post_excerpt="A multiline excerpt" --porcelain - """ @@ -265,16 +199,16 @@ Feature: Manage WordPress posts } """ - When I try `EDITOR="ex -i NONE -c q!" wp post edit {POST_ID}` + When I try `EDITOR='ex -i NONE -c q!' wp post edit {POST_ID}` Then STDERR should contain: """ No change made to post content. """ And the return code should be 0 - When I run `EDITOR="ex -i NONE -c %s/content/bunkum -c wq" wp post edit {POST_ID}` + When I run `EDITOR='ex -i NONE -c %s/content/bunkum -c wq' wp post edit {POST_ID}` Then STDERR should be empty - And STDOUT should contain: + Then STDOUT should contain: """ Updated post {POST_ID}. """ @@ -288,14 +222,8 @@ Feature: Manage WordPress posts When I run `wp post url 1 {POST_ID}` Then STDOUT should be: """ - https://example.com/?p=1 - https://example.com/?p={POST_ID} - """ - - When I run `wp post get 1 --field=url` - Then STDOUT should be: - """ - https://example.com/?p=1 + http://example.com/?p=1 + http://example.com/?p=3 """ Scenario: Update a post from file or STDIN @@ -380,7 +308,7 @@ Feature: Manage WordPress posts """ When I run `wp post list --post_type='page' --field=title` - Then STDOUT should contain: + Then STDOUT should be: """ Sample Page """ @@ -393,65 +321,6 @@ Feature: Manage WordPress posts | Publish post | publish-post | publish | | Sample Page | sample-page | publish | - Scenario: List posts with date query - When I run `wp post create --post_title='old post' --post_date='2023-01-24T09:52:00.000Z'` - And I run `wp post create --post_title='new post' --post_date='2025-01-24T09:52:00.000Z'` - And I run `wp post list --field=post_title --date_query='{"before":{"year":"2024"}}'` - Then STDOUT should contain: - """ - old post - """ - And STDOUT should not contain: - """ - new post - """ - - Scenario: List posts with tax query - When I run `wp term create category "First Category" --porcelain` - And I run `wp term create category "Second Category" --porcelain` - And I run `wp post create --post_title='post-1' --post_category="First Category"` - And I run `wp post create --post_title='post-2' --post_category="Second Category"` - And I run `wp post create --post_title='new post' --post_date='2025-01-24T09:52:00.000Z'` - And I run `wp post list --field=post_title --tax_query='[{"taxonomy":"category","field":"slug","terms":"first-category"}]'` - Then STDOUT should contain: - """ - post-1 - """ - And STDOUT should not contain: - """ - post-2 - """ - - Scenario: Creating/updating posts with taxonomies - When I run `wp term create category "First Category" --porcelain` - And save STDOUT as {CAT_1} - And I run `wp term create category "Second Category" --porcelain` - And save STDOUT as {CAT_2} - And I run `wp term create post_tag "Term One" --porcelain` - And I run `wp term create post_tag "Term Two" --porcelain` - And I run `wp post create --post_title='Test Post' --post_content='Test post content' --tax_input='{"category":[{CAT_1},{CAT_2}],"post_tag":["term-one", "term-two"]}' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term list {POST_ID} category post_tag --format=table --fields=name,taxonomy` - Then STDOUT should be a table containing rows: - | name | taxonomy | - | First Category | category | - | Second Category | category | - | Term One | post_tag | - | Term Two | post_tag | - When I run `wp post update {POST_ID} --tax_input='{"category":[{CAT_1}],"post_tag":["term-one"]}'` - Then STDOUT should contain: - """ - Success: Updated post {POST_ID}. - """ - - When I run `wp post term list {POST_ID} category post_tag --format=table --fields=name,taxonomy` - Then STDOUT should be a table containing rows: - | name | taxonomy | - | First Category | category | - | Term One | post_tag | - Scenario: Update categories on a post When I run `wp term create category "Test Category" --porcelain` Then save STDOUT as {TERM_ID} @@ -473,7 +342,7 @@ Feature: Manage WordPress posts My\Post """ - When I run `wp post update {POST_ID} --post_content="var isEmailValid = /^\S+@\S+.\S+$/.test(email);"` + When I run `wp post update {POST_ID} --post_content='var isEmailValid = /^\S+@\S+.\S+$/.test(email);'` Then STDOUT should not be empty When I run `wp post get {POST_ID} --field=content` @@ -502,115 +371,36 @@ Feature: Manage WordPress posts | {POST_ID} | key2 | value2b | | {POST_ID} | key3 | value3 | - When I run `wp post list --field=post_title --meta_query='[{"key":"key2","value":"value2b"}]'` - Then STDOUT should contain: - """ - Test Post - """ - - Scenario: Publishing a post and setting a date fails if the edit_date flag is not passed. - Given a WP install - - When I run `wp post create --post_title='test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_date='2005-01-24T09:52:00.000Z' --post_status='publish'` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp post get {POST_ID} --field=post_date` - Then STDOUT should not contain: - """ - 2005-01-24 09:52:00 - """ - - @require-mysql - Scenario: Publishing a post and setting a date succeeds if the edit_date flag is passed. - Given a WP install - - When I run `wp post create --post_title='test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_date='2005-01-24T09:52:00.000Z' --post_status='publish' --edit_date=1` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp post get {POST_ID} --field=post_date` - Then STDOUT should contain: - """ - 2005-01-24 09:52:00 - """ - - # Separate test because of a known bug in the SQLite plugin. - # See https://github.com/WordPress/sqlite-database-integration/issues/52. - # Once the bug is resolved, this separate test can be removed again. - @require-sqlite - Scenario: Publishing a post and setting a date succeeds if the edit_date flag is passed. - Given a WP install - - When I run `wp post create --post_title='test' --porcelain` - Then save STDOUT as {POST_ID} - - When I run `wp post update {POST_ID} --post_date='2005-01-24T09:52:00.000Z' --post_status='publish' --edit_date=1` - Then STDOUT should contain: + @less-than-wp-4.4 + Scenario: Creating/updating posts with meta keys for WP < 4.4 has no effect so should give warning + When I try `wp post create --post_title='Test Post' --post_content='Test post content' --meta_input='{"key1":"value1","key2":"value2"}' --porcelain` + Then the return code should be 0 + And STDOUT should be a number + And save STDOUT as {POST_ID} + And STDERR should be: """ - Success: + Warning: The 'meta_input' field was only introduced in WordPress 4.4 so will have no effect. """ - When I run `wp post get {POST_ID} --field=post_date` - Then STDOUT should contain: + When I run `wp post meta list {POST_ID} --format=count` + Then STDOUT should be: """ - 2005-01-24T09:52:00.000Z + 0 """ - @require-wp-5.0 - Scenario: Get block_version field for post with blocks - Given a block-post.html file: + When I try `wp post update {POST_ID} --meta_input='{"key2":"value2b","key3":"value3"}'` + Then the return code should be 0 + And STDERR should be: """ - <!-- wp:paragraph --><p>Hello block world</p><!-- /wp:paragraph --> + Warning: The 'meta_input' field was only introduced in WordPress 4.4 so will have no effect. """ - When I run `wp post create block-post.html --post_title='Block Post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post get {POST_ID} --field=block_version` - Then STDOUT should be: + And STDOUT should be: """ - 1 - """ - - @require-wp-5.0 - Scenario: Get block_version field for post without blocks - Given a classic-post.html file: - """ - <p>Just plain HTML</p> + Success: Updated post {POST_ID}. """ - When I run `wp post create classic-post.html --post_title='Classic Post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - When I run `wp post get {POST_ID} --field=block_version` + When I run `wp post meta list {POST_ID} --format=count` Then STDOUT should be: """ 0 """ - - @require-wp-5.0 - Scenario: Get block_version field included in default output - Given a heading-post.html file: - """ - <!-- wp:heading --><h2>Title</h2><!-- /wp:heading --> - """ - When I run `wp post create heading-post.html --post_title='Test Post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post get {POST_ID} --format=json` - Then STDOUT should be JSON containing: - """ - {"block_version":1} - """ diff --git a/features/signup.feature b/features/signup.feature deleted file mode 100644 index a3a132066..000000000 --- a/features/signup.feature +++ /dev/null @@ -1,198 +0,0 @@ -Feature: Manage signups in a multisite installation - - Scenario: Not applicable in single installation site - Given a WP install - - When I try `wp user signup list` - Then STDERR should be: - """ - Error: This is not a multisite installation. - """ - - Scenario: List signups - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - And I run `wp eval 'wpmu_signup_user( "johnuser", "johnuser@example.com" );'` - - When I run `wp user signup list --fields=signup_id,user_login,user_email,active --format=csv` - Then STDOUT should be: - """ - signup_id,user_login,user_email,active - 1,bobuser,bobuser@example.com,0 - 2,johnuser,johnuser@example.com,0 - """ - - When I run `wp user signup list --format=count --active=1` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp user signup activate bobuser` - Then STDOUT should contain: - """ - Success: Activated 1 of 1 signups. - """ - - When I run `wp user signup list --fields=signup_id,user_login,user_email,active --format=csv --active=1` - Then STDOUT should be: - """ - signup_id,user_login,user_email,active - 1,bobuser,bobuser@example.com,1 - """ - - When I run `wp user signup list --fields=signup_id,user_login,user_email,active --format=csv --per_page=1` - Then STDOUT should be: - """ - signup_id,user_login,user_email,active - 1,bobuser,bobuser@example.com,1 - """ - - Scenario: Get signup - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - - When I run `wp user signup get 1 --field=user_login` - Then STDOUT should be: - """ - bobuser - """ - - When I run `wp user signup get bobuser --fields=signup_id,user_login,user_email,active --format=csv` - Then STDOUT should be: - """ - signup_id,user_login,user_email,active - 1,bobuser,bobuser@example.com,0 - """ - - Scenario: Activate signup - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - - When I run `wp user signup get bobuser --field=active` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp user signup activate bobuser` - Then STDOUT should contain: - """ - Success: Activated 1 of 1 signups. - """ - - When I try the previous command again - Then STDERR should contain: - """ - Warning: Failed activating signup 1. - """ - - When I run `wp user signup get bobuser --field=active` - Then STDOUT should be: - """ - 1 - """ - - When I run `wp user get bobuser --field=user_email` - Then STDOUT should be: - """ - bobuser@example.com - """ - - Scenario: Activate multiple signups - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - And I run `wp eval 'wpmu_signup_user( "johnuser", "johnuser@example.com" );'` - - When I run `wp user signup list --active=0 --format=count` - Then STDOUT should be: - """ - 2 - """ - - When I run `wp user signup activate bobuser johnuser` - Then STDOUT should contain: - """ - Success: Activated 2 of 2 signups. - """ - - When I run `wp user signup list --active=1 --format=count` - Then STDOUT should be: - """ - 2 - """ - - Scenario: Activate blog signup entry - Given a WP multisite install - And I run `wp eval 'wpmu_signup_blog( "example.com", "/bobsite/", "My Awesome Title", "bobuser", "bobuser@example.com" );'` - - When I run `wp user signup get bobuser --fields=user_login,domain,path,active --format=csv` - Then STDOUT should be: - """ - user_login,domain,path,active - bobuser,example.com,/bobsite/,0 - """ - - When I run `wp user signup activate bobuser` - Then STDOUT should contain: - """ - Success: Activated 1 of 1 signups. - """ - - When I run `wp site list --fields=domain,path` - Then STDOUT should be a table containing rows: - | domain | path | - | example.com | / | - | example.com | /bobsite/ | - - Scenario: Delete signups - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - And I run `wp eval 'wpmu_signup_user( "johnuser", "johnuser@example.com" );'` - - When I run `wp user signup get bobuser --field=user_email` - Then STDOUT should be: - """ - bobuser@example.com - """ - - When I run `wp user signup get johnuser --field=user_email` - Then STDOUT should be: - """ - johnuser@example.com - """ - - When I run `wp user signup delete bobuser@example.com johnuser@example.com` - Then STDOUT should contain: - """ - Success: Deleted 2 of 2 signups. - """ - - When I try `wp user signup get bobuser` - Then STDERR should be: - """ - Error: Invalid signup ID, email, login, or activation key: 'bobuser' - """ - - Scenario: Delete all signups - Given a WP multisite install - And I run `wp eval 'wpmu_signup_user( "bobuser", "bobuser@example.com" );'` - And I run `wp eval 'wpmu_signup_user( "johnuser", "johnuser@example.com" );'` - - When I try `wp user signup delete` - Then STDERR should be: - """ - Error: You need to specify either one or more signups or provide the --all flag. - """ - - When I run `wp user signup delete --all` - Then STDOUT should contain: - """ - Success: Deleted all signups. - """ - - When I run `wp user signup list --format=count` - Then STDOUT should be: - """ - 0 - """ diff --git a/features/site-create.feature b/features/site-create.feature index 9c53543dc..c4cf546b5 100644 --- a/features/site-create.feature +++ b/features/site-create.feature @@ -16,7 +16,7 @@ Feature: Create a new site on a WP multisite define( 'BLOG_ID_CURRENT_SITE', 1 ); """ - When I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check --extra-php < extra-config` + When I run `wp core config {CORE_CONFIG_SETTINGS} --extra-php < extra-config` Then STDOUT should be: """ Success: Generated 'wp-config.php' file. @@ -46,207 +46,3 @@ Feature: Create a new site on a WP multisite | blog_id | url | | 1 | http://localhost/dev/ | | 2 | http://localhost/dev/newsite/ | - - Scenario: Create new site with custom `$super_admins` global - Given an empty directory - And WP files - And a database - And a extra-config file: - """ - define( 'WP_ALLOW_MULTISITE', true ); - define( 'MULTISITE', true ); - define( 'SUBDOMAIN_INSTALL', false ); - define( 'DOMAIN_CURRENT_SITE', 'localhost' ); - define( 'PATH_CURRENT_SITE', '/' ); - define('SITE_ID_CURRENT_SITE', 1); - define('BLOG_ID_CURRENT_SITE', 1); - - $super_admins = array( 1 => 'admin' ); - """ - When I run `wp core config {CORE_CONFIG_SETTINGS} --skip-check --extra-php < extra-config` - Then STDOUT should be: - """ - Success: Generated 'wp-config.php' file. - """ - - # Old versions of WP can generate wpdb database errors if the WP tables don't exist, so STDERR may or may not be empty - When I try `wp core multisite-install --url=localhost --title=Test --admin_user=admin --admin_email=admin@example.org` - Then STDOUT should contain: - """ - Success: Network installed. Don't forget to set up rewrite rules - """ - And the return code should be 0 - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | http://localhost/ | - - When I run `wp site create --slug=newsite` - Then STDOUT should be: - """ - Success: Site 2 created: http://localhost/newsite/ - """ - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | http://localhost/ | - | 2 | http://localhost/newsite/ | - - Scenario: Create site with custom URL in subdomain multisite - Given a WP multisite subdomain install - - When I run `wp site create --site-url=http://custom.example.com` - Then STDOUT should contain: - """ - Success: Site 2 created: http://custom.example.com/ - """ - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | http://custom.example.com/ | - - When I run `wp --url=custom.example.com option get home` - Then STDOUT should be: - """ - http://custom.example.com - """ - - Scenario: Create site with custom URL in subdirectory multisite - Given a WP multisite subdirectory install - - When I run `wp site create --site-url=http://example.com/custom/path/` - Then STDOUT should contain: - """ - Success: Site 2 created: - """ - And STDOUT should contain: - """ - ://example.com/custom/path/ - """ - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should contain: - """ - ://example.com/custom/path/ - """ - - Scenario: Create site with custom URL and explicit slug - Given a WP multisite subdomain install - - When I run `wp site create --site-url=http://custom.example.com --slug=myslug` - Then STDOUT should contain: - """ - Success: Site 2 created: http://custom.example.com/ - """ - - Scenario: Error when neither slug nor site-url is provided - Given a WP multisite install - - When I try `wp site create --title="Test Site"` - Then STDERR should be: - """ - Error: Either --slug or --site-url must be provided. - """ - And the return code should be 1 - - Scenario: Error when invalid URL format is provided - Given a WP multisite install - - When I try `wp site create --site-url=not-a-valid-url` - Then STDERR should contain: - """ - Error: Invalid URL format - """ - And the return code should be 1 - - Scenario: Error when invalid scheme is provided - Given a WP multisite install - - When I try `wp site create --site-url=ftp://example.com/site` - Then STDERR should be: - """ - Error: Invalid URL scheme. Only http and https schemes are supported. - """ - And the return code should be 1 - - Scenario: Error when root path provided without explicit slug - Given a WP multisite subdirectory install - - When I try `wp site create --site-url=http://example.com/` - Then STDERR should be: - """ - Error: Could not derive a valid slug from the URL path. Please provide --slug explicitly. - """ - And the return code should be 1 - - Scenario: Create site with URL without trailing slash - Given a WP multisite subdirectory install - - When I run `wp site create --site-url=http://example.com/notrailing` - Then STDOUT should contain: - """ - Success: Site 2 created: - """ - And STDOUT should contain: - """ - ://example.com/notrailing/ - """ - - Scenario: Error when numeric-only domain is provided without slug - Given a WP multisite subdomain install - - When I try `wp site create --site-url=http://123.example.com` - Then STDERR should be: - """ - Error: Could not derive a valid slug from the domain (numeric-only or empty slugs are not allowed). Please provide --slug explicitly. - """ - And the return code should be 1 - - Scenario: Create site with different domain in subdirectory multisite shows warning - Given a WP multisite subdirectory install - - When I try `wp site create --site-url=http://custom.example.com/mypath/` - Then STDERR should contain: - """ - Warning: Using a different domain for a subdirectory multisite install may require additional configuration - """ - And STDOUT should contain: - """ - Success: Site 2 created: - """ - And STDOUT should contain: - """ - ://custom.example.com/mypath/ - """ - - Scenario: Create site with both site-url and slug uses slug for internal operations - Given a WP multisite subdomain install - - When I run `wp site create --site-url=http://custom.example.com --slug=myslug --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site list --site__in={SITE_ID} --field=url` - Then STDOUT should contain: - """ - custom.example.com - """ - - Scenario: Preserve existing slug behavior - Given a WP multisite subdomain install - - When I run `wp site create --slug=testsite` - Then STDOUT should contain: - """ - Success: Site 2 created: http://testsite.example.com/ - """ - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | http://testsite.example.com/ | diff --git a/features/site-empty.feature b/features/site-empty.feature index 782da9c18..f04fe85c3 100644 --- a/features/site-empty.feature +++ b/features/site-empty.feature @@ -1,27 +1,11 @@ Feature: Empty a WordPress site of its data - @require-mysql Scenario: Empty a site Given a WP installation And I run `wp option update uploads_use_yearmonth_folders 0` And download: | path | url | - | {CACHE_DIR}/large-image.jpg | http://wp-cli.github.io/behat-data/large-image.jpg | - And a insert_link_data.sql file: - """ - INSERT INTO `wp_links` (`link_url`, `link_name`, `link_image`, `link_target`, `link_description`, `link_visible`, `link_owner`, `link_rating`, `link_rel`, `link_notes`, `link_rss`) - VALUES ('http://wordpress.org/', 'test', '', '', 'test', 'Y', 1, 0, '', '', '') - """ - - When I run `wp db query "SOURCE insert_link_data.sql;"` - Then STDERR should be empty - - When I run `wp db query "SELECT COUNT(link_id) FROM wp_links;"` - Then STDOUT should be: - """ - COUNT(link_id) - 1 - """ + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | When I run `wp media import {CACHE_DIR}/large-image.jpg --post_id=1` Then the wp-content/uploads/large-image.jpg file should exist @@ -33,10 +17,10 @@ Feature: Empty a WordPress site of its data """ And the return code should be 1 - When I run `wp post create --post_title='Test post' --post_content='Test content.'` - Then STDOUT should contain: + When I run `wp post create --post_title='Test post' --post_content='Test content.' --porcelain` + Then STDOUT should be: """ - Success: Created post + 4 """ When I run `wp term create post_tag 'Test term' --slug=test --description='This is a test term'` @@ -45,30 +29,6 @@ Feature: Empty a WordPress site of its data Success: Created post_tag 2. """ - When I run `wp post create --post_type=page --post_title='Sample Privacy Page' --post_content='Sample Privacy Terms' --porcelain` - Then save STDOUT as {PAGE_ID} - - When I run `wp option set wp_page_for_privacy_policy {PAGE_ID}` - Then STDOUT should be: - """ - Success: Updated 'wp_page_for_privacy_policy' option. - """ - - When I run `wp option get wp_page_for_privacy_policy` - Then STDOUT should be: - """ - {PAGE_ID} - """ - - When I run `wp post create --post_title='Sticky Post' --post_content='This is just a sticky post' --porcelain` - Then save STDOUT as {STICKY_POST_ID} - - When I run `wp option set sticky_posts '[{STICKY_POST_ID}]' --format=json` - Then STDOUT should be: - """ - Success: Updated 'sticky_posts' option. - """ - When I run `wp site empty --yes` Then STDOUT should be: """ @@ -82,32 +42,13 @@ Feature: Empty a WordPress site of its data When I run `wp term list post_tag --format=ids` Then STDOUT should be empty - When I run `wp option get wp_page_for_privacy_policy` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp option get sticky_posts --format=json` - Then STDOUT should be: - """ - [] - """ - - When I run `wp db query "SELECT COUNT(link_id) FROM wp_links;"` - Then STDOUT should be: - """ - COUNT(link_id) - 0 - """ - Scenario: Empty a site and its uploads directory Given a WP multisite installation And I run `wp site create --slug=foo` And I run `wp --url=example.com/foo option update uploads_use_yearmonth_folders 0` And download: | path | url | - | {CACHE_DIR}/large-image.jpg | http://wp-cli.github.io/behat-data/large-image.jpg | + | {CACHE_DIR}/large-image.jpg | http://wp-cli.org/behat-data/large-image.jpg | When I run `wp --url=example.com/foo media import {CACHE_DIR}/large-image.jpg --post_id=1` Then the wp-content/uploads/sites/2/large-image.jpg file should exist @@ -120,9 +61,9 @@ Feature: Empty a WordPress site of its data Then STDOUT should be empty When I run `wp --url=example.com/foo site empty --uploads --yes` - Then STDOUT should contain: + Then STDOUT should be: """ - ://example.com/foo' was emptied. + Success: The site at 'http://example.com/foo' was emptied. """ And the wp-content/uploads/sites/2/large-image.jpg file should not exist diff --git a/features/site-generate.feature b/features/site-generate.feature deleted file mode 100644 index 4f053420f..000000000 --- a/features/site-generate.feature +++ /dev/null @@ -1,107 +0,0 @@ -Feature: Generate new WordPress sites - - Scenario: Generate on single site - Given a WP install - When I try `wp site generate` - Then STDERR should contain: - """ - This is not a multisite installation. - """ - And STDOUT should be empty - And the return code should be 1 - - Scenario: Generate a specific number of sites - Given a WP multisite install - When I run `wp site generate --count=10` - And I run `wp site list --format=count` - Then STDOUT should be: - """ - 11 - """ - - Scenario: Generate sites assigned to a specific network - Given a WP multisite install - When I try `wp site generate --count=4 --network_id=2` - Then STDERR should contain: - """ - Network with id 2 does not exist. - """ - And STDOUT should be empty - And the return code should be 1 - - Scenario: Generate sites and output ids - Given a WP multisite install - When I run `wp site generate --count=3 --format=ids` - And I run `wp site list --format=ids` - Then STDOUT should be: - """ - 1 2 3 4 - """ - And STDERR should be empty - And the return code should be 0 - - Scenario: Generate subdomain sites - Given a WP multisite subdomain install - - When I run `wp site generate --count=1` - Then STDOUT should be empty - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | http://site1.example.com/ | - When I run `wp site list --format=ids` - Then STDOUT should be: - """ - 1 2 - """ - - Scenario: Generate subdirectory sites - Given a WP multisite subdirectory install - When I run `wp site generate --count=1` - Then STDOUT should be empty - And I run `wp site list --site__in=2 --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME} - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | {SCHEME}://example.com/site1/ | - When I run `wp site list --format=ids` - Then STDOUT should be: - """ - 1 2 - """ - - Scenario: Generate sites with a slug - Given a WP multisite subdirectory install - When I run `wp site generate --count=2 --slug=subsite` - Then STDOUT should be empty - And I run `wp site list --site__in=2 --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME1} - And I run `wp site list --site__in=3 --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME2} - - When I run `wp site list --fields=blog_id,url` - Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | {SCHEME1}://example.com/subsite1/ | - | 3 | {SCHEME2}://example.com/subsite2/ | - When I run `wp site list --format=ids` - Then STDOUT should be: - """ - 1 2 3 - """ - - Scenario: Generate sites with reserved slug - Given a WP multisite subdirectory install - When I try `wp site generate --count=2 --slug=page` - Then STDERR should contain: - """ - The following words are reserved and cannot be used as blog names: page, comments, blog, files, feed - """ - And STDOUT should be empty - And the return code should be 1 diff --git a/features/site-meta.feature b/features/site-meta.feature deleted file mode 100644 index d1bdfaee3..000000000 --- a/features/site-meta.feature +++ /dev/null @@ -1,36 +0,0 @@ -Feature: Manage site custom fields - - @require-wp-5.1 - Scenario: Site meta CRUD - Given a WP multisite installation - - When I run `wp site meta add 1 foo 'bar'` - Then STDOUT should not be empty - - When I run `wp site meta get 1 foo` - Then STDOUT should be: - """ - bar - """ - - When I try `wp site meta get 999999 foo` - Then STDERR should be: - """ - Error: Could not find the site with ID 999999. - """ - And the return code should be 1 - - When I run `wp site meta set 1 foo '[ "1", "2" ]' --format=json` - Then STDOUT should not be empty - - When I run `wp site meta get 1 foo --format=json` - Then STDOUT should be: - """ - ["1","2"] - """ - - When I run `wp site meta delete 1 foo` - Then STDOUT should not be empty - - When I try `wp site meta get 1 foo` - Then the return code should be 1 diff --git a/features/site-option.feature b/features/site-option.feature index 18b4182b5..de14b84a7 100644 --- a/features/site-option.feature +++ b/features/site-option.feature @@ -127,7 +127,6 @@ Feature: Manage WordPress site options """ And the return code should be 1 - @require-mysql Scenario: Filter options by `--site_id` Given a WP multisite installation diff --git a/features/site.feature b/features/site.feature index aea85e9a5..e217f746c 100644 --- a/features/site.feature +++ b/features/site.feature @@ -20,7 +20,7 @@ Feature: Manage sites in a multisite installation When I run `wp site list --fields=blog_id,url` Then STDOUT should be a table containing rows: | blog_id | url | - | 1 | https://example.com/ | + | 1 | http://example.com/ | | 2 | http://first.example.com/ | When I run `wp site list --format=ids` @@ -38,27 +38,24 @@ Feature: Manage sites in a multisite installation http://first.example.com """ - @skip-windows Scenario: Delete a site by id Given a WP multisite subdirectory install When I run `wp site create --slug=first --porcelain` Then STDOUT should be a number And save STDOUT as {SITE_ID} - And I run `wp site list --site__in={SITE_ID} --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME} When I run `wp site list --fields=blog_id,url` Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | {SCHEME}://example.com/first/ | + | blog_id | url | + | 1 | http://example.com/ | + | 2 | http://example.com/first/ | When I run `wp site list --field=url` Then STDOUT should be: """ - https://example.com/ - {SCHEME}://example.com/first/ + http://example.com/ + http://example.com/first/ """ When I try `wp site delete 1` @@ -72,243 +69,71 @@ Feature: Manage sites in a multisite installation When I run `wp site delete {SITE_ID} --yes` Then STDOUT should be: """ - Success: The site at '{SCHEME}://example.com/first/' was deleted. + Success: The site at 'http://example.com/first/' was deleted. """ When I try the previous command again Then the return code should be 1 - @skip-windows - Scenario: Delete a site by id and remove all prefixed tables - Given a WP multisite subdirectory install - - When I run `wp site create --slug=first --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - And I try `wp db query "CREATE TABLE wp_{SITE_ID}_custom_data (id INTEGER PRIMARY KEY);"` - And I run `wp site delete {SITE_ID} --yes --delete-tables-with-prefix` - And STDOUT should contain: - """ - Success: The site at ' - """ - - When I try `wp db query "CREATE TABLE wp_{SITE_ID}_custom_data (id INTEGER PRIMARY KEY);"` - Then STDOUT should be empty - And the return code should be 0 - And I try `wp db query "DROP TABLE wp_{SITE_ID}_custom_data;"` - And the return code should be 0 - - @skip-windows - Scenario: Deleting a site cannot combine keep-tables with delete-tables-with-prefix - Given a WP multisite subdirectory install - - When I run `wp site create --slug=first --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I try `wp site delete {SITE_ID} --yes --keep-tables --delete-tables-with-prefix` - Then STDERR should be: - """ - Error: The '--keep-tables' and '--delete-tables-with-prefix' flags cannot be used together. - """ - And STDOUT should be empty - And the return code should be 1 - - @skip-windows Scenario: Filter site list Given a WP multisite install When I run `wp site create --slug=first --porcelain` Then STDOUT should be a number And save STDOUT as {SITE_ID} - And I run `wp site list --site__in={SITE_ID} --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME} When I run `wp site list --fields=blog_id,url` Then STDOUT should be a table containing rows: - | blog_id | url | - | 1 | https://example.com/ | - | 2 | {SCHEME}://example.com/first/ | + | blog_id | url | + | 1 | http://example.com/ | + | 2 | http://example.com/first/ | When I run `wp site list --field=url --blog_id=2` Then STDOUT should be: """ - {SCHEME}://example.com/first/ - """ - - When I run `wp site list --field=url --site-path=/first/` - Then STDOUT should be: - """ - {SCHEME}://example.com/first/ + http://example.com/first/ """ - Scenario: Filter site list by user - Given a WP multisite install - - When I run `wp site create --slug=first --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - And I run `wp site list --blog_id={SITE_ID} --field=url` - And save STDOUT as {SITE_URL} - - When I run `wp user create newuser newuser@example.com --porcelain --url={SITE_URL}` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - And I run `wp user get {USER_ID} --field=user_login` - And save STDOUT as {USER_LOGIN} - - When I run `wp site list --field=url --site_user={USER_LOGIN}` - Then STDOUT should be: - """ - {SITE_URL} - """ - - When I try `wp site list --site_user=invalid_user` - Then the return code should be 1 - And STDERR should be: - """ - Error: Invalid user ID, email or login: 'invalid_user' - """ - - When I run `wp user remove-role {USER_LOGIN} --url={SITE_URL}` - Then STDOUT should contain: - """ - Success: Removed - """ - - When I run `wp site list --field=url --site_user={USER_LOGIN}` - Then STDOUT should be empty - Scenario: Delete a site by slug Given a WP multisite install When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: + Then STDOUT should be: """ - ://example.com/first/ + Success: Site 2 created: http://example.com/first/ """ When I run `wp site delete --slug=first --yes` - Then STDOUT should contain: + Then STDOUT should be: """ - ://example.com/first/' was deleted. + Success: The site at 'http://example.com/first/' was deleted. """ When I try the previous command again Then the return code should be 1 - When I run `wp site create --slug=42` - Then STDOUT should contain: - """ - Success: Site 3 created: http - """ - And STDOUT should contain: - """ - ://example.com/42/ - """ - - When I run `wp site delete --slug=42 --yes` - Then STDOUT should contain: - """ - ://example.com/42/' was deleted. - """ - - When I try the previous command again - Then STDERR should contain: - """ - Error: Could not find site with slug '42'. - """ - And the return code should be 1 - - Scenario: Archive a site by a numeric slug - Given a WP multisite install - - When I run `wp site create --slug=42` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/42/ - """ - - When I run `wp site archive --slug=42` - Then STDOUT should contain: - """ - Success: Site 2 archived. - """ - - When I try `wp site archive --slug=43` - Then STDERR should contain: - """ - Error: Could not find site with slug '43'. - """ - And the return code should be 1 - - @skip-windows Scenario: Get site info Given a WP multisite install When I run `wp site create --slug=first --porcelain` Then STDOUT should be a number And save STDOUT as {SITE_ID} - And I run `wp site list --site__in={SITE_ID} --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SCHEME} When I run `wp site url {SITE_ID}` Then STDOUT should be: """ - {SCHEME}://example.com/first/ + http://example.com/first/ """ When I run `wp site create --slug=second --porcelain` Then STDOUT should be a number And save STDOUT as {SECOND_ID} - And I run `wp site list --site__in={SECOND_ID} --field=url | sed -e's,^\(.*\)://.*,\1,g'` - And save STDOUT as {SECOND_SCHEME} When I run `wp site url {SECOND_ID} {SITE_ID}` Then STDOUT should be: """ - {SECOND_SCHEME}://example.com/second/ - {SCHEME}://example.com/first/ - """ - - Scenario: Not providing a site ID or slug when running an update blog status command should throw an error - Given a WP multisite install - - When I try `wp site private` - Then the return code should be 1 - And STDERR should be: - """ - Error: Please specify one or more IDs of sites, or pass the slug for a single site using --slug. - """ - And STDOUT should be empty - - Scenario: Site IDs or a slug can be provided, but not both. - Given a WP multisite install - And I run `wp site create --slug=first --porcelain` - - When I try `wp site private 1 --slug=first` - Then the return code should be 1 - And STDERR should be: - """ - Error: Please specify one or more IDs of sites, or pass the slug for a single site using --slug. - """ - - Scenario: Errors for an invalid slug - Given a WP multisite install - - When I try `wp site private --slug=first` - Then the return code should be 1 - And STDERR should be: - """ - Error: Could not find site with slug 'first'. + http://example.com/second/ + http://example.com/first/ """ Scenario: Archive/unarchive a site @@ -588,9 +413,9 @@ Feature: Manage sites in a multisite installation """ When I run `wp --url=example.com/first option get home` - Then STDOUT should contain: + Then STDOUT should be: """ - ://example.com/first + http://example.com/first """ Scenario: Create site with title containing slash @@ -603,336 +428,3 @@ Feature: Manage sites in a multisite installation """ My\Site """ - - Scenario: Activate/deactivate a site by slug - Given a WP multisite install - - When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/first/ - """ - - When I run `wp site deactivate --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 deactivated. - """ - - When I run `wp site list --fields=blog_id,deleted` - Then STDOUT should be a table containing rows: - | blog_id | deleted | - | 2 | 1 | - - When I try `wp site deactivate --slug=first` - Then STDERR should be: - """ - Warning: Site 2 already deactivated. - """ - - When I run `wp site activate --slug=first` - Then STDOUT should be: - """ - Success: Site 2 activated. - """ - - When I run `wp site list --fields=blog_id,deleted` - Then STDOUT should be a table containing rows: - | blog_id | deleted | - | 2 | 0 | - - Scenario: Archive/unarchive a site by slug - Given a WP multisite install - - When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/first/ - """ - - When I run `wp site archive --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 archived. - """ - - When I run `wp site list --fields=blog_id,archived` - Then STDOUT should be a table containing rows: - | blog_id | archived | - | 2 | 1 | - - When I try `wp site archive --slug=first` - Then STDERR should be: - """ - Warning: Site 2 already archived. - """ - - When I run `wp site unarchive --slug=first` - Then STDOUT should be: - """ - Success: Site 2 unarchived. - """ - - When I run `wp site list --fields=blog_id,archived` - Then STDOUT should be a table containing rows: - | blog_id | archived | - | 2 | 0 | - - Scenario: Mark/remove a site by slug from spam - Given a WP multisite install - - When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/first/ - """ - - When I run `wp site spam --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 marked as spam. - """ - - When I run `wp site list --fields=blog_id,spam` - Then STDOUT should be a table containing rows: - | blog_id | spam | - | 2 | 1 | - - When I try `wp site spam --slug=first` - Then STDERR should be: - """ - Warning: Site 2 already marked as spam. - """ - - When I run `wp site unspam --slug=first` - Then STDOUT should be: - """ - Success: Site 2 removed from spam. - """ - - When I run `wp site list --fields=blog_id,spam` - Then STDOUT should be a table containing rows: - | blog_id | spam | - | 2 | 0 | - - Scenario: Mark/remove a site by slug as mature - Given a WP multisite install - - When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/first/ - """ - - When I run `wp site mature --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 marked as mature. - """ - - When I run `wp site list --fields=blog_id,mature` - Then STDOUT should be a table containing rows: - | blog_id | mature | - | 2 | 1 | - - When I try `wp site mature --slug=first` - Then STDERR should be: - """ - Warning: Site 2 already marked as mature. - """ - - When I run `wp site unmature --slug=first` - Then STDOUT should be: - """ - Success: Site 2 marked as unmature. - """ - - When I run `wp site list --fields=blog_id,mature` - Then STDOUT should be a table containing rows: - | blog_id | mature | - | 2 | 0 | - - Scenario: Set/Unset a site by slug as public - Given a WP multisite install - - When I run `wp site create --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 created: http - """ - And STDOUT should contain: - """ - ://example.com/first/ - """ - - When I run `wp site private --slug=first` - Then STDOUT should contain: - """ - Success: Site 2 marked as private. - """ - - When I run `wp site list --fields=blog_id,public` - Then STDOUT should be a table containing rows: - | blog_id | public | - | 2 | 0 | - - When I try `wp site private --slug=first` - Then STDERR should be: - """ - Warning: Site 2 already marked as private. - """ - - When I run `wp site public --slug=first` - Then STDOUT should be: - """ - Success: Site 2 marked as public. - """ - - When I run `wp site list --fields=blog_id,public` - Then STDOUT should be a table containing rows: - | blog_id | public | - | 2 | 1 | - - Scenario: Get site by ID - Given a WP multisite install - - When I run `wp site create --slug=testsite --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get {SITE_ID} --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - When I run `wp site get {SITE_ID}` - Then STDOUT should be a table containing rows: - | Field | Value | - | blog_id | {SITE_ID} | - - Scenario: Get site by URL - Given a WP multisite install - - When I run `wp site create --slug=testsite --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - And I run `wp site list --blog_id={SITE_ID} --field=url` - And save STDOUT as {SITE_URL} - - When I run `wp site get {SITE_URL} --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - Scenario: Get site by URL with subdirectory - Given a WP multisite subdirectory install - - When I run `wp site create --slug=mysubdir --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get http://example.com/mysubdir/ --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - Scenario: Use site get with site delete - Given a WP multisite install - - When I run `wp site create --slug=deleteme --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get http://example.com/deleteme/ --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - And save STDOUT as {BLOG_ID} - - When I run `wp site delete {BLOG_ID} --yes` - Then STDOUT should contain: - """ - Success: The site at - """ - And STDOUT should contain: - """ - was deleted. - """ - - Scenario: Get site with invalid URL should fail - Given a WP multisite install - - When I try `wp site get http://example.com/nonexistent/ --field=blog_id` - Then STDERR should contain: - """ - Error: Could not find site with URL: http://example.com/nonexistent/ - """ - And the return code should be 1 - - Scenario: Get site by domain without scheme - Given a WP multisite subdirectory install - - When I run `wp site create --slug=noscheme --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get example.com/noscheme/ --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - Scenario: Get site by simple domain path - Given a WP multisite subdirectory install - - When I run `wp site create --slug=simplepath --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get example.com/simplepath --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - Scenario: Get site by subdomain without scheme - Given a WP multisite subdomain install - - When I run `wp site create --slug=subdomain --porcelain` - Then STDOUT should be a number - And save STDOUT as {SITE_ID} - - When I run `wp site get subdomain.example.com --field=blog_id` - Then STDOUT should be: - """ - {SITE_ID} - """ - - Scenario: Get main site by domain - Given a WP multisite install - - When I run `wp site get example.com --field=blog_id` - Then STDOUT should be: - """ - 1 - """ diff --git a/features/steps/given.php b/features/steps/given.php new file mode 100644 index 000000000..3e501c2c7 --- /dev/null +++ b/features/steps/given.php @@ -0,0 +1,219 @@ +<?php + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode, + WP_CLI\Process; + +$steps->Given( '/^an empty directory$/', + function ( $world ) { + $world->create_run_dir(); + } +); + +$steps->Given( '/^an? (empty|non-existent) ([^\s]+) directory$/', + function ( $world, $empty_or_nonexistent, $dir ) { + $dir = $world->replace_variables( $dir ); + if ( ! WP_CLI\Utils\is_path_absolute( $dir ) ) { + $dir = $world->variables['RUN_DIR'] . "/$dir"; + } + if ( 0 !== strpos( $dir, sys_get_temp_dir() ) ) { + throw new RuntimeException( sprintf( "Attempted to delete directory '%s' that is not in the temp directory '%s'. " . __FILE__ . ':' . __LINE__, $dir, sys_get_temp_dir() ) ); + } + $world->remove_dir( $dir ); + if ( 'empty' === $empty_or_nonexistent ) { + mkdir( $dir, 0777, true /*recursive*/ ); + } + } +); + +$steps->Given( '/^an empty cache/', + function ( $world ) { + $world->variables['SUITE_CACHE_DIR'] = FeatureContext::create_cache_dir(); + } +); + +$steps->Given( '/^an? ([^\s]+) file:$/', + function ( $world, $path, PyStringNode $content ) { + $content = (string) $content . "\n"; + $full_path = $world->variables['RUN_DIR'] . "/$path"; + $dir = dirname( $full_path ); + if ( ! file_exists( $dir ) ) { + mkdir( $dir, 0777, true /*recursive*/ ); + } + file_put_contents( $full_path, $content ); + } +); + +$steps->Given( '/^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/', function( $world, $search, $replace, $path ) { + $full_path = $world->variables['RUN_DIR'] . "/$path"; + $contents = file_get_contents( $full_path ); + $contents = str_replace( $search, $replace, $contents ); + file_put_contents( $full_path, $contents ); +}); + +$steps->Given( '/^WP files$/', + function ( $world ) { + $world->download_wp(); + } +); + +$steps->Given( '/^wp-config\.php$/', + function ( $world ) { + $world->create_config(); + } +); + +$steps->Given( '/^a database$/', + function ( $world ) { + $world->create_db(); + } +); + +$steps->Given( '/^a WP (install|installation)$/', + function ( $world ) { + $world->install_wp(); + } +); + +$steps->Given( "/^a WP (install|installation) in '([^\s]+)'$/", + function ( $world, $_, $subdir ) { + $world->install_wp( $subdir ); + } +); + +$steps->Given( '/^a WP (install|installation) with Composer$/', + function ( $world ) { + $world->install_wp_with_composer(); + } +); + +$steps->Given( "/^a WP (install|installation) with Composer and a custom vendor directory '([^\s]+)'$/", + function ( $world, $_, $vendor_directory ) { + $world->install_wp_with_composer( $vendor_directory ); + } +); + +$steps->Given( '/^a WP multisite (subdirectory|subdomain)?\s?(install|installation)$/', + function ( $world, $type = 'subdirectory' ) { + $world->install_wp(); + $subdomains = ! empty( $type ) && 'subdomain' === $type ? 1 : 0; + $world->proc( 'wp core install-network', array( 'title' => 'WP CLI Network', 'subdomains' => $subdomains ) )->run_check(); + } +); + +$steps->Given( '/^these installed and active plugins:$/', + function( $world, $stream ) { + $plugins = implode( ' ', array_map( 'trim', explode( PHP_EOL, (string)$stream ) ) ); + $world->proc( "wp plugin install $plugins --activate" )->run_check(); + } +); + +$steps->Given( '/^a custom wp-content directory$/', + function ( $world ) { + $wp_config_path = $world->variables['RUN_DIR'] . "/wp-config.php"; + + $wp_config_code = file_get_contents( $wp_config_path ); + + $world->move_files( 'wp-content', 'my-content' ); + $world->add_line_to_wp_config( $wp_config_code, + "define( 'WP_CONTENT_DIR', dirname(__FILE__) . '/my-content' );" ); + + $world->move_files( 'my-content/plugins', 'my-plugins' ); + $world->add_line_to_wp_config( $wp_config_code, + "define( 'WP_PLUGIN_DIR', __DIR__ . '/my-plugins' );" ); + + file_put_contents( $wp_config_path, $wp_config_code ); + } +); + +$steps->Given( '/^download:$/', + function ( $world, TableNode $table ) { + foreach ( $table->getHash() as $row ) { + $path = $world->replace_variables( $row['path'] ); + if ( file_exists( $path ) ) { + // assume it's the same file and skip re-download + continue; + } + + Process::create( \WP_CLI\Utils\esc_cmd( 'curl -sSL %s > %s', $row['url'], $path ) )->run_check(); + } + } +); + +$steps->Given( '/^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/', + function ( $world, $stream, $output_filter, $key ) { + + $stream = strtolower( $stream ); + + if ( $output_filter ) { + $output_filter = '/' . trim( str_replace( '%s', '(.+[^\b])', $output_filter ), "' " ) . '/'; + if ( false !== preg_match( $output_filter, $world->result->$stream, $matches ) ) + $output = array_pop( $matches ); + else + $output = ''; + } else { + $output = $world->result->$stream; + } + $world->variables[ $key ] = trim( $output, "\n" ); + } +); + +$steps->Given( '/^a new Phar with (?:the same version|version "([^"]+)")$/', + function ( $world, $version = 'same' ) { + $world->build_phar( $version ); + } +); + +$steps->Given( '/^a downloaded Phar with (?:the same version|version "([^"]+)")$/', + function ( $world, $version = 'same' ) { + $world->download_phar( $version ); + } +); + +$steps->Given( '/^save the (.+) file ([\'].+[^\'])?as \{(\w+)\}$/', + function ( $world, $filepath, $output_filter, $key ) { + $full_file = file_get_contents( $world->replace_variables( $filepath ) ); + + if ( $output_filter ) { + $output_filter = '/' . trim( str_replace( '%s', '(.+[^\b])', $output_filter ), "' " ) . '/'; + if ( false !== preg_match( $output_filter, $full_file, $matches ) ) + $output = array_pop( $matches ); + else + $output = ''; + } else { + $output = $full_file; + } + $world->variables[ $key ] = trim( $output, "\n" ); + } +); + +$steps->Given('/^a misconfigured WP_CONTENT_DIR constant directory$/', + function($world) { + $wp_config_path = $world->variables['RUN_DIR'] . "/wp-config.php"; + + $wp_config_code = file_get_contents( $wp_config_path ); + + $world->add_line_to_wp_config( $wp_config_code, + "define( 'WP_CONTENT_DIR', '' );" ); + + file_put_contents( $wp_config_path, $wp_config_code ); + } +); + +$steps->Given( '/^a dependency on current wp-cli$/', + function ( $world ) { + $world->composer_require_current_wp_cli(); + } +); + +$steps->Given( '/^a PHP built-in web server$/', + function ( $world ) { + $world->start_php_server(); + } +); + +$steps->Given( "/^a PHP built-in web server to serve '([^\s]+)'$/", + function ( $world, $subdir ) { + $world->start_php_server( $subdir ); + } +); diff --git a/features/steps/then.php b/features/steps/then.php new file mode 100644 index 000000000..21589e737 --- /dev/null +++ b/features/steps/then.php @@ -0,0 +1,237 @@ +<?php + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode; + +$steps->Then( '/^the return code should( not)? be (\d+)$/', + function ( $world, $not, $return_code ) { + if ( ( ! $not && $return_code != $world->result->return_code ) || ( $not && $return_code == $world->result->return_code ) ) { + throw new RuntimeException( $world->result ); + } + } +); + +$steps->Then( '/^(STDOUT|STDERR) should (be|contain|not contain):$/', + function ( $world, $stream, $action, PyStringNode $expected ) { + + $stream = strtolower( $stream ); + + $expected = $world->replace_variables( (string) $expected ); + + checkString( $world->result->$stream, $expected, $action, $world->result ); + } +); + +$steps->Then( '/^(STDOUT|STDERR) should be a number$/', + function ( $world, $stream ) { + + $stream = strtolower( $stream ); + + assertNumeric( trim( $world->result->$stream, "\n" ) ); + } +); + +$steps->Then( '/^(STDOUT|STDERR) should not be a number$/', + function ( $world, $stream ) { + + $stream = strtolower( $stream ); + + assertNotNumeric( trim( $world->result->$stream, "\n" ) ); + } +); + +$steps->Then( '/^STDOUT should be a table containing rows:$/', + function ( $world, TableNode $expected ) { + $output = $world->result->stdout; + $actual_rows = explode( "\n", rtrim( $output, "\n" ) ); + + $expected_rows = array(); + foreach ( $expected->getRows() as $row ) { + $expected_rows[] = $world->replace_variables( implode( "\t", $row ) ); + } + + compareTables( $expected_rows, $actual_rows, $output ); + } +); + +$steps->Then( '/^STDOUT should end with a table containing rows:$/', + function ( $world, TableNode $expected ) { + $output = $world->result->stdout; + $actual_rows = explode( "\n", rtrim( $output, "\n" ) ); + + $expected_rows = array(); + foreach ( $expected->getRows() as $row ) { + $expected_rows[] = $world->replace_variables( implode( "\t", $row ) ); + } + + $start = array_search( $expected_rows[0], $actual_rows ); + + if ( false === $start ) + throw new \Exception( $world->result ); + + compareTables( $expected_rows, array_slice( $actual_rows, $start ), $output ); + } +); + +$steps->Then( '/^STDOUT should be JSON containing:$/', + function ( $world, PyStringNode $expected ) { + $output = $world->result->stdout; + $expected = $world->replace_variables( (string) $expected ); + + if ( !checkThatJsonStringContainsJsonString( $output, $expected ) ) { + throw new \Exception( $world->result ); + } +}); + +$steps->Then( '/^STDOUT should be a JSON array containing:$/', + function ( $world, PyStringNode $expected ) { + $output = $world->result->stdout; + $expected = $world->replace_variables( (string) $expected ); + + $actualValues = json_decode( $output ); + $expectedValues = json_decode( $expected ); + + $missing = array_diff( $expectedValues, $actualValues ); + if ( !empty( $missing ) ) { + throw new \Exception( $world->result ); + } +}); + +$steps->Then( '/^STDOUT should be CSV containing:$/', + function ( $world, TableNode $expected ) { + $output = $world->result->stdout; + + $expected_rows = $expected->getRows(); + foreach ( $expected as &$row ) { + foreach ( $row as &$value ) { + $value = $world->replace_variables( $value ); + } + } + + if ( ! checkThatCsvStringContainsValues( $output, $expected_rows ) ) + throw new \Exception( $world->result ); + } +); + +$steps->Then( '/^STDOUT should be YAML containing:$/', + function ( $world, PyStringNode $expected ) { + $output = $world->result->stdout; + $expected = $world->replace_variables( (string) $expected ); + + if ( !checkThatYamlStringContainsYamlString( $output, $expected ) ) { + throw new \Exception( $world->result ); + } +}); + +$steps->Then( '/^(STDOUT|STDERR) should be empty$/', + function ( $world, $stream ) { + + $stream = strtolower( $stream ); + + if ( !empty( $world->result->$stream ) ) { + throw new \Exception( $world->result ); + } + } +); + +$steps->Then( '/^(STDOUT|STDERR) should not be empty$/', + function ( $world, $stream ) { + + $stream = strtolower( $stream ); + + if ( '' === rtrim( $world->result->$stream, "\n" ) ) { + throw new Exception( $world->result ); + } + } +); + +$steps->Then( '/^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|!=|<>) ([+\w.{}-]+)$/', + function ( $world, $stream, $operator, $goal_ver ) { + $goal_ver = $world->replace_variables( $goal_ver ); + $stream = strtolower( $stream ); + if ( false === version_compare( trim( $world->result->$stream, "\n" ), $goal_ver, $operator ) ) { + throw new Exception( $world->result ); + } + } +); + +$steps->Then( '/^the (.+) (file|directory) should (exist|not exist|be:|contain:|not contain:)$/', + function ( $world, $path, $type, $action, $expected = null ) { + $path = $world->replace_variables( $path ); + + // If it's a relative path, make it relative to the current test dir + if ( '/' !== $path[0] ) + $path = $world->variables['RUN_DIR'] . "/$path"; + + if ( 'file' == $type ) { + $test = 'file_exists'; + } else if ( 'directory' == $type ) { + $test = 'is_dir'; + } + + switch ( $action ) { + case 'exist': + if ( ! $test( $path ) ) { + throw new Exception( "$path doesn't exist." ); + } + break; + case 'not exist': + if ( $test( $path ) ) { + throw new Exception( "$path exists." ); + } + break; + default: + if ( ! $test( $path ) ) { + throw new Exception( "$path doesn't exist." ); + } + $action = substr( $action, 0, -1 ); + $expected = $world->replace_variables( (string) $expected ); + if ( 'file' == $type ) { + $contents = file_get_contents( $path ); + } else if ( 'directory' == $type ) { + $files = glob( rtrim( $path, '/' ) . '/*' ); + foreach( $files as &$file ) { + $file = str_replace( $path . '/', '', $file ); + } + $contents = implode( PHP_EOL, $files ); + } + checkString( $contents, $expected, $action ); + } + } +); + +$steps->Then( '/^the contents of the (.+) file should match (((\/.+\/)|(#.+#))([a-z]+)?)$/', + function ( $world, $path, $expected ) { + $path = $world->replace_variables( $path ); + // If it's a relative path, make it relative to the current test dir + if ( '/' !== $path[0] ) { + $path = $world->variables['RUN_DIR'] . "/$path"; + } + $contents = file_get_contents( $path ); + assertRegExp( $expected, $contents ); + } +); + +$steps->Then( '/^(STDOUT|STDERR) should match (((\/.+\/)|(#.+#))([a-z]+)?)$/', + function ( $world, $stream, $expected ) { + $stream = strtolower( $stream ); + assertRegExp( $expected, $world->result->$stream ); + } +); + +$steps->Then( '/^an email should (be sent|not be sent)$/', function( $world, $expected ) { + if ( 'be sent' === $expected ) { + assertNotEquals( 0, $world->email_sends ); + } else if ( 'not be sent' === $expected ) { + assertEquals( 0, $world->email_sends ); + } else { + throw new Exception( 'Invalid expectation' ); + } +}); + +$steps->Then( '/^the HTTP status code should be (\d+)$/', + function ( $world, $return_code ) { + $response = \Requests::request( 'http://localhost:8080' ); + assertEquals( $return_code, $response->status_code ); + } +); diff --git a/features/steps/when.php b/features/steps/when.php new file mode 100644 index 000000000..d23aa0e66 --- /dev/null +++ b/features/steps/when.php @@ -0,0 +1,54 @@ +<?php + +use Behat\Gherkin\Node\PyStringNode, + Behat\Gherkin\Node\TableNode, + WP_CLI\Process; + +function invoke_proc( $proc, $mode ) { + $map = array( + 'run' => 'run_check_stderr', + 'try' => 'run' + ); + $method = $map[ $mode ]; + + return $proc->$method(); +} + +function capture_email_sends( $stdout ) { + $stdout = preg_replace( '#WP-CLI test suite: Sent email to.+\n?#', '', $stdout, -1, $email_sends ); + return array( $stdout, $email_sends ); +} + +$steps->When( '/^I launch in the background `([^`]+)`$/', + function ( $world, $cmd ) { + $world->background_proc( $cmd ); + } +); + +$steps->When( '/^I (run|try) `([^`]+)`$/', + function ( $world, $mode, $cmd ) { + $cmd = $world->replace_variables( $cmd ); + $world->result = invoke_proc( $world->proc( $cmd ), $mode ); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends( $world->result->stdout ); + } +); + +$steps->When( "/^I (run|try) `([^`]+)` from '([^\s]+)'$/", + function ( $world, $mode, $cmd, $subdir ) { + $cmd = $world->replace_variables( $cmd ); + $world->result = invoke_proc( $world->proc( $cmd, array(), $subdir ), $mode ); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends( $world->result->stdout ); + } +); + +$steps->When( '/^I (run|try) the previous command again$/', + function ( $world, $mode ) { + if ( !isset( $world->result ) ) + throw new \Exception( 'No previous command.' ); + + $proc = Process::create( $world->result->command, $world->result->cwd, $world->result->env ); + $world->result = invoke_proc( $proc, $mode ); + list( $world->result->stdout, $world->email_sends ) = capture_email_sends( $world->result->stdout ); + } +); + diff --git a/features/taxonomy.feature b/features/taxonomy.feature index c9448cee8..d7af6de75 100644 --- a/features/taxonomy.feature +++ b/features/taxonomy.feature @@ -8,21 +8,13 @@ Feature: Manage WordPress taxonomies When I run `wp taxonomy list --format=csv` Then STDOUT should be CSV containing: | name | label | description | object_type | show_tagcloud | hierarchical | public | - | category | Categories | | post | true | true | true | - | post_tag | Tags | | post | true | false | true | + | category | Categories | | post | 1 | 1 | 1 | + | post_tag | Tags | | post | 1 | | 1 | When I run `wp taxonomy list --object_type=nav_menu_item --format=csv` Then STDOUT should be CSV containing: | name | label | description | object_type | show_tagcloud | hierarchical | public | - | nav_menu | Navigation Menus | | nav_menu_item | false | false | false | - - @require-wp-5.0 - Scenario: Listing taxonomies with counts - When I run `wp taxonomy list --fields=name,count --format=csv` - Then STDOUT should be CSV containing: - | name | count | - | category | 1 | - | post_tag | 0 | + | nav_menu | Navigation Menus | | nav_menu_item | | | | Scenario: Get taxonomy When I try `wp taxonomy get invalid-taxonomy` @@ -38,91 +30,3 @@ Feature: Manage WordPress taxonomies | name | category | | object_type | ["post"] | | label | Categories | - - @require-wp-5.0 - Scenario: Get taxonomy with count - When I run `wp taxonomy get category --fields=name,count` - Then STDOUT should be a table containing rows: - | Field | Value | - | name | category | - | count | 1 | - - @require-wp-5.1 - Scenario: Listing taxonomies with strict/no-strict mode - Given a WP installation - And a wp-content/mu-plugins/test-taxonomy-list.php file: - """ - <?php - // Plugin Name: Test Taxonomy Strict/No-Strict Mode - - add_action( 'init', function() { - $args = array( - 'hierarchical' => true, - 'show_ui' => true, - 'show_admin_column' => true, - 'update_count_callback' => '_update_post_term_count', - 'query_var' => true, - 'labels' => array( - 'name' => _x( 'Genres', 'taxonomy general name', 'textdomain' ), - ), - - ); - - register_taxonomy( 'genres', array( 'post','page' ), $args ); - } ); - """ - - When I run `wp taxonomy list --object_type=post --strict` - Then STDOUT should be a table containing rows: - | name | label | description | object_type | show_tagcloud | hierarchical | public | - | category | Categories | | post | true | true | true | - | post_tag | Tags | | post | true | false | true | - | post_format | Formats | | post | false | false | true | - - When I run `wp taxonomy list --object_type=post --no-strict` - Then STDOUT should be a table containing rows: - | name | label | description | object_type | show_tagcloud | hierarchical | public | - | category | Categories | | post | true | true | true | - | post_tag | Tags | | post | true | false | true | - | post_format | Formats | | post | false | false | true | - | genres | Genres | | post, page | true | true | true | - - @less-than-wp-5.1 - Scenario: Listing taxonomies with strict/no-strict mode (for WP < 5.1) - Given a WP installation - And a wp-content/mu-plugins/test-taxonomy-list.php file: - """ - <?php - // Plugin Name: Test Taxonomy Strict/No-Strict Mode - - add_action( 'init', function() { - $args = array( - 'hierarchical' => true, - 'show_ui' => true, - 'show_admin_column' => true, - 'update_count_callback' => '_update_post_term_count', - 'query_var' => true, - 'labels' => array( - 'name' => _x( 'Genres', 'taxonomy general name', 'textdomain' ), - ), - - ); - - register_taxonomy( 'genres', array( 'post','page' ), $args ); - } ); - """ - - When I run `wp taxonomy list --object_type=post --strict` - Then STDOUT should be a table containing rows: - | name | label | description | object_type | show_tagcloud | hierarchical | public | - | category | Categories | | post | true | true | true | - | post_tag | Tags | | post | true | false | true | - | post_format | Format | | post | false | false | true | - - When I run `wp taxonomy list --object_type=post --no-strict` - Then STDOUT should be a table containing rows: - | name | label | description | object_type | show_tagcloud | hierarchical | public | - | category | Categories | | post | true | true | true | - | post_tag | Tags | | post | true | false | true | - | post_format | Format | | post | false | false | true | - | genres | Genres | | post, page | true | true | true | diff --git a/features/term-migrate.feature b/features/term-migrate.feature deleted file mode 100644 index ea797c371..000000000 --- a/features/term-migrate.feature +++ /dev/null @@ -1,152 +0,0 @@ -Feature: Migrate term custom fields - - @require-wp-4.4 - Scenario: Migrate an existing term by slug - Given a WP install - - When I run `wp term create category apple` - Then STDOUT should not be empty - - When I run `wp post create --post_title='Test post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} category apple` - Then STDOUT should not be empty - - When I run `wp term migrate apple --by=slug --from=category --to=post_tag` - Then STDOUT should be: - """ - Term 'apple' assigned to post {POST_ID}. - Term 'apple' migrated. - Old instance of term 'apple' removed from its original taxonomy. - Success: Migrated the term 'apple' from taxonomy 'category' to taxonomy 'post_tag' for 1 post. - """ - - @require-wp-4.4 - Scenario: Migrate an existing term by ID - Given a WP install - - When I run `wp term create category apple --porcelain` - Then STDOUT should be a number - And save STDOUT as {TERM_ID} - - When I run `wp post create --post_title='Test post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} category {TERM_ID}` - Then STDOUT should not be empty - - When I run `wp term migrate {TERM_ID} --by=slug --from=category --to=post_tag` - Then STDOUT should be: - """ - Term '{TERM_ID}' assigned to post {POST_ID}. - Term '{TERM_ID}' migrated. - Old instance of term '{TERM_ID}' removed from its original taxonomy. - Success: Migrated the term '{TERM_ID}' from taxonomy 'category' to taxonomy 'post_tag' for 1 post. - """ - - @require-wp-4.4 - Scenario: Migrate a term in multiple posts - Given a WP install - - When I run `wp term create category orange` - Then STDOUT should not be empty - - When I run `wp post create --post_title='Test post' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} category orange` - Then STDOUT should not be empty - - When I run `wp post create --post_title='Test post 2' --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID_2} - - When I run `wp post term set {POST_ID_2} category orange` - Then STDOUT should not be empty - - When I run `wp term migrate orange --by=slug --from=category --to=post_tag` - Then STDOUT should be: - """ - Term 'orange' assigned to post {POST_ID}. - Term 'orange' assigned to post {POST_ID_2}. - Term 'orange' migrated. - Old instance of term 'orange' removed from its original taxonomy. - Success: Migrated the term 'orange' from taxonomy 'category' to taxonomy 'post_tag' for 2 posts. - """ - - @require-wp-4.4 - Scenario: Try to migrate a term that does not exist - Given a WP install - - When I try `wp term migrate peach --by=slug --from=category --to=post_tag` - Then STDERR should be: - """ - Error: Taxonomy term 'peach' for taxonomy 'category' doesn't exist. - """ - - @require-wp-4.4 - Scenario: Migrate a term when posts have been migrated to a different post type that supports the destination taxonomy - Given a WP install - And a wp-content/mu-plugins/test-migrate.php file: - """ - <?php - // Plugin Name: Test Migrate - - add_action( 'init', function() { - register_post_type( 'news', [ 'public' => true ] ); - register_taxonomy( 'topic', 'news', [ 'public' => true ] ); - } ); - """ - - When I run `wp term create category grape` - Then STDOUT should not be empty - - When I run `wp post create --post_title='Test post' --post_type=post --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} category grape` - Then STDOUT should not be empty - - When I run `wp post update {POST_ID} --post_type=news` - Then STDOUT should not be empty - - When I run `wp term migrate grape --by=slug --from=category --to=topic` - Then STDOUT should be: - """ - Term 'grape' assigned to post {POST_ID}. - Term 'grape' migrated. - Old instance of term 'grape' removed from its original taxonomy. - Success: Migrated the term 'grape' from taxonomy 'category' to taxonomy 'topic' for 1 post. - """ - - @require-wp-4.4 - Scenario: Migrate a term warns when post type is not registered with destination taxonomy - Given a WP install - - When I run `wp term create category grape` - Then STDOUT should not be empty - - When I run `wp post create --post_title='Test post' --post_type=post --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} category grape` - Then STDOUT should not be empty - - When I run `wp post update {POST_ID} --post_type=page` - Then STDOUT should not be empty - - When I try `wp term migrate grape --by=slug --from=category --to=post_tag` - Then STDERR should contain: - """ - Warning: Term 'grape' not assigned to post {POST_ID}. Post type 'page' is not registered with taxonomy 'post_tag'. - """ - And STDOUT should contain: - """ - Success: Migrated the term 'grape' from taxonomy 'category' to taxonomy 'post_tag' for 0 posts. - """ diff --git a/features/term-prune.feature b/features/term-prune.feature deleted file mode 100644 index 6bb4dfae1..000000000 --- a/features/term-prune.feature +++ /dev/null @@ -1,137 +0,0 @@ -Feature: Prune unused taxonomy terms - - Background: - Given a WP install - - Scenario: Prune terms with no published posts - When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` - Then STDOUT should be a number - And save STDOUT as {TERM_ID} - - When I run `wp term prune post_tag` - Then STDOUT should contain: - """ - Deleted post_tag {TERM_ID}. - """ - And STDOUT should contain: - """ - Success: - """ - And the return code should be 0 - - When I try `wp term get post_tag {TERM_ID}` - Then STDERR should contain: - """ - Error: Term doesn't exist. - """ - - Scenario: Does not prune terms with more than one published post - When I run `wp term create post_tag 'Popular Tag' --slug=popular-tag --porcelain` - Then STDOUT should be a number - And save STDOUT as {TERM_ID} - - When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID_1} - - When I run `wp post create --post_title='Post 2' --post_status=publish --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID_2} - - When I run `wp post term set {POST_ID_1} post_tag {TERM_ID} --by=id` - Then STDOUT should not be empty - - When I run `wp post term set {POST_ID_2} post_tag {TERM_ID} --by=id` - Then STDOUT should not be empty - - When I run `wp term prune post_tag` - Then STDOUT should not contain: - """ - Deleted post_tag {TERM_ID}. - """ - - When I run `wp term get post_tag {TERM_ID} --field=name` - Then STDOUT should be: - """ - Popular Tag - """ - - Scenario: Prune terms with exactly one published post - When I run `wp term create post_tag 'Single Post Tag' --slug=single-post-tag --porcelain` - Then STDOUT should be a number - And save STDOUT as {TERM_ID} - - When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain` - Then STDOUT should be a number - And save STDOUT as {POST_ID} - - When I run `wp post term set {POST_ID} post_tag {TERM_ID} --by=id` - Then STDOUT should not be empty - - When I run `wp term prune post_tag` - Then STDOUT should contain: - """ - Deleted post_tag {TERM_ID}. - """ - And the return code should be 0 - - When I try `wp term get post_tag {TERM_ID}` - Then STDERR should contain: - """ - Error: Term doesn't exist. - """ - - Scenario: Dry run previews terms without deleting them - When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` - Then STDOUT should be a number - And save STDOUT as {TERM_ID} - - When I run `wp term prune post_tag --dry-run` - Then STDOUT should contain: - """ - Would delete post_tag {TERM_ID}. - """ - And STDOUT should contain: - """ - Success: - """ - And the return code should be 0 - - When I run `wp term get post_tag {TERM_ID} --field=name` - Then STDOUT should be: - """ - Unused Tag - """ - - Scenario: Prune with an invalid taxonomy - When I try `wp term prune nonexistent_taxonomy` - Then STDERR should be: - """ - Error: Taxonomy nonexistent_taxonomy doesn't exist. - """ - And the return code should be 1 - - Scenario: Prune multiple taxonomies at once - # Assign an extra post to the default Uncategorized category so its count - # exceeds the prune threshold and it won't interfere with the test. - When I run `wp post create --post_title='Extra Post' --post_status=publish --post_category=1 --porcelain` - Then STDOUT should be a number - - When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain` - Then STDOUT should be a number - And save STDOUT as {TAG_TERM_ID} - - When I run `wp term create category 'Unused Category' --slug=unused-category --porcelain` - Then STDOUT should be a number - And save STDOUT as {CAT_TERM_ID} - - When I run `wp term prune post_tag category` - Then STDOUT should contain: - """ - Deleted post_tag {TAG_TERM_ID}. - """ - And STDOUT should contain: - """ - Deleted category {CAT_TERM_ID}. - """ - And the return code should be 0 diff --git a/features/term-recount.feature b/features/term-recount.feature index ca2edccf2..c86c88c9a 100644 --- a/features/term-recount.feature +++ b/features/term-recount.feature @@ -3,6 +3,7 @@ Feature: Recount terms on a taxonomy Background: Given a WP install + Scenario: Term recount with an invalid taxonomy When I try `wp term recount some-fake-taxonomy` Then STDERR should be: @@ -29,7 +30,7 @@ Feature: Recount terms on a taxonomy Scenario: Fixes an invalid term count for a taxonomy When I run `wp term create category "Term Recount Category" --porcelain` Then STDOUT should be a number - And save STDOUT as {TERM_ID} + Then save STDOUT as {TERM_ID} When I run `wp post create --post_title='Term Recount Test' --post_status=publish --post_category={TERM_ID} --porcelain` Then STDOUT should be a number @@ -40,14 +41,7 @@ Feature: Recount terms on a taxonomy """ 1 """ - Given a recount-terms.php file: - """ - <?php - global $wpdb; - $wpdb->update( $wpdb->term_taxonomy, array( "count" => 3 ), array( "term_id" => {TERM_ID} ) ); - clean_term_cache( {TERM_ID}, "category" ); - """ - When I run `wp eval-file recount-terms.php` + When I run `wp eval 'global $wpdb; $wpdb->update( $wpdb->term_taxonomy, array( "count" => 3 ), array( "term_id" => {TERM_ID} ) );'` And I run `wp term get category {TERM_ID} --field=count` Then STDOUT should be: """ @@ -56,7 +50,6 @@ Feature: Recount terms on a taxonomy When I run `wp term recount category` And I run `wp term get category {TERM_ID} --field=count` - Then STDOUT should be: """ 1 """ diff --git a/features/term.feature b/features/term.feature index 97655db6e..f957ee2a8 100644 --- a/features/term.feature +++ b/features/term.feature @@ -57,16 +57,6 @@ Feature: Manage WordPress terms """ And the return code should be 1 - Scenario: Updating an invalid term should exit with an error - Given a WP install - - When I try `wp term update category 22 --name=Foo` - Then the return code should be 1 - And STDERR should contain: - """ - Error: Term doesn't exist. - """ - Scenario: Creating/deleting a term When I run `wp term create post_tag 'Test delete term' --slug=test-delete --description='This is a test term to be deleted' --porcelain` Then STDOUT should be a number @@ -124,7 +114,7 @@ Feature: Manage WordPress terms Scenario: Filter terms by term_id When I run `wp term generate category --count=10` And I run `wp term create category "My Test Category" --porcelain` - Then save STDOUT as {TERM_ID} + And save STDOUT as {TERM_ID} When I run `wp term list category --term_id={TERM_ID} --field=name` Then STDOUT should be: @@ -134,35 +124,28 @@ Feature: Manage WordPress terms Scenario: Fetch term url When I run `wp term create category "First Category" --porcelain` - Then save STDOUT as {TERM_ID} - - When I run `wp term create category "Second Category" --porcelain` - Then save STDOUT as {SECOND_TERM_ID} + And save STDOUT as {TERM_ID} + And I run `wp term create category "Second Category" --porcelain` + And save STDOUT as {SECOND_TERM_ID} When I run `wp term url category {TERM_ID}` Then STDOUT should be: """ - https://example.com/?cat=2 + http://example.com/?cat=2 """ When I run `wp term url category {TERM_ID} {SECOND_TERM_ID}` Then STDOUT should be: """ - https://example.com/?cat=2 - https://example.com/?cat=3 + http://example.com/?cat=2 + http://example.com/?cat=3 """ When I run `wp term url category {SECOND_TERM_ID} {TERM_ID}` Then STDOUT should be: """ - https://example.com/?cat=3 - https://example.com/?cat=2 - """ - - When I run `wp term get category 1 --field=url` - Then STDOUT should be: - """ - https://example.com/?cat=1 + http://example.com/?cat=3 + http://example.com/?cat=2 """ Scenario: Make sure WordPress receives the slashed data it expects @@ -181,7 +164,7 @@ Feature: Manage WordPress terms My\Term\Description """ - When I run `wp term update category {TERM_ID} --name="My\New\Term" --description="var isEmailValid = /^\S+@\S+.\S+$/.test(email);"` + When I run `wp term update category {TERM_ID} --name='My\New\Term' --description='var isEmailValid = /^\S+@\S+.\S+$/.test(email);'` Then STDOUT should not be empty When I run `wp term get category {TERM_ID} --field=name` @@ -280,21 +263,3 @@ Feature: Manage WordPress terms """ Success: Term updated. """ - - Scenario: Term list includes term_group as optional field - When I run `wp term create category 'Group Test' --slug=group-test --description='Test term_group field'` - Then STDOUT should not be empty - - When I run `wp term list category --format=csv --fields=name,term_group` - Then STDOUT should contain: - """ - term_group - """ - - When I run `wp term list category --format=json --fields=term_id,name,term_group` - Then STDOUT should be JSON containing: - """ - [{ - "term_group":0 - }] - """ diff --git a/features/user-application-password.feature b/features/user-application-password.feature deleted file mode 100644 index 507b7ee28..000000000 --- a/features/user-application-password.feature +++ /dev/null @@ -1,317 +0,0 @@ -Feature: Manage user custom fields - - # SQLite requires WordPress 6.0+. - @less-than-php-8.0 @require-mysql - Scenario: User application passwords are disabled for WordPress lower than 5.6 - Given a WP install - And I try `wp theme install twentytwenty --activate` - And I run `wp core download --version=5.5 --force` - - When I try `wp user application-password create 1 myapp` - Then STDERR should contain: - """ - Error: Requires WordPress 5.6 or greater. - """ - - When I try `wp user application-password list 1` - Then STDERR should contain: - """ - Error: Requires WordPress 5.6 or greater. - """ - - When I try `wp user application-password get 1 123` - Then STDERR should contain: - """ - Error: Requires WordPress 5.6 or greater. - """ - - When I try `wp user application-password delete 1 123` - Then STDERR should contain: - """ - Error: Requires WordPress 5.6 or greater. - """ - - @require-wp-5.6 - Scenario: User application password CRUD - Given a WP install - - When I run `wp user application-password create 1 myapp` - Then STDOUT should not be empty - - When I run `wp user application-password list 1` - Then STDOUT should contain: - """ - myapp - """ - - When I run `wp user application-password list 1 --name=myapp --field=uuid` - And save STDOUT as {UUID} - And I run `wp user application-password get 1 {UUID}` - Then STDOUT should contain: - """ - myapp - """ - - When I try `wp user application-password get 2 {UUID}` - Then STDERR should be: - """ - Error: Invalid user ID, email or login: '2' - """ - And the return code should be 1 - - When I try `wp user application-password get 1 123` - Then STDERR should be: - """ - Error: No application password found for this user ID and UUID. - """ - And the return code should be 1 - - When I run `wp user application-password update 1 {UUID} --name=anotherapp` - Then STDOUT should not be empty - - When I run `wp user application-password get 1 {UUID}` - Then STDOUT should contain: - """ - anotherapp - """ - And STDOUT should not contain: - """ - myapp - """ - - When I run `wp user application-password delete 1 {UUID}` - Then STDOUT should contain: - """ - Success: Deleted 1 of 1 application password. - """ - - When I try `wp user application-password get 1 {UUID}` - Then the return code should be 1 - - When I run `wp user application-password create 1 myapp1` - And I run `wp user application-password create 1 myapp2` - And I run `wp user application-password create 1 myapp3` - And I run `wp user application-password create 1 myapp4` - And I run `wp user application-password create 1 myapp5` - Then STDOUT should not be empty - - When I run `wp user application-password list 1 --format=count` - Then STDOUT should be: - """ - 5 - """ - - When I run `wp user application-password list 1 --name=myapp1 --field=uuid` - Then save STDOUT as {UUID1} - - When I run `wp user application-password list 1 --name=myapp2 --field=uuid` - Then save STDOUT as {UUID2} - - When I try `wp user application-password delete 1 {UUID1} {UUID2} nonsense` - Then STDERR should contain: - """ - Warning: Failed to delete UUID nonsense - """ - And STDOUT should contain: - """ - Success: Deleted 2 of 3 application passwords. - """ - - When I run `wp user application-password list 1 --format=count` - Then STDOUT should be: - """ - 3 - """ - - When I run `wp user application-password delete 1 --all` - And I run `wp user application-password list 1 --format=count` - Then STDOUT should be: - """ - 0 - """ - - @require-wp-5.6 - Scenario: List user application passwords - Given a WP install - - When I run `wp user application-password create 1 myapp1` - Then STDOUT should not be empty - - When I run `wp user application-password create 1 myapp2 --app-id=42` - Then STDOUT should not be empty - - When I run `wp user application-password list 1 --name=myapp1 --field=uuid` - Then save STDOUT as {UUID1} - - When I run `wp user application-password list 1 --name=myapp2 --field=uuid` - Then save STDOUT as {UUID2} - - When I run `wp user application-password list 1 --name=myapp1 --field=password` - Then save STDOUT as {HASH1} - - When I run `wp user application-password list 1 --name=myapp1 --field=password | sed 's#/#\\\/#g'` - Then save STDOUT as {JSONHASH1} - - When I run `wp user application-password list 1 --name=myapp2 --field=password` - Then save STDOUT as {HASH2} - - When I run `wp user application-password list 1 --name=myapp2 --field=password | sed 's#/#\\\/#g'` - Then save STDOUT as {JSONHASH2} - - When I run `wp user application-password list 1 --name=myapp1 --field=created` - Then save STDOUT as {CREATED1} - - When I run `wp user application-password list 1 --name=myapp2 --field=created` - Then save STDOUT as {CREATED2} - - When I run `wp user application-password list 1 --format=json` - Then STDOUT should contain: - """ - {"uuid":"{UUID1}","app_id":"","name":"myapp1","password":"{JSONHASH1}","created":{CREATED1},"last_used":null,"last_ip":null} - """ - And STDOUT should contain: - """ - {"uuid":"{UUID2}","app_id":"42","name":"myapp2","password":"{JSONHASH2}","created":{CREATED2},"last_used":null,"last_ip":null} - """ - - When I run `wp user application-password list 1 --format=json --fields=uuid,name` - Then STDOUT should contain: - """ - {"uuid":"{UUID1}","name":"myapp1"} - """ - And STDOUT should contain: - """ - {"uuid":"{UUID2}","name":"myapp2"} - """ - - When I run `wp user application-password list 1` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | password | created | last_used | last_ip | - | {UUID2} | 42 | myapp2 | {HASH2} | {CREATED2} | | | - | {UUID1} | | myapp1 | {HASH1} | {CREATED1} | | | - - When I run `wp user application-password list 1 --fields=uuid,app_id,name` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | - | {UUID2} | 42 | myapp2 | - | {UUID1} | | myapp1 | - - When I run `wp user application-password list admin` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | password | created | last_used | last_ip | - | {UUID2} | 42 | myapp2 | {HASH2} | {CREATED2} | | | - | {UUID1} | | myapp1 | {HASH1} | {CREATED1} | | | - - When I run `wp user application-password list admin --orderby=created --order=asc` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | password | created | last_used | last_ip | - | {UUID1} | | myapp1 | {HASH1} | {CREATED1} | | | - | {UUID2} | 42 | myapp2 | {HASH2} | {CREATED2} | | | - - When I run `wp user application-password list admin --orderby=name --order=asc` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | password | created | last_used | last_ip | - | {UUID1} | | myapp1 | {HASH1} | {CREATED1} | | | - | {UUID2} | 42 | myapp2 | {HASH2} | {CREATED2} | | | - - When I run `wp user application-password list admin --orderby=name --order=desc` - Then STDOUT should be a table containing rows: - | uuid | app_id | name | password | created | last_used | last_ip | - | {UUID2} | 42 | myapp2 | {HASH2} | {CREATED2} | | | - | {UUID1} | | myapp1 | {HASH1} | {CREATED1} | | | - - When I run `wp user application-password list admin --name=myapp2 --format=json` - Then STDOUT should contain: - """ - myapp2 - """ - And STDOUT should not contain: - """ - myapp1 - """ - - When I run `wp user application-password list admin --field=name` - Then STDOUT should contain: - """ - myapp1 - """ - And STDOUT should contain: - """ - myapp2 - """ - - When I run `wp user application-password list 1 --field=name --app-id=42` - Then STDOUT should be: - """ - myapp2 - """ - - When I run `wp user application-password list 1 --format=ids --orderby=name --order=asc` - Then STDOUT should be: - """ - {UUID1} {UUID2} - """ - - # WordPress 6.8 uses BLAKE2b with wp_fast_hash() / wp_verify_fast_hash() for hashing application passwords. - # See https://make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/ - @require-wp-5.6 @less-than-wp-6.8 - Scenario: Get particular user application password hash - Given a WP install - - When I run `wp user create testuser testuser@example.com --porcelain` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - - When I try the previous command again - Then the return code should be 1 - - When I run `wp user application-password create {USER_ID} someapp --porcelain` - Then save STDOUT as {PASSWORD} - - When I run `wp user application-password list {USER_ID} --name=someapp --field=uuid` - Then save STDOUT as {UUID} - - When I run `wp user application-password get {USER_ID} {UUID} --field=password` - Then save STDOUT as {HASH} - - Given a check-password.php file: - """ - <?php - var_export( wp_check_password( '{PASSWORD}', '{HASH}', {USER_ID} ) ); - """ - When I run `wp eval-file check-password.php` - Then STDOUT should contain: - """ - true - """ - - @require-wp-6.8 - Scenario: Get particular user application password hash - Given a WP install - - When I run `wp user create testuser testuser@example.com --porcelain` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - - When I try the previous command again - Then the return code should be 1 - - When I run `wp user application-password create {USER_ID} someapp --porcelain` - Then save STDOUT as {PASSWORD} - - When I run `wp user application-password list {USER_ID} --name=someapp --field=uuid` - Then save STDOUT as {UUID} - - When I run `wp user application-password get {USER_ID} {UUID} --field=password` - Then save STDOUT as {HASH} - - Given a verify-fast-hash.php file: - """ - <?php - var_export( wp_verify_fast_hash( '{PASSWORD}', '{HASH}', {USER_ID} ) ); - """ - When I run `wp eval-file verify-fast-hash.php` - Then STDOUT should contain: - """ - true - """ diff --git a/features/user-import-csv.feature b/features/user-import-csv.feature index 802ffb92e..69f603358 100644 --- a/features/user-import-csv.feature +++ b/features/user-import-csv.feature @@ -103,7 +103,6 @@ Feature: Import users from CSV } """ - @skip-windows Scenario: Import users from a CSV file generated by `wp user list` Given a WP install diff --git a/features/user-list.feature b/features/user-list.feature index 073d325fc..44856eb01 100644 --- a/features/user-list.feature +++ b/features/user-list.feature @@ -26,32 +26,3 @@ Feature: List WordPress users """ bobjones """ - - Scenario: List network users excludes roles field - Given a WP multisite install - And I run `wp user create bobjones bob@example.com --role=author` - - When I run `wp user list --network --format=csv` - Then STDOUT should contain: - """ - ID,user_login,display_name,user_email,user_registered - """ - And STDOUT should not contain: - """ - roles - """ - - @require-wp-4.9 - Scenario: List users without roles - Given a WP install - When I run `wp user create bili bili@example.com --porcelain` - Then save STDOUT as {USER_ID} - - And I run `wp user create sally sally@example.com --role=editor` - And I run `wp user remove-role {USER_ID} subscriber` - - When I run `wp user list --role=none --field=user_login` - Then STDOUT should be: - """ - bili - """ diff --git a/features/user-meta.feature b/features/user-meta.feature index b7f08805d..dd15cdc6e 100644 --- a/features/user-meta.feature +++ b/features/user-meta.feature @@ -1,4 +1,4 @@ -Feature: Manage user custom meta fields +Feature: Manage user custom fields Scenario: Usermeta CRUD Given a WP install @@ -60,125 +60,55 @@ Feature: Manage user custom meta fields Then STDOUT should not be empty When I run `wp user meta list 1 --format=json --keys=nickname,foo --fields=meta_key,meta_value` - Then STDOUT should be JSON containing: - """ - [{"meta_key":"nickname","meta_value":"admin"},{"meta_key":"foo","meta_value":"a:2:{i:0;s:1:\"1\";i:1;s:1:\"2\";}"}] - """ - - When I run `wp user meta list 1 --format=json --keys=nickname,foo --fields=meta_key,meta_value --unserialize` Then STDOUT should be JSON containing: """ [{"meta_key":"nickname","meta_value":"admin"},{"meta_key":"foo","meta_value":["1","2"]}] """ When I run `wp user meta list 1 --keys=nickname,foo` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | nickname | admin | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - - When I run `wp user meta list 1 --keys=nickname,foo --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | nickname | admin | | 1 | foo | ["1","2"] | When I run `wp user meta list admin --keys=nickname,foo` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | nickname | admin | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - - When I run `wp user meta list admin --keys=nickname,foo --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | nickname | admin | | 1 | foo | ["1","2"] | When I run `wp user meta list admin --keys=nickname,foo --orderby=id --order=asc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | nickname | admin | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=id --order=asc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | nickname | admin | | 1 | foo | ["1","2"] | When I run `wp user meta list admin --keys=nickname,foo --orderby=id --order=desc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - | 1 | nickname | admin | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=id --order=desc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | foo | ["1","2"] | | 1 | nickname | admin | When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_key --order=asc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - | 1 | nickname | admin | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_key --order=asc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | foo | ["1","2"] | | 1 | nickname | admin | When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_key --order=desc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | nickname | admin | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_key --order=desc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | nickname | admin | | 1 | foo | ["1","2"] | When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_value --order=asc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | nickname | admin | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_value --order=asc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | nickname | admin | | 1 | foo | ["1","2"] | When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_value --order=desc` - Then STDOUT should be a table containing rows: - | user_id | meta_key | meta_value | - | 1 | foo | a:2:{i:0;s:1:"1";i:1;s:1:"2";} | - | 1 | nickname | admin | - - When I run `wp user meta list admin --keys=nickname,foo --orderby=meta_value --order=desc --unserialize` Then STDOUT should be a table containing rows: | user_id | meta_key | meta_value | | 1 | foo | ["1","2"] | | 1 | nickname | admin | - - Scenario: Get particular user meta - Given a WP install - - When I run `wp user create testuser testuser@example.com --description='This is description' --porcelain` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - - When I try the previous command again - Then the return code should be 1 - - When I try `wp user-meta get {USER_ID} description` - Then STDOUT should be: - """ - This is description - """ diff --git a/features/user-privacy-request.feature b/features/user-privacy-request.feature deleted file mode 100644 index bdfffc07e..000000000 --- a/features/user-privacy-request.feature +++ /dev/null @@ -1,260 +0,0 @@ -Feature: Manage user privacy requests - - @require-wp-4.9.6 - Scenario: Create and list privacy requests - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --porcelain` - Then STDOUT should be a number - And save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request list --format=csv --fields=ID,user_email,action_name,status` - Then STDOUT should contain: - """ - {REQUEST_ID},admin@example.com,export_personal_data,request-pending - """ - - When I run `wp user privacy-request list --format=ids` - Then STDOUT should contain: - """ - {REQUEST_ID} - """ - - When I run `wp user privacy-request list --format=count` - Then STDOUT should be: - """ - 1 - """ - - @require-wp-4.9.6 - Scenario: Create requests with confirmed status - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --status=confirmed --porcelain` - Then STDOUT should be a number - And save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request list --format=csv --fields=ID,status` - Then STDOUT should contain: - """ - {REQUEST_ID},request-confirmed - """ - - @require-wp-4.9.6 - Scenario: Filter privacy request list by action type - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --porcelain` - Then save STDOUT as {EXPORT_ID} - - When I run `wp user privacy-request create admin@example.com remove_personal_data --porcelain` - Then save STDOUT as {ERASE_ID} - - When I run `wp user privacy-request list --action-type=export_personal_data --format=ids` - Then STDOUT should contain: - """ - {EXPORT_ID} - """ - And STDOUT should not contain: - """ - {ERASE_ID} - """ - - When I run `wp user privacy-request list --action-type=remove_personal_data --format=ids` - Then STDOUT should contain: - """ - {ERASE_ID} - """ - And STDOUT should not contain: - """ - {EXPORT_ID} - """ - - @require-wp-4.9.6 - Scenario: Filter privacy request list by status - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --status=confirmed --porcelain` - Then save STDOUT as {CONFIRMED_ID} - - When I run `wp user privacy-request create admin@example.com remove_personal_data --porcelain` - Then save STDOUT as {PENDING_ID} - - When I run `wp user privacy-request list --status=request-confirmed --format=ids` - Then STDOUT should contain: - """ - {CONFIRMED_ID} - """ - And STDOUT should not contain: - """ - {PENDING_ID} - """ - - When I run `wp user privacy-request list --status=request-pending --format=ids` - Then STDOUT should contain: - """ - {PENDING_ID} - """ - And STDOUT should not contain: - """ - {CONFIRMED_ID} - """ - - @require-wp-4.9.6 - Scenario: Delete privacy requests - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request delete {REQUEST_ID}` - Then STDOUT should contain: - """ - Success: Deleted 1 of 1 privacy requests. - """ - - When I run `wp user privacy-request list --format=count` - Then STDOUT should be: - """ - 0 - """ - - When I try `wp user privacy-request delete 9999` - Then STDERR should contain: - """ - Warning: Could not find privacy request with ID 9999. - """ - - @require-wp-4.9.6 - Scenario: Complete privacy requests - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --status=confirmed --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request complete {REQUEST_ID}` - Then STDOUT should contain: - """ - Success: Completed 1 of 1 privacy requests. - """ - - When I run `wp user privacy-request list --status=request-completed --format=ids` - Then STDOUT should contain: - """ - {REQUEST_ID} - """ - - When I try `wp user privacy-request complete 9999` - Then STDERR should contain: - """ - Warning: Could not find privacy request with ID 9999. - """ - - @require-wp-4.9.6 - Scenario: Erase personal data for a request - Given a WP install - - When I run `wp user privacy-request create admin@example.com remove_personal_data --status=confirmed --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request erase {REQUEST_ID}` - Then STDOUT should contain: - """ - Success: Erased personal data for request {REQUEST_ID}. - """ - - When I run `wp user privacy-request list --status=request-completed --format=ids` - Then STDOUT should contain: - """ - {REQUEST_ID} - """ - - @require-wp-4.9.6 - Scenario: Erase command fails for non-erasure requests - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --status=confirmed --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I try `wp user privacy-request erase {REQUEST_ID}` - Then STDERR should contain: - """ - Error: Request {REQUEST_ID} is not a 'remove_personal_data' request. - """ - - @require-wp-4.9.6 - Scenario: Export personal data for a request - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data --status=confirmed --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I run `wp user privacy-request export {REQUEST_ID}` - Then STDOUT should contain: - """ - Success: Exported personal data to: - """ - And STDOUT should contain: - """ - .zip - """ - - When I run `wp user privacy-request list --status=request-completed --format=ids` - Then STDOUT should contain: - """ - {REQUEST_ID} - """ - - @require-wp-4.9.6 - Scenario: Export command fails for non-export requests - Given a WP install - - When I run `wp user privacy-request create admin@example.com remove_personal_data --status=confirmed --porcelain` - Then save STDOUT as {REQUEST_ID} - - When I try `wp user privacy-request export {REQUEST_ID}` - Then STDERR should contain: - """ - Error: Request {REQUEST_ID} is not an 'export_personal_data' request. - """ - - @require-wp-4.9.6 - Scenario: Create request without porcelain flag - Given a WP install - - When I run `wp user privacy-request create admin@example.com export_personal_data` - Then STDOUT should contain: - """ - Created privacy request - """ - - @require-wp-4.9.6 - Scenario: Create request with invalid email address - Given a WP install - - When I try `wp user privacy-request create not-an-email export_personal_data` - Then STDERR should contain: - """ - Error: Invalid email address. - """ - - @require-wp-4.9.6 - Scenario: Create request with invalid action type - Given a WP install - - When I try `wp user privacy-request create admin@example.com invalid_action` - Then STDERR should contain: - """ - Error: Invalid value specified for positional arg. - """ - - @require-wp-4.9.6 - Scenario: Create request with invalid status - Given a WP install - - When I try `wp user privacy-request create admin@example.com export_personal_data --status=invalid` - Then STDERR should contain: - """ - Error: Parameter errors: - Invalid value specified for 'status' (The initial status of the request.) - """ diff --git a/features/user-reset-password.feature b/features/user-reset-password.feature index b252a4a42..e96213749 100644 --- a/features/user-reset-password.feature +++ b/features/user-reset-password.feature @@ -11,7 +11,7 @@ Feature: Reset passwords for one or more WordPress users. Then STDOUT should contain: """ Reset password for admin. - Success: Password reset for 1 user. + Success: Password reset. """ And an email should be sent @@ -32,7 +32,7 @@ Feature: Reset passwords for one or more WordPress users. Then STDOUT should contain: """ Reset password for admin. - Success: Password reset for 1 user. + Success: Password reset. """ And an email should not be sent @@ -41,40 +41,3 @@ Feature: Reset passwords for one or more WordPress users. """ {ORIGINAL_PASSWORD} """ - - @require-wp-4.3 - Scenario: Reset the password of a WordPress user, and show the new password - Given a WP installation - - When I run `wp user get 1 --field=user_pass` - Then save STDOUT as {ORIGINAL_PASSWORD} - - When I run `wp user reset-password 1 --skip-email --show-password` - Then STDOUT should contain: - """ - Password: - """ - And an email should not be sent - - When I run `wp user get 1 --field=user_pass` - Then STDOUT should not contain: - """ - {ORIGINAL_PASSWORD} - """ - - @require-wp-4.3 - Scenario: Reset the password of a WordPress user, and show only the new password - Given a WP installation - - When I run `wp user get 1 --field=user_pass` - Then save STDOUT as {ORIGINAL_PASSWORD} - - When I run `wp user reset-password 1 --skip-email --porcelain` - Then STDOUT should not be empty - And an email should not be sent - - When I run `wp user get 1 --field=user_pass` - Then STDOUT should not contain: - """ - {ORIGINAL_PASSWORD} - """ diff --git a/features/user-session.feature b/features/user-session.feature index e68db294b..c4a576eb2 100644 --- a/features/user-session.feature +++ b/features/user-session.feature @@ -33,7 +33,7 @@ Feature: Manage user session Success: Destroyed all sessions. """ - When I run `wp user session list admin --format=count` + And I run `wp user session list admin --format=count` Then STDOUT should be: """ 0 diff --git a/features/user-term.feature b/features/user-term.feature index 36bafe642..d32547e42 100644 --- a/features/user-term.feature +++ b/features/user-term.feature @@ -15,6 +15,7 @@ Feature: Manage user term """ And I run `wp plugin activate test-add-tax` + When I run `wp user term add 1 user_type foo` Then STDOUT should be: """ diff --git a/features/user.feature b/features/user.feature index 76ede2740..1ddc1fcb4 100644 --- a/features/user.feature +++ b/features/user.feature @@ -17,23 +17,6 @@ Feature: Manage WordPress users | ID | {USER_ID} | | roles | author | - When I run `wp user exists {USER_ID}` - Then STDOUT should be: - """ - Success: User with ID {USER_ID} exists. - """ - And the return code should be 0 - - When I try `wp user exists 1000` - Then STDOUT should be empty - And the return code should be 1 - - When I run `wp user get {USER_ID} --field=user_registered` - Then STDOUT should not contain: - """ - 0000-00-00 00:00:00 - """ - When I run `wp user meta get {USER_ID} first_name` Then STDOUT should be: """ @@ -56,7 +39,7 @@ Feature: Manage WordPress users When I try `wp user create testuser2 testuser2@example.com --role=wrongrole --porcelain` Then the return code should be 1 - And STDOUT should be empty + Then STDOUT should be empty When I run `wp user create testuser testuser@example.com --porcelain` Then STDOUT should be a number @@ -81,57 +64,14 @@ Feature: Manage WordPress users When I run `wp user delete {USER_ID} --yes` Then STDOUT should not be empty - When I run `wp user create testuser3 testuser3@example.com --user_pass=testuser3pass` - Then STDOUT should not contain: - """ - Password: - """ - - # Check with valid password. - When I run `wp user check-password testuser3 testuser3pass` - Then the return code should be 0 - - # Check with invalid password. - When I try `wp user check-password testuser3 invalidpass` - Then the return code should be 1 - - When I try `wp user check-password invaliduser randomstring` - Then STDERR should contain: - """ - Invalid user ID, email or login: 'invaliduser' - """ - And the return code should be 1 - - When I run `wp user create testuser3b testuser3b@example.com --user_pass="test\"user3b's\pass\!"` - Then STDOUT should not contain: - """ - Password: - """ - - # Check password without the `--escape-chars` option. - When I try `wp user check-password testuser3b "test\"user3b's\pass\!"` - Then STDERR should be: - """ - Warning: Password contains characters that need to be escaped. Please escape them manually or use the `--escape-chars` option. - """ - And the return code should be 1 - - # Check password with the `--escape-chars` option. - When I try `wp user check-password testuser3b "test\"user3b's\pass\!" --escape-chars` - Then the return code should be 0 - - # Check password with manually escaped characters. - When I try `wp user check-password testuser3b "test\\\"user3b\'s\\\pass\\\!"` - Then the return code should be 0 - Scenario: Reassigning user posts Given a WP multisite install When I run `wp user create bobjones bob@example.com --role=author --porcelain` - Then save STDOUT as {BOB_ID} + And save STDOUT as {BOB_ID} - When I run `wp user create sally sally@example.com --role=editor --porcelain` - Then save STDOUT as {SALLY_ID} + And I run `wp user create sally sally@example.com --role=editor --porcelain` + And save STDOUT as {SALLY_ID} When I run `wp post generate --count=3 --post_author=bobjones` And I run `wp post list --author={BOB_ID} --format=count` @@ -147,61 +87,11 @@ Feature: Manage WordPress users 3 """ - When I try `wp user update 9999 --user_pass=securepassword` - Then the return code should be 1 - And STDERR should contain: - """ - Error: No valid users found. - """ - - Scenario: Delete user with invalid reassign - Given a WP install - And a session_no file: - """ - n - """ - And a session_yes file: - """ - y - """ - - When I run `wp user create bobjones bob@example.com --role=author --porcelain` - Then save STDOUT as {BOB_ID} - - When I run `wp post list --format=count` - Then save STDOUT as {TOTAL_POSTS} - - When I run `wp post generate --count=3 --format=ids --post_author=bobjones` - And I run `wp post list --author={BOB_ID} --format=count` - Then STDOUT should be: - """ - 3 - """ - - When I run `wp user delete bobjones < session_no` - Then STDOUT should contain: - """ - --reassign parameter not passed. All associated posts will be deleted. Proceed? [y/n] - """ - - When I run `wp user delete bobjones --reassign=99999 < session_no` - Then STDOUT should contain: - """ - --reassign parameter is invalid. All associated posts will be deleted. Proceed? [y/n] - """ - - When I run `wp user delete bobjones < session_yes` - And I run `wp post list --format=count` - Then STDOUT should be: - """ - {TOTAL_POSTS} - """ - Scenario: Deleting user from the whole network Given a WP multisite install When I run `wp user create bobjones bob@example.com --role=author --porcelain` - Then save STDOUT as {BOB_ID} + And save STDOUT as {BOB_ID} When I run `wp user get bobjones` Then STDOUT should not be empty @@ -213,41 +103,6 @@ Feature: Manage WordPress users Then STDERR should not be empty And the return code should be 1 - Scenario: Trying to delete existing user with no roles from a subsite - Given a WP multisite install - - When I run `wp user create bobjones bob@example.com --role=author --url=https://example.com --porcelain` - Then save STDOUT as {BOB_ID} - - When I run `wp user delete bobjones --yes` - Then STDOUT should contain: - """ - Success: Removed user - """ - And STDERR should be empty - - When I try `wp user delete bobjones --yes` - Then STDERR should be: - """ - Warning: No roles found for user {BOB_ID} on https://example.com, no users deleted. - """ - And the return code should be 1 - - @require-wp-4.0 - Scenario: Trying to delete super admin - Given a WP multisite install - - When I run `wp user create bobjones bob@example.com --role=author --porcelain` - Then save STDOUT as {BOB_ID} - - When I run `wp super-admin add {BOB_ID}` - And I try `wp user delete bobjones --network --yes` - Then STDERR should be: - """ - Warning: Failed deleting user {BOB_ID}. The user is a super admin. - """ - And the return code should be 1 - Scenario: Create new users on multisite Given a WP multisite install @@ -267,90 +122,17 @@ Feature: Manage WordPress users Bob Jones """ - # The error message changed in WP 5.9. - @less-than-wp-5.9 - Scenario: Creating a user with an existing email in multisite shows a clean error message - Given a WP multisite install - - When I run `wp user create bobjones bobjones@example.com` - Then STDOUT should not be empty - - When I try `wp user create bobjones2 bobjones@example.com` - Then STDERR should contain: - """ - Sorry, that email address is already used! - """ - And STDERR should not contain: - """ - < - """ - And the return code should be 1 - - @require-wp-5.9 - Scenario: Creating a user with an existing email in multisite shows a clean error message - Given a WP multisite install - - When I run `wp user create bobjones bobjones@example.com` - Then STDOUT should not be empty - - When I try `wp user create bobjones2 bobjones@example.com` - Then STDERR should contain: - """ - This email address is already registered. - """ - And STDERR should not contain: - """ - < - """ - And the return code should be 1 - Scenario: Managing user roles Given a WP install - When I try `wp user add-role 1` - Then the return code should be 1 - And STDERR should be: - """ - Error: Please specify at least one role to add. - """ - And STDOUT should be empty - When I run `wp user add-role 1 editor` - Then STDOUT should be: - """ - Success: Added 'editor' role for admin (1). - """ - - When I run `wp user get 1 --field=roles` + Then STDOUT should not be empty + And I run `wp user get 1 --field=roles` Then STDOUT should be: """ administrator, editor """ - When I run `wp user add-role 1 editor contributor` - Then STDOUT should be: - """ - Success: Added 'editor', 'contributor' roles for admin (1). - """ - - When I run `wp user get 1 --field=roles` - Then STDOUT should be: - """ - administrator, editor, contributor - """ - - When I run `wp user remove-role 1 editor contributor` - Then STDOUT should be: - """ - Success: Removed 'editor', 'contributor' roles from admin (1). - """ - - When I run `wp user get 1 --field=roles` - Then STDOUT should be: - """ - administrator - """ - When I try `wp user add-role 1 edit` Then STDERR should contain: """ @@ -374,51 +156,25 @@ Feature: Manage WordPress users When I run `wp user set-role 1 author` Then STDOUT should not be empty - - When I run `wp user get 1` + And I run `wp user get 1` Then STDOUT should be a table containing rows: | Field | Value | | roles | author | When I run `wp user remove-role 1 editor` Then STDOUT should not be empty - - When I run `wp user get 1` + And I run `wp user get 1` Then STDOUT should be a table containing rows: | Field | Value | | roles | author | When I run `wp user remove-role 1` - Then STDOUT should contain: - """ - Success: Removed all roles from admin (1) on - """ - - When I run `wp user get 1` + Then STDOUT should not be empty + And I run `wp user get 1` Then STDOUT should be a table containing rows: | Field | Value | | roles | | - Scenario: Invalid User Role - Given a WP install - When I run `wp user create testuser4 testemail4@example.com` - And I try `wp user update testuser4 --role=banana` - Then STDERR should be: - """ - Warning: Role doesn't exist: banana - """ - And STDOUT should contain: - """ - Success: - """ - And the return code should be 0 - - When I run `wp user get admin --field=roles` - Then STDOUT should be: - """ - administrator - """ - Scenario: Managing user capabilities Given a WP install @@ -428,19 +184,19 @@ Feature: Manage WordPress users Success: Added 'edit_vip_product' capability for admin (1). """ - When I run `wp user list-caps 1 | tail -n 1` + And I run `wp user list-caps 1 | tail -n 1` Then STDOUT should be: """ edit_vip_product """ - When I run `wp user remove-cap 1 edit_vip_product` + And I run `wp user remove-cap 1 edit_vip_product` Then STDOUT should be: """ Success: Removed 'edit_vip_product' cap for admin (1). """ - When I try the previous command again + And I try the previous command again Then the return code should be 1 And STDERR should be: """ @@ -466,59 +222,26 @@ Feature: Manage WordPress users """ And STDOUT should be empty - When I run `wp user list-caps 1` + And I run `wp user list-caps 1` Then STDOUT should contain: """ publish_posts """ - Scenario: Show error when trying to remove capability same as role - Given a WP install - - When I run `wp user create testuser2 testuser2@example.com --first_name=test --last_name=user --role=contributor --porcelain` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - - When I run `wp user list-caps {USER_ID}` - Then STDOUT should contain: - """ - contributor - """ - - When I run `wp user get {USER_ID} --field=roles` - Then STDOUT should contain: - """ - contributor - """ - - When I try `wp user remove-cap {USER_ID} contributor` - Then the return code should be 1 - And STDERR should be: - """ - Error: Aborting because a role has the same name as 'contributor'. Use `wp user remove-cap {USER_ID} contributor --force` to proceed with the removal. - """ - And STDOUT should be empty - - When I run `wp user remove-cap {USER_ID} contributor --force` - Then STDOUT should be: - """ - Success: Removed 'contributor' cap for testuser2 ({USER_ID}). - """ - Scenario: Show password when creating a user Given a WP install When I run `wp user create testrandompass testrandompass@example.com` Then STDOUT should contain: - """ - Password: - """ + """ + Password: + """ When I run `wp user create testsuppliedpass testsuppliedpass@example.com --user_pass=suppliedpass` Then STDOUT should not contain: - """ - Password: - """ + """ + Password: + """ Scenario: List network users Given a WP multisite install @@ -568,46 +291,18 @@ Feature: Manage WordPress users contributor """ - When I run `wp user list-caps bob --format=json` + And I run `wp user list-caps bob --format=json` Then STDOUT should be: """ [{"name":"edit_posts"},{"name":"read"},{"name":"level_1"},{"name":"level_0"},{"name":"delete_posts"},{"name":"contributor"}] """ - When I run `wp user list-caps bob --format=count` + And I run `wp user list-caps bob --format=count` Then STDOUT should be: """ 6 """ - When I run `wp user list-caps bob --exclude-role-names` - Then STDOUT should be: - """ - edit_posts - read - level_1 - level_0 - delete_posts - """ - - When I run `wp user add-cap bob newcap` - And I run `wp user list-caps bob --origin=role` - Then STDOUT should be: - """ - edit_posts - read - level_1 - level_0 - delete_posts - contributor - """ - - When I run `wp user list-caps bob --origin=user` - Then STDOUT should be: - """ - newcap - """ - Scenario: Make sure WordPress receives the slashed data it expects Given a WP install @@ -645,8 +340,8 @@ Feature: Manage WordPress users When I run `wp user list --include=1,2 --field=url` Then STDOUT should be: """ - https://example.com/?author=1 - https://example.com/?author=2 + http://example.com/?author=1 + http://example.com/?author=2 """ Scenario: Get user with email as login @@ -672,18 +367,9 @@ Feature: Manage WordPress users And I run `wp user create oprime oprime@example.com --role=author --porcelain` And save STDOUT as {OP_ID} And I run `wp user get bumblebee` - And STDOUT should not be empty + Then STDOUT should not be empty And I run `wp user get oprime` - And STDOUT should not be empty - - When I run `wp site create --slug=foo --porcelain` - Then save STDOUT as {SPAM_SITE_ID} - - When I run `wp --url=example.com/foo user set-role {BBEE_ID} administrator` - Then STDOUT should contain: - """ - Success: - """ + Then STDOUT should not be empty When I run `wp user spam {BBEE_ID}` Then STDOUT should be: @@ -703,18 +389,6 @@ Feature: Manage WordPress users """ And the return code should be 0 - When I run `wp site list --site__in=1 --field=spam` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp site list --site__in={SPAM_SITE_ID} --field=spam` - Then STDOUT should be: - """ - 1 - """ - When I try `wp user spam {OP_ID} 9999` Then STDOUT should be: """ @@ -723,91 +397,7 @@ Feature: Manage WordPress users And STDERR should be: """ Warning: Invalid user ID, email or login: '9999' + Warning: User 9999 doesn't exist. Error: Only spammed 1 of 2 users. """ And the return code should be 1 - - When I run `wp user unspam {BBEE_ID}` - Then STDOUT should contain: - """ - Success: - """ - - When I run `wp site list --site__in=1 --field=spam` - Then STDOUT should be: - """ - 0 - """ - - When I run `wp site list --site__in={SPAM_SITE_ID} --field=spam` - Then STDOUT should be: - """ - 0 - """ - - @require-wp-4.3 - Scenario: Sending emails on update - Given a WP install - - When I run `wp user get 1 --field=user_email` - Then save STDOUT as {ORIGINAL_EMAIL} - - When I run `wp user update 1 --user_email=different.mail@example.com` - Then STDOUT should contain: - """ - Success: Updated user 1. - """ - And an email should be sent - - When I run `wp user update 1 --user_email={ORIGINAL_EMAIL} --skip-email` - Then STDOUT should contain: - """ - Success: Updated user 1. - """ - And an email should not be sent - - When I run `wp user get 1 --field=user_pass` - Then save STDOUT as {ORIGINAL_PASSWORD} - - When I run `wp user update 1 --user_pass=different_password` - Then STDOUT should contain: - """ - Success: Updated user 1. - """ - And an email should be sent - - When I run `wp user update 1 --user_pass={ORIGINAL_PASSWORD} --skip-email` - Then STDOUT should contain: - """ - Success: Updated user 1. - """ - And an email should not be sent - - Scenario: Set user url when creating a user - Given a WP install - And I run `wp user create testurl sample@email.com --user_url='http://www.testsite.com'` - - When I run `wp user get testurl --fields=user_url` - Then STDOUT should be a table containing rows: - | Field | Value | - | user_url | http://www.testsite.com | - - Scenario: Support nickname creating and updating user - Given a WP install - - When I run `wp user create testuser testuser@example.com --nickname=customtestuser --porcelain` - Then STDOUT should be a number - And save STDOUT as {USER_ID} - - When I run `wp user meta get {USER_ID} nickname` - Then STDOUT should be: - """ - customtestuser - """ - - When I run `wp user update {USER_ID} --nickname=newtestuser` - And I run `wp user meta get {USER_ID} nickname` - Then STDOUT should be: - """ - newtestuser - """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 834afe6a0..000000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,125 +0,0 @@ -<?xml version="1.0"?> -<ruleset name="WP-CLI-entity"> - <description>Custom ruleset for WP-CLI entity-command</description> - - <!-- - ############################################################################# - COMMAND LINE ARGUMENTS - For help understanding this file: https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Annotated-ruleset.xml - For help using PHPCS: https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Usage - ############################################################################# - --> - - <!-- What to scan. --> - <file>.</file> - - <!-- Show progress. --> - <arg value="p"/> - - <!-- Strip the filepaths down to the relevant bit. --> - <arg name="basepath" value="./"/> - - <!-- Check up to 8 files simultaneously. --> - <arg name="parallel" value="8"/> - - <!-- - ############################################################################# - USE THE WP_CLI_CS RULESET - ############################################################################# - --> - - <rule ref="WP_CLI_CS"/> - - <!-- - ############################################################################# - PROJECT SPECIFIC CONFIGURATION FOR SNIFFS - ############################################################################# - --> - - <!-- For help understanding the `testVersion` configuration setting: - https://github.com/PHPCompatibility/PHPCompatibility#sniffing-your-code-for-compatibility-with-specific-php-versions --> - <config name="testVersion" value="7.2-"/> - - <!-- Verify that everything in the global namespace is either namespaced or prefixed. - See: https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/wiki/Customizable-sniff-properties#naming-conventions-prefix-everything-in-the-global-namespace --> - <rule ref="WordPress.NamingConventions.PrefixAllGlobals"> - <properties> - <property name="prefixes" type="array"> - <element value="WP_CLI\Entity"/><!-- Namespaces. --> - <element value="wpcli_entity"/><!-- Global variables and such. --> - <element value="wp_cli_"/><!-- Public WP-CLI hooks. --> - </property> - </properties> - </rule> - - <!-- Exclude existing classes from the prefix rule as it would break BC to prefix them now. --> - <rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound"> - <exclude-pattern>*/src/WP_CLI/Fetchers/(Comment|Post|Signup|Site|User)\.php$</exclude-pattern> - <exclude-pattern>*/src/WP_CLI/CommandWith(DBObject|Meta|Terms)\.php$</exclude-pattern> - </rule> - - <rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound"> - <exclude-pattern>*/src/Taxonomy_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Comment(_Meta)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Font(_Collection|_Face|_Family)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Font_Namespace\.php$</exclude-pattern> - <exclude-pattern>*/src/Menu(_Item|_Location)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Network_Meta_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Network_Namespace\.php$</exclude-pattern> - <exclude-pattern>*/src/Option_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Post(_Block|_Meta|_Revision|_Term|_Type)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Signup_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Site(_Meta|_Option)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/Term(_Meta)?_Command\.php$</exclude-pattern> - <exclude-pattern>*/src/User(_Application_Password|_Meta|_Privacy_Request|_Session|_Term)?_Command\.php$</exclude-pattern> - </rule> - - <!-- Whitelisting to provide backward compatibility to classes possibly extending this class. --> - <rule ref="PSR2.Methods.MethodDeclaration.Underscore"> - <exclude-pattern>*/src/WP_CLI/CommandWithDBObject\.php$</exclude-pattern> - </rule> - - <!-- - ############################################################################# - POLYFILL FILES EXCLUSIONS - These files are copies/adaptations of WordPress core code and must maintain - compatibility with the original implementation. - ############################################################################# - --> - - <!-- Exclude polyfill files from prefix rules - they must match WordPress core class names --> - <rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound"> - <exclude-pattern>*/src/Compat/WP_Block_Processor\.php$</exclude-pattern> - <exclude-pattern>*/src/Compat/WP_HTML_Span\.php$</exclude-pattern> - </rule> - - <!-- Exclude polyfill files from PHP compatibility checks - polyfills handle older PHP --> - <rule ref="PHPCompatibility.FunctionUse.NewFunctions.str_ends_withFound"> - <exclude-pattern>*/src/Compat/*</exclude-pattern> - <exclude-pattern>*/tests/Compat/PolyfillsTest\.php$</exclude-pattern> - </rule> - <rule ref="PHPCompatibility.FunctionUse.NewFunctions.str_starts_withFound"> - <exclude-pattern>*/src/Compat/*</exclude-pattern> - <exclude-pattern>*/tests/Compat/PolyfillsTest\.php$</exclude-pattern> - </rule> - <rule ref="PHPCompatibility.FunctionUse.NewFunctions.str_containsFound"> - <exclude-pattern>*/src/Compat/*</exclude-pattern> - <exclude-pattern>*/tests/Compat/PolyfillsTest\.php$</exclude-pattern> - </rule> - - <!-- Exclude GOTO usage in polyfill - copied from WordPress core --> - <rule ref="Generic.PHP.DiscourageGoto.Found"> - <exclude-pattern>*/src/Compat/WP_Block_Processor\.php$</exclude-pattern> - </rule> - - <!-- Exclude unreachable code warnings in polyfill - due to GOTO control flow --> - <rule ref="Squiz.PHP.NonExecutableCode.Unreachable"> - <exclude-pattern>*/src/Compat/WP_Block_Processor\.php$</exclude-pattern> - </rule> - - <!-- Exclude bootstrap files from global variable prefix rules --> - <rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound"> - <exclude-pattern>*/tests/bootstrap\.php$</exclude-pattern> - </rule> - -</ruleset> diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 1ebe3bd32..000000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,21 +0,0 @@ -parameters: - level: 9 - paths: - - src - - entity-command.php - excludePaths: - # Polyfill files are copies of WordPress core code with GOTO control flow - # that PHPStan cannot analyze correctly - - src/Compat/WP_Block_Processor.php - - src/Compat/WP_HTML_Span.php - - src/Compat/polyfills.php - scanDirectories: - - vendor/wp-cli/wp-cli/php - scanFiles: - - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php - treatPhpDocTypesAsCertain: false - ignoreErrors: - - identifier: missingType.iterableValue - - identifier: missingType.property - - identifier: missingType.parameter - - identifier: missingType.return diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..16cff729f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/4.8/phpunit.xsd" + bootstrap="vendor/autoload.php" + backupGlobals="false" + beStrictAboutCoversAnnotation="true" + beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="true" + beStrictAboutTodoAnnotatedTests="true" + colors="true" + verbose="true"> + <testsuite> + <directory suffix="Test.php">tests</directory> + </testsuite> + + <filter> + <whitelist processUncoveredFilesFromWhitelist="true"> + <directory suffix=".php">src</directory> + </whitelist> + </filter> +</phpunit> diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index c419b3064..000000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,26 +0,0 @@ -<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" - bootstrap="tests/bootstrap.php" - backupGlobals="false" - beStrictAboutCoversAnnotation="true" - beStrictAboutOutputDuringTests="true" - beStrictAboutTestsThatDoNotTestAnything="true" - beStrictAboutTodoAnnotatedTests="true" - convertErrorsToExceptions="true" - convertWarningsToExceptions="true" - convertNoticesToExceptions="true" - convertDeprecationsToExceptions="true" - colors="true" - verbose="true"> - <testsuites> - <testsuite name="wp-cli/entity-command tests"> - <directory suffix="Test.php">tests</directory> - </testsuite> - </testsuites> - - <coverage processUncoveredFiles="false"> - <include> - <directory suffix=".php">src</directory> - </include> - </coverage> -</phpunit> diff --git a/src/Block_HTML_Sync_Filters.php b/src/Block_HTML_Sync_Filters.php deleted file mode 100644 index bcc86679f..000000000 --- a/src/Block_HTML_Sync_Filters.php +++ /dev/null @@ -1,135 +0,0 @@ -<?php -/** - * Default filters for synchronizing block HTML with attribute changes. - * - * This file contains the built-in filter callbacks that update block HTML - * when attributes are changed via `wp post block update`. These are registered - * as WordPress filters, allowing them to be modified, replaced, or extended - * by custom code loaded via --require, plugins, or themes. - * - * @package WP_CLI\Entity - */ - -namespace WP_CLI\Entity; - -/** - * Handles synchronization of block HTML with updated attributes. - * - * Each method is a filter callback for 'wp_cli_post_block_update_html'. - * Methods check if they handle the given block type and return early if not. - */ -class Block_HTML_Sync_Filters { - - /** - * Registers all default sync filters. - * - * Called during command initialization to set up the built-in handlers. - * Users can remove these filters or add their own at different priorities. - * - * @return void - */ - public static function register() { - add_filter( 'wp_cli_post_block_update_html', [ __CLASS__, 'sync_heading_level' ], 10, 3 ); - add_filter( 'wp_cli_post_block_update_html', [ __CLASS__, 'sync_list_type' ], 10, 3 ); - } - - /** - * Synchronizes heading HTML tag with the level attribute. - * - * When a heading's level attribute changes (e.g., from 2 to 3), - * this updates the HTML tag from <h2> to <h3>. - * - * @param array $block The block structure. - * @param array $new_attrs The newly applied attributes. - * @param string $block_name The block type name. - * @return array The block with synchronized HTML. - */ - public static function sync_heading_level( $block, $new_attrs, $block_name ) { - if ( 'core/heading' !== $block_name ) { - return $block; - } - - if ( ! isset( $new_attrs['level'] ) ) { - return $block; - } - - $new_level = (int) $new_attrs['level']; - if ( $new_level < 1 || $new_level > 6 ) { - return $block; - } - - $inner_html = $block['innerHTML'] ?? ''; - if ( empty( $inner_html ) ) { - return $block; - } - - // Replace opening and closing heading tags. - // Pattern matches <h1> through <h6> with optional attributes. - $updated_html = preg_replace( - '/<h[1-6](\s[^>]*)?>/', - "<h{$new_level}$1>", - $inner_html - ); - $updated_html = preg_replace( - '/<\/h[1-6]>/', - "</h{$new_level}>", - $updated_html - ); - - if ( null !== $updated_html && $updated_html !== $inner_html ) { - $block['innerHTML'] = $updated_html; - $block['innerContent'] = [ $updated_html ]; - } - - return $block; - } - - /** - * Synchronizes list HTML tag with the ordered attribute. - * - * When a list's ordered attribute changes, this updates the HTML - * from <ul> to <ol> or vice versa. - * - * @param array $block The block structure. - * @param array $new_attrs The newly applied attributes. - * @param string $block_name The block type name. - * @return array The block with synchronized HTML. - */ - public static function sync_list_type( $block, $new_attrs, $block_name ) { - if ( 'core/list' !== $block_name ) { - return $block; - } - - if ( ! isset( $new_attrs['ordered'] ) ) { - return $block; - } - - $inner_html = $block['innerHTML'] ?? ''; - if ( empty( $inner_html ) ) { - return $block; - } - - $is_ordered = (bool) $new_attrs['ordered']; - $new_tag = $is_ordered ? 'ol' : 'ul'; - $old_tag = $is_ordered ? 'ul' : 'ol'; - - // Replace opening and closing list tags. - $updated_html = preg_replace( - "/<{$old_tag}(\s[^>]*)?>/", - "<{$new_tag}$1>", - $inner_html - ); - $updated_html = preg_replace( - "/<\/{$old_tag}>/", - "</{$new_tag}>", - $updated_html - ); - - if ( null !== $updated_html && $updated_html !== $inner_html ) { - $block['innerHTML'] = $updated_html; - $block['innerContent'] = [ $updated_html ]; - } - - return $block; - } -} diff --git a/src/Block_Processor_Helper.php b/src/Block_Processor_Helper.php deleted file mode 100644 index 57e968d1e..000000000 --- a/src/Block_Processor_Helper.php +++ /dev/null @@ -1,448 +0,0 @@ -<?php -/** - * Helper class for streaming block processing. - * - * Provides common streaming patterns for working with WP_Block_Processor. - * This class abstracts the low-level WP_Block_Processor API into higher-level - * operations that can be used by commands. - * - * @package WP_CLI\Entity - */ - -namespace WP_CLI\Entity; - -use WP_Block_Processor; -use WP_CLI\Entity\Compat\BlockProcessorLoader; - -/** - * Helper class for streaming block processing operations. - * - * This class provides static methods that encapsulate common patterns - * for working with blocks using the WP_Block_Processor streaming API. - * All methods work consistently across WordPress versions by using the - * polyfilled or native WP_Block_Processor. - */ -class Block_Processor_Helper { - - /** - * Ensures the WP_Block_Processor class is available. - * - * This method loads the polyfill if needed. It's called at the start - * of each public method to ensure the dependency is available. - * - * @return void - */ - private static function ensure_loaded(): void { - BlockProcessorLoader::load(); - } - - /** - * Parses all blocks from content using streaming processor. - * - * This method provides an alternative to parse_blocks() that uses - * WP_Block_Processor for consistent behavior across WP versions. - * - * @param string $content Block content to parse. - * @return array Array of parsed block structures. - */ - public static function parse_all( string $content ): array { - if ( '' === $content ) { - return []; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $blocks = []; - - while ( $processor->next_block() ) { - // Only process top-level blocks (depth 1). - if ( 1 !== $processor->get_depth() ) { - continue; - } - - $delimiter_type = $processor->get_delimiter_type(); - - // Handle void blocks specially - don't use extract which causes issues. - if ( WP_Block_Processor::VOID === $delimiter_type ) { - $blocks[] = [ - 'blockName' => $processor->get_block_type(), - 'attrs' => $processor->allocate_and_return_parsed_attributes() ?? [], - 'innerBlocks' => [], - 'innerHTML' => '', - 'innerContent' => [], - ]; - } elseif ( WP_Block_Processor::OPENER === $delimiter_type ) { - // For opener blocks, extract_full_block_and_advance works correctly. - $block = $processor->extract_full_block_and_advance(); - if ( null !== $block ) { - $blocks[] = $block; - } - } - } - - return $blocks; - } - - /** - * Gets a block at a specific index. - * - * Uses streaming to find the block without parsing the entire document. - * - * @param string $content Block content to search. - * @param int $target_index The 0-based index of the block to get. - * @param bool $skip_freeform Whether to skip freeform HTML blocks (default true). - * @return array|null The block structure or null if not found. - */ - public static function get_at_index( string $content, int $target_index, bool $skip_freeform = true ): ?array { - if ( '' === $content || $target_index < 0 ) { - return null; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $current_index = 0; - - while ( $processor->next_block() ) { - // Only consider top-level blocks (depth 1). - if ( 1 !== $processor->get_depth() ) { - continue; - } - - // Skip freeform content unless requested. - if ( null === $processor->get_block_type() ) { - if ( $skip_freeform ) { - continue; - } - } - - if ( $current_index === $target_index ) { - $delimiter_type = $processor->get_delimiter_type(); - - // Handle void blocks specially. - if ( WP_Block_Processor::VOID === $delimiter_type ) { - return [ - 'blockName' => $processor->get_block_type(), - 'attrs' => $processor->allocate_and_return_parsed_attributes() ?? [], - 'innerBlocks' => [], - 'innerHTML' => '', - 'innerContent' => [], - ]; - } - - return $processor->extract_full_block_and_advance(); - } - - ++$current_index; - } - - return null; - } - - /** - * Counts blocks by type using streaming. - * - * @param string $content Block content to analyze. - * @param bool $nested Whether to include nested blocks in count. - * @return array Associative array of block type => count. - */ - public static function count_by_type( string $content, bool $nested = false ): array { - if ( '' === $content ) { - return []; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $counts = []; - - while ( $processor->next_block() ) { - $block_type = $processor->get_block_type(); - - // Skip freeform HTML. - if ( null === $block_type ) { - continue; - } - - // If not counting nested, only count top-level blocks. - if ( ! $nested && $processor->get_depth() > 1 ) { - continue; - } - - if ( ! isset( $counts[ $block_type ] ) ) { - $counts[ $block_type ] = 0; - } - - ++$counts[ $block_type ]; - } - - return $counts; - } - - /** - * Checks if a specific block type exists in content. - * - * Uses streaming for early exit on first match. - * - * @param string $content Block content to search. - * @param string $block_type Block type to find (e.g., 'core/paragraph' or 'paragraph'). - * @return bool True if block type exists, false otherwise. - */ - public static function has_block( string $content, string $block_type ): bool { - if ( '' === $content ) { - return false; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - - // Use the processor's built-in type filtering for efficiency. - return $processor->next_block( $block_type ); - } - - /** - * Gets the total count of blocks in content. - * - * @param string $content Block content to count. - * @param bool $nested Whether to include nested blocks. - * @param bool $skip_freeform Whether to skip freeform HTML blocks. - * @return int Total number of blocks. - */ - public static function get_block_count( string $content, bool $nested = false, bool $skip_freeform = true ): int { - if ( '' === $content ) { - return 0; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $count = 0; - - while ( $processor->next_block() ) { - $block_type = $processor->get_block_type(); - - // Skip freeform HTML if requested. - if ( $skip_freeform && null === $block_type ) { - continue; - } - - // If not counting nested, only count top-level blocks. - if ( ! $nested && $processor->get_depth() > 1 ) { - continue; - } - - ++$count; - } - - return $count; - } - - /** - * Extracts blocks matching a filter condition. - * - * @param string $content Block content to search. - * @param callable $predicate Function that receives block type and attributes, returns bool. - * @param int $limit Maximum number of blocks to return (0 = unlimited). - * @return array Array of matching block structures. - */ - public static function extract_matching( string $content, callable $predicate, int $limit = 0 ): array { - if ( '' === $content ) { - return []; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $blocks = []; - - while ( $processor->next_block() ) { - $block_type = $processor->get_block_type(); - - // Skip freeform content for matching. - if ( null === $block_type ) { - continue; - } - - // Only check top-level blocks. - if ( $processor->get_depth() > 1 ) { - continue; - } - - $attrs = $processor->allocate_and_return_parsed_attributes() ?? []; - - if ( $predicate( $block_type, $attrs ) ) { - $delimiter_type = $processor->get_delimiter_type(); - - // Handle void blocks specially. - if ( WP_Block_Processor::VOID === $delimiter_type ) { - $block = [ - 'blockName' => $block_type, - 'attrs' => $attrs, - 'innerBlocks' => [], - 'innerHTML' => '', - 'innerContent' => [], - ]; - } else { - $block = $processor->extract_full_block_and_advance(); - } - - if ( null !== $block ) { - $blocks[] = $block; - - if ( $limit > 0 && count( $blocks ) >= $limit ) { - break; - } - } - } - } - - return $blocks; - } - - /** - * Gets block span (position) information by index. - * - * Returns the byte offset and length of a block at a given index. - * Useful for string splice operations. - * - * @param string $content Block content to search. - * @param int $target_index The 0-based index of the block. - * @return array|null Array with 'start' and 'end' keys, or null if not found. - */ - public static function get_block_span( string $content, int $target_index ): ?array { - if ( '' === $content || $target_index < 0 ) { - return null; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - $current_index = 0; - - while ( $processor->next_block() ) { - $block_type = $processor->get_block_type(); - - // Skip freeform content. - if ( null === $block_type ) { - continue; - } - - // Only consider top-level blocks. - if ( $processor->get_depth() > 1 ) { - continue; - } - - if ( $current_index === $target_index ) { - $start_span = $processor->get_span(); - if ( null === $start_span ) { - return null; - } - - $start = $start_span->start; - $delimiter_type = $processor->get_delimiter_type(); - - // For void blocks, the span is just the delimiter. - if ( WP_Block_Processor::VOID === $delimiter_type ) { - return [ - 'start' => $start, - 'end' => $start + $start_span->length, - ]; - } - - // For opener blocks, extract to find the end. - $processor->extract_full_block_and_advance(); - - // After extract, we're positioned at the closer (or next token). - // The span now points to the closer delimiter. - $end_span = $processor->get_span(); - if ( null !== $end_span ) { - // End position is after the closer delimiter. - $end = $end_span->start + $end_span->length; - } else { - $end = strlen( $content ); - } - - return [ - 'start' => $start, - 'end' => $end, - ]; - } - - ++$current_index; - } - - return null; - } - - /** - * Lists all block types present in content. - * - * Returns a simple array of unique block type names found. - * - * @param string $content Block content to analyze. - * @param bool $nested Whether to include nested block types. - * @return array Array of unique block type names. - */ - public static function get_block_types( string $content, bool $nested = false ): array { - $counts = self::count_by_type( $content, $nested ); - return array_keys( $counts ); - } - - /** - * Checks if content contains any blocks. - * - * @param string $content Block content to check. - * @return bool True if content contains at least one block. - */ - public static function has_blocks( string $content ): bool { - if ( '' === $content ) { - return false; - } - - self::ensure_loaded(); - $processor = new WP_Block_Processor( $content ); - - while ( $processor->next_block() ) { - // Skip freeform HTML. - if ( null !== $processor->get_block_type() ) { - return true; - } - } - - return false; - } - - /** - * Strips innerHTML and innerContent from blocks recursively. - * - * @param array $blocks Array of blocks. - * @return array Blocks with innerHTML stripped. - */ - public static function strip_inner_html( array $blocks ): array { - return array_map( - function ( $block ) { - unset( $block['innerHTML'] ); - unset( $block['innerContent'] ); - if ( ! empty( $block['innerBlocks'] ) ) { - $block['innerBlocks'] = self::strip_inner_html( $block['innerBlocks'] ); - } - return $block; - }, - $blocks - ); - } - - /** - * Filters blocks to only those with non-null blockName. - * - * Removes freeform/whitespace blocks from array. - * - * @param array $blocks Array of blocks. - * @return array Filtered blocks with re-indexed keys. - */ - public static function filter_empty_blocks( array $blocks ): array { - return array_values( - array_filter( - $blocks, - function ( $block ) { - return ! empty( $block['blockName'] ); - } - ) - ); - } -} diff --git a/src/Comment_Command.php b/src/Comment_Command.php index 3b1578017..58cc70359 100644 --- a/src/Comment_Command.php +++ b/src/Comment_Command.php @@ -1,7 +1,5 @@ <?php -use WP_CLI\CommandWithDBObject; -use WP_CLI\Fetchers\Comment as CommentFetcher; use WP_CLI\Utils; /** @@ -21,58 +19,28 @@ * $ wp comment delete 1337 --force * Success: Deleted comment 1337. * - * # Trash all spam comments. + * # Delete all spam comments. * $ wp comment delete $(wp comment list --status=spam --format=ids) - * Success: Trashed comment 264. - * Success: Trashed comment 262. - * - * # Create a note for a block (WordPress 6.9+). - * $ wp comment create --comment_post_ID=15 --comment_content="This block needs revision" --comment_author="editor" --comment_type="note" - * Success: Created comment 945. - * - * # List notes for a specific post (WordPress 6.9+). - * $ wp comment list --type=note --post_id=15 - * +------------+---------------------+----------------------------------+ - * | comment_ID | comment_date | comment_content | - * +------------+---------------------+----------------------------------+ - * | 945 | 2024-11-10 14:30:00 | This block needs revision | - * +------------+---------------------+----------------------------------+ - * - * # Reply to a note (WordPress 6.9+). - * $ wp comment create --comment_post_ID=15 --comment_content="Updated per feedback" --comment_author="editor" --comment_type="note" --comment_parent=945 - * Success: Created comment 946. - * - * # Resolve a note by adding a comment with status meta (WordPress 6.9+). - * $ wp comment create --comment_post_ID=15 --comment_content="Resolving" --comment_author="editor" --comment_type="note" --comment_parent=945 --porcelain - * 947 - * $ wp comment meta add 947 _wp_note_status resolved - * Success: Added custom field. - * - * # Reopen a resolved note (WordPress 6.9+). - * $ wp comment create --comment_post_ID=15 --comment_content="Reopening for further review" --comment_author="editor" --comment_type="note" --comment_parent=945 --porcelain - * 948 - * $ wp comment meta add 948 _wp_note_status reopen - * Success: Added custom field. + * Success: Deleted comment 264. + * Success: Deleted comment 262. * * @package wp-cli */ -class Comment_Command extends CommandWithDBObject { +class Comment_Command extends \WP_CLI\CommandWithDBObject { - protected $obj_type = 'comment'; + protected $obj_type = 'comment'; protected $obj_id_key = 'comment_ID'; - protected $obj_fields = [ + protected $obj_fields = array( 'comment_ID', 'comment_post_ID', 'comment_date', 'comment_approved', 'comment_author', 'comment_author_email', - ]; - - private $fetcher; + ); public function __construct() { - $this->fetcher = new CommentFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\Comment; } /** @@ -91,42 +59,31 @@ public function __construct() { * # Create comment. * $ wp comment create --comment_post_ID=15 --comment_content="hello blog" --comment_author="wp-cli" * Success: Created comment 932. - * - * # Create a note (WordPress 6.9+). - * $ wp comment create --comment_post_ID=15 --comment_content="This block needs revision" --comment_author="editor" --comment_type="note" - * Success: Created comment 933. - * - * @param string[] $args Positional arguments. Unused. - * @param array<string, mixed> $assoc_args Associative arguments. */ public function create( $args, $assoc_args ) { $assoc_args = wp_slash( $assoc_args ); - parent::_create( - $args, - $assoc_args, - function ( $params ) { - if ( isset( $params['comment_post_ID'] ) ) { - $post_id = $params['comment_post_ID']; - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( 'no_post', "Can't find post {$post_id}." ); - } - } else { - // Make sure it's set for older WP versions else get undefined PHP notice. - $params['comment_post_ID'] = 0; + parent::_create( $args, $assoc_args, function ( $params ) { + if ( isset( $params['comment_post_ID'] ) ) { + $post_id = $params['comment_post_ID']; + $post = get_post( $post_id ); + if ( !$post ) { + return new WP_Error( 'no_post', "Can't find post $post_id." ); } + } else { + // Make sure it's set for older WP versions else get undefined PHP notice. + $params['comment_post_ID'] = 0; + } - // We use wp_insert_comment() instead of wp_new_comment() to stay at a low level and - // avoid wp_die() formatted messages or notifications - $comment_id = wp_insert_comment( $params ); - - if ( ! $comment_id ) { - return new WP_Error( 'db_error', 'Could not create comment.' ); - } + // We use wp_insert_comment() instead of wp_new_comment() to stay at a low level and + // avoid wp_die() formatted messages or notifications + $comment_id = wp_insert_comment( $params ); - return $comment_id; + if ( !$comment_id ) { + return new WP_Error( 'db_error', 'Could not create comment.' ); } - ); + + return $comment_id; + } ); } /** @@ -145,23 +102,16 @@ function ( $params ) { * # Update comment. * $ wp comment update 123 --comment_author='That Guy' * Success: Updated comment 123. - * - * @param string[] $args Positional arguments. Comment IDs to update. - * @param array<string, mixed> $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { $assoc_args = wp_slash( $assoc_args ); - parent::_update( - $args, - $assoc_args, - function ( $params ) { - if ( ! wp_update_comment( $params ) ) { - return new WP_Error( 'db_error', 'Could not update comment.' ); - } - - return true; + parent::_update( $args, $assoc_args, function ( $params ) { + if ( !wp_update_comment( $params ) ) { + return new WP_Error( 'Could not update comment.' ); } - ); + + return true; + } ); } /** @@ -203,36 +153,36 @@ function ( $params ) { */ public function generate( $args, $assoc_args ) { - $defaults = [ - 'count' => 100, - 'post_id' => 0, - ]; + $defaults = array( + 'count' => 100, + 'post_id' => 0, + ); $assoc_args = array_merge( $defaults, $assoc_args ); - $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'progress' ); $notify = false; if ( 'progress' === $format ) { - $notify = Utils\make_progress_bar( 'Generating comments', $assoc_args['count'] ); + $notify = \WP_CLI\Utils\make_progress_bar( 'Generating comments', $assoc_args['count'] ); } $comment_count = wp_count_comments(); - $total = (int) $comment_count->total_comments; - $limit = $total + $assoc_args['count']; - - for ( $index = $total; $index < $limit; $index++ ) { - $comment_id = wp_insert_comment( - [ - 'comment_content' => "Comment {$index}", - 'comment_post_ID' => $assoc_args['post_id'], - ] - ); + $total = (int )$comment_count->total_comments; + $limit = $total + $assoc_args['count']; + + for ( $i = $total; $i < $limit; $i++ ) { + $comment_id = wp_insert_comment( array( + 'comment_content' => "Comment {$i}", + 'comment_post_ID' => $assoc_args['post_id'], + ) ); if ( 'progress' === $format ) { $notify->tick(); - } elseif ( 'ids' === $format ) { - echo $comment_id; - if ( $index < $limit - 1 ) { - echo ' '; + } else if ( 'ids' === $format ) { + if ( 'ids' === $format ) { + echo $comment_id; + if ( $i < $limit - 1 ) { + echo ' '; + } } } } @@ -240,6 +190,7 @@ public function generate( $args, $assoc_args ) { if ( 'progress' === $format ) { $notify->finish(); } + } /** @@ -274,20 +225,14 @@ public function generate( $args, $assoc_args ) { * Thanks for all the comments, everyone! */ public function get( $args, $assoc_args ) { - $comment_id = (int) $args[0]; - $comment = get_comment( $comment_id ); + $comment_id = (int)$args[0]; + $comment = get_comment( $comment_id ); if ( empty( $comment ) ) { - WP_CLI::error( 'Invalid comment ID.' ); - } - - // @phpstan-ignore property.notFound - if ( ! isset( $comment->url ) ) { - // @phpstan-ignore property.notFound - $comment->url = get_comment_link( $comment ); + WP_CLI::error( "Invalid comment ID." ); } if ( empty( $assoc_args['fields'] ) ) { - $comment_array = get_object_vars( $comment ); + $comment_array = get_object_vars( $comment ); $assoc_args['fields'] = array_keys( $comment_array ); } @@ -298,9 +243,6 @@ public function get( $args, $assoc_args ) { /** * Gets a list of comments. * - * Display comments based on all arguments supported by - * [WP_Comment_Query()](https://developer.wordpress.org/reference/classes/WP_Comment_Query/__construct/). - * * ## OPTIONS * * [--<field>=<value>] @@ -375,47 +317,12 @@ public function get( $args, $assoc_args ) { * | 29 | 2013-03-14 11:56:08 | Jane Doe | * +------------+---------------------+----------------+ * - * # List unapproved comments. - * $ wp comment list --number=3 --status=hold --fields=ID,comment_date,comment_author - * +------------+---------------------+----------------+ - * | comment_ID | comment_date | comment_author | - * +------------+---------------------+----------------+ - * | 8 | 2023-11-10 13:13:06 | John Doe | - * | 7 | 2023-11-10 13:09:55 | Mr WordPress | - * | 9 | 2023-11-10 11:22:31 | Jane Doe | - * +------------+---------------------+----------------+ - * - * # List comments marked as spam. - * $ wp comment list --status=spam --fields=ID,comment_date,comment_author - * +------------+---------------------+----------------+ - * | comment_ID | comment_date | comment_author | - * +------------+---------------------+----------------+ - * | 2 | 2023-11-10 11:22:31 | Jane Doe | - * +------------+---------------------+----------------+ - * - * # List comments in trash. - * $ wp comment list --status=trash --fields=ID,comment_date,comment_author - * +------------+---------------------+----------------+ - * | comment_ID | comment_date | comment_author | - * +------------+---------------------+----------------+ - * | 3 | 2023-11-10 11:22:31 | John Doe | - * +------------+---------------------+----------------+ - * - * # List notes for a specific post (WordPress 6.9+). - * $ wp comment list --type=note --post_id=15 --fields=ID,comment_date,comment_content - * +------------+---------------------+----------------------------------+ - * | comment_ID | comment_date | comment_content | - * +------------+---------------------+----------------------------------+ - * | 10 | 2024-11-10 14:30:00 | This block needs revision | - * | 11 | 2024-11-10 15:45:00 | Updated per feedback | - * +------------+---------------------+----------------------------------+ - * * @subcommand list */ - public function list_( $args, $assoc_args ) { + public function list_( $_, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { $assoc_args['fields'] = 'comment_ID'; } @@ -425,39 +332,34 @@ public function list_( $args, $assoc_args ) { $assoc_args['count'] = true; } - $query = new WP_Comment_Query(); - $comments = $query->query( $assoc_args ); + if ( ! empty( $assoc_args['comment__in'] ) + && ! empty( $assoc_args['orderby'] ) + && 'comment__in' === $assoc_args['orderby'] + && Utils\wp_version_compare( '4.4', '<' ) ) { + $comments = array(); + foreach( $assoc_args['comment__in'] as $comment_id ) { + $comment = get_comment( $comment_id ); + if ( $comment ) { + $comments[] = $comment; + } else { + WP_CLI::warning( sprintf( "Invalid comment %s.", $comment_id ) ); + } + } + } else { + $query = new WP_Comment_Query(); + $comments = $query->query( $assoc_args ); + } if ( 'count' === $formatter->format ) { - /** - * @var int $comments - */ echo $comments; - return; } else { - /** - * @var array $comments - */ - - if ( 'ids' === $formatter->format ) { - /** - * @var \WP_Comment[] $comments - */ - $items = wp_list_pluck( $comments, 'comment_ID' ); - - $comments = $items; + if ( 'ids' == $formatter->format ) { + $comments = wp_list_pluck( $comments, 'comment_ID' ); } elseif ( is_array( $comments ) ) { - $comments = array_map( - function ( $comment ) { - /** - * @var \WP_Comment $comment - */ - // @phpstan-ignore property.notFound - $comment->url = get_comment_link( (int) $comment->comment_ID ); - return $comment; - }, - $comments - ); + $comments = array_map( function( $comment ){ + $comment->url = get_comment_link( $comment->comment_ID ); + return $comment; + }, $comments ); } $formatter->display_items( $comments ); } @@ -486,49 +388,46 @@ function ( $comment ) { * Success: Deleted comment 2341. */ public function delete( $args, $assoc_args ) { - parent::_delete( - $args, - $assoc_args, - function ( $comment_id, $assoc_args ) { - $force = (bool) Utils\get_flag_value( $assoc_args, 'force' ); + parent::_delete( $args, $assoc_args, function ( $comment_id, $assoc_args ) { + $force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force' ); - $status = wp_get_comment_status( $comment_id ); - $result = wp_delete_comment( $comment_id, $force ); + $status = wp_get_comment_status( $comment_id ); + $r = wp_delete_comment( $comment_id, $force ); - if ( ! $result ) { - return [ 'error', "Failed deleting comment {$comment_id}." ]; + if ( $r ) { + if ( $force || 'trash' === $status ) { + return array( 'success', "Deleted comment $comment_id." ); + } else { + return array( 'success', "Trashed comment $comment_id." ); } - - $verb = ( $force || 'trash' === $status ) ? 'Deleted' : 'Trashed'; - return [ 'success', "{$verb} comment {$comment_id}." ]; + } else { + return array( 'error', "Failed deleting comment $comment_id." ); } - ); + } ); } private function call( $args, $status, $success, $failure ) { $comment_id = absint( $args ); - /** - * @var callable $func - */ - $func = "wp_{$status}_comment"; + $func = sprintf( 'wp_%s_comment', $status ); - if ( ! $func( $comment_id ) ) { - WP_CLI::error( sprintf( $failure, "comment {$comment_id}" ) ); + if ( $func( $comment_id ) ) { + WP_CLI::success( "$success comment $comment_id." ); + } else { + WP_CLI::error( "$failure comment $comment_id." ); } - WP_CLI::success( sprintf( $success, "comment {$comment_id}" ) ); } private function set_status( $args, $status, $success ) { $comment = $this->fetcher->get_check( $args ); - $result = wp_set_comment_status( $comment->comment_ID, $status, true ); + $r = wp_set_comment_status( $comment->comment_ID, $status, true ); - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result ); + if ( is_wp_error( $r ) ) { + WP_CLI::error( $r ); + } else { + WP_CLI::success( "$success comment $comment->comment_ID." ); } - - WP_CLI::success( "{$success} comment {$comment->comment_ID}." ); } /** @@ -556,8 +455,8 @@ private function check_server_name() { * Success: Trashed comment 1337. */ public function trash( $args, $assoc_args ) { - foreach ( $args as $id ) { - $this->call( $id, __FUNCTION__, 'Trashed %s.', 'Failed trashing %s.' ); + foreach( $args as $id ) { + $this->call( $id, __FUNCTION__, 'Trashed', 'Failed trashing' ); } } @@ -577,8 +476,8 @@ public function trash( $args, $assoc_args ) { */ public function untrash( $args, $assoc_args ) { $this->check_server_name(); - foreach ( $args as $id ) { - $this->call( $id, __FUNCTION__, 'Untrashed %s.', 'Failed untrashing %s.' ); + foreach( $args as $id ) { + $this->call( $id, __FUNCTION__, 'Untrashed', 'Failed untrashing' ); } } @@ -597,8 +496,8 @@ public function untrash( $args, $assoc_args ) { * Success: Marked as spam comment 1337. */ public function spam( $args, $assoc_args ) { - foreach ( $args as $id ) { - $this->call( $id, __FUNCTION__, 'Marked %s as spam.', 'Failed marking %s as spam.' ); + foreach( $args as $id ) { + $this->call( $id, __FUNCTION__, 'Marked as spam', 'Failed marking as spam' ); } } @@ -618,8 +517,8 @@ public function spam( $args, $assoc_args ) { */ public function unspam( $args, $assoc_args ) { $this->check_server_name(); - foreach ( $args as $id ) { - $this->call( $id, __FUNCTION__, 'Unspammed %s.', 'Failed unspamming %s.' ); + foreach( $args as $id ) { + $this->call( $id, __FUNCTION__, 'Unspammed', 'Failed unspamming' ); } } @@ -639,8 +538,8 @@ public function unspam( $args, $assoc_args ) { */ public function approve( $args, $assoc_args ) { $this->check_server_name(); - foreach ( $args as $id ) { - $this->set_status( $id, 'approve', 'Approved' ); + foreach( $args as $id ) { + $this->set_status( $id, 'approve', "Approved" ); } } @@ -660,8 +559,8 @@ public function approve( $args, $assoc_args ) { */ public function unapprove( $args, $assoc_args ) { $this->check_server_name(); - foreach ( $args as $id ) { - $this->set_status( $id, 'hold', 'Unapproved' ); + foreach( $args as $id ) { + $this->set_status( $id, 'hold', "Unapproved" ); } } @@ -696,17 +595,16 @@ public function unapprove( $args, $assoc_args ) { * total_comments: 19 */ public function count( $args, $assoc_args ) { - $post_id = $args[0] ?? null; + $post_id = \WP_CLI\Utils\get_flag_value( $args, 0, 0 ); $count = wp_count_comments( $post_id ); // Move total_comments to the end of the object $total = $count->total_comments; unset( $count->total_comments ); - // @phpstan-ignore assign.propertyReadOnly $count->total_comments = $total; - foreach ( (array) $count as $status => $count ) { + foreach ( $count as $status => $count ) { WP_CLI::line( str_pad( "$status:", 17 ) . $count ); } } @@ -726,15 +624,13 @@ public function count( $args, $assoc_args ) { * Updated post 123 comment count to 67. */ public function recount( $args ) { - foreach ( $args as $id ) { - if ( wp_update_comment_count( $id ) ) { - /** - * @var \WP_Post $post - */ - $post = get_post( $id ); - WP_CLI::log( "Updated post {$post->ID} comment count to {$post->comment_count}." ); + foreach( $args as $id ) { + wp_update_comment_count( $id ); + $post = get_post( $id ); + if ( $post ) { + WP_CLI::log( sprintf( "Updated post %d comment count to %d.", $post->ID, $post->comment_count ) ); } else { - WP_CLI::warning( "Post {$id} doesn't exist." ); + WP_CLI::warning( sprintf( "Post %d doesn't exist.", $post->ID ) ); } } } @@ -759,7 +655,7 @@ public function status( $args, $assoc_args ) { $status = wp_get_comment_status( $comment_id ); if ( false === $status ) { - WP_CLI::error( "Could not check status of comment {$comment_id}." ); + WP_CLI::error( "Could not check status of comment $comment_id." ); } else { WP_CLI::line( $status ); } @@ -783,7 +679,7 @@ public function status( $args, $assoc_args ) { */ public function exists( $args ) { if ( $this->fetcher->get( $args[0] ) ) { - WP_CLI::success( "Comment with ID {$args[0]} exists." ); + WP_CLI::success( "Comment with ID $args[0] exists." ); } } } diff --git a/src/Comment_Meta_Command.php b/src/Comment_Meta_Command.php index f9b41b8f2..c8950e1d5 100644 --- a/src/Comment_Meta_Command.php +++ b/src/Comment_Meta_Command.php @@ -1,8 +1,5 @@ <?php -use WP_CLI\CommandWithMeta; -use WP_CLI\Fetchers\Comment as CommentFetcher; - /** * Adds, updates, deletes, and lists comment custom fields. * @@ -24,95 +21,18 @@ * $ wp comment meta delete 123 description * Success: Deleted custom field. */ -class Comment_Meta_Command extends CommandWithMeta { +class Comment_Meta_Command extends \WP_CLI\CommandWithMeta { protected $meta_type = 'comment'; - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false The meta ID on success, false on failure. - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_comment_meta( $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_comment_meta( $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_comment_meta( $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_comment_meta( $object_id, $meta_key, $meta_value ); - } - /** * Check that the comment ID exists * - * @param string|int $object_id - * @return int|never + * @param int */ protected function check_object_id( $object_id ) { - $fetcher = new CommentFetcher(); - $comment = $fetcher->get_check( (string) $object_id ); - return (int) $comment->comment_ID; + $fetcher = new \WP_CLI\Fetchers\Comment; + $comment = $fetcher->get_check( $object_id ); + return $comment->comment_ID; } } + diff --git a/src/Compat/BlockProcessorLoader.php b/src/Compat/BlockProcessorLoader.php deleted file mode 100644 index 8eeb90f80..000000000 --- a/src/Compat/BlockProcessorLoader.php +++ /dev/null @@ -1,88 +0,0 @@ -<?php -/** - * Loader for WP_Block_Processor polyfills. - * - * This class handles conditional loading of polyfills for WordPress block processing - * classes that may not be available in older versions of WordPress. - * - * @package WP_CLI\Entity\Compat - */ - -namespace WP_CLI\Entity\Compat; - -/** - * Handles loading of polyfill classes for block processing. - * - * The polyfills provide compatibility for: - * - WP_Block_Processor (WordPress 6.9+) - * - WP_HTML_Span (WordPress 6.2+) - * - str_ends_with() function (PHP 8.0+) - */ -class BlockProcessorLoader { - - /** - * Whether the polyfills have been loaded. - * - * @var bool - */ - private static $loaded = false; - - /** - * Loads the polyfill classes if they haven't been loaded already. - * - * This method is idempotent - calling it multiple times has no effect - * after the first call. - * - * The loading order is important: - * 1. Function polyfills (str_ends_with) - needed by WP_Block_Processor - * 2. WP_HTML_Span - dependency of WP_Block_Processor - * 3. WP_Block_Processor - main class - * - * Each polyfill file checks if the class/function already exists before - * defining it, so this is safe to call even after WordPress loads the - * native classes. - * - * @return void - */ - public static function load(): void { - if ( self::$loaded ) { - return; - } - self::$loaded = true; - - // Load function polyfills first (str_ends_with for PHP < 8.0). - // This MUST be loaded before WP_Block_Processor as it uses str_ends_with(). - require_once __DIR__ . '/polyfills.php'; - - // Load WP_HTML_Span polyfill if not provided by WordPress. - // This is a dependency of WP_Block_Processor. - if ( ! class_exists( 'WP_HTML_Span', false ) ) { - require_once __DIR__ . '/WP_HTML_Span.php'; - } - - // Load WP_Block_Processor polyfill if not provided by WordPress. - if ( ! class_exists( 'WP_Block_Processor', false ) ) { - require_once __DIR__ . '/WP_Block_Processor.php'; - } - } - - /** - * Checks if the polyfills have been loaded. - * - * @return bool True if load() has been called, false otherwise. - */ - public static function is_loaded(): bool { - return self::$loaded; - } - - /** - * Resets the loaded state for testing purposes. - * - * WARNING: This is intended for testing only. Do not use in production code. - * - * @return void - */ - public static function reset_for_testing(): void { - self::$loaded = false; - } -} diff --git a/src/Compat/WP_Block_Processor.php b/src/Compat/WP_Block_Processor.php deleted file mode 100644 index 4f59d9157..000000000 --- a/src/Compat/WP_Block_Processor.php +++ /dev/null @@ -1,1990 +0,0 @@ -<?php -/** - * Polyfill for WP_Block_Processor class. - * - * This class is a polyfill for the WP_Block_Processor class introduced in WordPress 6.9.0. - * It will only be loaded if the class doesn't already exist in WordPress Core. - * - * Source: WordPress 6.9.0 wp-includes/class-wp-block-processor.php - * - * @package WP_CLI\Entity\Compat - */ - -if ( class_exists( 'WP_Block_Processor' ) ) { - return; -} - - -/** - * Class for efficiently scanning through block structure in a document - * without parsing the entire block tree and JSON attributes into memory. - * - * ## Overview - * - * This class is designed to help analyze and modify block structure in a - * streaming fashion and to bridge the gap between parsed block trees and - * the text representing them. - * - * Use-cases for this class include but are not limited to: - * - * - Counting block types in a document. - * - Queuing stylesheets based on the presence of various block types. - * - Modifying blocks of a given type, i.e. migrations, updates, and styling. - * - Searching for content of specific kinds, e.g. checking for blocks - * with certain theme support attributes, or block bindings. - * - Adding CSS class names to the element wrapping a block’s inner blocks. - * - * > *Note!* If a fully-parsed block tree of a document is necessary, including - * > all the parsed JSON attributes, nested blocks, and HTML, consider - * > using {@see \parse_blocks()} instead which will parse the document - * > in one swift pass. - * - * For typical usage, jump first to the methods {@see self::next_block()}, - * {@see self::next_delimiter()}, or {@see self::next_token()}. - * - * ### Values - * - * As a lower-level interface than {@see parse_blocks()} this class follows - * different performance-focused values: - * - * - Minimize allocations so that documents of any size may be processed - * on a fixed or marginal amount of memory. - * - Make hidden costs explicit so that calling code only has to pay the - * performance penalty for features it needs. - * - Operate with a streaming and re-entrant design to make it possible - * to operate on chunks of a document and to resume after pausing. - * - * This means that some operations might appear more cumbersome than one - * might expect. This design tradeoff opens up opportunity to wrap this in - * a convenience class to add higher-level functionality. - * - * ## Concepts - * - * All text documents can be considered a block document containing a combination - * of “freeform HTML” and explicit block structure. Block structure forms through - * special HTML comments called _delimiters_ which include a block type and, - * optionally, block attributes encoded as a JSON object payload. - * - * This processor is designed to scan through a block document from delimiter to - * delimiter, tracking how the delimiters impact the structure of the document. - * Spans of HTML appear between delimiters. If these spans exist at the top level - * of the document, meaning there is no containing block around them, they are - * considered freeform HTML content. If, however, they appear _inside_ block - * structure they are interpreted as `innerHTML` for the containing block. - * - * ### Tokens and scanning - * - * As the processor scans through a document is reports information about the token - * on which is pauses. Tokens represent spans of text in the input comprising block - * delimiters and spans of HTML. - * - * - {@see self::next_token()} visits every contiguous subspan of text in the - * input document. This includes all explicit block comment delimiters and spans - * of HTML content (whether freeform or inner HTML). - * - {@see self::next_delimiter()} visits every explicit block comment delimiter - * unless passed a block type which covers freeform HTML content. In these cases - * it will stop at top-level spans of HTML and report a `null` block type. - * - {@see self::next_block()} visits every block delimiter which _opens_ a block. - * This includes opening block delimiters as well as void block delimiters. With - * the same exception as above for freeform HTML block types, this will visit - * top-level spans of HTML content. - * - * When matched on a particular token, the following methods provide structural - * and textual information about it: - * - * - {@see self::get_delimiter_type()} reports whether the delimiter is an opener, - * a closer, or if it represents a whole void block. - * - {@see self::get_block_type()} reports the fully-qualified block type which - * the delimiter represents. - * - {@see self::get_printable_block_type()} reports the fully-qualified block type, - * but returns `core/freeform` instead of `null` for top-level freeform HTML content. - * - {@see self::is_block_type()} indicates if the delimiter represents a block of - * the given block type, or wildcard or pseudo-block type described below. - * - {@see self::opens_block()} indicates if the delimiter opens a block of one - * of the provided block types. Opening, void, and top-level freeform HTML content - * all open blocks. - * - {@see static::get_attributes()} is currently reserved for a future streaming - * JSON parser class. - * - {@see self::allocate_and_return_parsed_attributes()} extracts the JSON attributes - * for delimiters which open blocks and return the fully-parsed attributes as an - * associative array. {@see static::get_last_json_error()} for when this fails. - * - {@see self::is_html()} indicates if the token is a span of HTML which might - * be top-level freeform content or a block’s inner HTML. - * - {@see self::get_html_content()} returns the span of HTML. - * - {@see self::get_span()} for the byte offset and length into the input document - * representing the token. - * - * It’s possible for the processor to fail to scan forward if the input document ends - * in a proper prefix of an explicit block comment delimiter. For example, if the input - * ends in `<!-- wp:` then it _might_ be the start of another delimiter. The parser - * cannot know, however, and therefore refuses to proceed. {@see static::get_last_error()} - * to distinguish between a failure to find the next token and an incomplete input. - * - * ### Block types - * - * A block’s “type” comprises an optional _namespace_ and _name_. If the namespace - * isn’t provided it will be interpreted as the implicit `core` namespace. For example, - * the type `gallery` is the name of the block in the `core` namespace, but the type - * `abc/gallery` is the _fully-qualified_ block type for the block whose name is still - * `gallery`, but in the `abc` namespace. - * - * Methods on this class are aware of this block naming semantic and anywhere a block - * type is an argument to a method it will be normalized to account for implicit namespaces. - * Passing `paragraph` is the same as passing `core/paragraph`. On the contrary, anywhere - * this class returns a block type, it will return the fully-qualified and normalized form. - * For example, for the `<!-- wp:group -->` delimiter it will return `core/group` as the - * block type. - * - * There are two special block types that change the behavior of the processor: - * - * - The wildcard `*` represents _any block_. In addition to matching all block types, - * it also represents top-level freeform HTML whose block type is reported as `null`. - * - * - The `core/freeform` block type is a pseudo-block type which explicitly matches - * top-level freeform HTML. - * - * These special block types can be passed into any method which searches for blocks. - * - * There is one additional special block type which may be returned from - * {@see self::get_printable_block_type()}. This is the `#innerHTML` type, which - * indicates that the HTML span on which the processor is paused is inner HTML for - * a containing block. - * - * ### Spans of HTML - * - * Non-block content plays a complicated role in processing block documents. This - * processor exposes tools to help work with these spans of HTML. - * - * - {@see self::is_html()} indicates if the processor is paused at a span of - * HTML but does not differentiate between top-level freeform content and inner HTML. - * - {@see self::is_non_whitespace_html()} indicates not only if the processor - * is paused at a span of HTML, but also whether that span incorporates more than - * whitespace characters. Because block serialization often inserts newlines between - * block comment delimiters, this is useful for distinguishing “real” freeform - * content from purely aesthetic syntax. - * - {@see self::is_block_type()} matches top-level freeform HTML content when - * provided one of the special block types described above. - * - * ### Block structure - * - * As the processor traverses block delimiters it maintains a stack of which blocks are - * open at the given place in the document where it’s paused. This stack represents the - * block structure of a document and is used to determine where blocks end, which blocks - * represent inner blocks, whether a span of HTML is top-level freeform content, and - * more. Investigate the stack with {@see self::get_breadcrumbs()}, which returns an - * array of block types starting at the outermost-open block and descending to the - * currently-visited block. - * - * Unlike {@parse_blocks()}, spans of HTML appear in this structure as the special - * reported block type `#html`. Such a span represents inner HTML for a block if the - * depth reported by {@see self::get_depth()} is greater than one. - * - * It will generally not be necessary to inspect the stack of open blocks, though - * depth may be important for finding where blocks end. When visiting a block opener, - * the depth will have been increased before pausing; in contrast the depth is - * decremented before visiting a closer. This makes the following an easy way to - * determine if a block is still open. - * - * Example: - * - * $depth = $processor->get_depth(); - * while ( $processor->next_token() && $processor->get_depth() > $depth ) { - * continue - * } - * // Processor is now paused at the token immediately following the closed block. - * - * #### Extracting blocks - * - * A unique feature of this processor is the ability to return the same output as - * {@see \parse_blocks()} would produce, but for a subset of the input document. - * For example, it’s possible to extract an image block, manipulate that parsed - * block, and re-serialize it into the original document. It’s possible to do so - * while skipping over the parse of the rest of the document. - * - * {@see self::extract_full_block_and_advance()} will scan forward from the current block opener - * and build the parsed block structure until the current block is closed. It will - * include all inner HTML and inner blocks, and parse all of the inner blocks. It - * can be used to extract a block at any depth in the document, helpful for operating - * on blocks within nested structure. - * - * Example: - * - * if ( ! $processor->next_block( 'gallery' ) ) { - * return $post_content; - * } - * - * $gallery_at = $processor->get_span()->start; - * $gallery_block = $processor->extract_full_block_and_advance(); - * $after_gallery = $processor->get_span()->start; - * return ( - * substr( $post_content, 0, $gallery_at ) . - * serialize_block( modify_gallery( $gallery_block ) . - * substr( $post_content, $after_gallery ) - * ); - * - * #### Handling of malformed structure - * - * There are situations where closing block delimiters appear for which no open block - * exists, or where a document ends before a block is closed, or where a closing block - * delimiter appears but references a different block type than the most-recently - * opened block does. In all of these cases, the stack of open blocks should mirror - * the behavior in {@see \parse_blocks()}. - * - * Unlike {@see \parse_blocks()}, however, this processor can still operate on the - * invalid block delimiters. It provides a few functions which can be used for building - * custom and non-spec-compliant error handling. - * - * - {@see self::has_closing_flag()} indicates if the block delimiter contains the - * closing flag at the end. Some invalid block delimiters might contain both the - * void and closing flag, in which case {@see self::get_delimiter_type()} will - * report that it’s a void block. - * - {@see static::get_last_error()} indicates if the processor reached an invalid - * block closing. Depending on the context, {@see \parse_blocks()} might instead - * ignore the token or treat it as freeform HTML content. - * - * ## Static helpers - * - * This class provides helpers for performing semantic block-related operations. - * - * - {@see self::normalize_block_type()} takes a block type with or without the - * implicit `core` namespace and returns a fully-qualified block type. - * - {@see self::are_equal_block_types()} indicates if two spans across one or - * more input texts represent the same fully-qualified block type. - * - * ## Subclassing - * - * This processor is designed to accurately parse a block document. Therefore, many - * of its methods are not meant for subclassing. However, overall this class supports - * building higher-level convenience classes which may choose to subclass it. For those - * classes, avoid re-implementing methods except for the list below. Instead, create - * new names representing the higher-level concepts being introduced. For example, instead - * of creating a new method named `next_block()` which only advances to blocks of a given - * kind, consider creating a new method named something like `next_layout_block()` which - * won’t interfere with the base class method. - * - * - {@see static::get_last_error()} may be reimplemented to report new errors in the subclass - * which aren’t intrinsic to block parsing. - * - {@see static::get_attributes()} may be reimplemented to provide a streaming interface - * to reading and modifying a block’s JSON attributes. It should be fast and memory efficient. - * - {@see static::get_last_json_error()} may be reimplemented to report new errors introduced - * with a reimplementation of {@see static::get_attributes()}. - * - * @since 6.9.0 - */ -class WP_Block_Processor { - /** - * Indicates if the last operation failed, otherwise - * will be `null` for success. - * - * @since 6.9.0 - * - * @var string|null - */ - private $last_error = null; - - /** - * Indicates failures from decoding JSON attributes. - * - * @since 6.9.0 - * - * @see \json_last_error() - * - * @var int - */ - private $last_json_error = JSON_ERROR_NONE; - - /** - * Source text provided to processor. - * - * @since 6.9.0 - * - * @var string - */ - protected $source_text; - - /** - * Byte offset into source text where a matched delimiter starts. - * - * Example: - * - * 5 10 15 20 25 30 35 40 45 50 - * <!-- wp:group --><!-- wp:void /--><!-- /wp:group --> - * ╰─ Starts at byte offset 17. - * - * @since 6.9.0 - * - * @var int - */ - private $matched_delimiter_at = 0; - - /** - * Byte length of full span of a matched delimiter. - * - * Example: - * - * 5 10 15 20 25 30 35 40 45 50 - * <!-- wp:group --><!-- wp:void /--><!-- /wp:group --> - * ╰───────────────╯ - * 17 bytes long. - * - * @since 6.9.0 - * - * @var int - */ - private $matched_delimiter_length = 0; - - /** - * First byte offset into source text following any previously-matched delimiter. - * Used to indicate where an HTML span starts. - * - * Example: - * - * 5 10 15 20 25 30 35 40 45 50 55 - * <!-- wp:paragraph --><p>Content</p><⃨!⃨-⃨-⃨ ⃨/⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨-⃨>⃨ - * │ ╰─ This delimiter was matched, and after matching, - * │ revealed the preceding HTML span. - * │ - * ╰─ The first byte offset after the previous matched delimiter - * is 21. Because the matched delimiter starts at 55, which is after - * this, a span of HTML must exist between these boundaries. - * - * @since 6.9.0 - * - * @var int - */ - private $after_previous_delimiter = 0; - - /** - * Byte offset where namespace span begins. - * - * When no namespace is present, this will be the same as the starting - * byte offset for the block name. - * - * Example: - * - * <!-- wp:core/gallery --> - * │ ╰─ Name starts here. - * ╰─ Namespace starts here. - * - * <!-- wp:gallery --> - * ├─ The namespace would start here but is implied as “core.” - * ╰─ The name starts here. - * - * @since 6.9.0 - * - * @var int - */ - private $namespace_at = 0; - - /** - * Byte offset where block name span begins. - * - * When no namespace is present, this will be the same as the starting - * byte offset for the block namespace. - * - * Example: - * - * <!-- wp:core/gallery --> - * │ ╰─ Name starts here. - * ╰─ Namespace starts here. - * - * <!-- wp:gallery --> - * ├─ The namespace would start here but is implied as “core.” - * ╰─ The name starts here. - * - * @since 6.9.0 - * - * @var int - */ - private $name_at = 0; - - /** - * Byte length of block name span. - * - * Example: - * - * 5 10 15 20 25 - * <!-- wp:core/gallery --> - * ╰─────╯ - * 7 bytes long. - * - * @since 6.9.0 - * - * @var int - */ - private $name_length = 0; - - /** - * Whether the delimiter contains the block-closing flag. - * - * This may be erroneous if present within a void block, - * therefore the {@see self::has_closing_flag()} can be used by - * calling code to perform custom error-handling. - * - * @since 6.9.0 - * - * @var bool - */ - private $has_closing_flag = false; - - /** - * Byte offset where JSON attributes span begins. - * - * Example: - * - * 5 10 15 20 25 30 35 40 - * <!-- wp:paragraph {"dropCaps":true} --> - * ╰─ Starts at byte offset 18. - * - * @since 6.9.0 - * - * @var int - */ - private $json_at; - - /** - * Byte length of JSON attributes span, or 0 if none are present. - * - * Example: - * - * 5 10 15 20 25 30 35 40 - * <!-- wp:paragraph {"dropCaps":true} --> - * ╰───────────────╯ - * 17 bytes long. - * - * @since 6.9.0 - * - * @var int - */ - private $json_length = 0; - - /** - * Internal parser state, differentiating whether the instance is currently matched, - * on an implicit freeform node, in error, or ready to begin parsing. - * - * @see self::READY - * @see self::MATCHED - * @see self::HTML_SPAN - * @see self::INCOMPLETE_INPUT - * @see self::COMPLETE - * - * @since 6.9.0 - * - * @var string - */ - protected $state = self::READY; - - /** - * Indicates what kind of block comment delimiter was matched. - * - * One of: - * - * - {@see self::OPENER} If the delimiter is opening a block. - * - {@see self::CLOSER} If the delimiter is closing an open block. - * - {@see self::VOID} If the delimiter represents a void block with no inner content. - * - * If a parsed comment delimiter contains both the closing and the void - * flags then it will be interpreted as a void block to match the behavior - * of the official block parser, however, this is a syntax error and probably - * the block ought to close an open block of the same name, if one is open. - * - * @since 6.9.0 - * - * @var string - */ - private $type; - - /** - * Whether the last-matched delimiter acts like a void block and should be - * popped from the stack of open blocks as soon as the parser advances. - * - * This applies to void block delimiters and to HTML spans. - * - * @since 6.9.0 - * - * @var bool - */ - private $was_void = false; - - /** - * For every open block, in hierarchical order, this stores the byte offset - * into the source text where the block type starts, including for HTML spans. - * - * To avoid allocating and normalizing block names when they aren’t requested, - * the stack of open blocks is stored as the byte offsets and byte lengths of - * each open block’s block type. This allows for minimal tracking and quick - * reading or comparison of block types when requested. - * - * @since 6.9.0 - * - * @see self::$open_blocks_length - * - * @var int[] - */ - private $open_blocks_at = array(); - - /** - * For every open block, in hierarchical order, this stores the byte length - * of the block’s block type in the source text. For HTML spans this is 0. - * - * @since 6.9.0 - * - * @see self::$open_blocks_at - * - * @var int[] - */ - private $open_blocks_length = array(); - - /** - * Indicates which operation should apply to the stack of open blocks after - * processing any pending spans of HTML. - * - * Since HTML spans are discovered after matching block delimiters, those - * delimiters need to defer modifying the stack of open blocks. This value, - * if set, indicates what operation should be applied. The properties - * associated with token boundaries still point to the delimiters even - * when processing HTML spans, so there’s no need to track them independently. - * - * @var 'push'|'void'|'pop'|null - */ - private $next_stack_op = null; - - /** - * Creates a new block processor. - * - * Example: - * - * $processor = new WP_Block_Processor( $post_content ); - * if ( $processor->next_block( 'core/image' ) ) { - * echo "Found an image!\n"; - * } - * - * @see self::next_block() to advance to the start of the next block (skips closers). - * @see self::next_delimiter() to advance to the next explicit block delimiter. - * @see self::next_token() to advance to the next block delimiter or HTML span. - * - * @since 6.9.0 - * - * @param string $source_text Input document potentially containing block content. - */ - public function __construct( string $source_text ) { - $this->source_text = $source_text; - } - - /** - * Advance to the next block delimiter which opens a block, indicating if one was found. - * - * Delimiters which open blocks include opening and void block delimiters. To visit - * freeform HTML content, pass the wildcard “*” as the block type. - * - * Use this function to walk through the blocks in a document, pausing where they open. - * - * Example blocks: - * - * // The first delimiter opens the paragraph block. - * <⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨-⃨>⃨<p>Content</p><!-- /wp:paragraph--> - * - * // The void block is the first opener in this sequence of closers. - * <!-- /wp:group --><⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨s⃨p⃨a⃨c⃨e⃨r⃨ ⃨{⃨"⃨h⃨e⃨i⃨g⃨h⃨t⃨"⃨:⃨"⃨2⃨0⃨0⃨p⃨x⃨"⃨}⃨ ⃨/⃨-⃨-⃨>⃨<!-- /wp:group --> - * - * // If, however, `*` is provided as the block type, freeform content is matched. - * <⃨h⃨2⃨>⃨M⃨y⃨ ⃨s⃨y⃨n⃨o⃨p⃨s⃨i⃨s⃨<⃨/⃨h⃨2⃨>⃨\⃨n⃨<!-- wp:my/table-of-contents /--> - * - * // Inner HTML is never freeform content, and will not be matched even with the wildcard. - * <!-- /wp:list-item --></ul><!-- /wp:list --><⃨!⃨-⃨-⃨ ⃨w⃨p⃨:⃨p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ ⃨-⃨>⃨<p> - * - * Example: - * - * // Find all textual ranges of image block opening delimiters. - * $images = array(); - * $processor = new WP_Block_Processor( $html ); - * while ( $processor->next_block( 'core/image' ) ) { - * $images[] = $processor->get_span(); - * } - * - * In some cases it may be useful to conditionally visit the implicit freeform - * blocks, such as when determining if a post contains freeform content that - * isn’t purely whitespace. - * - * Example: - * - * $seen_block_types = []; - * $block_type = '*'; - * $processor = new WP_Block_Processor( $html ); - * while ( $processor->next_block( $block_type ) { - * // Stop wasting time visiting freeform blocks after one has been found. - * if ( - * '*' === $block_type && - * null === $processor->get_block_type() && - * $processor->is_non_whitespace_html() - * ) { - * $block_type = null; - * $seen_block_types['core/freeform'] = true; - * continue; - * } - * - * $seen_block_types[ $processor->get_block_type() ] = true; - * } - * - * @since 6.9.0 - * - * @see self::next_delimiter() to advance to the next explicit block delimiter. - * @see self::next_token() to advance to the next block delimiter or HTML span. - * - * @param string|null $block_type Optional. If provided, advance until a block of this type is found. - * Default is to stop at any block regardless of its type. - * @return bool Whether an opening delimiter for a block was found. - */ - public function next_block( ?string $block_type = null ): bool { - while ( $this->next_delimiter( $block_type ) ) { - if ( self::CLOSER !== $this->get_delimiter_type() ) { - return true; - } - } - - return false; - } - - /** - * Advance to the next block delimiter in a document, indicating if one was found. - * - * Delimiters may include invalid JSON. This parser does not attempt to parse the - * JSON attributes until requested; when invalid, the attributes will be null. This - * matches the behavior of {@see \parse_blocks()}. To visit freeform HTML content, - * pass the wildcard “*” as the block type. - * - * Use this function to walk through the block delimiters in a document. - * - * Example delimiters: - * - * <!-- wp:paragraph {"dropCap": true} --> - * <!-- wp:separator /--> - * <!-- /wp:paragraph --> - * - * // If the wildcard `*` is provided as the block type, freeform content is matched. - * <⃨h⃨2⃨>⃨M⃨y⃨ ⃨s⃨y⃨n⃨o⃨p⃨s⃨i⃨s⃨<⃨/⃨h⃨2⃨>⃨\⃨n⃨<!-- wp:my/table-of-contents /--> - * - * // Inner HTML is never freeform content, and will not be matched even with the wildcard. - * ...</ul><⃨!⃨-⃨-⃨ ⃨/⃨w⃨p⃨:⃨l⃨i⃨s⃨t⃨ ⃨-⃨-⃨>⃨<!-- wp:paragraph --><p> - * - * Example: - * - * $html = '<!-- wp:void /-->\n<!-- wp:void /-->'; - * $processor = new WP_Block_Processor( $html ); - * while ( $processor->next_delimiter() { - * // Runs twice, seeing both void blocks of type “core/void.” - * } - * - * $processor = new WP_Block_Processor( $html ); - * while ( $processor->next_delimiter( '*' ) ) { - * // Runs thrice, seeing the void block, the newline span, and the void block. - * } - * - * @since 6.9.0 - * - * @param string|null $block_name Optional. Keep searching until a block of this name is found. - * Defaults to visit every block regardless of type. - * @return bool Whether a block delimiter was matched. - */ - public function next_delimiter( ?string $block_name = null ): bool { - if ( ! isset( $block_name ) ) { - while ( $this->next_token() ) { - if ( ! $this->is_html() ) { - return true; - } - } - - return false; - } - - while ( $this->next_token() ) { - if ( $this->is_block_type( $block_name ) ) { - return true; - } - } - - return false; - } - - /** - * Advance to the next block delimiter or HTML span in a document, indicating if one was found. - * - * This function steps through every syntactic chunk in a document. This includes explicit - * block comment delimiters, freeform non-block content, and inner HTML segments. - * - * Example tokens: - * - * <!-- wp:paragraph {"dropCap": true} --> - * <!-- wp:separator /--> - * <!-- /wp:paragraph --> - * <p>Normal HTML content</p> - * Plaintext content too! - * - * Example: - * - * // Find span containing wrapping HTML element surrounding inner blocks. - * $processor = new WP_Block_Processor( $html ); - * if ( ! $processor->next_block( 'gallery' ) ) { - * return null; - * } - * - * $containing_span = null; - * while ( $processor->next_token() && $processor->is_html() ) { - * $containing_span = $processor->get_span(); - * } - * - * This method will visit all HTML spans including those forming freeform non-block - * content as well as those which are part of a block’s inner HTML. - * - * @since 6.9.0 - * - * @return bool Whether a token was matched or the end of the document was reached without finding any. - */ - public function next_token(): bool { - if ( $this->last_error || self::COMPLETE === $this->state || self::INCOMPLETE_INPUT === $this->state ) { - return false; - } - - // Void tokens automatically pop off the stack of open blocks. - if ( $this->was_void ) { - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - $this->was_void = false; - } - - $text = $this->source_text; - $end = strlen( $text ); - - /* - * Because HTML spans are inferred after finding the next delimiter, it means that - * the parser must transition out of that HTML state and reuse the token boundaries - * it found after the HTML span. If those boundaries are before the end of the - * document it implies that a real delimiter was found; otherwise this must be the - * terminating HTML span and the parsing is complete. - */ - if ( self::HTML_SPAN === $this->state ) { - if ( $this->matched_delimiter_at >= $end ) { - $this->state = self::COMPLETE; - return false; - } - - switch ( $this->next_stack_op ) { - case 'void': - $this->was_void = true; - $this->open_blocks_at[] = $this->namespace_at; - $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at; - break; - - case 'push': - $this->open_blocks_at[] = $this->namespace_at; - $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at; - break; - - case 'pop': - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - break; - } - - $this->next_stack_op = null; - $this->state = self::MATCHED; - return true; - } - - $this->state = self::READY; - $after_prev_delimiter = $this->matched_delimiter_at + $this->matched_delimiter_length; - $at = $after_prev_delimiter; - - while ( $at < $end ) { - /* - * Find the next possible start of a delimiter. - * - * This follows the behavior in the official block parser, which segments a post - * by the block comment delimiters. It is possible for an HTML attribute to contain - * what looks like a block comment delimiter but which is actually an HTML attribute - * value. In such a case, the parser here will break apart the HTML and create the - * block boundary inside the HTML attribute. In other words, the block parser - * isolates sections of HTML from each other, even if that leads to malformed markup. - * - * For a more robust parse, scan through the document with the HTML API and parse - * comments once they are matched to see if they are also block delimiters. In - * practice, this nuance has not caused any known problems since developing blocks. - * - * <⃨!⃨-⃨-⃨ /wp:core/paragraph {"dropCap":true} /--> - */ - $comment_opening_at = strpos( $text, '<!--', $at ); - - /* - * Even if the start of a potential block delimiter is not found, the document - * might end in a prefix of such, and in that case there is incomplete input. - */ - if ( false === $comment_opening_at ) { - if ( str_ends_with( $text, '<!-' ) ) { - $backup = 3; - } elseif ( str_ends_with( $text, '<!' ) ) { - $backup = 2; - } elseif ( str_ends_with( $text, '<' ) ) { - $backup = 1; - } else { - $backup = 0; - } - - // Whether or not there is a potential delimiter, there might be an HTML span. - if ( $after_prev_delimiter < ( $end - $backup ) ) { - $this->state = self::HTML_SPAN; - $this->after_previous_delimiter = $after_prev_delimiter; - $this->matched_delimiter_at = $end - $backup; - $this->matched_delimiter_length = $backup; - $this->open_blocks_at[] = $after_prev_delimiter; - $this->open_blocks_length[] = 0; - $this->was_void = true; - return true; - } - - /* - * In the case that there is the start of an HTML comment, it means that there - * might be a block delimiter, but it’s not possible know, therefore it’s incomplete. - */ - if ( $backup > 0 ) { - goto incomplete; - } - - // Otherwise this is the end. - $this->state = self::COMPLETE; - return false; - } - - // <!-- ⃨/wp:core/paragraph {"dropCap":true} /--> - $opening_whitespace_at = $comment_opening_at + 4; - if ( $opening_whitespace_at >= $end ) { - goto incomplete; - } - - $opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at ); - - /* - * The `wp` prefix cannot come before this point, but it may come after it - * depending on the presence of the closer. This is detected next. - */ - $wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length; - if ( $wp_prefix_at >= $end ) { - goto incomplete; - } - - if ( 0 === $opening_whitespace_length ) { - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - // <!-- /⃨wp:core/paragraph {"dropCap":true} /--> - $has_closer = false; - if ( '/' === $text[ $wp_prefix_at ] ) { - $has_closer = true; - ++$wp_prefix_at; - } - - // <!-- /w⃨p⃨:⃨core/paragraph {"dropCap":true} /--> - if ( $wp_prefix_at < $end && 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) { - if ( - ( $wp_prefix_at + 2 >= $end && str_ends_with( $text, 'wp' ) ) || - ( $wp_prefix_at + 1 >= $end && str_ends_with( $text, 'w' ) ) - ) { - goto incomplete; - } - - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - /* - * If the block contains no namespace, this will end up masquerading with - * the block name. It’s easier to first detect the span and then determine - * if it’s a namespace of a name. - * - * <!-- /wp:c⃨o⃨r⃨e⃨/paragraph {"dropCap":true} /--> - */ - $namespace_at = $wp_prefix_at + 3; - if ( $namespace_at >= $end ) { - goto incomplete; - } - - $start_of_namespace = $text[ $namespace_at ]; - - // The namespace must start with a-z. - if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) { - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - $namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 ); - $separator_at = $namespace_at + $namespace_length; - if ( $separator_at >= $end ) { - goto incomplete; - } - - // <!-- /wp:core/⃨paragraph {"dropCap":true} /--> - $has_separator = '/' === $text[ $separator_at ]; - if ( $has_separator ) { - $name_at = $separator_at + 1; - - if ( $name_at >= $end ) { - goto incomplete; - } - - // <!-- /wp:core/p⃨a⃨r⃨a⃨g⃨r⃨a⃨p⃨h⃨ {"dropCap":true} /--> - $start_of_name = $text[ $name_at ]; - if ( 'a' > $start_of_name || 'z' < $start_of_name ) { - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - $name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 ); - } else { - $name_at = $namespace_at; - $name_length = $namespace_length; - } - - if ( $name_at + $name_length >= $end ) { - goto incomplete; - } - - /* - * For this next section of the delimiter, it could be the JSON attributes - * or it could be the end of the comment. Assume that the JSON is there and - * update if it’s not. - */ - - // <!-- /wp:core/paragraph ⃨{"dropCap":true} /--> - $after_name_whitespace_at = $name_at + $name_length; - $after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at ); - $json_at = $after_name_whitespace_at + $after_name_whitespace_length; - - if ( $json_at >= $end ) { - goto incomplete; - } - - if ( 0 === $after_name_whitespace_length ) { - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - // <!-- /wp:core/paragraph {⃨"dropCap":true} /--> - $has_json = '{' === $text[ $json_at ]; - $json_length = 0; - - /* - * For the final span of the delimiter it's most efficient to find the end of the - * HTML comment and work backwards. This prevents complicated parsing inside the - * JSON span, which is not allowed to contain the HTML comment terminator. - * - * This also matches the behavior in the official block parser, - * even though it allows for matching invalid JSON content. - * - * <!-- /wp:core/paragraph {"dropCap":true} /-⃨-⃨>⃨ - */ - $comment_closing_at = strpos( $text, '-->', $json_at ); - if ( false === $comment_closing_at ) { - goto incomplete; - } - - // <!-- /wp:core/paragraph {"dropCap":true} /⃨--> - if ( '/' === $text[ $comment_closing_at - 1 ] ) { - $has_void_flag = true; - $void_flag_length = 1; - } else { - $has_void_flag = false; - $void_flag_length = 0; - } - - /* - * If there's no JSON, then the span of text after the name - * until the comment closing must be completely whitespace. - * Otherwise it’s a normal HTML comment. - */ - if ( ! $has_json ) { - if ( $after_name_whitespace_at + $after_name_whitespace_length === $comment_closing_at - $void_flag_length ) { - // This must be a block delimiter! - $this->state = self::MATCHED; - break; - } - - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - /* - * There's JSON, so attempt to find its boundary. - * - * @todo It’s likely faster to scan forward instead of in reverse. - * - * <!-- /wp:core/paragraph {"dropCap":true}⃨ ⃨/--> - */ - $after_json_whitespace_length = 0; - for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) { - $char = $text[ $char_at ]; - - switch ( $char ) { - case ' ': - case "\t": - case "\f": - case "\r": - case "\n": - ++$after_json_whitespace_length; - continue 2; - - case '}': - $json_length = $char_at - $json_at + 1; - break 2; - - default: - ++$at; - continue 3; - } - } - - /* - * This covers cases where there is no terminating “}” or where - * mandatory whitespace is missing. - */ - if ( 0 === $json_length || 0 === $after_json_whitespace_length ) { - $at = $this->find_html_comment_end( $comment_opening_at, $end ); - continue; - } - - // This must be a block delimiter! - $this->state = self::MATCHED; - break; - } - - // The end of the document was reached without a match. - if ( self::MATCHED !== $this->state ) { - $this->state = self::COMPLETE; - return false; - } - - /* - * From this point forward, a delimiter has been matched. There - * might also be an HTML span that appears before the delimiter. - */ - - $this->after_previous_delimiter = $after_prev_delimiter; - - $this->matched_delimiter_at = $comment_opening_at; - $this->matched_delimiter_length = $comment_closing_at + 3 - $comment_opening_at; - - $this->namespace_at = $namespace_at; - $this->name_at = $name_at; - $this->name_length = $name_length; - - $this->json_at = $json_at; - $this->json_length = $json_length; - - /* - * When delimiters contain both the void flag and the closing flag - * they shall be interpreted as void blocks, per the spec parser. - */ - if ( $has_void_flag ) { - $this->type = self::VOID; - $this->next_stack_op = 'void'; - } elseif ( $has_closer ) { - $this->type = self::CLOSER; - $this->next_stack_op = 'pop'; - - /* - * @todo Check if the name matches and bail according to the spec parser. - * The default parser doesn’t examine the names. - */ - } else { - $this->type = self::OPENER; - $this->next_stack_op = 'push'; - } - - $this->has_closing_flag = $has_closer; - - // HTML spans are visited before the delimiter that follows them. - if ( $comment_opening_at > $after_prev_delimiter ) { - $this->state = self::HTML_SPAN; - $this->open_blocks_at[] = $after_prev_delimiter; - $this->open_blocks_length[] = 0; - $this->was_void = true; - - return true; - } - - // If there were no HTML spans then flush the enqueued stack operations immediately. - switch ( $this->next_stack_op ) { - case 'void': - $this->was_void = true; - $this->open_blocks_at[] = $namespace_at; - $this->open_blocks_length[] = $name_at + $name_length - $namespace_at; - break; - - case 'push': - $this->open_blocks_at[] = $namespace_at; - $this->open_blocks_length[] = $name_at + $name_length - $namespace_at; - break; - - case 'pop': - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - break; - } - - $this->next_stack_op = null; - - return true; - - incomplete: - $this->state = self::COMPLETE; - $this->last_error = self::INCOMPLETE_INPUT; - return false; - } - - /** - * Returns an array containing the names of the currently-open blocks, in order - * from outermost to innermost, with HTML spans indicated as “#html”. - * - * Example: - * - * // Freeform HTML content is an HTML span. - * $processor = new WP_Block_Processor( 'Just text' ); - * $processor->next_token(); - * array( '#text' ) === $processor->get_breadcrumbs(); - * - * $processor = new WP_Block_Processor( '<!-- wp:a --><!-- wp:b --><!-- wp:c /--><!-- /wp:b --><!-- /wp:a -->' ); - * $processor->next_token(); - * array( 'core/a' ) === $processor->get_breadcrumbs(); - * $processor->next_token(); - * array( 'core/a', 'core/b' ) === $processor->get_breadcrumbs(); - * $processor->next_token(); - * // Void blocks are only open while visiting them. - * array( 'core/a', 'core/b', 'core/c' ) === $processor->get_breadcrumbs(); - * $processor->next_token(); - * // Blocks are closed before visiting their closing delimiter. - * array( 'core/a' ) === $processor->get_breadcrumbs(); - * $processor->next_token(); - * array() === $processor->get_breadcrumbs(); - * - * // Inner HTML is also an HTML span. - * $processor = new WP_Block_Processor( '<!-- wp:a -->Inner HTML<!-- /wp:a -->' ); - * $processor->next_token(); - * $processor->next_token(); - * array( 'core/a', '#html' ) === $processor->get_breadcrumbs(); - * - * @since 6.9.0 - * - * @return string[] - */ - public function get_breadcrumbs(): array { - $breadcrumbs = array_fill( 0, count( $this->open_blocks_at ), null ); - - /* - * Since HTML spans can only be at the very end, set the normalized block name for - * each open element and then work backwards after creating the array. This allows - * for the elimination of a conditional on each iteration of the loop. - */ - foreach ( $this->open_blocks_at as $i => $at ) { - $block_type = substr( $this->source_text, $at, $this->open_blocks_length[ $i ] ); - $breadcrumbs[ $i ] = self::normalize_block_type( $block_type ); - } - - if ( isset( $i ) && 0 === $this->open_blocks_length[ $i ] ) { - $breadcrumbs[ $i ] = '#html'; - } - - return $breadcrumbs; - } - - /** - * Returns the depth of the open blocks where the processor is currently matched. - * - * Depth increases before visiting openers and void blocks and decreases before - * visiting closers. HTML spans behave like void blocks. - * - * @since 6.9.0 - * - * @return int - */ - public function get_depth(): int { - return count( $this->open_blocks_at ); - } - - /** - * Extracts a block object, and all inner content, starting at a matched opening - * block delimiter, or at a matched top-level HTML span as freeform HTML content. - * - * Use this function to extract some blocks within a document, but not all. For example, - * one might want to find image galleries, parse them, modify them, and then reserialize - * them in place. - * - * Once this function returns, the parser will be matched on token following the close - * of the given block. - * - * The return type of this method is compatible with the return of {@see \parse_blocks()}. - * - * Example: - * - * $processor = new WP_Block_Processor( $post_content ); - * if ( ! $processor->next_block( 'gallery' ) ) { - * return $post_content; - * } - * - * $gallery_at = $processor->get_span()->start; - * $gallery = $processor->extract_full_block_and_advance(); - * $ends_before = $processor->get_span(); - * $ends_before = $ends_before->start ?? strlen( $post_content ); - * - * $new_gallery = update_gallery( $gallery ); - * $new_gallery = serialize_block( $new_gallery ); - * - * return ( - * substr( $post_content, 0, $gallery_at ) . - * $new_gallery . - * substr( $post_content, $ends_before ) - * ); - * - * @since 6.9.0 - * - * @return array[]|null { - * Array of block structures. - * - * @type array ...$0 { - * An associative array of a single parsed block object. See WP_Block_Parser_Block. - * - * @type string|null $blockName Name of block. - * @type array $attrs Attributes from block comment delimiters. - * @type array[] $innerBlocks List of inner blocks. An array of arrays that - * have the same structure as this one. - * @type string $innerHTML HTML from inside block comment delimiters. - * @type array $innerContent List of string fragments and null markers where - * inner blocks were found. - * } - * } - */ - public function extract_full_block_and_advance(): ?array { - if ( $this->is_html() ) { - $chunk = $this->get_html_content(); - - return array( - 'blockName' => null, - 'attrs' => array(), - 'innerBlocks' => array(), - 'innerHTML' => $chunk, - 'innerContent' => array( $chunk ), - ); - } - - $block = array( - 'blockName' => $this->get_block_type(), - 'attrs' => $this->allocate_and_return_parsed_attributes() ?? array(), - 'innerBlocks' => array(), - 'innerHTML' => '', - 'innerContent' => array(), - ); - - $depth = $this->get_depth(); - while ( $this->next_token() && $this->get_depth() > $depth ) { - if ( $this->is_html() ) { - $chunk = $this->get_html_content(); - $block['innerHTML'] .= $chunk; - $block['innerContent'][] = $chunk; - continue; - } - - /** - * Inner blocks. - * - * @todo This is a decent place to call {@link \render_block()} - * @todo Use iteration instead of recursion, or at least refactor to tail-call form. - */ - if ( $this->opens_block() ) { - $inner_block = $this->extract_full_block_and_advance(); - $block['innerBlocks'][] = $inner_block; - $block['innerContent'][] = null; - } - } - - return $block; - } - - /** - * Returns the byte-offset after the ending character of an HTML comment, - * assuming the proper starting byte offset. - * - * @since 6.9.0 - * - * @param int $comment_starting_at Where the HTML comment started, the leading `<`. - * @param int $search_end Last offset in which to search, for limiting search span. - * @return int Offset after the current HTML comment ends, or `$search_end` if no end was found. - */ - private function find_html_comment_end( int $comment_starting_at, int $search_end ): int { - $text = $this->source_text; - - // Find span-of-dashes comments which look like `<!----->`. - $span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 ); - if ( - $comment_starting_at + 2 + $span_of_dashes < $search_end && - '>' === $text[ $comment_starting_at + 2 + $span_of_dashes ] - ) { - return $comment_starting_at + $span_of_dashes + 1; - } - - // Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`. - $now_at = $comment_starting_at + 4; - while ( $now_at < $search_end ) { - $dashes_at = strpos( $text, '--', $now_at ); - if ( false === $dashes_at ) { - return $search_end; - } - - $closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 ); - if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) { - ++$closer_must_be_at; - } - - if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) { - return $closer_must_be_at + 1; - } - - ++$now_at; - } - - return $search_end; - } - - /** - * Indicates if the last attempt to parse a block comment delimiter - * failed, if set, otherwise `null` if the last attempt succeeded. - * - * @since 6.9.0 - * - * @return string|null Error from last attempt at parsing next block delimiter, - * or `null` if last attempt succeeded. - */ - public function get_last_error(): ?string { - return $this->last_error; - } - - /** - * Indicates if the last attempt to parse a block’s JSON attributes failed. - * - * @see \json_last_error() - * - * @since 6.9.0 - * - * @return int JSON_ERROR_ code from last attempt to parse block JSON attributes. - */ - public function get_last_json_error(): int { - return $this->last_json_error; - } - - /** - * Returns the type of the block comment delimiter. - * - * One of: - * - * - {@see self::OPENER} - * - {@see self::CLOSER} - * - {@see self::VOID} - * - `null` - * - * @since 6.9.0 - * - * @return string|null type of the block comment delimiter, if currently matched. - */ - public function get_delimiter_type(): ?string { - switch ( $this->state ) { - case self::HTML_SPAN: - return self::VOID; - - case self::MATCHED: - return $this->type; - - default: - return null; - } - } - - /** - * Returns whether the delimiter contains the closing flag. - * - * This should be avoided except in cases of custom error-handling - * with block closers containing the void flag. For normative use, - * {@see self::get_delimiter_type()}. - * - * @since 6.9.0 - * - * @return bool Whether the currently-matched block delimiter contains the closing flag. - */ - public function has_closing_flag(): bool { - return $this->has_closing_flag; - } - - /** - * Indicates if the block delimiter represents a block of the given type. - * - * Since the “core” namespace may be implicit, it’s allowable to pass - * either the fully-qualified block type with namespace and block name - * as well as the shorthand version only containing the block name, if - * the desired block is in the “core” namespace. - * - * Since freeform HTML content is non-block content, it has no block type. - * Passing the wildcard “*” will, however, return true for all block types, - * even the implicit freeform content, though not for spans of inner HTML. - * - * Example: - * - * $is_core_paragraph = $processor->is_block_type( 'paragraph' ); - * $is_core_paragraph = $processor->is_block_type( 'core/paragraph' ); - * $is_formula = $processor->is_block_type( 'math-block/formula' ); - * - * @param string $block_type Block type name for the desired block. - * E.g. "paragraph", "core/paragraph", "math-blocks/formula". - * @return bool Whether this delimiter represents a block of the given type. - */ - public function is_block_type( string $block_type ): bool { - if ( '*' === $block_type ) { - return true; - } - - // This is a core/freeform text block, it’s special. - if ( $this->is_html() && 0 === ( $this->open_blocks_length[0] ?? null ) ) { - return ( - 'core/freeform' === $block_type || - 'freeform' === $block_type - ); - } - - return $this->are_equal_block_types( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length, $block_type, 0, strlen( $block_type ) ); - } - - /** - * Given two spans of text, indicate if they represent identical block types. - * - * This function normalizes block types to account for implicit core namespacing. - * - * Note! This function only returns valid results when the complete block types are - * represented in the span offsets and lengths. This means that the full optional - * namespace and block name must be represented in the input arguments. - * - * Example: - * - * 0 5 10 15 20 25 30 35 40 - * $text = '<!-- wp:block --><!-- /wp:core/block -->'; - * - * true === WP_Block_Processor::are_equal_block_types( $text, 9, 5, $text, 27, 10 ); - * false === WP_Block_Processor::are_equal_block_types( $text, 9, 5, 'my/block', 0, 8 ); - * - * @since 6.9.0 - * - * @param string $a_text Text in which first block type appears. - * @param int $a_at Byte offset into text in which first block type starts. - * @param int $a_length Byte length of first block type. - * @param string $b_text Text in which second block type appears (may be the same as the first text). - * @param int $b_at Byte offset into text in which second block type starts. - * @param int $b_length Byte length of second block type. - * @return bool Whether the spans of text represent identical block types, normalized for namespacing. - */ - public static function are_equal_block_types( string $a_text, int $a_at, int $a_length, string $b_text, int $b_at, int $b_length ): bool { - $a_ns_length = strcspn( $a_text, '/', $a_at, $a_length ); - $b_ns_length = strcspn( $b_text, '/', $b_at, $b_length ); - - $a_has_ns = $a_ns_length !== $a_length; - $b_has_ns = $b_ns_length !== $b_length; - - // Both contain namespaces. - if ( $a_has_ns && $b_has_ns ) { - if ( $a_length !== $b_length ) { - return false; - } - - $a_block_type = substr( $a_text, $a_at, $a_length ); - - return 0 === substr_compare( $b_text, $a_block_type, $b_at, $b_length ); - } - - if ( $a_has_ns ) { - $b_block_type = 'core/' . substr( $b_text, $b_at, $b_length ); - - return ( - strlen( $b_block_type ) === $a_length && - 0 === substr_compare( $a_text, $b_block_type, $a_at, $a_length ) - ); - } - - if ( $b_has_ns ) { - $a_block_type = 'core/' . substr( $a_text, $a_at, $a_length ); - - return ( - strlen( $a_block_type ) === $b_length && - 0 === substr_compare( $b_text, $a_block_type, $b_at, $b_length ) - ); - } - - // Neither contains a namespace. - if ( $a_length !== $b_length ) { - return false; - } - - $a_name = substr( $a_text, $a_at, $a_length ); - - return 0 === substr_compare( $b_text, $a_name, $b_at, $b_length ); - } - - /** - * Indicates if the matched delimiter is an opening or void delimiter of the given type, - * if a type is provided, otherwise if it opens any block or implicit freeform HTML content. - * - * This is a helper method to ease handling of code inspecting where blocks start, and for - * checking if the blocks are of a given type. The function is variadic to allow for - * checking if the delimiter opens one of many possible block types. - * - * To advance to the start of a block {@see self::next_block()}. - * - * Example: - * - * $processor = new WP_Block_Processor( $html ); - * while ( $processor->next_delimiter() ) { - * if ( $processor->opens_block( 'core/code', 'syntaxhighlighter/code' ) ) { - * echo "Found code!"; - * continue; - * } - * - * if ( $processor->opens_block( 'core/image' ) ) { - * echo "Found an image!"; - * continue; - * } - * - * if ( $processor->opens_block() ) { - * echo "Found a new block!"; - * } - * } - * - * @since 6.9.0 - * - * @see self::is_block_type() - * - * @param string[] $block_type Optional. Is the matched block type one of these? - * If none are provided, will not test block type. - * @return bool Whether the matched block delimiter opens a block, and whether it - * opens a block of one of the given block types, if provided. - */ - public function opens_block( string ...$block_type ): bool { - // HTML spans only open implicit freeform content at the top level. - if ( self::HTML_SPAN === $this->state && 1 !== count( $this->open_blocks_at ) ) { - return false; - } - - /* - * Because HTML spans are discovered after the next delimiter is found, - * the delimiter type when visiting HTML spans refers to the type of the - * following delimiter. Therefore the HTML case is handled by checking - * the state and depth of the stack of open block. - */ - if ( self::CLOSER === $this->type && ! $this->is_html() ) { - return false; - } - - if ( count( $block_type ) === 0 ) { - return true; - } - - foreach ( $block_type as $block ) { - if ( $this->is_block_type( $block ) ) { - return true; - } - } - - return false; - } - - /** - * Indicates if the matched delimiter is an HTML span. - * - * @since 6.9.0 - * - * @see self::is_non_whitespace_html() - * - * @return bool Whether the processor is matched on an HTML span. - */ - public function is_html(): bool { - return self::HTML_SPAN === $this->state; - } - - /** - * Indicates if the matched delimiter is an HTML span and comprises more - * than whitespace characters, i.e. contains real content. - * - * Many block serializers introduce newlines between block delimiters, - * so the presence of top-level non-block content does not imply that - * there are “real” freeform HTML blocks. Checking if there is content - * beyond whitespace is a more certain check, such as for determining - * whether to load CSS for the freeform or fallback block type. - * - * @since 6.9.0 - * - * @see self::is_html() - * - * @return bool Whether the currently-matched delimiter is an HTML - * span containing non-whitespace text. - */ - public function is_non_whitespace_html(): bool { - if ( ! $this->is_html() ) { - return false; - } - - $length = $this->matched_delimiter_at - $this->after_previous_delimiter; - - $whitespace_length = strspn( - $this->source_text, - " \t\f\r\n", - $this->after_previous_delimiter, - $length - ); - - return $whitespace_length !== $length; - } - - /** - * Returns the string content of a matched HTML span, or `null` otherwise. - * - * @since 6.9.0 - * - * @return string|null Raw HTML content, or `null` if not currently matched on HTML. - */ - public function get_html_content(): ?string { - if ( ! $this->is_html() ) { - return null; - } - - return substr( - $this->source_text, - $this->after_previous_delimiter, - $this->matched_delimiter_at - $this->after_previous_delimiter - ); - } - - /** - * Allocates a substring for the block type and returns the fully-qualified - * name, including the namespace, if matched on a delimiter, otherwise `null`. - * - * This function is like {@see self::get_printable_block_type()} but when - * paused on a freeform HTML block, will return `null` instead of “core/freeform”. - * The `null` behavior matches what {@see \parse_blocks()} returns but may not - * be as useful as having a string value. - * - * This function allocates a substring for the given block type. This - * allocation will be small and likely fine in most cases, but it's - * preferable to call {@see self::is_block_type()} if only needing - * to know whether the delimiter is for a given block type, as that - * function is more efficient for this purpose and avoids the allocation. - * - * Example: - * - * // Avoid. - * 'core/paragraph' = $processor->get_block_type(); - * - * // Prefer. - * $processor->is_block_type( 'core/paragraph' ); - * $processor->is_block_type( 'paragraph' ); - * $processor->is_block_type( 'core/freeform' ); - * - * // Freeform HTML content has no block type. - * $processor = new WP_Block_Processor( 'non-block content' ); - * $processor->next_token(); - * null === $processor->get_block_type(); - * - * @since 6.9.0 - * - * @see self::are_equal_block_types() - * - * @return string|null Fully-qualified block namespace and type, e.g. "core/paragraph", - * if matched on an explicit delimiter, otherwise `null`. - */ - public function get_block_type(): ?string { - if ( - self::READY === $this->state || - self::COMPLETE === $this->state || - self::INCOMPLETE_INPUT === $this->state - ) { - return null; - } - - // This is a core/freeform text block, it’s special. - if ( $this->is_html() ) { - return null; - } - - $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length ); - return self::normalize_block_type( $block_type ); - } - - /** - * Allocates a printable substring for the block type and returns the fully-qualified - * name, including the namespace, if matched on a delimiter or freeform block, otherwise `null`. - * - * This function is like {@see self::get_block_type()} but when paused on a freeform - * HTML block, will return “core/freeform” instead of `null`. The `null` behavior matches - * what {@see \parse_blocks()} returns but may not be as useful as having a string value. - * - * This function allocates a substring for the given block type. This - * allocation will be small and likely fine in most cases, but it's - * preferable to call {@see self::is_block_type()} if only needing - * to know whether the delimiter is for a given block type, as that - * function is more efficient for this purpose and avoids the allocation. - * - * Example: - * - * // Avoid. - * 'core/paragraph' = $processor->get_printable_block_type(); - * - * // Prefer. - * $processor->is_block_type( 'core/paragraph' ); - * $processor->is_block_type( 'paragraph' ); - * $processor->is_block_type( 'core/freeform' ); - * - * // Freeform HTML content is given an implicit type. - * $processor = new WP_Block_Processor( 'non-block content' ); - * $processor->next_token(); - * 'core/freeform' === $processor->get_printable_block_type(); - * - * @since 6.9.0 - * - * @see self::are_equal_block_types() - * - * @return string|null Fully-qualified block namespace and type, e.g. "core/paragraph", - * if matched on an explicit delimiter or freeform block, otherwise `null`. - */ - public function get_printable_block_type(): ?string { - if ( - self::READY === $this->state || - self::COMPLETE === $this->state || - self::INCOMPLETE_INPUT === $this->state - ) { - return null; - } - - // This is a core/freeform text block, it’s special. - if ( $this->is_html() ) { - return 1 === count( $this->open_blocks_at ) - ? 'core/freeform' - : '#innerHTML'; - } - - $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length ); - return self::normalize_block_type( $block_type ); - } - - /** - * Normalizes a block name to ensure that missing implicit “core” namespaces are present. - * - * Example: - * - * 'core/paragraph' === WP_Block_Processor::normalize_block_byte( 'paragraph' ); - * 'core/paragraph' === WP_Block_Processor::normalize_block_byte( 'core/paragraph' ); - * 'my/paragraph' === WP_Block_Processor::normalize_block_byte( 'my/paragraph' ); - * - * @since 6.9.0 - * - * @param string $block_type Valid block name, potentially without a namespace. - * @return string Fully-qualified block type including namespace. - */ - public static function normalize_block_type( string $block_type ): string { - return false === strpos( $block_type, '/' ) - ? "core/{$block_type}" - : $block_type; - } - - /** - * Returns a lazy wrapper around the block attributes, which can be used - * for efficiently interacting with the JSON attributes. - * - * This stub hints that there should be a lazy interface for parsing - * block attributes but doesn’t define it. It serves both as a placeholder - * for one to come as well as a guard against implementing an eager - * function in its place. - * - * @throws Exception This function is a stub for subclasses to implement - * when providing streaming attribute parsing. - * - * @since 6.9.0 - * - * @see self::allocate_and_return_parsed_attributes() - * - * @return never - */ - public function get_attributes() { - throw new Exception( 'Lazy attribute parsing not yet supported' ); - } - - /** - * Attempts to parse and return the entire JSON attributes from the delimiter, - * allocating memory and processing the JSON span in the process. - * - * This does not return any parsed attributes for a closing block delimiter - * even if there is a span of JSON content; this JSON is a parsing error. - * - * Consider calling {@see static::get_attributes()} instead if it's not - * necessary to read all the attributes at the same time, as that provides - * a more efficient mechanism for typical use cases. - * - * Since the JSON span inside the comment delimiter may not be valid JSON, - * this function will return `null` if it cannot parse the span and set the - * {@see static::get_last_json_error()} to the appropriate JSON_ERROR_ constant. - * - * If the delimiter contains no JSON span, it will also return `null`, - * but the last error will be set to {@see \JSON_ERROR_NONE}. - * - * Example: - * - * $processor = new WP_Block_Processor( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->' ); - * $processor->next_delimiter(); - * $memory_hungry_and_slow_attributes = $processor->allocate_and_return_parsed_attributes(); - * $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' ); - * - * $processor = new WP_Block_Processor( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->' ); - * $processor->next_delimiter(); - * null = $processor->allocate_and_return_parsed_attributes(); - * JSON_ERROR_NONE = $processor->get_last_json_error(); - * - * $processor = new WP_Block_Processor( '<!-- wp:separator {} /-->' ); - * $processor->next_delimiter(); - * array() === $processor->allocate_and_return_parsed_attributes(); - * - * $processor = new WP_Block_Processor( '<!-- wp:separator /-->' ); - * $processor->next_delimiter(); - * null = $processor->allocate_and_return_parsed_attributes(); - * - * $processor = new WP_Block_Processor( '<!-- wp:image {"url} -->' ); - * $processor->next_delimiter(); - * null = $processor->allocate_and_return_parsed_attributes(); - * JSON_ERROR_CTRL_CHAR = $processor->get_last_json_error(); - * - * @since 6.9.0 - * - * @return array|null Parsed JSON attributes, if present and valid, otherwise `null`. - */ - public function allocate_and_return_parsed_attributes(): ?array { - $this->last_json_error = JSON_ERROR_NONE; - - if ( self::CLOSER === $this->type || $this->is_html() || 0 === $this->json_length ) { - return null; - } - - $json_span = substr( $this->source_text, $this->json_at, $this->json_length ); - $parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE ); - - $last_error = json_last_error(); - $this->last_json_error = $last_error; - - return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) ) - ? $parsed - : null; - } - - /** - * Returns the span representing the currently-matched delimiter, if matched, otherwise `null`. - * - * Example: - * - * $processor = new WP_Block_Processor( '<!-- wp:void /-->' ); - * null === $processor->get_span(); - * - * $processor->next_delimiter(); - * WP_HTML_Span( 0, 17 ) === $processor->get_span(); - * - * @since 6.9.0 - * - * @return WP_HTML_Span|null Span of text in source text spanning matched delimiter. - */ - public function get_span(): ?WP_HTML_Span { - switch ( $this->state ) { - case self::HTML_SPAN: - return new WP_HTML_Span( $this->after_previous_delimiter, $this->matched_delimiter_at - $this->after_previous_delimiter ); - - case self::MATCHED: - return new WP_HTML_Span( $this->matched_delimiter_at, $this->matched_delimiter_length ); - - default: - return null; - } - } - - // - // Constant declarations that would otherwise pollute the top of the class. - // - - /** - * Indicates that the block comment delimiter closes an open block. - * - * @see self::$type - * - * @since 6.9.0 - */ - const CLOSER = 'closer'; - - /** - * Indicates that the block comment delimiter opens a block. - * - * @see self::$type - * - * @since 6.9.0 - */ - const OPENER = 'opener'; - - /** - * Indicates that the block comment delimiter represents a void block - * with no inner content of any kind. - * - * @see self::$type - * - * @since 6.9.0 - */ - const VOID = 'void'; - - /** - * Indicates that the processor is ready to start parsing but hasn’t yet begun. - * - * @see self::$state - * - * @since 6.9.0 - */ - const READY = 'processor-ready'; - - /** - * Indicates that the processor is matched on an explicit block delimiter. - * - * @see self::$state - * - * @since 6.9.0 - */ - const MATCHED = 'processor-matched'; - - /** - * Indicates that the processor is matched on the opening of an implicit freeform delimiter. - * - * @see self::$state - * - * @since 6.9.0 - */ - const HTML_SPAN = 'processor-html-span'; - - /** - * Indicates that the parser started parsing a block comment delimiter, but - * the input document ended before it could finish. The document was likely truncated. - * - * @see self::$state - * - * @since 6.9.0 - */ - const INCOMPLETE_INPUT = 'incomplete-input'; - - /** - * Indicates that the processor has finished parsing and has nothing left to scan. - * - * @see self::$state - * - * @since 6.9.0 - */ - const COMPLETE = 'processor-complete'; -} diff --git a/src/Compat/WP_HTML_Span.php b/src/Compat/WP_HTML_Span.php deleted file mode 100644 index 08faa3f9f..000000000 --- a/src/Compat/WP_HTML_Span.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php -/** - * Polyfill for WP_HTML_Span class. - * - * This class is a polyfill for the WP_HTML_Span class introduced in WordPress 6.2.0. - * It will only be loaded if the class doesn't already exist in WordPress Core. - * - * Source: WordPress 6.9.0 wp-includes/html-api/class-wp-html-span.php - * - * @package WP_CLI\Entity\Compat - */ - -if ( class_exists( 'WP_HTML_Span' ) ) { - return; -} - -/** - * Core class used by the HTML tag processor to represent a textual span - * inside an HTML document. - * - * This is a two-tuple in disguise, used to avoid the memory overhead - * involved in using an array for the same purpose. - * - * This class is for internal usage of the WP_HTML_Tag_Processor class. - * - * @access private - * @since 6.2.0 - * @since 6.5.0 Replaced `end` with `length` to more closely align with `substr()`. - * - * @see WP_HTML_Tag_Processor - */ -class WP_HTML_Span { - /** - * Byte offset into document where span begins. - * - * @since 6.2.0 - * - * @var int - */ - public $start; - - /** - * Byte length of this span. - * - * @since 6.5.0 - * - * @var int - */ - public $length; - - /** - * Constructor. - * - * @since 6.2.0 - * - * @param int $start Byte offset into document where replacement span begins. - * @param int $length Byte length of span. - */ - public function __construct( int $start, int $length ) { - $this->start = $start; - $this->length = $length; - } -} diff --git a/src/Compat/polyfills.php b/src/Compat/polyfills.php deleted file mode 100644 index caf684c15..000000000 --- a/src/Compat/polyfills.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php -/** - * Function polyfills for PHP 7.2+ compatibility. - * - * This file provides polyfills for PHP functions that are used by WP_Block_Processor - * but were introduced in PHP 8.0+. - * - * @package WP_CLI\Entity\Compat - */ - -// Polyfill for str_ends_with() - introduced in PHP 8.0. -if ( ! function_exists( 'str_ends_with' ) ) { - /** - * Checks if a string ends with a given substring. - * - * @param string $haystack The string to search in. - * @param string $needle The substring to search for in the haystack. - * @return bool True if haystack ends with needle, false otherwise. - */ - function str_ends_with( string $haystack, string $needle ): bool { - if ( '' === $needle ) { - return true; - } - $len = strlen( $needle ); - return substr( $haystack, -$len ) === $needle; - } -} - -// Polyfill for str_starts_with() - introduced in PHP 8.0. -// Not currently used by WP_Block_Processor but included for completeness. -if ( ! function_exists( 'str_starts_with' ) ) { - /** - * Checks if a string starts with a given substring. - * - * @param string $haystack The string to search in. - * @param string $needle The substring to search for in the haystack. - * @return bool True if haystack starts with needle, false otherwise. - */ - function str_starts_with( string $haystack, string $needle ): bool { - if ( '' === $needle ) { - return true; - } - return 0 === strncmp( $haystack, $needle, strlen( $needle ) ); - } -} - -// Polyfill for str_contains() - introduced in PHP 8.0. -// Not currently used by WP_Block_Processor but included for completeness. -if ( ! function_exists( 'str_contains' ) ) { - /** - * Checks if a string contains a given substring. - * - * @param string $haystack The string to search in. - * @param string $needle The substring to search for in the haystack. - * @return bool True if needle is in haystack, false otherwise. - */ - function str_contains( string $haystack, string $needle ): bool { - if ( '' === $needle ) { - return true; - } - return false !== strpos( $haystack, $needle ); - } -} diff --git a/src/Font_Collection_Command.php b/src/Font_Collection_Command.php deleted file mode 100644 index 6a81a26e6..000000000 --- a/src/Font_Collection_Command.php +++ /dev/null @@ -1,415 +0,0 @@ -<?php - -use WP_CLI\Formatter; - -/** - * Manages font collections. - * - * Font collections are predefined sets of fonts that can be used in WordPress. - * Collections are registered by WordPress core or themes and cannot be created - * or deleted via the command line. - * - * ## EXAMPLES - * - * # List all font collections - * $ wp font collection list - * +------------------+-------------------+---------+ - * | slug | name | count | - * +------------------+-------------------+---------+ - * | google-fonts | Google Fonts | 1500 | - * +------------------+-------------------+---------+ - * - * # Get details about a specific font collection - * $ wp font collection get google-fonts - * +-------+------------------+ - * | Field | Value | - * +-------+------------------+ - * | slug | google-fonts | - * | name | Google Fonts | - * +-------+------------------+ - * - * @package wp-cli - * - * @phpstan-type FontCategory array{name: string, slug: string} - * @phpstan-type FontFamily array{font_family_settings: array{name: string, slug: string, fontFamily: string, preview: string}, categories: string[]} - * @phpstan-type FontCollectionData array{name: string, font_families: FontFamily[], description: string, categories: FontCategory[]} - */ -class Font_Collection_Command extends WP_CLI_Command { - - private $fields = [ - 'slug', - 'name', - 'description', - 'categories', - ]; - - /** - * Lists registered font collections. - * - * ## OPTIONS - * - * [--field=<field>] - * : Prints the value of a single field for each collection. - * - * [--fields=<fields>] - * : Limit the output to specific collection fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - count - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for each collection: - * - * * slug - * * name - * * description - * * categories - * - * ## EXAMPLES - * - * # List all font collections - * $ wp font collection list - * +------------------+-------------------+ - * | slug | name | - * +------------------+-------------------+ - * | google-fonts | Google Fonts | - * +------------------+-------------------+ - * - * # List collections in JSON format - * $ wp font collection list --format=json - * [{"slug":"google-fonts","name":"Google Fonts"}] - * - * @subcommand list - */ - public function list_( $args, $assoc_args ) { - $font_library = WP_Font_Library::get_instance(); - $collections = $font_library->get_font_collections(); - - $items = []; - - /** - * @var \WP_Font_Collection $collection - */ - foreach ( $collections as $collection ) { - $data = $collection->get_data(); - - if ( is_wp_error( $data ) ) { - WP_CLI::warning( $data ); - continue; - } - - $items[] = [ - 'slug' => $collection->slug, - 'name' => $data['name'] ?? '', - 'description' => $data['description'] ?? '', - 'categories' => $this->format_categories( $data['categories'] ?? [] ), - ]; - } - - $formatter = $this->get_formatter( $assoc_args ); - $formatter->display_items( $items ); - } - - /** - * Gets details about a registered font collection. - * - * ## OPTIONS - * - * <slug> - * : Font collection slug. - * - * [--field=<field>] - * : Instead of returning the whole collection, returns the value of a single field. - * - * [--fields=<fields>] - * : Limit the output to specific fields. Defaults to all fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for the specified collection: - * - * * slug - * * name - * * description - * * categories - * - * ## EXAMPLES - * - * # Get details of a specific collection - * $ wp font collection get google-fonts - * +-------+------------------+ - * | Field | Value | - * +-------+------------------+ - * | slug | google-fonts | - * | name | Google Fonts | - * +-------+------------------+ - * - * # Get the name field only - * $ wp font collection get google-fonts --field=name - * Google Fonts - */ - public function get( $args, $assoc_args ) { - $slug = $args[0]; - $font_library = WP_Font_Library::get_instance(); - $collection = $font_library->get_font_collection( $slug ); - - if ( ! $collection ) { - WP_CLI::error( "Font collection {$slug} doesn't exist." ); - } - - $collection_data = $collection->get_data(); - - if ( is_wp_error( $collection_data ) ) { - WP_CLI::error( $collection_data ); - } - - $data = [ - 'slug' => $collection->slug, - 'name' => $collection_data['name'] ?? '', - 'description' => $collection_data['description'] ?? '', - 'categories' => $this->format_categories( $collection_data['categories'] ?? [] ), - ]; - - $formatter = $this->get_formatter( $assoc_args ); - $formatter->display_item( $data ); - } - - /** - * Checks if a font collection is registered. - * - * ## OPTIONS - * - * <slug> - * : Font collection slug. - * - * ## EXAMPLES - * - * # Bash script for checking if a font collection is registered, with fallback. - * - * if wp font collection is-registered google-fonts 2>/dev/null; then - * # Font collection is registered. Do something. - * else - * # Fallback if collection is not registered. - * fi - * - * @subcommand is-registered - * - * @param string[] $args Positional arguments. - * @param array<string, mixed> $assoc_args Associative arguments. - */ - public function is_registered( $args, $assoc_args ) { - $slug = $args[0]; - $font_library = WP_Font_Library::get_instance(); - $collection = $font_library->get_font_collection( $slug ); - - if ( ! $collection ) { - WP_CLI::halt( 1 ); - } - - WP_CLI::halt( 0 ); - } - - /** - * Lists font families in a collection. - * - * ## OPTIONS - * - * <slug> - * : Font collection slug. - * - * [--category=<slug>] - * : Filter by category slug. - * - * [--field=<field>] - * : Prints the value of a single field for each family. - * - * [--fields=<fields>] - * : Limit the output to specific family fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - count - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * * slug - * * name - * * fontFamily - * * categories - * * preview - * - * ## EXAMPLES - * - * # List all font families in a collection - * $ wp font collection list-families google-fonts - * - * # List font families in a specific category - * $ wp font collection list-families google-fonts --category=sans-serif - * - * @subcommand list-families - */ - public function list_families( $args, $assoc_args ) { - $slug = $args[0]; - $font_library = WP_Font_Library::get_instance(); - $collection = $font_library->get_font_collection( $slug ); - - if ( ! $collection ) { - WP_CLI::error( "Font collection {$slug} doesn't exist." ); - } - - $collection_data = $collection->get_data(); - - if ( is_wp_error( $collection_data ) ) { - WP_CLI::error( $collection_data ); - } - - /** - * @var FontCollectionData $collection_data - */ - - $font_families = $collection_data['font_families'] ?? []; - - if ( empty( $font_families ) || ! is_array( $font_families ) ) { - WP_CLI::error( 'No font families found in this collection.' ); - } - - $category = \WP_CLI\Utils\get_flag_value( $assoc_args, 'category' ); - - $items = []; - foreach ( $font_families as $family ) { - $family_categories = $family['categories'] ?? []; - if ( $category && ! in_array( $category, $family_categories, true ) ) { - continue; - } - - $settings = $family['font_family_settings'] ?? []; - - $items[] = [ - 'slug' => $settings['slug'] ?? '', - 'name' => $settings['name'] ?? '', - 'fontFamily' => $settings['fontFamily'] ?? '', - 'categories' => implode( ', ', $family_categories ), - 'preview' => $settings['preview'] ?? '', - ]; - } - - $fields = [ 'slug', 'name', 'fontFamily', 'categories', 'preview' ]; - $formatter = new Formatter( $assoc_args, $fields, 'font-family' ); - $formatter->display_items( $items ); - } - - /** - * Lists categories in a collection. - * - * ## OPTIONS - * - * <slug> - * : Font collection slug. - * - * [--field=<field>] - * : Prints the value of a single field for each category. - * - * [--fields=<fields>] - * : Limit the output to specific category fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - count - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * * slug - * * name - * - * ## EXAMPLES - * - * # List all categories in a collection - * $ wp font collection list-categories google-fonts - * +-------------+--------------+ - * | slug | name | - * +-------------+--------------+ - * | sans-serif | Sans Serif | - * | display | Display | - * +-------------+--------------+ - * - * @subcommand list-categories - */ - public function list_categories( $args, $assoc_args ) { - $slug = $args[0]; - $font_library = WP_Font_Library::get_instance(); - $collection = $font_library->get_font_collection( $slug ); - - if ( ! $collection ) { - WP_CLI::error( "Font collection {$slug} doesn't exist." ); - } - - $collection_data = $collection->get_data(); - - if ( is_wp_error( $collection_data ) ) { - WP_CLI::error( $collection_data ); - } - - $categories = $collection_data['categories'] ?? null; - - if ( empty( $categories ) || ! is_array( $categories ) ) { - WP_CLI::error( 'No categories found in this collection.' ); - } - - $fields = [ 'slug', 'name' ]; - $formatter = new Formatter( $assoc_args, $fields, 'category' ); - $formatter->display_items( $categories ); - } - - private function format_categories( array $categories ): string { - return implode( - ', ', - array_map( - static function ( $category ) { - return "{$category['name']} ({$category['slug']})"; - }, - $categories - ) - ); - } - - private function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->fields, 'font-collection' ); - } -} diff --git a/src/Font_Face_Command.php b/src/Font_Face_Command.php deleted file mode 100644 index 1ca5b2941..000000000 --- a/src/Font_Face_Command.php +++ /dev/null @@ -1,124 +0,0 @@ -<?php - -use WP_CLI\Utils; - -/** - * Manages font faces. - * - * To list, get, create, update or delete font faces, use `wp post` with - * `--post_type=wp_font_face`. - * - * ## EXAMPLES - * - * # Install a font face for an existing family - * $ wp font face install 42 --src="https://example.com/font.woff2" --font-weight=700 - * Success: Created font face 43. - * - * # List installed font faces - * $ wp post list --post_type=wp_font_face - * - * @package wp-cli - */ -class Font_Face_Command extends WP_CLI_Command { - - /** - * Installs a font face. - * - * Creates a new font face post with the specified settings. - * - * ## OPTIONS - * - * <family-id> - * : Font family ID. - * - * --src=<src> - * : Font face source URL or file path. - * - * [--font-family=<family>] - * : CSS font-family value. - * - * [--font-style=<style>] - * : CSS font-style value (e.g., normal, italic). - * --- - * default: normal - * --- - * - * [--font-weight=<weight>] - * : CSS font-weight value (e.g., 400, 700). - * --- - * default: 400 - * --- - * - * [--font-display=<display>] - * : CSS font-display value. - * - * [--porcelain] - * : Output just the new font face ID. - * - * ## EXAMPLES - * - * # Install a font face - * $ wp font face install 42 --src="https://example.com/font.woff2" --font-weight=700 --font-style=normal - * Success: Created font face 43. - * - * # Install a font face with porcelain output - * $ wp font face install 42 --src="font.woff2" --porcelain - * 44 - */ - public function install( $args, $assoc_args ) { - $family_id = $args[0]; - - // Verify parent font family exists. - $parent_post = get_post( $family_id ); - if ( ! $parent_post || 'wp_font_family' !== $parent_post->post_type ) { - WP_CLI::error( "Font family {$family_id} doesn't exist." ); - } - - if ( ! isset( $assoc_args['src'] ) ) { - WP_CLI::error( 'missing --src parameter' ); - } - - // Prepare font face settings. - $face_settings = [ - 'src' => $assoc_args['src'], - ]; - - if ( isset( $assoc_args['font-family'] ) ) { - $face_settings['fontFamily'] = $assoc_args['font-family']; - } - - $font_style = Utils\get_flag_value( $assoc_args, 'font-style', 'normal' ); - $face_settings['fontStyle'] = $font_style; - - $font_weight = Utils\get_flag_value( $assoc_args, 'font-weight', '400' ); - $face_settings['fontWeight'] = $font_weight; - - if ( isset( $assoc_args['font-display'] ) ) { - $face_settings['fontDisplay'] = $assoc_args['font-display']; - } - - // Generate title. - $title_parts = [ $font_weight, $font_style ]; - $title = implode( ' ', $title_parts ); - - $post_data = [ - 'post_type' => 'wp_font_face', - 'post_parent' => $family_id, - 'post_title' => $title, - 'post_status' => 'publish', - 'post_content' => wp_json_encode( $face_settings ) ?: '{}', - ]; - - $font_face_id = wp_insert_post( $post_data, true ); - - if ( is_wp_error( $font_face_id ) ) { - WP_CLI::error( $font_face_id ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $font_face_id ); - } else { - WP_CLI::success( "Created font face {$font_face_id}." ); - } - } -} diff --git a/src/Font_Family_Command.php b/src/Font_Family_Command.php deleted file mode 100644 index d0820d745..000000000 --- a/src/Font_Family_Command.php +++ /dev/null @@ -1,164 +0,0 @@ -<?php - -use WP_CLI\Utils; - -/** - * Manages font families. - * - * To list, get, create, update or delete font families, use `wp post` with - * `--post_type=wp_font_family`. - * - * ## EXAMPLES - * - * # Install a font family from a collection - * $ wp font family install google-fonts inter - * Success: Installed font family "Inter" (ID: 42) with 9 font faces. - * - * # List installed font families - * $ wp post list --post_type=wp_font_family - * - * @package wp-cli - */ -class Font_Family_Command extends WP_CLI_Command { - - /** - * Installs a font family from a collection. - * - * Retrieves a font family from a collection and creates the wp_font_family post - * along with all associated font faces. - * - * ## OPTIONS - * - * <collection> - * : Font collection slug. - * - * <family> - * : Font family slug from the collection. - * - * [--porcelain] - * : Output just the new font family ID. - * - * ## EXAMPLES - * - * # Install a font family from a collection - * $ wp font family install google-fonts inter - * Success: Installed font family "Inter" (ID: 42) with 9 font faces. - * - * # Install and get the family ID - * $ wp font family install google-fonts roboto --porcelain - * 43 - */ - public function install( $args, $assoc_args ) { - $collection_slug = $args[0]; - $family_slug = $args[1]; - - // Get the collection. - $font_library = WP_Font_Library::get_instance(); - $collection = $font_library->get_font_collection( $collection_slug ); - - if ( ! $collection ) { - WP_CLI::error( "Font collection {$collection_slug} doesn't exist." ); - } - - $collection_data = $collection->get_data(); - - if ( is_wp_error( $collection_data ) ) { - WP_CLI::error( $collection_data ); - } - - $font_families = isset( $collection_data['font_families'] ) ? $collection_data['font_families'] : []; - - // Find the font family in the collection. - $family_data = null; - foreach ( $font_families as $family ) { - if ( isset( $family['font_family_settings']['slug'] ) && $family['font_family_settings']['slug'] === $family_slug ) { - $family_data = $family['font_family_settings']; - break; - } - } - - if ( ! $family_data ) { - WP_CLI::error( "Font family '{$family_slug}' not found in collection '{$collection_slug}'." ); - } - - // Prepare font family post data. - $font_family_settings = []; - if ( isset( $family_data['fontFamily'] ) ) { - $font_family_settings['fontFamily'] = $family_data['fontFamily']; - } - if ( isset( $family_data['preview'] ) ) { - $font_family_settings['preview'] = $family_data['preview']; - } - if ( isset( $family_data['slug'] ) ) { - $font_family_settings['slug'] = $family_data['slug']; - } - - $post_data = [ - 'post_type' => 'wp_font_family', - 'post_title' => isset( $family_data['name'] ) ? $family_data['name'] : $family_slug, - 'post_name' => $family_slug, - 'post_status' => 'publish', - 'post_content' => wp_json_encode( $font_family_settings ) ?: '{}', - ]; - - $font_family_id = wp_insert_post( $post_data, true ); - - if ( is_wp_error( $font_family_id ) ) { - WP_CLI::error( $font_family_id ); - } - - // Install font faces. - $face_count = 0; - $font_faces = $family_data['fontFace'] ?? []; - $face_errors = 0; - - foreach ( $font_faces as $face_data ) { - $face_settings = []; - - // Copy over relevant settings. - $settings_to_copy = [ 'fontFamily', 'fontStyle', 'fontWeight', 'src', 'fontDisplay' ]; - foreach ( $settings_to_copy as $setting ) { - if ( isset( $face_data[ $setting ] ) ) { - $face_settings[ $setting ] = $face_data[ $setting ]; - } - } - - // Generate a title for the font face. - $face_title_parts = []; - if ( isset( $face_data['fontWeight'] ) ) { - $face_title_parts[] = $face_data['fontWeight']; - } - if ( isset( $face_data['fontStyle'] ) ) { - $face_title_parts[] = $face_data['fontStyle']; - } - $face_title = ! empty( $face_title_parts ) ? implode( ' ', $face_title_parts ) : 'Regular'; - - $face_post_data = [ - 'post_type' => 'wp_font_face', - 'post_parent' => $font_family_id, - 'post_title' => $face_title, - 'post_status' => 'publish', - 'post_content' => wp_json_encode( $face_settings ) ?: '{}', - ]; - - $face_id = wp_insert_post( $face_post_data, true ); - - if ( is_wp_error( $face_id ) ) { - ++$face_errors; - } else { - ++$face_count; - } - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $font_family_id ); - } else { - $family_name = $post_data['post_title']; - if ( $face_errors > 0 ) { - WP_CLI::warning( "Installed font family \"{$family_name}\" (ID: {$font_family_id}) with {$face_count} font faces. {$face_errors} font faces failed." ); - } else { - WP_CLI::success( "Installed font family \"{$family_name}\" (ID: {$font_family_id}) with {$face_count} font faces." ); - } - } - } -} diff --git a/src/Font_Namespace.php b/src/Font_Namespace.php deleted file mode 100644 index 5e8b1842d..000000000 --- a/src/Font_Namespace.php +++ /dev/null @@ -1,24 +0,0 @@ -<?php - -/** - * Manages WordPress fonts. - * - * ## EXAMPLES - * - * # List all font collections - * $ wp font collection list - * - * # Install a font family from a collection - * $ wp font family install google-fonts inter - * - * # List installed font families - * $ wp post list --post_type=wp_font_family - * - * # List installed font faces - * $ wp post list --post_type=wp_font_face - * - * @package wp-cli - */ -class Font_Namespace extends WP_CLI\Dispatcher\CommandNamespace { - -} diff --git a/src/Menu_Command.php b/src/Menu_Command.php index b23df5c7a..a675c7c05 100644 --- a/src/Menu_Command.php +++ b/src/Menu_Command.php @@ -1,6 +1,5 @@ <?php -use WP_CLI\Formatter; use WP_CLI\Utils; /** @@ -29,20 +28,20 @@ * * # Assign the 'my-menu' menu to the 'primary' location * $ wp menu location assign my-menu primary - * Success: Assigned location primary to menu my-menu. + * Success: Assigned location to menu. * * @package wp-cli */ class Menu_Command extends WP_CLI_Command { - protected $obj_type = 'nav_menu'; - protected $obj_fields = [ + protected $obj_type = 'nav_menu'; + protected $obj_fields = array( 'term_id', 'name', 'slug', 'locations', 'count', - ]; + ); /** * Creates a new menu. @@ -68,11 +67,14 @@ public function create( $args, $assoc_args ) { WP_CLI::error( $menu_id->get_error_message() ); - } elseif ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - - WP_CLI::line( (string) $menu_id ); } else { - WP_CLI::success( "Created menu {$menu_id}." ); + + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { + WP_CLI::line( $menu_id ); + } else { + WP_CLI::success( "Created menu $menu_id." ); + } + } } @@ -87,21 +89,19 @@ public function create( $args, $assoc_args ) { * ## EXAMPLES * * $ wp menu delete "My Menu" - * Deleted menu 'My Menu'. - * Success: Deleted 1 of 1 menus. + * Success: 1 menu deleted. */ - public function delete( $args, $assoc_args ) { + public function delete( $args, $_ ) { - $count = 0; - $errors = 0; - foreach ( $args as $arg ) { + $count = $errors = 0; + foreach( $args as $arg ) { $ret = wp_delete_nav_menu( $arg ); if ( ! $ret || is_wp_error( $ret ) ) { WP_CLI::warning( "Couldn't delete menu '{$arg}'." ); - ++$errors; + $errors++; } else { WP_CLI::log( "Deleted menu '{$arg}'." ); - ++$count; + $count++; } } @@ -159,36 +159,35 @@ public function delete( $args, $assoc_args ) { * * @subcommand list */ - public function list_( $args, $assoc_args ) { + public function list_( $_, $assoc_args ) { $menus = wp_get_nav_menus(); $menu_locations = get_nav_menu_locations(); - foreach ( $menus as &$menu ) { + foreach( $menus as &$menu ) { - // @phpstan-ignore property.notFound - $menu->locations = []; - foreach ( $menu_locations as $location => $term_id ) { + $menu->locations = array(); + foreach( $menu_locations as $location => $term_id ) { - if ( $term_id === $menu->term_id ) { + if ( $term_id == $menu->term_id ) { $menu->locations[] = $location; } + } // Normalize the data for some output formats. - if ( ! isset( $assoc_args['format'] ) || in_array( $assoc_args['format'], [ 'csv', 'table' ], true ) ) { + if ( ! isset( $assoc_args['format'] ) || in_array( $assoc_args['format'], array( 'csv', 'table' ) ) ) { $menu->locations = implode( ',', $menu->locations ); } } $formatter = $this->get_formatter( $assoc_args ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { $ids = array_map( - function ( $o ) { + function($o) { return $o->term_id; - }, - $menus + }, $menus ); $formatter->display_items( $ids ); } else { @@ -197,6 +196,7 @@ function ( $o ) { } protected function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->obj_fields, $this->obj_type ); + return new \WP_CLI\Formatter( $assoc_args, $this->obj_fields, $this->obj_type ); } + } diff --git a/src/Menu_Item_Command.php b/src/Menu_Item_Command.php index 2fa97edfa..cdefdf20b 100644 --- a/src/Menu_Item_Command.php +++ b/src/Menu_Item_Command.php @@ -1,6 +1,5 @@ <?php -use WP_CLI\Formatter; use WP_CLI\Utils; /** @@ -18,17 +17,17 @@ * * # Delete menu item * $ wp menu item delete 45 - * Success: Deleted 1 of 1 menu items. + * Success: 1 menu item deleted. */ class Menu_Item_Command extends WP_CLI_Command { - protected $obj_fields = [ + protected $obj_fields = array( 'db_id', 'type', 'title', 'link', 'position', - ]; + ); /** * Gets a list of items associated with a menu. @@ -92,121 +91,27 @@ class Menu_Item_Command extends WP_CLI_Command { public function list_( $args, $assoc_args ) { $items = wp_get_nav_menu_items( $args[0] ); - if ( false === $items ) { - WP_CLI::error( 'Invalid menu.' ); + if ( false === $items || is_wp_error( $items ) ) { + WP_CLI::error( "Invalid menu." ); } // Correct position inconsistency and // protected `url` param in WP-CLI - $items = array_map( - function ( $item ) { - $item->position = $item->menu_order; - $item->link = $item->url; - return $item; - }, - $items - ); - - if ( ! empty( $assoc_args['format'] ) && 'ids' === $assoc_args['format'] ) { - $items = array_map( - function ( $item ) { - return $item->db_id; - }, - $items - ); + $items = array_map( function( $item ) use ( $assoc_args ) { + $item->position = $item->menu_order; + $item->link = $item->url; + return $item; + }, $items ); + + if ( ! empty( $assoc_args['format'] ) && 'ids' == $assoc_args['format'] ) { + $items = array_map( function( $item ) { + return $item->db_id; + }, $items ); } $formatter = $this->get_formatter( $assoc_args ); $formatter->display_items( $items ); - } - - /** - * Gets details about a menu item. - * - * ## OPTIONS - * - * <db-id> - * : Database ID for the menu item. - * - * [--field=<field>] - * : Instead of returning the whole menu item, returns the value of a single field. - * - * [--fields=<fields>] - * : Limit the output to specific fields. Defaults to db_id, type, title, link, position. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * These fields are available: - * - * * db_id - * * type - * * title - * * link - * * position - * * menu_item_parent - * * object_id - * * object - * * type_label - * * target - * * attr_title - * * description - * * classes - * * xfn - * - * ## EXAMPLES - * - * # Get details about a menu item with ID 45 - * $ wp menu item get 45 - * +-------------+----------------------------------+ - * | Field | Value | - * +-------------+----------------------------------+ - * | db_id | 45 | - * | type | custom | - * | title | WordPress | - * | link | https://wordpress.org | - * | position | 1 | - * +-------------+----------------------------------+ - * - * # Get a specific field from a menu item - * $ wp menu item get 45 --field=title - * WordPress - * - * # Get menu item data in JSON format - * $ wp menu item get 45 --format=json - * {"db_id":45,"type":"custom","title":"WordPress","link":"https://wordpress.org","position":1} - */ - public function get( $args, $assoc_args ) { - $db_id = $args[0]; - - $menu_item = get_post( $db_id ); - - if ( ! $menu_item || 'nav_menu_item' !== $menu_item->post_type ) { - WP_CLI::error( 'Invalid menu item.' ); - } - - /** - * @var object{title: string, url: string, description: string, object: string, object_id: int, menu_item_parent: int, attr_title: string, target: string, classes: string[], xfn: string, type: string, type_label: string, menu_order: int, db_id: int, post_type: string}&\stdClass $menu_item - */ - $menu_item = wp_setup_nav_menu_item( $menu_item ); - - // Correct position inconsistency and protected `url` param in WP-CLI - $menu_item->position = ( 0 === $menu_item->menu_order ) ? 1 : $menu_item->menu_order; - $menu_item->link = $menu_item->url; - - $formatter = $this->get_formatter( $assoc_args ); - $formatter->display_item( $menu_item ); } /** @@ -260,7 +165,7 @@ public function add_post( $args, $assoc_args ) { unset( $args[1] ); $post = get_post( $assoc_args['object-id'] ); if ( ! $post ) { - WP_CLI::error( 'Invalid post.' ); + WP_CLI::error( "Invalid post." ); } $assoc_args['object'] = $post->post_type; @@ -323,75 +228,12 @@ public function add_term( $args, $assoc_args ) { unset( $args[2] ); if ( ! get_term_by( 'id', $assoc_args['object-id'], $assoc_args['object'] ) ) { - WP_CLI::error( 'Invalid term.' ); + WP_CLI::error( "Invalid term." ); } $this->add_or_update_item( 'add', 'taxonomy', $args, $assoc_args ); } - /** - * Adds a post type archive as a menu item. - * - * ## OPTIONS - * - * <menu> - * : The name, slug, or term ID for the menu. - * - * <post-type> - * : Post type slug. - * - * [--title=<title>] - * : Set a custom title for the menu item. - * - * [--link=<link>] - * : Set a custom url for the menu item. - * - * [--description=<description>] - * : Set a custom description for the menu item. - * - * [--attr-title=<attr-title>] - * : Set a custom title attribute for the menu item. - * - * [--target=<target>] - * : Set a custom link target for the menu item. - * - * [--classes=<classes>] - * : Set a custom link classes for the menu item. - * - * [--position=<position>] - * : Specify the position of this menu item. - * - * [--parent-id=<parent-id>] - * : Make this menu item a child of another menu item. - * - * [--porcelain] - * : Output just the new menu item id. - * - * ## EXAMPLES - * - * $ wp menu item add-post-type-archive sidebar-menu post - * Success: Menu item added. - * - * @subcommand add-post-type-archive - */ - public function add_post_type_archive( $args, $assoc_args ) { - - $assoc_args['object'] = $args[1]; - unset( $args[1] ); - - $post_type = $assoc_args['object']; - - if ( ! get_post_type_object( $post_type ) ) { - WP_CLI::error( 'Invalid post type.' ); - } - - if ( false === get_post_type_archive_link( $post_type ) ) { - WP_CLI::error( 'Post type does not have an archive.' ); - } - - $this->add_or_update_item( 'add', 'post_type_archive', $args, $assoc_args ); - } - /** * Adds a custom menu item. * @@ -486,14 +328,15 @@ public function update( $args, $assoc_args ) { // Shuffle the position of these. $args[1] = $args[0]; - $terms = get_the_terms( $args[1], 'nav_menu' ); + $terms = get_the_terms( $args[1], 'nav_menu' ); if ( $terms && ! is_wp_error( $terms ) ) { - $args[0] = (int) $terms[0]->term_id; + $args[0] = (int)$terms[0]->term_id; } else { $args[0] = 0; } $type = get_post_meta( $args[1], '_menu_item_type', true ); $this->add_or_update_item( 'update', $type, $args, $assoc_args ); + } /** @@ -507,50 +350,37 @@ public function update( $args, $assoc_args ) { * ## EXAMPLES * * $ wp menu item delete 45 - * Success: Deleted 1 of 1 menu items. + * Success: 1 menu item deleted. * * @subcommand delete */ - public function delete( $args, $assoc_args ) { + public function delete( $args, $_ ) { global $wpdb; - $count = 0; - $errors = 0; + $count = $errors = 0; - foreach ( $args as $arg ) { + foreach( $args as $arg ) { - $post = get_post( $arg ); - $menu_term = get_the_terms( $arg, 'nav_menu' ); - - // @phpstan-ignore cast.int $parent_menu_id = (int) get_post_meta( $arg, '_menu_item_menu_item_parent', true ); - $result = wp_delete_post( $arg, true ); - if ( ! $result ) { + $ret = wp_delete_post( $arg, true ); + if ( ! $ret ) { WP_CLI::warning( "Couldn't delete menu item {$arg}." ); - ++$errors; - } else { - - if ( is_array( $menu_term ) && ! empty( $menu_term ) && $post ) { - $this->reorder_menu_items( $menu_term[0]->term_id, $post->menu_order, -1, 0 ); - } - - if ( $parent_menu_id ) { - $children = $wpdb->get_results( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key='_menu_item_menu_item_parent' AND meta_value=%s", (int) $arg ) ); - if ( $children ) { - $children_query = $wpdb->prepare( "UPDATE $wpdb->postmeta SET meta_value = %d WHERE meta_key = '_menu_item_menu_item_parent' AND meta_value=%s", $parent_menu_id, (int) $arg ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $children_query is already prepared above. - $wpdb->query( $children_query ); - foreach ( $children as $child ) { - clean_post_cache( $child ); - } + $errors++; + } else if ( $parent_menu_id ) { + $children = $wpdb->get_results( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key='_menu_item_menu_item_parent' AND meta_value=%s", (int) $arg ) ); + if ( $children ) { + $children_query = $wpdb->prepare( "UPDATE $wpdb->postmeta SET meta_value = %d WHERE meta_key = '_menu_item_menu_item_parent' AND meta_value=%s", $parent_menu_id, (int) $arg ); + $wpdb->query( $children_query ); + foreach( $children as $child ) { + clean_post_cache( $child ); } } } - // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Will increase count for non existent menu. - if ( false != $result ) { - ++$count; + if ( false != $ret ) { + $count++; } + } Utils\report_batch_operation_results( 'menu item', 'delete', count( $args ), $count, $errors ); @@ -561,178 +391,82 @@ public function delete( $args, $assoc_args ) { */ private function add_or_update_item( $method, $type, $args, $assoc_args ) { - $menu = $args[0]; - $menu_item_db_id = $args[1] ?? 0; + $menu = $args[0]; + $menu_item_db_id = \WP_CLI\Utils\get_flag_value( $args, 1, 0 ); $menu = wp_get_nav_menu_object( $menu ); - if ( false === $menu ) { - WP_CLI::error( 'Invalid menu.' ); + if ( ! $menu || is_wp_error( $menu ) ) { + WP_CLI::error( "Invalid menu." ); } // `url` is protected in WP-CLI, so we use `link` instead - $assoc_args['url'] = Utils\get_flag_value( $assoc_args, 'link' ); + $assoc_args['url'] = \WP_CLI\Utils\get_flag_value( $assoc_args, 'link' ); // Need to persist the menu item data. See https://core.trac.wordpress.org/ticket/28138 - if ( 'update' === $method ) { + if ( 'update' == $method ) { $menu_item_obj = get_post( $menu_item_db_id ); - - if ( ! $menu_item_obj ) { - WP_CLI::error( 'Invalid menu.' ); - } - - /** - * @var object{title: string, url: string, description: string, object: string, object_id: int, menu_item_parent: int, attr_title: string, target: string, classes: string[], xfn: string, post_status: string, menu_order: int} $menu_item_obj - */ $menu_item_obj = wp_setup_nav_menu_item( $menu_item_obj ); // Correct the menu position if this was the first item. See https://core.trac.wordpress.org/ticket/28140 $position = ( 0 === $menu_item_obj->menu_order ) ? 1 : $menu_item_obj->menu_order; - $default_args = [ - 'position' => $position, - 'title' => $menu_item_obj->title, - 'url' => $menu_item_obj->url, - 'description' => $menu_item_obj->description, - 'object' => $menu_item_obj->object, - 'object-id' => $menu_item_obj->object_id, - 'parent-id' => $menu_item_obj->menu_item_parent, - 'attr-title' => $menu_item_obj->attr_title, - 'target' => $menu_item_obj->target, - 'classes' => implode( ' ', $menu_item_obj->classes ), // stored in the database as array - 'xfn' => $menu_item_obj->xfn, - 'status' => $menu_item_obj->post_status, - ]; + $default_args = array( + 'position' => $position, + 'title' => $menu_item_obj->title, + 'url' => $menu_item_obj->url, + 'description' => $menu_item_obj->description, + 'object' => $menu_item_obj->object, + 'object-id' => $menu_item_obj->object_id, + 'parent-id' => $menu_item_obj->menu_item_parent, + 'attr-title' => $menu_item_obj->attr_title, + 'target' => $menu_item_obj->target, + 'classes' => implode( ' ', $menu_item_obj->classes ), // stored in the database as array + 'xfn' => $menu_item_obj->xfn, + 'status' => $menu_item_obj->post_status, + ); } else { - $default_args = [ - 'position' => 0, - 'title' => '', - 'url' => '', - 'description' => '', - 'object' => '', - 'object-id' => 0, - 'parent-id' => 0, - 'attr-title' => '', - 'target' => '', - 'classes' => '', - 'xfn' => '', + $default_args = array( + 'position' => 0, + 'title' => '', + 'url' => '', + 'description' => '', + 'object' => '', + 'object-id' => 0, + 'parent-id' => 0, + 'attr-title' => '', + 'target' => '', + 'classes' => '', + 'xfn' => '', // Core oddly defaults to 'draft' for create, // and 'publish' for update // Easiest to always work with publish - 'status' => 'publish', - ]; + 'status' => 'publish', + ); } - $menu_item_args = []; - foreach ( $default_args as $key => $default_value ) { + $menu_item_args = array(); + foreach( $default_args as $key => $default_value ) { // wp_update_nav_menu_item() has a weird argument prefix - $new_key = 'menu-item-' . $key; - $menu_item_args[ $new_key ] = Utils\get_flag_value( $assoc_args, $key, $default_value ); + $new_key = 'menu-item-' . $key; + $menu_item_args[ $new_key ] = \WP_CLI\Utils\get_flag_value( $assoc_args, $key, $default_value ); } $menu_item_args['menu-item-type'] = $type; - $pending_menu_order_updates = []; - - // Reorder other menu items when the position changes on update. - if ( 'update' === $method ) { - $new_position = (int) $menu_item_args['menu-item-position']; - if ( $new_position > 0 ) { - // Fetch all menu items sorted by their raw menu_order to determine - // normalized (1-indexed) ranks, since wp_get_nav_menu_items(ARRAY_A) - // normalises menu_order to 1,2,3… which may differ from the raw DB values. - $sorted_item_ids = get_posts( - [ - 'post_type' => 'nav_menu_item', - 'numberposts' => -1, - 'orderby' => 'menu_order', - 'order' => 'ASC', - 'post_status' => 'any', - 'tax_query' => [ - [ - 'taxonomy' => 'nav_menu', - 'field' => 'term_taxonomy_id', - 'terms' => $menu->term_taxonomy_id, - ], - ], - 'fields' => 'ids', - ] - ); + $ret = wp_update_nav_menu_item( $menu->term_id, $menu_item_db_id, $menu_item_args ); - // Normalise to integers so that strict comparisons below work regardless of - // whether $wpdb->get_col() returned strings or integers. - $sorted_item_ids = array_map( 'intval', $sorted_item_ids ); - - // Clamp the requested position to the valid range of menu items. - $max_position = count( $sorted_item_ids ); - if ( $max_position > 0 && $new_position > $max_position ) { - // Treat out-of-range positions as "move to end", consistent with core behavior. - $new_position = $max_position; - } - - // Find the 1-indexed normalized rank of the item being moved. - $item_idx = array_search( (int) $menu_item_db_id, $sorted_item_ids, true ); - $old_position_normalized = ( false !== $item_idx ) ? $item_idx + 1 : 0; - - if ( $old_position_normalized > 0 && $new_position !== $old_position_normalized ) { - if ( $new_position < $old_position_normalized ) { - // Moving up: items at 0-indexed [new_pos-1, old_pos-2] shift down by +1. - for ( $i = $new_position - 1; $i <= $old_position_normalized - 2; $i++ ) { - $pending_menu_order_updates[] = [ - 'ID' => $sorted_item_ids[ $i ], - 'menu_order' => $i + 2, - ]; - } - } else { - // Moving down: items at 0-indexed [old_pos, new_pos-1] shift up by -1. - for ( $i = $old_position_normalized; $i <= $new_position - 1; $i++ ) { - $pending_menu_order_updates[] = [ - 'ID' => $sorted_item_ids[ $i ], - 'menu_order' => $i, - ]; - } - } - } - } - } - - $result = wp_update_nav_menu_item( $menu->term_id, $menu_item_db_id, $menu_item_args ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } elseif ( ! $result ) { - if ( 'add' === $method ) { + if ( is_wp_error( $ret ) ) { + WP_CLI::error( $ret->get_error_message() ); + } else if ( ! $ret ) { + if ( 'add' == $method ) { WP_CLI::error( "Couldn't add menu item." ); - } elseif ( 'update' === $method ) { + } else if ( 'update' == $method ) { WP_CLI::error( "Couldn't update menu item." ); } } else { - // Apply deferred reordering of other menu items only after a successful update. - if ( ! empty( $pending_menu_order_updates ) ) { - global $wpdb; - - $ids_to_update = []; - $case_clauses = ''; - foreach ( $pending_menu_order_updates as $update_args ) { - $item_id = (int) $update_args['ID']; - $ids_to_update[] = $item_id; - $case_clauses .= $wpdb->prepare( ' WHEN %d THEN %d', $item_id, $update_args['menu_order'] ); - } - - $ids_sql = implode( ',', $ids_to_update ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $case_clauses and $ids_sql are constructed from prepared/safe integer values. - $wpdb->query( "UPDATE {$wpdb->posts} SET menu_order = CASE ID {$case_clauses} END WHERE ID IN ({$ids_sql})" ); - - foreach ( $ids_to_update as $id ) { - clean_post_cache( $id ); - } - } - - if ( ( 'add' === $method ) && $menu_item_args['menu-item-position'] ) { - $this->reorder_menu_items( $menu->term_id, $menu_item_args['menu-item-position'], +1, $result ); - } /** * Set the menu @@ -743,36 +477,25 @@ private function add_or_update_item( $method, $type, $args, $assoc_args ) { * * @see https://core.trac.wordpress.org/ticket/27113 */ - if ( ! is_object_in_term( $result, 'nav_menu', (int) $menu->term_id ) ) { - wp_set_object_terms( $result, [ (int) $menu->term_id ], 'nav_menu' ); + if ( ! is_object_in_term( $ret, 'nav_menu', (int) $menu->term_id ) ) { + wp_set_object_terms( $ret, array( (int)$menu->term_id ), 'nav_menu' ); } - if ( 'add' === $method && ! empty( $assoc_args['porcelain'] ) ) { - WP_CLI::line( (string) $result ); - } elseif ( 'add' === $method ) { - WP_CLI::success( 'Menu item added.' ); - } elseif ( 'update' === $method ) { - WP_CLI::success( 'Menu item updated.' ); + if ( 'add' == $method && ! empty( $assoc_args['porcelain'] ) ) { + WP_CLI::line( $ret ); + } else { + if ( 'add' == $method ) { + WP_CLI::success( "Menu item added." ); + } else if ( 'update' == $method ) { + WP_CLI::success( "Menu item updated." ); + } } } - } - /** - * Move block of items in one nav_menu up or down by incrementing/decrementing their menu_order field. - * Expects the menu items to have proper menu_orders (i.e. doesn't fix errors from previous incorrect operations). - * - * @param int $menu_id ID of the nav_menu - * @param int $min_position minimal menu_order to touch - * @param int $increment how much to change menu_order: +1 to move down, -1 to move up - * @param int $ignore_item_id menu item that should be ignored by the change (e.g. newly created menu item) - * @return int number of rows affected - */ - private function reorder_menu_items( $menu_id, $min_position, $increment, $ignore_item_id = 0 ) { - global $wpdb; - return $wpdb->query( $wpdb->prepare( "UPDATE $wpdb->posts SET `menu_order`=`menu_order`+(%d) WHERE `menu_order`>=%d AND ID IN (SELECT object_id FROM $wpdb->term_relationships WHERE term_taxonomy_id=%d) AND ID<>%d", (int) $increment, (int) $min_position, (int) $menu_id, (int) $ignore_item_id ) ); } protected function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->obj_fields ); + return new \WP_CLI\Formatter( $assoc_args, $this->obj_fields ); } + } diff --git a/src/Menu_Location_Command.php b/src/Menu_Location_Command.php index 2c27bea0c..46744389f 100644 --- a/src/Menu_Location_Command.php +++ b/src/Menu_Location_Command.php @@ -1,7 +1,5 @@ <?php -use WP_CLI\Formatter; - /** * Assigns, removes, and lists a menu's locations. * @@ -18,7 +16,7 @@ * * # Assign the 'primary-menu' menu to the 'primary' location * $ wp menu location assign primary-menu primary - * Success: Assigned location primary to menu primary-menu. + * Success: Assigned location to menu. * * # Remove the 'primary-menu' menu from the 'primary' location * $ wp menu location remove primary-menu primary @@ -63,25 +61,24 @@ class Menu_Location_Command extends WP_CLI_Command { * * @subcommand list */ - public function list_( $args, $assoc_args ) { + public function list_( $_, $assoc_args ) { - $locations = get_registered_nav_menus(); - $location_objs = []; - foreach ( $locations as $location => $description ) { - $location_obj = new stdClass(); - $location_obj->location = $location; + $locations = get_registered_nav_menus(); + $location_objs = array(); + foreach( $locations as $location => $description ) { + $location_obj = new \stdClass; + $location_obj->location = $location; $location_obj->description = $description; - $location_objs[] = $location_obj; + $location_objs[] = $location_obj; } - $formatter = new Formatter( $assoc_args, [ 'location', 'description' ] ); + $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'location', 'description' ) ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { $ids = array_map( - function ( $o ) { + function($o) { return $o->location; - }, - $location_objs + }, $location_objs ); $formatter->display_items( $ids ); } else { @@ -106,29 +103,27 @@ function ( $o ) { * Success: Assigned location primary to menu primary-menu. * * @subcommand assign - * - * @param array{string, string} $args */ - public function assign( $args, $assoc_args ) { + public function assign( $args, $_ ) { list( $menu, $location ) = $args; $menu_obj = wp_get_nav_menu_object( $menu ); if ( ! $menu_obj ) { - WP_CLI::error( "Invalid menu {$menu}." ); + WP_CLI::error( "Invalid menu $menu." ); } $locations = get_registered_nav_menus(); if ( ! array_key_exists( $location, $locations ) ) { - WP_CLI::error( "Invalid location {$location}." ); + WP_CLI::error( "Invalid location $location." ); } - $locations = get_nav_menu_locations(); + $locations = get_nav_menu_locations(); $locations[ $location ] = $menu_obj->term_id; set_theme_mod( 'nav_menu_locations', $locations ); - WP_CLI::success( "Assigned location {$location} to menu {$menu}." ); + WP_CLI::success( "Assigned location $location to menu $menu." ); } /** @@ -148,26 +143,26 @@ public function assign( $args, $assoc_args ) { * Success: Removed location from menu. * * @subcommand remove - * - * @param array{string, string} $args */ - public function remove( $args, $assoc_args ) { + public function remove( $args, $_ ) { list( $menu, $location ) = $args; $menu = wp_get_nav_menu_object( $menu ); - if ( false === $menu ) { - WP_CLI::error( 'Invalid menu.' ); + if ( ! $menu || is_wp_error( $menu ) ) { + WP_CLI::error( "Invalid menu." ); } $locations = get_nav_menu_locations(); - if ( ( $locations[ $location ] ?? null ) !== $menu->term_id ) { + if ( \WP_CLI\Utils\get_flag_value( $locations, $location ) != $menu->term_id ) { WP_CLI::error( "Menu isn't assigned to location." ); } $locations[ $location ] = 0; set_theme_mod( 'nav_menu_locations', $locations ); - WP_CLI::success( 'Removed location from menu.' ); + WP_CLI::success( "Removed location from menu." ); + } + } diff --git a/src/Network_Meta_Command.php b/src/Network_Meta_Command.php index 000411855..62dc98fd6 100644 --- a/src/Network_Meta_Command.php +++ b/src/Network_Meta_Command.php @@ -1,7 +1,5 @@ <?php -use WP_CLI\CommandWithMeta; - /** * Gets, adds, updates, deletes, and lists network custom fields. * @@ -13,84 +11,6 @@ * 0 => 'supervisor', * ) */ -class Network_Meta_Command extends CommandWithMeta { +class Network_Meta_Command extends \WP_CLI\CommandWithMeta { protected $meta_type = 'site'; - - /** - * Override add_metadata() to use add_network_option(). - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return bool The meta ID on success, false on failure. - * - * @phpstan-ignore method.childReturnType - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_network_option( $object_id, $meta_key, $meta_value ); - } - - /** - * Override update_metadata() to use update_network_option(). - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_network_option( $object_id, $meta_key, $meta_value ); - } - - /** - * Override get_metadata() to use get_network_option(). - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-ignore method.childReturnType - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - return get_network_option( $object_id, $meta_key ); - } - - /** - * Override delete_metadata() to use delete_network_option(). - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_network_option( $object_id, $meta_key ); - } } diff --git a/src/Option_Command.php b/src/Option_Command.php index 01cf348db..cfe11d10d 100644 --- a/src/Option_Command.php +++ b/src/Option_Command.php @@ -1,7 +1,6 @@ <?php -use WP_CLI\Formatter; -use WP_CLI\Traverser\RecursiveDataStructureTraverser; +use WP_CLI\Entity\RecursiveDataStructureTraverser; use WP_CLI\Utils; /** @@ -9,21 +8,6 @@ * * See the [Plugin Settings API](https://developer.wordpress.org/plugins/settings/settings-api/) and the [Theme Options](https://developer.wordpress.org/themes/customize-api/) for more information on adding customized options. * - * ## COMMON OPTIONS - * - * These are some of the most commonly used WordPress options: - * - * * `siteurl` - Site URL, e.g. http://example.com - * * `blogname` - Site title - * * `blogdescription` - Site tagline - * * `admin_email` - Administration email address - * * `default_role` - Default role for new users - * * `timezone_string` - Local timezone, e.g. "America/New_York" - * * `home` - Home URL, e.g. http://example.com - * * `blog_public` - Discourage search engines when set to 0 - * - * For the full list of available options, see the [Option Reference](https://developer.wordpress.org/apis/options/). - * * ## EXAMPLES * * # Get site URL. @@ -92,7 +76,7 @@ public function get( $args, $assoc_args ) { $value = get_option( $key ); if ( false === $value ) { - WP_CLI::error( "Could not get '{$key}' option. Does it exist?" ); + WP_CLI::error( "Could not get '$key' option. Does it exist?" ); } WP_CLI::print_value( $value, $assoc_args ); @@ -109,7 +93,7 @@ public function get( $args, $assoc_args ) { * : The name of the option to add. * * [<value>] - * : The value of the option to add. If omitted, the value is read from STDIN. + * : The value of the option to add. If ommited, the value is read from STDIN. * * [--format=<format>] * : The serialization format for the value. @@ -124,8 +108,6 @@ public function get( $args, $assoc_args ) { * : Should this option be automatically loaded. * --- * options: - * - 'on' - * - 'off' * - 'yes' * - 'no' * --- @@ -142,17 +124,16 @@ public function add( $args, $assoc_args ) { $value = WP_CLI::get_value_from_arg_or_stdin( $args, 1 ); $value = WP_CLI::read_value( $value, $assoc_args ); - if ( in_array( Utils\get_flag_value( $assoc_args, 'autoload' ), [ 'no', 'off' ], true ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'autoload' ) === 'no' ) { $autoload = 'no'; } else { $autoload = 'yes'; } - // @phpstan-ignore argument.type - if ( ! add_option( $key, $value, '', $autoload ) ) { - WP_CLI::error( "Could not add option '{$key}'. Does it already exist?" ); + if ( !add_option( $key, $value, '', $autoload ) ) { + WP_CLI::error( "Could not add option '$key'. Does it already exist?" ); } else { - WP_CLI::success( "Added '{$key}' option." ); + WP_CLI::success( "Added '$key' option." ); } } @@ -173,9 +154,6 @@ public function add( $args, $assoc_args ) { * [--transients] * : List only transients. Use `--no-transients` to ignore all transients. * - * [--unserialize] - * : Unserialize option values in output. - * * [--field=<field>] * : Prints the value of a single field. * @@ -256,18 +234,16 @@ public function add( $args, $assoc_args ) { * Success: Deleted 'theme_mods_twentyfourteen' option. * * @subcommand list - * - * @param string[] $args Positional arguments. Unused. - * @param array{search?: string, exclude: string, autoload: string, transients?: bool, unserialize?: bool, field?: string, fields: string, format: 'table'|'csv'|'json'|'yaml'|'count'|'total_bytes', orderby: 'option_id'|'option_name'|'option_value', order: 'asc'|'desc'} $assoc_args Associative arguments. */ public function list_( $args, $assoc_args ) { global $wpdb; - $pattern = '%'; - $exclude = ''; - $fields = array( 'option_name', 'option_value' ); - $size_query = ',LENGTH(option_value) AS `size_bytes`'; + $pattern = '%'; + $exclude = ''; + $fields = array( 'option_name', 'option_value' ); + $size_query = ",LENGTH(option_value) AS `size_bytes`"; $autoload_query = ''; + $sort = Utils\get_flag_value( $assoc_args, 'order' ); if ( isset( $assoc_args['search'] ) ) { $pattern = self::esc_like( $assoc_args['search'] ); @@ -286,80 +262,65 @@ public function list_( $args, $assoc_args ) { $fields = explode( ',', $assoc_args['fields'] ); } - if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { - $fields = array( 'size_bytes' ); - $size_query = ',SUM(LENGTH(option_value)) AS `size_bytes`'; + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { + $fields = array( 'size_bytes' ); + $size_query = ",SUM(LENGTH(option_value)) AS `size_bytes`"; } if ( isset( $assoc_args['autoload'] ) ) { - $autoload = $assoc_args['autoload']; - if ( 'on' === $autoload || 'yes' === $autoload ) { - $autoload_query = " AND (autoload='on' OR autoload='yes')"; - } elseif ( 'off' === $autoload || 'no' === $autoload ) { - $autoload_query = " AND (autoload='off' OR autoload='no')"; + if ( 'on' === $assoc_args['autoload'] ) { + $autoload_query = " AND autoload='yes'"; + } elseif ( 'off' === $assoc_args['autoload'] ) { + $autoload_query = " AND autoload='no'"; } else { - WP_CLI::error( "Value of '--autoload' should be 'on', 'off', 'yes', or 'no'." ); + WP_CLI::error( "Value of '--autoload' should be on or off." ); } } // By default we don't want to display transients. $show_transients = Utils\get_flag_value( $assoc_args, 'transients', false ); + $transients_query = ''; if ( $show_transients ) { - $transients_query = " AND (option_name LIKE '\_transient\_%' - OR option_name LIKE '\_site\_transient\_%')"; + $transients_query = " AND option_name LIKE '\_transient\_%' + OR option_name LIKE '\_site\_transient\_%'"; } else { - $transients_query = " AND (option_name NOT LIKE '\_transient\_%' - AND option_name NOT LIKE '\_site\_transient\_%')"; + $transients_query = " AND option_name NOT LIKE '\_transient\_%' + AND option_name NOT LIKE '\_site\_transient\_%'"; } $where = ''; if ( $pattern ) { - $where .= $wpdb->prepare( 'WHERE `option_name` LIKE %s', $pattern ); + $where .= $wpdb->prepare( "WHERE `option_name` LIKE %s", $pattern ); } if ( $exclude ) { - $where .= $wpdb->prepare( ' AND `option_name` NOT LIKE %s', $exclude ); + $where .= $wpdb->prepare( " AND `option_name` NOT LIKE %s", $exclude ); } $where .= $autoload_query . $transients_query; - // phpcs:disable WordPress.DB.PreparedSQL -- Hardcoded query parts without user input. - $results = $wpdb->get_results( - 'SELECT `option_name`,`option_value`,`autoload`' . $size_query - . " FROM `$wpdb->options` {$where}" - ); - // phpcs:enable + $results = $wpdb->get_results( "SELECT `option_name`,`option_value`,`autoload`" . $size_query + . " FROM `$wpdb->options` {$where}" ); - $orderby = Utils\get_flag_value( $assoc_args, 'orderby' ); - $order = Utils\get_flag_value( $assoc_args, 'order' ); + $orderby = \WP_CLI\Utils\get_flag_value( $assoc_args, 'orderby' ); + $order = \WP_CLI\Utils\get_flag_value( $assoc_args, 'order' ); // Sort result. if ( 'option_id' !== $orderby ) { - usort( - $results, - function ( $a, $b ) use ( $orderby, $order ) { - // Sort array. - return 'asc' === $order - ? $a->$orderby <=> $b->$orderby - : $b->$orderby <=> $a->$orderby; - } - ); - } elseif ( 'desc' === $order ) { // Sort by default descending. + usort( $results, function ( $a, $b ) use ( $orderby, $order ) { + // Sort array. + return 'asc' === $order + ? $a->$orderby > $b->$orderby + : $a->$orderby < $b->$orderby; + }); + } elseif ( 'option_id' === $orderby && 'desc' === $order ) { // Sort by default descending. krsort( $results ); } - if ( true === Utils\get_flag_value( $assoc_args, 'unserialize', null ) ) { - foreach ( $results as $k => &$v ) { - if ( ! empty( $v->option_value ) ) { - $v->option_value = maybe_unserialize( $v->option_value ); - } - } - } - - if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { WP_CLI::line( $results[0]->size_bytes ); } else { - $formatter = new Formatter( + $formatter = new \WP_CLI\Formatter( $assoc_args, $fields ); @@ -376,14 +337,12 @@ function ( $a, $b ) use ( $orderby, $order ) { * : The name of the option to update. * * [<value>] - * : The new value. If omitted, the value is read from STDIN. + * : The new value. If ommited, the value is read from STDIN. * * [--autoload=<autoload>] * : Requires WP 4.2. Should this option be automatically loaded. * --- * options: - * - 'on' - * - 'off' * - 'yes' * - 'no' * --- @@ -436,138 +395,27 @@ public function update( $args, $assoc_args ) { $value = WP_CLI::get_value_from_arg_or_stdin( $args, 1 ); $value = WP_CLI::read_value( $value, $assoc_args ); - $autoload = Utils\get_flag_value( $assoc_args, 'autoload' ); - if ( ! in_array( $autoload, [ 'on', 'off', 'yes', 'no' ], true ) ) { + $autoload = \WP_CLI\Utils\get_flag_value( $assoc_args, 'autoload' ); + if ( ! in_array( $autoload, array( 'yes', 'no' ) ) ) { $autoload = null; } - /** - * @var string $value - */ $value = sanitize_option( $key, $value ); - // Sanitization WordPress normally performs when getting an option - if ( in_array( $key, [ 'siteurl', 'home', 'category_base', 'tag_base' ], true ) ) { + if ( in_array( $key, array('siteurl', 'home', 'category_base', 'tag_base') ) ) { $value = untrailingslashit( $value ); } $old_value = sanitize_option( $key, get_option( $key ) ); - if ( $value === $old_value && null === $autoload ) { - WP_CLI::success( "Value passed for '{$key}' option is unchanged." ); - // @phpstan-ignore argument.type - } elseif ( update_option( $key, $value, $autoload ) ) { - WP_CLI::success( "Updated '{$key}' option." ); + if ( $value === $old_value && is_null( $autoload ) ) { + WP_CLI::success( "Value passed for '$key' option is unchanged." ); } else { - WP_CLI::error( "Could not update option '{$key}'." ); - } - } - - /** - * Gets the 'autoload' value for an option. - * - * ## OPTIONS - * - * <key> - * : The name of the option to get 'autoload' of. - * - * ## EXAMPLES - * - * # Get the 'autoload' value for an option. - * $ wp option get-autoload blogname - * yes - * - * @subcommand get-autoload - */ - public function get_autoload( $args ) { - global $wpdb; - - list( $option ) = $args; - - $existing = $wpdb->get_row( - $wpdb->prepare( - "SELECT autoload FROM $wpdb->options WHERE option_name=%s", - $option - ) - ); - if ( ! $existing ) { - WP_CLI::error( "Could not get '{$option}' option. Does it exist?" ); - - } - WP_CLI::log( $existing->autoload ); - } - - /** - * Sets the 'autoload' value for an option. - * - * ## OPTIONS - * - * <key> - * : The name of the option to set 'autoload' for. - * - * <autoload> - * : Should this option be automatically loaded. - * --- - * options: - * - 'on' - * - 'off' - * - 'yes' - * - 'no' - * --- - * - * ## EXAMPLES - * - * # Set the 'autoload' value for an option. - * $ wp option set-autoload abc_options no - * Success: Updated autoload value for 'abc_options' option. - * - * @subcommand set-autoload - */ - public function set_autoload( $args ) { - global $wpdb; - - list( $option, $autoload ) = $args; - - $previous = $wpdb->get_row( - $wpdb->prepare( - "SELECT autoload, option_value FROM $wpdb->options WHERE option_name=%s", - $option - ) - ); - if ( ! $previous ) { - WP_CLI::error( "Could not get '{$option}' option. Does it exist?" ); - - } - - if ( $previous->autoload === $autoload ) { - WP_CLI::success( "Autoload value passed for '{$option}' option is unchanged." ); - return; - } - - $wpdb->update( - $wpdb->options, - array( 'autoload' => $autoload ), - array( 'option_name' => $option ) - ); - - // Recreate cache refreshing from update_option(). - $notoptions = wp_cache_get( 'notoptions', 'options' ); - - if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) { - unset( $notoptions[ $option ] ); - wp_cache_set( 'notoptions', $notoptions, 'options' ); - } - - if ( ! defined( 'WP_INSTALLING' ) ) { - $alloptions = wp_load_alloptions( true ); - if ( isset( $alloptions[ $option ] ) ) { - $alloptions[ $option ] = $previous->option_value; - wp_cache_set( 'alloptions', $alloptions, 'options' ); + if ( update_option( $key, $value, $autoload ) ) { + WP_CLI::success( "Updated '$key' option." ); } else { - wp_cache_set( $option, $previous->option_value, 'options' ); + WP_CLI::error( "Could not update option '$key'." ); } } - - WP_CLI::success( "Updated autoload value for '{$option}' option." ); } /** @@ -575,7 +423,7 @@ public function set_autoload( $args ) { * * ## OPTIONS * - * <key>... + * <key> * : Key for the option. * * ## EXAMPLES @@ -583,20 +431,14 @@ public function set_autoload( $args ) { * # Delete an option. * $ wp option delete my_option * Success: Deleted 'my_option' option. - * - * # Delete multiple options. - * $ wp option delete option_one option_two option_three - * Success: Deleted 'option_one' option. - * Success: Deleted 'option_two' option. - * Warning: Could not delete 'option_three' option. Does it exist? */ public function delete( $args ) { - foreach ( $args as $arg ) { - if ( ! delete_option( $arg ) ) { - WP_CLI::warning( "Could not delete '{$arg}' option. Does it exist?" ); - } else { - WP_CLI::success( "Deleted '{$arg}' option." ); - } + list( $key ) = $args; + + if ( !delete_option( $key ) ) { + WP_CLI::error( "Could not delete '$key' option. Does it exist?" ); + } else { + WP_CLI::success( "Deleted '$key' option." ); } } @@ -630,21 +472,18 @@ public function pluck( $args, $assoc_args ) { WP_CLI::halt( 1 ); } - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 1 ) - ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 1 ) ); $traverser = new RecursiveDataStructureTraverser( $value ); try { $value = $traverser->get( $key_path ); - } catch ( Exception $exception ) { + } catch ( \Exception $e ) { die( 1 ); } @@ -682,97 +521,62 @@ function ( $key ) { * - plaintext * - json * --- - * - * ## EXAMPLES - * - * # Add 'bar' to the 'foo' key on an option with name 'option_name' - * $ wp option patch insert option_name foo bar - * Success: Updated 'option_name' option. - * - * # Update the value of 'foo' key to 'new' on an option with name 'option_name' - * $ wp option patch update option_name foo new - * Success: Updated 'option_name' option. - * - * # Set nested value of 'bar' key to value we have in the patch file on an option with name 'option_name'. - * $ wp option patch update option_name foo bar < patch - * Success: Updated 'option_name' option. - * - * # Update the value for the key 'not-a-key' which is not exist on an option with name 'option_name'. - * $ wp option patch update option_name foo not-a-key new-value - * Error: No data exists for key "not-a-key" - * - * # Update the value for the key 'foo' without passing value on an option with name 'option_name'. - * $ wp option patch update option_name foo - * Error: Please provide value to update. - * - * # Delete the nested key 'bar' under 'foo' key on an option with name 'option_name'. - * $ wp option patch delete option_name foo bar - * Success: Updated 'option_name' option. */ public function patch( $args, $assoc_args ) { list( $action, $key ) = $args; - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 2 ) - ); - - if ( 'delete' === $action ) { - $patch_value = null; - } else { - $stdin_value = Utils\has_stdin() - ? trim( WP_CLI::get_value_from_arg_or_stdin( $args, -1 ) ) - : null; - - if ( ! empty( $stdin_value ) ) { - $patch_value = WP_CLI::read_value( $stdin_value, $assoc_args ); - } elseif ( count( $key_path ) > 1 ) { - $patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); - } else { - $patch_value = null; + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; } + return $key; + }, array_slice( $args, 2 ) ); - if ( null === $patch_value ) { - WP_CLI::error( 'Please provide value to update.' ); - } + if ( 'delete' == $action ) { + $patch_value = null; + } elseif ( \WP_CLI\Entity\Utils::has_stdin() ) { + $stdin_value = WP_CLI::get_value_from_arg_or_stdin( $args, -1 ); + $patch_value = WP_CLI::read_value( trim( $stdin_value ), $assoc_args ); + } else { + // Take the patch value as the last positional argument. Mutates $key_path to be 1 element shorter! + $patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); } /* Need to make a copy of $current_value here as it is modified by reference */ - $old_value = sanitize_option( $key, get_option( $key ) ); - $current_value = $old_value; - if ( is_object( $current_value ) ) { - $old_value = clone $current_value; - } + $old_value = $current_value = sanitize_option( $key, get_option( $key ) ); $traverser = new RecursiveDataStructureTraverser( $current_value ); try { $traverser->$action( $key_path, $patch_value ); - } catch ( Exception $exception ) { - WP_CLI::error( $exception->getMessage() ); + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); } $patched_value = sanitize_option( $key, $traverser->value() ); if ( $patched_value === $old_value ) { - WP_CLI::success( "Value passed for '{$key}' option is unchanged." ); - } elseif ( update_option( $key, $patched_value ) ) { - WP_CLI::success( "Updated '{$key}' option." ); + WP_CLI::success( "Value passed for '$key' option is unchanged." ); } else { - WP_CLI::error( "Could not update option '{$key}'." ); + if ( update_option( $key, $patched_value ) ) { + WP_CLI::success( "Updated '$key' option." ); + } else { + WP_CLI::error( "Could not update option '$key'." ); + } } } private static function esc_like( $old ) { - /** - * @var \wpdb $wpdb - */ global $wpdb; - return $wpdb->esc_like( $old ); + // Remove notices in 4.0 and support backwards compatibility + if( method_exists( $wpdb, 'esc_like' ) ) { + // 4.0 + $old = $wpdb->esc_like( $old ); + } else { + // 3.9 or less + $old = like_escape( esc_sql( $old ) ); + } + + return $old; } } diff --git a/src/Post_Block_Command.php b/src/Post_Block_Command.php deleted file mode 100644 index ebca6e454..000000000 --- a/src/Post_Block_Command.php +++ /dev/null @@ -1,1844 +0,0 @@ -<?php - -use Mustangostang\Spyc; -use WP_CLI\Entity\Block_HTML_Sync_Filters; -use WP_CLI\Entity\Block_Processor_Helper; -use WP_CLI\Formatter; -use WP_CLI\Fetchers\Post as PostFetcher; -use WP_CLI\Utils; - -/** - * Manages blocks within post content. - * - * Provides commands for inspecting, manipulating, and managing - * Gutenberg blocks in post content. - * - * ## EXAMPLES - * - * # List all blocks in a post. - * $ wp post block list 123 - * +------------------+-------+ - * | blockName | count | - * +------------------+-------+ - * | core/paragraph | 2 | - * | core/heading | 1 | - * +------------------+-------+ - * - * # Parse blocks in a post to JSON. - * $ wp post block parse 123 --format=json - * - * # Insert a paragraph block. - * $ wp post block insert 123 core/paragraph --content="Hello World" - * - * @package wp-cli - * - * @phpstan-type ParsedBlock array{blockName?: string, attrs: array<string, mixed>, innerBlocks: array<array<mixed>>, innerHTML: string, innerContent: list<mixed>} - * @phpstan-type ParsedBlockWithBlockName array{blockName: string, attrs: array<string, mixed>, innerBlocks: array<array<mixed>>, innerHTML: string, innerContent: list<mixed>} - */ -class Post_Block_Command extends WP_CLI_Command { - - /** - * @var PostFetcher - */ - private $fetcher; - - /** - * Whether filters have been registered. - * - * @var bool - */ - private static $filters_registered = false; - - /** - * Default fields to display for block list. - * - * @var string[] - */ - protected $obj_fields = [ - 'index', - 'blockName', - 'attrs', - ]; - - public function __construct() { - $this->fetcher = new PostFetcher(); - - // Register default HTML sync filters once. - if ( ! self::$filters_registered ) { - Block_HTML_Sync_Filters::register(); - self::$filters_registered = true; - } - } - - /** - * Gets a single block by index. - * - * Retrieves the full structure of a block at the specified position. - * - * ## OPTIONS - * - * <id> - * : The ID of the post. - * - * <index> - * : The block index (0-indexed). - * - * [--raw] - * : Include innerHTML in output. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: json - * options: - * - json - * - yaml - * --- - * - * ## EXAMPLES - * - * # Get the first block in a post. - * $ wp post block get 123 0 - * { - * "blockName": "core/paragraph", - * "attrs": {}, - * "innerBlocks": [] - * } - * - * # Get the third block (index 2) with attributes. - * $ wp post block get 123 2 - * { - * "blockName": "core/heading", - * "attrs": { - * "level": 2 - * }, - * "innerBlocks": [] - * } - * - * # Get block as YAML format. - * $ wp post block get 123 1 --format=yaml - * blockName: core/image - * attrs: - * id: 456 - * sizeSlug: large - * innerBlocks: [] - * - * # Get block with raw HTML content included. - * $ wp post block get 123 0 --raw - * { - * "blockName": "core/paragraph", - * "attrs": {}, - * "innerBlocks": [], - * "innerHTML": "<p>Hello World</p>", - * "innerContent": ["<p>Hello World</p>"] - * } - * - * @subcommand get - */ - public function get( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $index = (int) $args[1]; - - // Use streaming helper to get block at index. - $block = Block_Processor_Helper::get_at_index( $post->post_content, $index ); - - if ( null === $block ) { - $block_count = Block_Processor_Helper::get_block_count( $post->post_content ); - WP_CLI::error( "Invalid index: {$index}. Post has {$block_count} block(s) (0-indexed)." ); - } - - $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false ); - - if ( ! $include_raw ) { - $block = Block_Processor_Helper::strip_inner_html( [ $block ] )[0]; - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'json' ); - - if ( 'yaml' === $format ) { - echo Spyc::YAMLDump( $block, 2, 0, true ); - } else { - // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - echo json_encode( $block, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; - } - } - - /** - * Updates a block's attributes or content by index. - * - * Modifies a specific block without changing its type. For blocks where - * attributes are reflected in HTML (like heading levels), the HTML is - * automatically updated to match the new attributes. - * - * ## OPTIONS - * - * <id> - * : The ID of the post. - * - * <index> - * : The block index to update (0-indexed). - * - * [--attrs=<attrs>] - * : Block attributes as JSON. Merges with existing attributes by default. - * - * [--content=<content>] - * : New innerHTML content for the block. - * - * [--replace-attrs] - * : Replace all attributes instead of merging. - * - * [--porcelain] - * : Output just the post ID. - * - * ## EXAMPLES - * - * # Change a heading from h2 to h3. - * $ wp post block update 123 0 --attrs='{"level":3}' - * Success: Updated block at index 0 in post 123. - * - * # Add alignment to an existing paragraph (merges with existing attrs). - * $ wp post block update 123 1 --attrs='{"align":"center"}' - * Success: Updated block at index 1 in post 123. - * - * # Update the text content of a paragraph block. - * $ wp post block update 123 2 --content="<p>Updated paragraph text</p>" - * Success: Updated block at index 2 in post 123. - * - * # Update both attributes and content at once. - * $ wp post block update 123 0 --attrs='{"level":2}' --content="<h2>New Heading</h2>" - * Success: Updated block at index 0 in post 123. - * - * # Replace all attributes instead of merging (removes existing attrs). - * $ wp post block update 123 0 --attrs='{"level":4}' --replace-attrs - * Success: Updated block at index 0 in post 123. - * - * # Get just the post ID for scripting. - * $ wp post block update 123 0 --attrs='{"level":2}' --porcelain - * 123 - * - * # Use custom HTML sync logic via the wp_cli_post_block_update_html filter. - * # Use WP_CLI::add_wp_hook() in a file loaded with --require. - * $ wp post block update 123 0 --attrs='{"url":"https://example.com"}' --require=my-sync-filters.php - * Success: Updated block at index 0 in post 123. - * - * @subcommand update - */ - public function update( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $index = (int) $args[1]; - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks but keep track of original indices. - $filtered_blocks = []; - $index_map = []; - foreach ( $blocks as $original_idx => $block ) { - if ( ! empty( $block['blockName'] ) ) { - $index_map[ count( $filtered_blocks ) ] = $original_idx; - $filtered_blocks[] = $block; - } - } - - if ( $index < 0 || $index >= count( $filtered_blocks ) ) { - WP_CLI::error( "Invalid index: {$index}. Post has " . count( $filtered_blocks ) . ' block(s) (0-indexed).' ); - } - - $original_idx = $index_map[ $index ]; - $block = $blocks[ $original_idx ]; - - $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', null ); - $content = Utils\get_flag_value( $assoc_args, 'content', null ); - $replace_attrs = Utils\get_flag_value( $assoc_args, 'replace-attrs', false ); - - if ( null === $attrs_json && null === $content ) { - WP_CLI::error( 'You must specify either --attrs or --content.' ); - } - - if ( null !== $attrs_json ) { - $new_attrs = json_decode( $attrs_json, true ); - if ( ! is_array( $new_attrs ) ) { - WP_CLI::error( 'Invalid JSON provided for --attrs. Must be a JSON object.' ); - } - - if ( $replace_attrs ) { - $block['attrs'] = $new_attrs; - } else { - $block['attrs'] = array_merge( - is_array( $block['attrs'] ) ? $block['attrs'] : [], - $new_attrs - ); - } - - // Update HTML to reflect attribute changes for known block types. - $block = $this->sync_html_with_attrs( $block, $new_attrs ); - } - - if ( null !== $content ) { - $block['innerHTML'] = $content; - $block['innerContent'] = [ $content ]; - } - - $blocks[ $original_idx ] = $block; - - // @phpstan-ignore argument.type - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $post->ID ); - } else { - WP_CLI::success( "Updated block at index {$index} in post {$post->ID}." ); - } - } - - /** - * Moves a block from one position to another. - * - * Reorders blocks within the post by moving a block from one index to another. - * - * ## OPTIONS - * - * <id> - * : The ID of the post. - * - * <from-index> - * : Current block index (0-indexed). - * - * <to-index> - * : Target position index (0-indexed). - * - * [--porcelain] - * : Output just the post ID. - * - * ## EXAMPLES - * - * # Move the first block to the third position. - * $ wp post block move 123 0 2 - * Success: Moved block from index 0 to index 2 in post 123. - * - * # Move the last block (index 4) to the beginning. - * $ wp post block move 123 4 0 - * Success: Moved block from index 4 to index 0 in post 123. - * - * # Move a heading block from position 3 to position 1. - * $ wp post block move 123 3 1 - * Success: Moved block from index 3 to index 1 in post 123. - * - * # Move block and get post ID for scripting. - * $ wp post block move 123 2 0 --porcelain - * 123 - * - * @subcommand move - */ - public function move( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $from_index = (int) $args[1]; - $to_index = (int) $args[2]; - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks but keep track of original indices. - $filtered_blocks = []; - $index_map = []; - foreach ( $blocks as $original_idx => $block ) { - if ( ! empty( $block['blockName'] ) ) { - $index_map[ count( $filtered_blocks ) ] = $original_idx; - $filtered_blocks[] = $block; - } - } - - $block_count = count( $filtered_blocks ); - - if ( $from_index < 0 || $from_index >= $block_count ) { - WP_CLI::error( "Invalid from-index: {$from_index}. Post has {$block_count} block(s) (0-indexed)." ); - } - - if ( $to_index < 0 || $to_index >= $block_count ) { - WP_CLI::error( "Invalid to-index: {$to_index}. Post has {$block_count} block(s) (0-indexed)." ); - } - - if ( $from_index === $to_index ) { - WP_CLI::warning( 'Source and destination indices are the same. No changes made.' ); - return; - } - - // Work with the actual blocks array (including whitespace). - $original_from = (int) $index_map[ $from_index ]; - $block_to_move = $blocks[ $original_from ]; - - // Remove the block from original position. - array_splice( $blocks, $original_from, 1 ); - - // Recalculate index map after removal. - $new_filtered = []; - $new_index_map = []; - foreach ( $blocks as $idx => $block ) { - if ( ! empty( $block['blockName'] ) ) { - $new_index_map[ count( $new_filtered ) ] = (int) $idx; - $new_filtered[] = $block; - } - } - - // Calculate the actual insertion position. - if ( $to_index >= count( $new_filtered ) ) { - // Insert at end. - $insert_pos = count( $blocks ); - } else { - $insert_pos = (int) $new_index_map[ $to_index ]; - } - - // Insert at new position. - array_splice( $blocks, $insert_pos, 0, [ $block_to_move ] ); - - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $post->ID ); - } else { - WP_CLI::success( "Moved block from index {$from_index} to index {$to_index} in post {$post->ID}." ); - } - } - - /** - * Exports block content to a file. - * - * Exports blocks from a post to a file for backup or migration. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to export blocks from. - * - * [--file=<file>] - * : Output file path. If not specified, outputs to STDOUT. - * - * [--format=<format>] - * : Export format. - * --- - * default: json - * options: - * - json - * - yaml - * - html - * --- - * - * [--raw] - * : Include innerHTML in JSON/YAML output. - * - * ## EXAMPLES - * - * # Export blocks to a JSON file for backup. - * $ wp post block export 123 --file=blocks.json - * Success: Exported 5 blocks to blocks.json - * - * # Export blocks to STDOUT as JSON. - * $ wp post block export 123 - * { - * "version": "1.0", - * "generator": "wp-cli/entity-command", - * "post_id": 123, - * "exported_at": "2024-12-10T12:00:00+00:00", - * "blocks": [...] - * } - * - * # Export as YAML format. - * $ wp post block export 123 --format=yaml - * version: "1.0" - * generator: wp-cli/entity-command - * blocks: - * - blockName: core/paragraph - * attrs: [] - * - * # Export rendered HTML (final output, not block structure). - * $ wp post block export 123 --format=html --file=content.html - * Success: Exported 5 blocks to content.html - * - * # Export with raw innerHTML included for complete backup. - * $ wp post block export 123 --raw --file=blocks-full.json - * Success: Exported 5 blocks to blocks-full.json - * - * # Pipe export to another command. - * $ wp post block export 123 | jq '.blocks[].blockName' - * - * @subcommand export - */ - public function export( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $file = Utils\get_flag_value( $assoc_args, 'file', null ); - $format = Utils\get_flag_value( $assoc_args, 'format', 'json' ); - $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false ); - - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks. - $blocks = array_values( - array_filter( - $blocks, - function ( $block ) { - return ! empty( $block['blockName'] ); - } - ) - ); - - $block_count = count( $blocks ); - - if ( 'html' === $format ) { - $output = ''; - foreach ( $blocks as $block ) { - $output .= render_block( $block ); - } - } else { - if ( ! $include_raw ) { - $blocks = $this->strip_inner_html( $blocks ); - } - - $export_data = [ - 'version' => '1.0', - 'generator' => 'wp-cli/entity-command', - 'post_id' => $post->ID, - 'exported_at' => gmdate( 'c' ), - 'blocks' => $blocks, - ]; - - if ( 'yaml' === $format ) { - $output = Spyc::YAMLDump( $export_data, 2, 0, true ); - } else { - // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - $output = json_encode( $export_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; - } - } - - if ( null !== $file ) { - $dir = dirname( $file ); - if ( ! empty( $dir ) && ! is_dir( $dir ) ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - if ( ! mkdir( $dir, 0755, true ) ) { - WP_CLI::error( "Could not create directory: {$dir}" ); - } - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - $result = file_put_contents( $file, $output ); - if ( false === $result ) { - WP_CLI::error( "Could not write to file: {$file}" ); - } - - $block_word = 1 === $block_count ? 'block' : 'blocks'; - WP_CLI::success( "Exported {$block_count} {$block_word} to {$file}" ); - } else { - echo $output; - } - } - - /** - * Imports blocks from a file into a post. - * - * Imports blocks from a JSON or YAML file into a post's content. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to import blocks into. - * - * [--file=<file>] - * : Input file path. If not specified, reads from STDIN. - * - * [--position=<position>] - * : Where to insert imported blocks. Accepts 'start', 'end', or a numeric index. - * --- - * default: end - * --- - * - * [--replace] - * : Replace all existing blocks instead of appending. - * - * [--porcelain] - * : Output just the number of blocks imported. - * - * ## EXAMPLES - * - * # Import blocks from a JSON file, append to end of post. - * $ wp post block import 123 --file=blocks.json - * Success: Imported 5 blocks into post 123. - * - * # Import blocks at the beginning of the post. - * $ wp post block import 123 --file=blocks.json --position=start - * Success: Imported 5 blocks into post 123. - * - * # Replace all existing content with imported blocks. - * $ wp post block import 123 --file=blocks.json --replace - * Success: Imported 5 blocks into post 123. - * - * # Import from STDIN (piped from another command). - * $ cat blocks.json | wp post block import 123 - * Success: Imported 5 blocks into post 123. - * - * # Copy blocks from one post to another. - * $ wp post block export 123 | wp post block import 456 - * Success: Imported 5 blocks into post 456. - * - * # Import YAML format. - * $ wp post block import 123 --file=blocks.yaml - * Success: Imported 3 blocks into post 123. - * - * # Get just the count of imported blocks for scripting. - * $ wp post block import 123 --file=blocks.json --porcelain - * 5 - * - * @subcommand import - */ - public function import( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $file = Utils\get_flag_value( $assoc_args, 'file', null ); - $position = Utils\get_flag_value( $assoc_args, 'position', 'end' ); - $replace = Utils\get_flag_value( $assoc_args, 'replace', false ); - - if ( null !== $file ) { - if ( ! file_exists( $file ) ) { - WP_CLI::error( "File not found: {$file}" ); - } - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $input = file_get_contents( $file ); - } else { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $input = file_get_contents( 'php://stdin' ); - } - - if ( false === $input || '' === trim( $input ) ) { - WP_CLI::error( 'No input data provided.' ); - } - - // Try to parse as JSON first, then YAML. - $data = json_decode( $input, true ); - if ( null === $data ) { - $data = Spyc::YAMLLoadString( $input ); - } - - if ( ! is_array( $data ) ) { - WP_CLI::error( 'Invalid input format. Expected JSON or YAML.' ); - } - - // Handle export format (with metadata wrapper) or plain blocks array. - $import_blocks = isset( $data['blocks'] ) ? $data['blocks'] : $data; - - if ( ! is_array( $import_blocks ) ) { - WP_CLI::error( 'No blocks found in import data.' ); - } - - /** - * @phpstan-var array<int|string, ParsedBlock> $import_blocks - */ - - // Validate block structure. - foreach ( $import_blocks as $idx => $block ) { - if ( ! isset( $block['blockName'] ) ) { - WP_CLI::error( "Invalid block structure at index {$idx}: missing blockName." ); - } - } - - /** - * @phpstan-var array<int|string, ParsedBlockWithBlockName> $import_blocks - */ - - $imported_count = count( $import_blocks ); - - if ( $replace ) { - $blocks = $import_blocks; - } else { - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks. - $blocks = array_values( - array_filter( - $blocks, - function ( $block ) { - return ! empty( $block['blockName'] ); - } - ) - ); - - if ( 'start' === $position ) { - $blocks = array_merge( $import_blocks, $blocks ); - } elseif ( 'end' === $position ) { - $blocks = array_merge( $blocks, $import_blocks ); - } elseif ( is_numeric( $position ) ) { - $pos = (int) $position; - if ( $pos < 0 || $pos > count( $blocks ) ) { - WP_CLI::error( "Invalid position: {$pos}. Post has " . count( $blocks ) . ' block(s) (0-indexed).' ); - } - array_splice( $blocks, $pos, 0, $import_blocks ); - } else { - $blocks = array_merge( $blocks, $import_blocks ); - } - } - - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $imported_count ); - } else { - $block_word = 1 === $imported_count ? 'block' : 'blocks'; - WP_CLI::success( "Imported {$imported_count} {$block_word} into post {$post->ID}." ); - } - } - - /** - * Counts blocks across multiple posts. - * - * Analyzes block usage across posts for site-wide reporting. - * - * ## OPTIONS - * - * [<id>...] - * : Optional post IDs. If not specified, queries all posts. - * - * [--block=<block-name>] - * : Only count specific block type. - * - * [--post-type=<type>] - * : Limit to specific post type(s). Comma-separated. - * --- - * default: post,page - * --- - * - * [--post-status=<status>] - * : Post status to include. - * --- - * default: publish - * --- - * - * [--format=<format>] - * : Output format. - * --- - * default: table - * options: - * - table - * - json - * - csv - * - yaml - * - count - * --- - * - * ## EXAMPLES - * - * # Count all blocks across published posts and pages. - * $ wp post block count - * +------------------+-------+-------+ - * | blockName | count | posts | - * +------------------+-------+-------+ - * | core/paragraph | 1542 | 234 | - * | core/heading | 523 | 198 | - * | core/image | 312 | 156 | - * +------------------+-------+-------+ - * - * # Count blocks in specific posts only. - * $ wp post block count 123 456 789 - * +------------------+-------+-------+ - * | blockName | count | posts | - * +------------------+-------+-------+ - * | core/paragraph | 8 | 3 | - * | core/heading | 3 | 2 | - * +------------------+-------+-------+ - * - * # Count only paragraph blocks across the site. - * $ wp post block count --block=core/paragraph --format=count - * 1542 - * - * # Count blocks in a custom post type. - * $ wp post block count --post-type=product - * - * # Count blocks in multiple post types. - * $ wp post block count --post-type=post,page,product - * - * # Count blocks including drafts. - * $ wp post block count --post-status=draft - * - * # Get count as JSON for further processing. - * $ wp post block count --format=json - * [{"blockName":"core/paragraph","count":1542,"posts":234}] - * - * # Get total number of unique block types used. - * $ wp post block count --format=count - * 15 - * - * @subcommand count - */ - public function count( $args, $assoc_args ) { - $block_filter = Utils\get_flag_value( $assoc_args, 'block', null ); - $post_types = Utils\get_flag_value( $assoc_args, 'post-type', 'post,page' ); - $post_status = Utils\get_flag_value( $assoc_args, 'post-status', 'publish' ); - $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); - - if ( ! empty( $args ) ) { - $post_ids = array_map( 'intval', $args ); - } else { - $query_args = [ - 'post_type' => explode( ',', $post_types ), - 'post_status' => $post_status, - 'posts_per_page' => -1, - 'fields' => 'ids', - ]; - - $post_ids = get_posts( $query_args ); - } - - if ( empty( $post_ids ) ) { - WP_CLI::warning( 'No posts found matching criteria.' ); - return; - } - - $block_counts = []; - $post_counts = []; - - foreach ( $post_ids as $post_id ) { - $post = get_post( $post_id ); - if ( ! $post || ! has_blocks( $post->post_content ) ) { - continue; - } - - $blocks = parse_blocks( $post->post_content ); - $this->aggregate_block_counts( $blocks, $block_counts, $post_counts, $post_id, $block_filter ); - } - - if ( empty( $block_counts ) ) { - WP_CLI::warning( 'No blocks found in queried posts.' ); - return; - } - - // Sort by count descending. - arsort( $block_counts ); - - // Handle single block filter with count format. - if ( null !== $block_filter && 'count' === $format ) { - $count = isset( $block_counts[ $block_filter ] ) ? $block_counts[ $block_filter ] : 0; - WP_CLI::line( (string) $count ); - return; - } - - $items = []; - foreach ( $block_counts as $block_name => $count ) { - $items[] = [ - 'blockName' => $block_name, - 'count' => $count, - 'posts' => isset( $post_counts[ $block_name ] ) ? count( $post_counts[ $block_name ] ) : 0, - ]; - } - - if ( 'count' === $format ) { - WP_CLI::line( (string) count( $items ) ); - return; - } - - $formatter = new Formatter( $assoc_args, [ 'blockName', 'count', 'posts' ] ); - $formatter->display_items( $items ); - } - - /** - * Clones a block within a post. - * - * Duplicates an existing block and inserts it at a specified position. - * - * ## OPTIONS - * - * <id> - * : The ID of the post. - * - * <source-index> - * : Index of the block to clone (0-indexed). - * - * [--position=<position>] - * : Where to insert the cloned block. Accepts 'after', 'before', 'start', 'end', or a numeric index. - * --- - * default: after - * --- - * - * [--porcelain] - * : Output just the new block index. - * - * ## EXAMPLES - * - * # Clone a block and insert immediately after it (default). - * $ wp post block clone 123 2 - * Success: Cloned block to index 3 in post 123. - * - * # Clone the first block and insert immediately before it. - * $ wp post block clone 123 0 --position=before - * Success: Cloned block to index 0 in post 123. - * - * # Clone a block and insert at the end of the post. - * $ wp post block clone 123 0 --position=end - * Success: Cloned block to index 5 in post 123. - * - * # Clone a block and insert at the start of the post. - * $ wp post block clone 123 3 --position=start - * Success: Cloned block to index 0 in post 123. - * - * # Clone and get just the new block index for scripting. - * $ wp post block clone 123 1 --porcelain - * 2 - * - * # Duplicate the hero section (first block) at the end for a footer. - * $ wp post block clone 123 0 --position=end - * Success: Cloned block to index 10 in post 123. - * - * @subcommand clone - */ - public function clone_block( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $source_index = (int) $args[1]; - $position = Utils\get_flag_value( $assoc_args, 'position', 'after' ); - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks but keep track of original indices. - $filtered_blocks = []; - $index_map = []; - foreach ( $blocks as $original_idx => $block ) { - if ( ! empty( $block['blockName'] ) ) { - $index_map[ count( $filtered_blocks ) ] = $original_idx; - $filtered_blocks[] = $block; - } - } - - $block_count = count( $filtered_blocks ); - - if ( $source_index < 0 || $source_index >= $block_count ) { - WP_CLI::error( "Invalid source-index: {$source_index}. Post has {$block_count} block(s) (0-indexed)." ); - } - - $original_idx = (int) $index_map[ $source_index ]; - $cloned_block = $this->deep_copy_block( $blocks[ $original_idx ] ); - - // Calculate insertion position. - if ( is_numeric( $position ) ) { - $new_index = (int) $position; - if ( $new_index < 0 || $new_index > $block_count ) { - WP_CLI::error( "Invalid position: {$new_index}. Must be between 0 and {$block_count}." ); - } - // Map the filtered index to original index for insertion. - if ( $new_index >= $block_count ) { - $insert_pos = count( $blocks ); - } else { - $insert_pos = $index_map[ $new_index ]; - } - } else { - switch ( $position ) { - case 'before': - $insert_pos = $original_idx; - $new_index = $source_index; - break; - case 'after': - $insert_pos = $original_idx + 1; - $new_index = $source_index + 1; - break; - case 'start': - $insert_pos = 0; - $new_index = 0; - break; - case 'end': - default: - $insert_pos = count( $blocks ); - $new_index = $block_count; - break; - } - } - - array_splice( $blocks, (int) $insert_pos, 0, [ $cloned_block ] ); - - // @phpstan-ignore argument.type - $new_content = serialize_blocks( $blocks ); - - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $new_index ); - } else { - WP_CLI::success( "Cloned block to index {$new_index} in post {$post->ID}." ); - } - } - - /** - * Extracts data from blocks. - * - * Extracts specific attribute values or content from blocks for scripting. - * - * ## OPTIONS - * - * <id> - * : The ID of the post. - * - * [--block=<block-name>] - * : Filter by block type. - * - * [--index=<index>] - * : Get from specific block index. - * - * [--attr=<attr>] - * : Extract specific attribute value. - * - * [--content] - * : Extract innerHTML content. - * - * [--format=<format>] - * : Output format. - * --- - * default: json - * options: - * - json - * - yaml - * - csv - * - ids - * --- - * - * ## EXAMPLES - * - * # Extract all image IDs from the post (one per line). - * $ wp post block extract 123 --block=core/image --attr=id --format=ids - * 456 - * 789 - * 1024 - * - * # Extract all image URLs as JSON array. - * $ wp post block extract 123 --block=core/image --attr=url --format=json - * ["https://example.com/img1.jpg","https://example.com/img2.jpg"] - * - * # Extract text content from all headings. - * $ wp post block extract 123 --block=core/heading --content --format=ids - * Introduction - * Getting Started - * Conclusion - * - * # Get the heading level from the first block. - * $ wp post block extract 123 --index=0 --attr=level --format=ids - * 2 - * - * # Extract all heading levels as CSV. - * $ wp post block extract 123 --block=core/heading --attr=level --format=csv - * 2,3,3,2 - * - * # Extract paragraph content as YAML. - * $ wp post block extract 123 --block=core/paragraph --content --format=yaml - * - "First paragraph text" - * - "Second paragraph text" - * - * # Get all button URLs for link checking. - * $ wp post block extract 123 --block=core/button --attr=url --format=ids - * https://example.com/signup - * https://example.com/learn-more - * - * # Extract cover block image IDs for media audit. - * $ wp post block extract 123 --block=core/cover --attr=id --format=json - * - * @subcommand extract - */ - public function extract( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $block_filter = Utils\get_flag_value( $assoc_args, 'block', null ); - $index = Utils\get_flag_value( $assoc_args, 'index', null ); - $attr = Utils\get_flag_value( $assoc_args, 'attr', null ); - $get_content = Utils\get_flag_value( $assoc_args, 'content', false ); - $format = Utils\get_flag_value( $assoc_args, 'format', 'json' ); - - if ( null === $attr && ! $get_content ) { - WP_CLI::error( 'You must specify either --attr or --content.' ); - } - - $blocks = parse_blocks( $post->post_content ); - - // Filter out empty blocks. - $blocks = array_values( - array_filter( - $blocks, - function ( $block ) { - return ! empty( $block['blockName'] ); - } - ) - ); - - // Filter by index. - if ( null !== $index ) { - $index = (int) $index; - if ( $index < 0 || $index >= count( $blocks ) ) { - WP_CLI::error( "Invalid index: {$index}. Post has " . count( $blocks ) . ' block(s) (0-indexed).' ); - } - $blocks = [ $blocks[ $index ] ]; - } - - // Filter by block type. - if ( null !== $block_filter ) { - $blocks = array_filter( - $blocks, - function ( $block ) use ( $block_filter ) { - return $block['blockName'] === $block_filter; - } - ); - } - - if ( empty( $blocks ) ) { - WP_CLI::warning( 'No matching blocks found.' ); - return; - } - - // Extract values. - $values = []; - foreach ( $blocks as $block ) { - if ( $get_content ) { - $content = isset( $block['innerHTML'] ) ? $block['innerHTML'] : ''; - // Strip HTML tags for cleaner output. - $values[] = trim( wp_strip_all_tags( $content ) ); - } elseif ( null !== $attr ) { - if ( isset( $block['attrs'][ $attr ] ) ) { - $values[] = $block['attrs'][ $attr ]; - } - } - } - - if ( empty( $values ) ) { - WP_CLI::warning( 'No values found for extraction criteria.' ); - return; - } - - // Output based on format. - switch ( $format ) { - case 'ids': - foreach ( $values as $value ) { - WP_CLI::line( (string) $value ); - } - break; - case 'csv': - WP_CLI::line( implode( ',', array_map( 'strval', $values ) ) ); - break; - case 'yaml': - echo Spyc::YAMLDump( $values, 2, 0, true ); - break; - case 'json': - default: - // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - echo json_encode( $values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; - break; - } - } - - /** - * Parses and displays the block structure of a post. - * - * Outputs the parsed block structure as JSON or YAML. By default, - * innerHTML is stripped from the output for readability. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to parse. - * - * [--raw] - * : Include raw innerHTML in output. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: json - * options: - * - json - * - yaml - * --- - * - * ## EXAMPLES - * - * # Parse blocks to JSON. - * $ wp post block parse 123 - * [ - * { - * "blockName": "core/paragraph", - * "attrs": {} - * } - * ] - * - * # Parse blocks to YAML format. - * $ wp post block parse 123 --format=yaml - * - - * blockName: core/paragraph - * attrs: { } - * - * # Parse blocks including raw HTML content. - * $ wp post block parse 123 --raw - * - * @subcommand parse - */ - public function parse( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $blocks = parse_blocks( $post->post_content ); - - $include_raw = Utils\get_flag_value( $assoc_args, 'raw', false ); - - if ( ! $include_raw ) { - $blocks = $this->strip_inner_html( $blocks ); - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'json' ); - - if ( 'yaml' === $format ) { - echo Spyc::YAMLDump( $blocks, 2, 0, true ); - } else { - // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - echo json_encode( $blocks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; - } - } - - /** - * Renders blocks from a post to HTML. - * - * Outputs the rendered HTML of blocks in a post. This uses WordPress's - * block rendering system to produce the final HTML output. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to render. - * - * [--block=<block-name>] - * : Only render blocks of this type. - * - * ## EXAMPLES - * - * # Render all blocks to HTML. - * $ wp post block render 123 - * <p>Hello World</p> - * <h2>My Heading</h2> - * - * # Render only paragraph blocks. - * $ wp post block render 123 --block=core/paragraph - * <p>Hello World</p> - * - * # Render only heading blocks. - * $ wp post block render 123 --block=core/heading - * - * @subcommand render - */ - public function render( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $block_name = Utils\get_flag_value( $assoc_args, 'block', null ); - $blocks = parse_blocks( $post->post_content ); - $output_html = ''; - - foreach ( $blocks as $block ) { - if ( null !== $block_name && $block['blockName'] !== $block_name ) { - continue; - } - $output_html .= render_block( $block ); - } - - echo $output_html; - } - - /** - * Lists blocks in a post with counts. - * - * Displays a summary of block types used in the post and how many - * times each block type appears. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to analyze. - * - * [--nested] - * : Include nested/inner blocks in the list. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * - count - * --- - * - * ## EXAMPLES - * - * # List blocks with counts. - * $ wp post block list 123 - * +------------------+-------+ - * | blockName | count | - * +------------------+-------+ - * | core/paragraph | 5 | - * | core/heading | 2 | - * | core/image | 1 | - * +------------------+-------+ - * - * # List blocks as JSON. - * $ wp post block list 123 --format=json - * [{"blockName":"core/paragraph","count":5}] - * - * # Include nested blocks (e.g., blocks inside columns or groups). - * $ wp post block list 123 --nested - * - * # Get the number of unique block types. - * $ wp post block list 123 --format=count - * 3 - * - * @subcommand list - */ - public function list_( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $include_nested = Utils\get_flag_value( $assoc_args, 'nested', false ); - - $block_counts = Block_Processor_Helper::count_by_type( $post->post_content, $include_nested ); - - $items = []; - foreach ( $block_counts as $block_name => $count ) { - $items[] = [ - 'blockName' => $block_name, - 'count' => $count, - ]; - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); - - if ( 'count' === $format ) { - WP_CLI::line( (string) count( $items ) ); - return; - } - - $formatter = new Formatter( $assoc_args, [ 'blockName', 'count' ] ); - $formatter->display_items( $items ); - } - - /** - * Inserts a block into a post at a specified position. - * - * Adds a new block to the post content. By default, the block is - * appended to the end of the post. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to modify. - * - * <block-name> - * : The block type name (e.g., 'core/paragraph'). - * - * [--content=<content>] - * : The inner content/HTML for the block. - * - * [--attrs=<attrs>] - * : Block attributes as JSON. - * - * [--position=<position>] - * : Position to insert the block (0-indexed). Use 'start' or 'end'. - * --- - * default: end - * --- - * - * [--porcelain] - * : Output just the post ID. - * - * ## EXAMPLES - * - * # Insert a paragraph block at the end of the post. - * $ wp post block insert 123 core/paragraph --content="Hello World" - * Success: Inserted block into post 123. - * - * # Insert a level-2 heading at the start. - * $ wp post block insert 123 core/heading --content="My Title" --attrs='{"level":2}' --position=start - * Success: Inserted block into post 123. - * - * # Insert an image block at position 2. - * $ wp post block insert 123 core/image --attrs='{"id":456,"url":"https://example.com/image.jpg"}' --position=2 - * - * # Insert a separator block. - * $ wp post block insert 123 core/separator - * - * @subcommand insert - */ - public function insert( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $block_name = $args[1]; - $content = Utils\get_flag_value( $assoc_args, 'content', '' ); - $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', '{}' ); - $position = Utils\get_flag_value( $assoc_args, 'position', 'end' ); - - $attrs = json_decode( $attrs_json, true ); - if ( null === $attrs && '{}' !== $attrs_json ) { - WP_CLI::error( 'Invalid JSON provided for --attrs.' ); - } - if ( ! is_array( $attrs ) ) { - $attrs = []; - } - - $blocks = parse_blocks( $post->post_content ); - - $new_block = $this->create_block( $block_name, $attrs, $content ); - - if ( 'start' === $position ) { - array_unshift( $blocks, $new_block ); - } elseif ( 'end' === $position ) { - $blocks[] = $new_block; - } else { - $pos = (int) $position; - if ( $pos < 0 || $pos > count( $blocks ) ) { - WP_CLI::error( "Invalid position: {$position}. Must be between 0 and " . count( $blocks ) . '.' ); - } - array_splice( $blocks, $pos, 0, [ $new_block ] ); - } - - // @phpstan-ignore argument.type - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $post->ID ); - } else { - WP_CLI::success( "Inserted block into post {$post->ID}." ); - } - } - - /** - * Removes blocks from a post by name or index. - * - * Removes one or more blocks from the post content. Blocks can be - * removed by their type name or by their position index. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to modify. - * - * [<block-name>] - * : The block type name to remove (e.g., 'core/paragraph'). - * - * [--index=<index>] - * : Remove block at specific index (0-indexed). Can be comma-separated for multiple indices. - * - * [--all] - * : Remove all blocks of the specified type. - * - * [--porcelain] - * : Output just the number of blocks removed. - * - * ## EXAMPLES - * - * # Remove the first block (index 0). - * $ wp post block remove 123 --index=0 - * Success: Removed 1 block from post 123. - * - * # Remove the first paragraph block found. - * $ wp post block remove 123 core/paragraph - * Success: Removed 1 block from post 123. - * - * # Remove all paragraph blocks. - * $ wp post block remove 123 core/paragraph --all - * Success: Removed 5 blocks from post 123. - * - * # Remove blocks at multiple indices. - * $ wp post block remove 123 --index=0,2,4 - * Success: Removed 3 blocks from post 123. - * - * # Remove all image blocks and get count. - * $ wp post block remove 123 core/image --all --porcelain - * 2 - * - * @subcommand remove - */ - public function remove( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $block_name = isset( $args[1] ) ? $args[1] : null; - $indices = Utils\get_flag_value( $assoc_args, 'index', null ); - $remove_all = Utils\get_flag_value( $assoc_args, 'all', false ); - - if ( null === $block_name && null === $indices ) { - WP_CLI::error( 'You must specify either a block name or --index.' ); - } - - // Use Block_Processor_Helper for consistent parsing across WP versions. - $filtered_blocks = Block_Processor_Helper::parse_all( $post->post_content ); - - // For serialization, we need the full parse_blocks output including whitespace blocks. - $blocks = parse_blocks( $post->post_content ); - - // Build index map from filtered to original indices. - $index_map = []; - $filtered_idx = 0; - foreach ( $blocks as $original_idx => $block ) { - if ( ! empty( $block['blockName'] ) ) { - $index_map[ $filtered_idx ] = $original_idx; - ++$filtered_idx; - } - } - - $removed_count = 0; - - if ( null !== $indices ) { - $index_array = array_map( 'intval', explode( ',', $indices ) ); - - // Validate all indices first. - foreach ( $index_array as $idx ) { - if ( $idx < 0 || $idx >= count( $filtered_blocks ) ) { - WP_CLI::error( "Invalid index: {$idx}. Post has " . count( $filtered_blocks ) . ' block(s) (0-indexed).' ); - } - } - - // Map to original indices and sort in reverse order to remove from end first. - $original_indices = array_map( - function ( $idx ) use ( $index_map ) { - return $index_map[ $idx ]; - }, - $index_array - ); - rsort( $original_indices ); - - foreach ( $original_indices as $original_idx ) { - array_splice( $blocks, (int) $original_idx, 1 ); - ++$removed_count; - } - } elseif ( $remove_all && null !== $block_name ) { - $new_blocks = []; - foreach ( $blocks as $block ) { - if ( $block['blockName'] === $block_name ) { - ++$removed_count; - } else { - $new_blocks[] = $block; - } - } - $blocks = $new_blocks; - } elseif ( null !== $block_name ) { - foreach ( $blocks as $idx => $block ) { - if ( $block['blockName'] === $block_name ) { - array_splice( $blocks, (int) $idx, 1 ); - ++$removed_count; - break; - } - } - } - - if ( 0 === $removed_count ) { - WP_CLI::warning( 'No blocks were removed.' ); - return; - } - - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $removed_count ); - } else { - $block_word = 1 === $removed_count ? 'block' : 'blocks'; - WP_CLI::success( "Removed {$removed_count} {$block_word} from post {$post->ID}." ); - } - } - - /** - * Replaces blocks in a post. - * - * Replaces blocks of one type with blocks of another type. Can also - * be used to update block attributes without changing the block type. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to modify. - * - * <old-block-name> - * : The block type name to replace. - * - * <new-block-name> - * : The new block type name. - * - * [--attrs=<attrs>] - * : New block attributes as JSON. - * - * [--content=<content>] - * : New block content. Use '{content}' to preserve original content. - * - * [--all] - * : Replace all matching blocks. By default, only the first match is replaced. - * - * [--porcelain] - * : Output just the number of blocks replaced. - * - * ## EXAMPLES - * - * # Replace the first paragraph block with a heading. - * $ wp post block replace 123 core/paragraph core/heading - * Success: Replaced 1 block in post 123. - * - * # Replace all paragraphs with preformatted blocks, keeping content. - * $ wp post block replace 123 core/paragraph core/preformatted --content='{content}' --all - * Success: Replaced 3 blocks in post 123. - * - * # Change all h2 headings to h3. - * $ wp post block replace 123 core/heading core/heading --attrs='{"level":3}' --all - * - * # Replace and get count for scripting. - * $ wp post block replace 123 core/quote core/pullquote --all --porcelain - * 2 - * - * @subcommand replace - */ - public function replace( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $old_block_name = $args[1]; - $new_block_name = $args[2]; - $attrs_json = Utils\get_flag_value( $assoc_args, 'attrs', null ); - $content = Utils\get_flag_value( $assoc_args, 'content', null ); - $replace_all = Utils\get_flag_value( $assoc_args, 'all', false ); - - $new_attrs = null; - if ( null !== $attrs_json ) { - $new_attrs = json_decode( $attrs_json, true ); - if ( null === $new_attrs ) { - WP_CLI::error( 'Invalid JSON provided for --attrs.' ); - } - } - - $blocks = parse_blocks( $post->post_content ); - $replaced_count = 0; - - foreach ( $blocks as $idx => $block ) { - if ( $block['blockName'] !== $old_block_name ) { - continue; - } - - $block_attrs = is_array( $new_attrs ) ? $new_attrs : ( is_array( $block['attrs'] ) ? $block['attrs'] : [] ); - $block_content = $content; - - if ( null === $block_content || '{content}' === $block_content ) { - $block_content = $block['innerHTML']; - } - - $blocks[ $idx ] = $this->create_block( $new_block_name, $block_attrs, (string) $block_content ); - ++$replaced_count; - - if ( ! $replace_all ) { - break; - } - } - - if ( 0 === $replaced_count ) { - WP_CLI::warning( "No blocks of type '{$old_block_name}' were found." ); - return; - } - - // @phpstan-ignore argument.type - $new_content = serialize_blocks( $blocks ); - $result = wp_update_post( - [ - 'ID' => $post->ID, - 'post_content' => $new_content, - ], - true - ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $replaced_count ); - } else { - $block_word = 1 === $replaced_count ? 'block' : 'blocks'; - WP_CLI::success( "Replaced {$replaced_count} {$block_word} in post {$post->ID}." ); - } - } - - /** - * Strips innerHTML and innerContent from blocks recursively. - * - * @param array $blocks Array of blocks. - * @return array Blocks with innerHTML stripped. - */ - private function strip_inner_html( $blocks ) { - return array_map( - function ( $block ) { - unset( $block['innerHTML'] ); - unset( $block['innerContent'] ); - if ( ! empty( $block['innerBlocks'] ) ) { - $block['innerBlocks'] = $this->strip_inner_html( $block['innerBlocks'] ); - } - return $block; - }, - $blocks - ); - } - - /** - * Creates a block array structure. - * - * @param string $block_name Block name. - * @param array $attrs Block attributes. - * @param string $content Block content. - * @return array Block structure. - */ - private function create_block( $block_name, $attrs, $content = '' ) { - $inner_html = $content; - - if ( ! empty( $content ) && ! preg_match( '/^</', $content ) ) { - $inner_html = "<p>{$content}</p>"; - } - - return [ - 'blockName' => $block_name, - 'attrs' => $attrs ?: [], - 'innerBlocks' => [], - 'innerHTML' => $inner_html, - 'innerContent' => [ $inner_html ], - ]; - } - - /** - * Aggregates block counts across posts. - * - * @param array $blocks Array of blocks. - * @param array $block_counts Reference to block counts. - * @param array $post_counts Reference to post counts per block type. - * @param int $post_id Current post ID. - * @param string|null $block_filter Optional filter for specific block type. - */ - private function aggregate_block_counts( $blocks, &$block_counts, &$post_counts, $post_id, $block_filter = null ) { - foreach ( $blocks as $block ) { - if ( empty( $block['blockName'] ) ) { - continue; - } - - $block_name = $block['blockName']; - - if ( null !== $block_filter && $block_name !== $block_filter ) { - // Still recurse into inner blocks in case they match. - if ( ! empty( $block['innerBlocks'] ) ) { - $this->aggregate_block_counts( $block['innerBlocks'], $block_counts, $post_counts, $post_id, $block_filter ); - } - continue; - } - - if ( ! isset( $block_counts[ $block_name ] ) ) { - $block_counts[ $block_name ] = 0; - $post_counts[ $block_name ] = []; - } - ++$block_counts[ $block_name ]; - $post_counts[ $block_name ][ $post_id ] = true; - - // Recurse into inner blocks. - if ( ! empty( $block['innerBlocks'] ) ) { - $this->aggregate_block_counts( $block['innerBlocks'], $block_counts, $post_counts, $post_id, $block_filter ); - } - } - } - - /** - * Deep copies a block structure. - * - * @param array $block Block to copy. - * @return array Copied block. - */ - private function deep_copy_block( $block ) { - $copy = $block; - - if ( ! empty( $copy['innerBlocks'] ) ) { - $copy['innerBlocks'] = array_map( [ $this, 'deep_copy_block' ], $copy['innerBlocks'] ); - } - - return $copy; - } - - /** - * Synchronizes block HTML with updated attributes. - * - * Applies the 'wp_cli_post_block_update_html' filter to allow handlers - * to update block HTML when attributes change. - * - * Custom handlers can be added via --require (using WP_CLI::add_wp_hook()) - * or in plugins/themes (using add_filter()): - * - * WP_CLI::add_wp_hook( 'wp_cli_post_block_update_html', function( $block, $new_attrs, $block_name ) { - * if ( 'core/button' === $block_name && isset( $new_attrs['url'] ) ) { - * // Update href in the HTML... - * } - * return $block; - * }, 10, 3 ); - * - * @see \WP_CLI\Entity\Block_HTML_Sync_Filters Default filter implementations. - * - * @param array $block The block structure. - * @param array $new_attrs The newly applied attributes. - * @return array The block with synchronized HTML. - */ - private function sync_html_with_attrs( $block, $new_attrs ) { - $block_name = $block['blockName'] ?? ''; - - /** - * Filters a block after attribute updates to sync HTML with attributes. - * - * Allows handlers to update block HTML when attributes change. - * - * @since 3.0.0 - * - * @see \WP_CLI\Entity\Block_HTML_Sync_Filters Default filter implementations. - * - * @param array $block The block structure with updated attrs. - * @param array $new_attrs The newly applied attributes. - * @param string $block_name The block type name (e.g., 'core/heading'). - */ - return apply_filters( 'wp_cli_post_block_update_html', $block, $new_attrs, $block_name ); - } -} diff --git a/src/Post_Command.php b/src/Post_Command.php index 5e10c546b..2ee342bcb 100644 --- a/src/Post_Command.php +++ b/src/Post_Command.php @@ -1,11 +1,5 @@ <?php -use WP_CLI\CommandWithDBObject; -use WP_CLI\Entity\Block_Processor_Helper; -use WP_CLI\Fetchers\Post as PostFetcher; -use WP_CLI\Fetchers\User as UserFetcher; -use WP_CLI\Utils; - /** * Manages posts, content, and meta. * @@ -25,21 +19,19 @@ * * @package wp-cli */ -class Post_Command extends CommandWithDBObject { +class Post_Command extends \WP_CLI\CommandWithDBObject { - protected $obj_type = 'post'; - protected $obj_fields = [ + protected $obj_type = 'post'; + protected $obj_fields = array( 'ID', 'post_title', 'post_name', 'post_date', 'post_status', - ]; - - private $fetcher; + ); public function __construct() { - $this->fetcher = new PostFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\Post; } /** @@ -86,9 +78,6 @@ public function __construct() { * [--post_name=<post_name>] * : The post name. Default is the sanitized post title when creating a new post. * - * [--from-post=<post_id>] - * : Post id of a post to be duplicated. - * * [--to_ping=<to_ping>] * : Space or carriage return-separated list of URLs to ping. Default empty. * @@ -122,8 +111,6 @@ public function __construct() { * [--tax_input=<tax_input>] * : Array of taxonomy terms keyed by their taxonomy name. Default empty. * - * Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 - * * [--meta_input=<meta_input>] * : Array in JSON format of post meta values keyed by their post meta key. Default empty. * @@ -150,7 +137,7 @@ public function __construct() { * ## EXAMPLES * * # Create post and schedule for future - * $ wp post create --post_type=post --post_title='A future post' --post_status=future --post_date='2030-12-01 07:00:00' + * $ wp post create --post_type=page --post_title='A future post' --post_status=future --post_date='2020-12-01 07:00:00' * Success: Created post 1921. * * # Create post with content from given file @@ -158,99 +145,38 @@ public function __construct() { * Success: Created post 1922. * * # Create a post with multiple meta values. - * $ wp post create --post_title='A post' --post_content='Just a small post.' --meta_input='{"key1":"value1","key2":"value2"}' + * $ wp post create --post_title='A post' --post_content='Just a small post.' --meta_input='{"key1":"value1","key2":"value2"} * Success: Created post 1923. - * - * # Create a duplicate post from existing posts. - * $ wp post create --from-post=123 --post_title='Different Title' - * Success: Created post 2350. */ public function create( $args, $assoc_args ) { if ( ! empty( $args[0] ) ) { $assoc_args['post_content'] = $this->read_from_file_or_stdin( $args[0] ); } - if ( Utils\get_flag_value( $assoc_args, 'edit' ) ) { - $input = Utils\get_flag_value( $assoc_args, 'post_content', '' ); + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'edit' ) ) { + $input = \WP_CLI\Utils\get_flag_value( $assoc_args, 'post_content', '' ); - $output = $this->_edit( $input, 'WP-CLI: New Post' ); - if ( $output ) { + if ( $output = $this->_edit( $input, 'WP-CLI: New Post' ) ) $assoc_args['post_content'] = $output; - } else { + else $assoc_args['post_content'] = $input; - } } if ( isset( $assoc_args['post_category'] ) ) { $assoc_args['post_category'] = $this->get_category_ids( $assoc_args['post_category'] ); } - $array_arguments = [ 'meta_input', 'tax_input' ]; - $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - - if ( isset( $assoc_args['from-post'] ) ) { - $post = $this->fetcher->get_check( $assoc_args['from-post'] ); - $post_arr = get_object_vars( $post ); - $post_id = $post_arr['ID']; - unset( $post_arr['post_date'] ); - unset( $post_arr['post_date_gmt'] ); - unset( $post_arr['guid'] ); - unset( $post_arr['ID'] ); - - if ( empty( $assoc_args['meta_input'] ) ) { - $assoc_args['meta_input'] = $this->get_metadata( $post_id ); - } - if ( empty( $assoc_args['post_category'] ) ) { - $post_arr['post_category'] = $this->get_category( $post_id ); - } - if ( empty( $assoc_args['tags_input'] ) ) { - $post_arr['tags_input'] = $this->get_tags( $post_id ); - } - $assoc_args = array_merge( $post_arr, $assoc_args ); + if ( isset( $assoc_args['meta_input'] ) && \WP_CLI\Utils\wp_version_compare( '4.4', '<' ) ) { + WP_CLI::warning( "The 'meta_input' field was only introduced in WordPress 4.4 so will have no effect." ); } - $assoc_args = wp_slash( $assoc_args ); - parent::_create( - $args, - $assoc_args, - function ( $params ) { - $filter_callback = null; - - if ( 0 === get_current_user_id() && ! empty( $params['tax_input'] ) ) { - $allowed_caps = []; - /** - * @var string $taxonomy - */ - foreach ( array_keys( $params['tax_input'] ) as $taxonomy ) { - $tax_obj = get_taxonomy( $taxonomy ); - if ( $tax_obj ) { - $primitive_caps = map_meta_cap( $tax_obj->cap->assign_terms, 0 ); - $allowed_caps = array_merge( $allowed_caps, $primitive_caps ); - } - } - - if ( ! empty( $allowed_caps ) ) { - $filter_callback = function ( $allcaps, $caps ) use ( $allowed_caps ) { - foreach ( $caps as $cap ) { - if ( in_array( $cap, $allowed_caps, true ) ) { - $allcaps[ $cap ] = true; - } - } - return $allcaps; - }; - add_filter( 'user_has_cap', $filter_callback, 10, 2 ); - } - } + $array_arguments = array( 'meta_input' ); + $assoc_args = \WP_CLI\Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - $result = wp_insert_post( $params, true ); - - if ( $filter_callback ) { - remove_filter( 'user_has_cap', $filter_callback ); - } - - return $result; - } - ); + $assoc_args = wp_slash( $assoc_args ); + parent::_create( $args, $assoc_args, function ( $params ) { + return wp_insert_post( $params, true ); + } ); } /** @@ -333,8 +259,6 @@ function ( $params ) { * [--tax_input=<tax_input>] * : Array of taxonomy terms keyed by their taxonomy name. Default empty. * - * Note: In WordPress core, this normally requires a user context to satisfy capability checks. WP-CLI bypasses this for convenience. See https://core.trac.wordpress.org/ticket/19373 - * * [--meta_input=<meta_input>] * : Array in JSON format of post meta values keyed by their post meta key. Default empty. * @@ -357,22 +281,12 @@ function ( $params ) { * Success: Updated post 123. * * # Update a post with multiple meta values. - * $ wp post update 123 --meta_input='{"key1":"value1","key2":"value2"}' - * Success: Updated post 123. - * - * # Update multiple posts at once. - * $ wp post update 123 456 --post_author=789 + * $ wp post update 123 --meta_input='{"key1":"value1","key2":"value2"} * Success: Updated post 123. - * Success: Updated post 456. - * - * # Update all posts of a given post type at once. - * $ wp post update $(wp post list --post_type=page --format=ids) --post_author=123 - * Success: Updated post 123. - * Success: Updated post 456. */ public function update( $args, $assoc_args ) { - foreach ( $args as $key => $arg ) { + foreach( $args as $key => $arg ) { if ( is_numeric( $arg ) ) { continue; } @@ -386,51 +300,17 @@ public function update( $args, $assoc_args ) { $assoc_args['post_category'] = $this->get_category_ids( $assoc_args['post_category'] ); } - $array_arguments = [ 'meta_input', 'tax_input' ]; - $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - - $assoc_args = wp_slash( $assoc_args ); - parent::_update( - $args, - $assoc_args, - function ( $params ) { - $filter_callback = null; - - if ( 0 === get_current_user_id() && ! empty( $params['tax_input'] ) ) { - $allowed_caps = []; - /** - * @var string $taxonomy - */ - foreach ( array_keys( $params['tax_input'] ) as $taxonomy ) { - $tax_obj = get_taxonomy( $taxonomy ); - if ( $tax_obj ) { - $primitive_caps = map_meta_cap( $tax_obj->cap->assign_terms, 0 ); - $allowed_caps = array_merge( $allowed_caps, $primitive_caps ); - } - } - - if ( ! empty( $allowed_caps ) ) { - $filter_callback = function ( $allcaps, $caps ) use ( $allowed_caps ) { - foreach ( $caps as $cap ) { - if ( in_array( $cap, $allowed_caps, true ) ) { - $allcaps[ $cap ] = true; - } - } - return $allcaps; - }; - add_filter( 'user_has_cap', $filter_callback, 10, 2 ); - } - } - - $result = wp_update_post( $params, true ); + if ( isset( $assoc_args['meta_input'] ) && \WP_CLI\Utils\wp_version_compare( '4.4', '<' ) ) { + WP_CLI::warning( "The 'meta_input' field was only introduced in WordPress 4.4 so will have no effect." ); + } - if ( $filter_callback ) { - remove_filter( 'user_has_cap', $filter_callback ); - } + $array_arguments = array( 'meta_input' ); + $assoc_args = \WP_CLI\Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - return $result; - } - ); + $assoc_args = wp_slash( $assoc_args ); + parent::_update( $args, $assoc_args, function ( $params ) { + return wp_update_post( $params, true ); + } ); } /** @@ -446,25 +326,22 @@ function ( $params ) { * # Launch system editor to edit post * $ wp post edit 123 */ - public function edit( $args, $assoc_args ) { + public function edit( $args, $_ ) { $post = $this->fetcher->get_check( $args[0] ); - $result = $this->_edit( $post->post_content, "WP-CLI post {$post->ID}" ); + $r = $this->_edit( $post->post_content, "WP-CLI post {$post->ID}" ); - if ( false === $result ) { - WP_CLI::warning( 'No change made to post content.' ); - } else { - $this->update( $args, [ 'post_content' => $result ] ); - } + if ( $r === false ) + \WP_CLI::warning( 'No change made to post content.', 'Aborted' ); + else + $this->update( $args, array( 'post_content' => $r, ) ); } - // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Whitelisting to provide backward compatibility to classes possibly extending this class. protected function _edit( $content, $title ) { - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. $content = apply_filters( 'the_editor_content', $content ); - $output = Utils\launch_editor_for_input( $content, $title ); - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling native WordPress hook. - return ( is_string( $output ) ) ? apply_filters( 'content_save_pre', $output ) : $output; + $output = \WP_CLI\Utils\launch_editor_for_input( $content, $title ); + return ( is_string( $output ) ) ? + apply_filters( 'content_save_pre', $output ) : $output; } /** @@ -496,11 +373,6 @@ protected function _edit( $content, $title ) { * * # Save the post content to a file * $ wp post get 123 --field=content > file.txt - * - * # Get the block version of a post (1 = has blocks, 0 = no blocks) - * # Requires WordPress 5.0+. - * $ wp post get 123 --field=block_version - * 1 */ public function get( $args, $assoc_args ) { $post = $this->fetcher->get_check( $args[0] ); @@ -508,14 +380,6 @@ public function get( $args, $assoc_args ) { $post_arr = get_object_vars( $post ); unset( $post_arr['filter'] ); - if ( ! isset( $post_arr['url'] ) ) { - $post_arr['url'] = get_permalink( $post->ID ); - } - - if ( function_exists( 'block_version' ) ) { - $post_arr['block_version'] = block_version( $post->post_content ); - } - if ( empty( $assoc_args['fields'] ) ) { $assoc_args['fields'] = array_keys( $post_arr ); } @@ -544,12 +408,6 @@ public function get( $args, $assoc_args ) { * $ wp post delete 123 --force * Success: Deleted post 123. * - * # Delete multiple posts - * $ wp post delete 123 456 789 - * Success: Trashed post 123. - * Success: Trashed post 456. - * Success: Trashed post 789. - * * # Delete all pages * $ wp post delete $(wp post list --post_type='page' --format=ids) * Success: Trashed post 1164. @@ -561,49 +419,29 @@ public function get( $args, $assoc_args ) { * Success: Deleted post 1294. */ public function delete( $args, $assoc_args ) { - $defaults = [ 'force' => false ]; + $defaults = array( + 'force' => false, + ); $assoc_args = array_merge( $defaults, $assoc_args ); - parent::_delete( $args, $assoc_args, [ $this, 'delete_callback' ] ); - } - - /** - * Callback used to delete a post. - * - * @param $post_id - * @param $assoc_args - * @return array - */ - protected function delete_callback( $post_id, $assoc_args ) { - $status = get_post_status( $post_id ); - $post_type = get_post_type( $post_id ); + parent::_delete( $args, $assoc_args, function ( $post_id, $assoc_args ) { + $status = get_post_status( $post_id ); + $post_type = get_post_type( $post_id ); + $r = wp_delete_post( $post_id, $assoc_args['force'] ); - $force_delete = $assoc_args['force'] || 'trash' === $status || 'revision' === $post_type; + if ( $r ) { + $action = $assoc_args['force'] || 'trash' === $status || 'revision' === $post_type ? 'Deleted' : 'Trashed'; - if ( $force_delete || ! EMPTY_TRASH_DAYS ) { - if ( ! wp_delete_post( $post_id, true ) ) { - return [ 'error', "Failed deleting post {$post_id}." ]; + return array( 'success', "$action post $post_id." ); + } else { + return array( 'error', "Failed deleting post $post_id." ); } - - return [ 'success', "Deleted post {$post_id}." ]; - } - - // Use wp_trash_post() directly because wp_delete_post() only auto-trashes - // 'post' and 'page' types, permanently deleting all other post types even - // when $force_delete is false. wp_trash_post() works for all post types. - if ( wp_trash_post( $post_id ) ) { - return [ 'success', "Trashed post {$post_id}." ]; - } - - return [ 'error', "Failed trashing post {$post_id}." ]; + } ); } /** * Gets a list of posts. * - * Display posts based on all arguments supported by [WP_Query()](https://developer.wordpress.org/reference/classes/wp_query/). - * Only shows post types marked as post by default. - * * ## OPTIONS * * [--<field>=<value>] @@ -695,54 +533,35 @@ protected function delete_callback( $post_id, $assoc_args ) { * | 1 | Hello world! | hello-world | 2016-06-01 14:31:12 | publish | * +----+--------------+-------------+---------------------+-------------+ * - * # List given post by a specific author - * $ wp post list --author=2 - * +----+-------------------+-------------------+---------------------+-------------+ - * | ID | post_title | post_name | post_date | post_status | - * +----+-------------------+-------------------+---------------------+-------------+ - * | 14 | New documentation | new-documentation | 2021-06-18 21:05:11 | publish | - * +----+-------------------+-------------------+---------------------+-------------+ - * * @subcommand list */ - public function list_( $args, $assoc_args ) { + public function list_( $_, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); - $defaults = [ + $defaults = array( 'posts_per_page' => -1, 'post_status' => 'any', - ]; - $array_arguments = [ 'date_query', 'tax_query', 'meta_query' ]; - $assoc_args = Utils\parse_shell_arrays( $assoc_args, $array_arguments ); - $query_args = array_merge( $defaults, $assoc_args ); - $query_args = self::process_csv_arguments_to_arrays( $query_args ); + ); + $query_args = array_merge( $defaults, $assoc_args ); + $query_args = self::process_csv_arguments_to_arrays( $query_args ); if ( isset( $query_args['post_type'] ) && 'any' !== $query_args['post_type'] ) { $query_args['post_type'] = explode( ',', $query_args['post_type'] ); } - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { $query_args['fields'] = 'ids'; - $query = new WP_Query( $query_args ); - // @phpstan-ignore argument.type + $query = new WP_Query( $query_args ); echo implode( ' ', $query->posts ); - } elseif ( 'count' === $formatter->format ) { + } else if ( 'count' === $formatter->format ) { $query_args['fields'] = 'ids'; - $query = new WP_Query( $query_args ); + $query = new WP_Query( $query_args ); $formatter->display_items( $query->posts ); } else { $query = new WP_Query( $query_args ); - $posts = array_map( - function ( $post ) { - /** - * @var \WP_Post $post - */ - - // @phpstan-ignore property.notFound - $post->url = get_permalink( $post->ID ); - return $post; - }, - $query->posts - ); + $posts = array_map( function( $post ) { + $post->url = get_permalink( $post->ID ); + return $post; + }, $query->posts ); $formatter->display_items( $posts ); } } @@ -785,10 +604,7 @@ function ( $post ) { * --- * * [--post_date=<yyyy-mm-dd-hh-ii-ss>] - * : The date of the post. Default is the current time. - * - * [--post_date_gmt=<yyyy-mm-dd-hh-ii-ss>] - * : The date of the post in the GMT timezone. Default is the value of --post_date. + * : The date of the generated posts. Default: current date * * [--post_content] * : If set, the command reads the post_content from STDIN. @@ -815,7 +631,7 @@ function ( $post ) { * Generating posts 100% [================================================] 0:01 / 0:04 * * # Generate posts with fetched content. - * $ curl -N https://loripsum.net/api/5 | wp post generate --post_content --count=10 + * $ curl http://loripsum.net/api/5 | wp post generate --post_content --count=10 * % Total % Received % Xferd Average Speed Time Time Time Current * Dload Upload Total Spent Left Speed * 100 2509 100 2509 0 0 616 0 0:00:04 0:00:04 --:--:-- 616 @@ -826,124 +642,83 @@ function ( $post ) { * Success: Added custom field. * Success: Added custom field. * Success: Added custom field. - * - * @param array<string> $args Positional arguments. Unused. - * @param array{count: string, post_type: string, post_status: string, post_title: string, post_author: string, post_date?: string, post_date_gmt?: string, post_content?: string, max_depth: string, format: string} $assoc_args Associative arguments. */ public function generate( $args, $assoc_args ) { global $wpdb; - $defaults = [ - 'count' => 100, - 'max_depth' => 1, - 'post_type' => 'post', - 'post_status' => 'publish', - 'post_author' => false, - 'post_date' => '', - 'post_date_gmt' => '', - 'post_content' => '', - 'post_title' => '', - ]; - - $post_data = array_merge( $defaults, $assoc_args ); - - $post_data['post_date'] = $this->maybe_convert_hyphenated_date_format( $post_data['post_date'] ); - $post_data['post_date_gmt'] = $this->maybe_convert_hyphenated_date_format( $post_data['post_date_gmt'] ); - - // Add time if the string is a valid date without time. - $date = DateTime::createFromFormat( 'Y-m-d', $post_data['post_date'] ); - if ( $date && $date->format( 'Y-m-d' ) === $post_data['post_date'] ) { - $post_data['post_date'] .= ' 00:00:00'; - } - - $date_gmt = DateTime::createFromFormat( 'Y-m-d', $post_data['post_date_gmt'] ); - if ( $date_gmt && $date_gmt->format( 'Y-m-d' ) === $post_data['post_date_gmt'] ) { - $post_data['post_date_gmt'] .= ' 00:00:00'; - } - - // In older WordPress versions, wp_insert_post post dates default to the current time when a value is absent. We need to send a value for post_date_gmt if post_date is set and vice versa. - if ( ! empty( $post_data['post_date'] ) && empty( $post_data['post_date_gmt'] ) ) { - $post_data['post_date_gmt'] = get_gmt_from_date( $post_data['post_date'] ); - } - - if ( ! empty( $post_data['post_date_gmt'] ) && empty( $post_data['post_date'] ) ) { - $post_data['post_date'] = get_date_from_gmt( $post_data['post_date_gmt'] ); - } + $defaults = array( + 'count' => 100, + 'max_depth' => 1, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_author' => false, + 'post_date' => current_time( 'mysql' ), + 'post_content' => '', + 'post_title' => '', + ); + extract( array_merge( $defaults, $assoc_args ), EXTR_SKIP ); - if ( ! post_type_exists( $post_data['post_type'] ) ) { - WP_CLI::error( "'{$post_data['post_type']}' is not a registered post type." ); + // @codingStandardsIgnoreStart + if ( !post_type_exists( $post_type ) ) { + WP_CLI::error( sprintf( "'%s' is not a registered post type.", $post_type ) ); } - if ( $post_data['post_author'] ) { - $user_fetcher = new UserFetcher(); - $post_data['post_author'] = $user_fetcher->get_check( $post_data['post_author'] )->ID; + if ( $post_author ) { + $user_fetcher = new \WP_CLI\Fetchers\User; + $post_author = $user_fetcher->get_check( $post_author )->ID; } - if ( Utils\get_flag_value( $assoc_args, 'post_content' ) ) { - if ( ! Utils\has_stdin() ) { - WP_CLI::error( 'The parameter `post_content` reads from STDIN.' ); - } - - $post_data['post_content'] = (string) file_get_contents( 'php://stdin' ); + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'post_content' ) ) { + $post_content = file_get_contents( 'php://stdin' ); } // Get the total number of posts. - $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = %s", $post_data['post_type'] ) ); + $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = %s", $post_type ) ); - /** - * @var \WP_Post_Type $post_type - */ - $post_type = get_post_type_object( $post_data['post_type'] ); + $label = ! empty( $post_title ) ? $post_title : get_post_type_object( $post_type )->labels->singular_name; - $label = ! empty( $post_data['post_title'] ) - ? $post_data['post_title'] - : $post_type->labels->singular_name; + $hierarchical = get_post_type_object( $post_type )->hierarchical; - $limit = $post_data['count'] + $total; + $limit = $count + $total; - $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'progress' ); $notify = false; if ( 'progress' === $format ) { - $notify = Utils\make_progress_bar( 'Generating posts', (int) $post_data['count'] ); + $notify = \WP_CLI\Utils\make_progress_bar( 'Generating posts', $count ); } $previous_post_id = 0; - $current_depth = 1; - $current_parent = 0; + $current_depth = 1; + $current_parent = 0; - for ( $index = $total; $index < $limit; $index++ ) { + for ( $i = $total; $i < $limit; $i++ ) { - if ( $post_type->hierarchical ) { + if ( $hierarchical ) { - if ( $this->maybe_make_child() && $current_depth < $post_data['max_depth'] ) { + if( $this->maybe_make_child() && $current_depth < $max_depth ) { $current_parent = $previous_post_id; - ++$current_depth; + $current_depth++; - } elseif ( $this->maybe_reset_depth() ) { + } else if( $this->maybe_reset_depth() ) { - $current_depth = 1; + $current_depth = 1; $current_parent = 0; } } - $args = [ - 'post_type' => $post_data['post_type'], - 'post_title' => ( ! empty( $post_data['post_title'] ) && $index === $total ) - ? $label - : "{$label} {$index}", - 'post_status' => $post_data['post_status'], - 'post_author' => (int) $post_data['post_author'], - 'post_parent' => $current_parent, - 'post_name' => ! empty( $post_data['post_title'] ) - ? sanitize_title( $post_data['post_title'] . ( $index === $total ? '' : "-{$index}" ) ) - : "post-{$index}", - 'post_date' => $post_data['post_date'], - 'post_date_gmt' => $post_data['post_date_gmt'], - 'post_content' => $post_data['post_content'], - ]; + $args = array( + 'post_type' => $post_type, + 'post_title' => ! empty( $post_title ) && $i === $total ? "$label" : "$label $i", + 'post_status' => $post_status, + 'post_author' => $post_author, + 'post_parent' => $current_parent, + 'post_name' => ! empty( $post_title ) ? sanitize_title( $post_title . ( $i === $total ) ? '' : '-$i' ) : "post-$i", + 'post_date' => $post_date, + 'post_content' => $post_content, + ); $post_id = wp_insert_post( $args, true ); if ( is_wp_error( $post_id ) ) { @@ -952,59 +727,30 @@ public function generate( $args, $assoc_args ) { $previous_post_id = $post_id; if ( 'ids' === $format ) { echo $post_id; - if ( $index < $limit - 1 ) { + if ( $i < $limit - 1 ) { echo ' '; } } } if ( 'progress' === $format ) { - // @phpstan-ignore method.nonObject $notify->tick(); } } if ( 'progress' === $format ) { - // @phpstan-ignore method.nonObject $notify->finish(); } - } - - /** - * Gets the post ID for a given URL. - * - * ## OPTIONS - * - * <url> - * : The URL of the post to get. - * - * ## EXAMPLES - * - * # Get post ID by URL - * $ wp post url-to-id https://example.com/?p=1 - * 1 - * - * @subcommand url-to-id - */ - public function url_to_id( $args, $assoc_args ) { - $post_id = url_to_postid( $args[0] ); - - $post = get_post( $post_id ); - - if ( null === $post ) { - WP_CLI::error( "Could not get post with url $args[0]." ); - } - - WP_CLI::print_value( $post_id, $assoc_args ); + // @codingStandardsIgnoreEnd } private function maybe_make_child() { - // 50% chance of making child post. - return ( wp_rand( 1, 2 ) === 1 ); + // 50% chance of making child post + return ( mt_rand(1, 2) == 1 ); } private function maybe_reset_depth() { - // 10% chance of resetting to root depth, - return ( wp_rand( 1, 10 ) === 7 ); + // 10% chance of reseting to root depth + return ( mt_rand(1, 10) == 7 ); } /** @@ -1014,15 +760,15 @@ private function maybe_reset_depth() { * @return string */ private function read_from_file_or_stdin( $arg ) { - if ( '-' !== $arg ) { + if ( $arg !== '-' ) { $readfile = $arg; if ( ! file_exists( $readfile ) || ! is_file( $readfile ) ) { - WP_CLI::error( "Unable to read content from '{$readfile}'." ); + \WP_CLI::error( "Unable to read content from '$readfile'." ); } } else { $readfile = 'php://stdin'; } - return (string) file_get_contents( $readfile ); + return file_get_contents( $readfile ); } /** @@ -1034,7 +780,7 @@ private function read_from_file_or_stdin( $arg ) { private function get_category_ids( $arg ) { $categories = explode( ',', $arg ); - $category_ids = []; + $category_ids = array(); foreach ( $categories as $post_category ) { if ( trim( $post_category ) ) { if ( is_numeric( $post_category ) && (int) $post_category ) { @@ -1043,202 +789,12 @@ private function get_category_ids( $arg ) { $category_id = category_exists( $post_category ); } if ( ! $category_id ) { - WP_CLI::error( "No such post category '{$post_category}'." ); + WP_CLI::error( "No such post category '$post_category'." ); } $category_ids[] = $category_id; } } // If no category ids found, return exploded array for compat with previous WP-CLI versions. - return $category_ids ?: $categories; - } - - /** - * Get post metadata. - * - * @param $post_id ID of the post. - * - * @return array - */ - private function get_metadata( $post_id ) { - /** - * @var array<string, array<string>> $metadata - */ - $metadata = get_metadata( 'post', $post_id ); - $items = []; - foreach ( $metadata as $key => $values ) { - foreach ( $values as $item_value ) { - $item_value = maybe_unserialize( $item_value ); - $items[ $key ] = $item_value; - } - } - - return $items; - } - - /** - * Get Categories of a post. - * - * @param $post_id ID of the post. - * - * @return array - */ - private function get_category( $post_id ) { - $category_data = get_the_category( $post_id ); - $category_arr = []; - foreach ( $category_data as $cat ) { - array_push( $category_arr, $cat->term_id ); - } - - return $category_arr; - } - - /** - * Get Tags of a post. - * - * @param $post_id ID of the post. - * - * @return array - */ - private function get_tags( $post_id ) { - $tag_data = get_the_tags( $post_id ); - $tag_arr = []; - if ( $tag_data && ! is_wp_error( $tag_data ) ) { - foreach ( $tag_data as $tag ) { - array_push( $tag_arr, $tag->slug ); - } - } - - return $tag_arr; - } - - /** - * Verifies whether a post exists. - * - * Displays a success message if the post does exist. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to check. - * - * ## EXAMPLES - * - * # The post exists. - * $ wp post exists 1337 - * Success: Post with ID 1337 exists. - * $ echo $? - * 0 - * - * # The post does not exist. - * $ wp post exists 10000 - * $ echo $? - * 1 - */ - public function exists( $args ) { - if ( $this->fetcher->get( $args[0] ) ) { - WP_CLI::success( "Post with ID {$args[0]} exists." ); - } else { - WP_CLI::halt( 1 ); - } - } - - /** - * Checks if a post contains any blocks. - * - * Exits with return code 0 if the post contains blocks, - * or return code 1 if it does not. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to check. - * - * ## EXAMPLES - * - * # Check if post contains blocks. - * $ wp post has-blocks 123 - * Success: Post 123 contains blocks. - * - * # Check a classic (non-block) post. - * $ wp post has-blocks 456 - * Error: Post 456 does not contain blocks. - * - * # Use in a shell conditional. - * $ if wp post has-blocks 123 2>/dev/null; then - * > echo "Post uses blocks" - * > fi - * - * @subcommand has-blocks - */ - public function has_blocks( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - - if ( Block_Processor_Helper::has_blocks( $post->post_content ) ) { - WP_CLI::success( "Post {$post->ID} contains blocks." ); - } else { - WP_CLI::error( "Post {$post->ID} does not contain blocks." ); - } - } - - /** - * Checks if a post contains a specific block type. - * - * Exits with return code 0 if the post contains the specified block, - * or return code 1 if it does not. - * - * ## OPTIONS - * - * <id> - * : The ID of the post to check. - * - * <block-name> - * : The block type name to check for (e.g., 'core/paragraph'). - * - * ## EXAMPLES - * - * # Check if post contains a paragraph block. - * $ wp post has-block 123 core/paragraph - * Success: Post 123 contains block 'core/paragraph'. - * - * # Check for a heading block. - * $ wp post has-block 123 core/heading - * Success: Post 123 contains block 'core/heading'. - * - * # Check for a block that doesn't exist. - * $ wp post has-block 123 core/gallery - * Error: Post 123 does not contain block 'core/gallery'. - * - * # Check for a custom block from a plugin. - * $ wp post has-block 123 my-plugin/custom-block - * - * @subcommand has-block - */ - public function has_block( $args, $assoc_args ) { - $post = $this->fetcher->get_check( $args[0] ); - $block_name = $args[1]; - - if ( has_block( $block_name, $post ) ) { - WP_CLI::success( "Post {$post->ID} contains block '{$block_name}'." ); - } else { - WP_CLI::error( "Post {$post->ID} does not contain block '{$block_name}'." ); - } - } - - /** - * Convert a date-time string with a hyphen separator to a space separator. - * - * @param string $date_string The date-time string to convert. - * @return string The converted date-time string. - * - * Example: - * maybe_convert_hyphenated_date_format( "2018-07-05-17:17:17" ); - * Returns: "2018-07-05 17:17:17" - */ - private function maybe_convert_hyphenated_date_format( $date_string ) { - // Check if the date string matches the format with the hyphen between date and time. - if ( preg_match( '/^(\d{4}-\d{2}-\d{2})-(\d{2}:\d{2}:\d{2})$/', $date_string, $matches ) ) { - return $matches[1] . ' ' . $matches[2]; - } - return $date_string; + return $category_ids ? $category_ids : $categories; } } diff --git a/src/Post_Meta_Command.php b/src/Post_Meta_Command.php index dc8bea1ac..eca85f1fc 100644 --- a/src/Post_Meta_Command.php +++ b/src/Post_Meta_Command.php @@ -1,8 +1,5 @@ <?php -use WP_CLI\CommandWithMeta; -use WP_CLI\Fetchers\Post as PostFetcher; - /** * Adds, updates, deletes, and lists post custom fields. * @@ -24,165 +21,17 @@ * $ wp post meta delete 123 _wp_page_template * Success: Deleted custom field. */ -class Post_Meta_Command extends CommandWithMeta { +class Post_Meta_Command extends \WP_CLI\CommandWithMeta { protected $meta_type = 'post'; /** * Check that the post ID exists * - * @param string|int $object_id - * @return int|never + * @param int */ protected function check_object_id( $object_id ) { - $fetcher = new PostFetcher(); - $post = $fetcher->get_check( (string) $object_id ); + $fetcher = new \WP_CLI\Fetchers\Post; + $post = $fetcher->get_check( $object_id ); return $post->ID; } - - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false The meta ID on success, false on failure. - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_post_meta( $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_post_meta( $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_post_meta( $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_post_meta( $object_id, $meta_key, $meta_value ); - } - - /** - * Cleans up duplicate post meta values on a post. - * - * ## OPTIONS - * - * <id> - * : ID of the post to clean. - * - * <key> - * : Meta key to clean up. - * - * ## EXAMPLES - * - * # Delete duplicate post meta. - * wp post meta clean-duplicates 1234 enclosure - * Success: Cleaned up duplicate 'enclosure' meta values. - * - * @subcommand clean-duplicates - */ - public function clean_duplicates( $args, $assoc_args ) { - global $wpdb; - - list( $post_id, $key ) = $args; - - $metas = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$wpdb->postmeta} WHERE meta_key=%s AND post_id=%d", - $key, - $post_id - ) - ); - - if ( empty( $metas ) ) { - WP_CLI::error( sprintf( 'No meta values found for \'%s\'.', $key ) ); - } - - $uniq_metas = array(); - $dupe_metas = array(); - foreach ( $metas as $meta ) { - if ( ! isset( $uniq_metas[ $meta->meta_value ] ) ) { - $uniq_metas[ $meta->meta_value ] = (int) $meta->meta_id; - } else { - $dupe_metas[] = (int) $meta->meta_id; - } - } - - if ( count( $dupe_metas ) ) { - WP_CLI::confirm( - sprintf( - 'Are you sure you want to delete %d duplicate meta values and keep %d valid meta value?', - count( $dupe_metas ), - count( $uniq_metas ) - ) - ); - foreach ( $dupe_metas as $meta_id ) { - delete_metadata_by_mid( 'post', $meta_id ); - WP_CLI::log( sprintf( 'Deleted meta id %d.', $meta_id ) ); - } - WP_CLI::success( sprintf( 'Cleaned up duplicate \'%s\' meta values.', $key ) ); - } else { - WP_CLI::success( - sprintf( - 'Nothing to clean up: found %d valid meta value and %d duplicates.', - count( $uniq_metas ), - count( $dupe_metas ) - ) - ); - } - } } diff --git a/src/Post_Revision_Command.php b/src/Post_Revision_Command.php deleted file mode 100644 index c5a696a76..000000000 --- a/src/Post_Revision_Command.php +++ /dev/null @@ -1,436 +0,0 @@ -<?php - -use WP_CLI\Utils; - -/** - * Manages post revisions. - * - * ## EXAMPLES - * - * # Restore a post revision - * $ wp post revision restore 123 - * Success: Restored revision 123. - * - * # Show diff between two revisions - * $ wp post revision diff 123 456 - * - * @package wp-cli - */ -class Post_Revision_Command { - - /** - * Valid post fields that can be compared. - * - * @var array<string> - */ - private $valid_fields = [ - 'post_title', - 'post_content', - 'post_excerpt', - 'post_name', - 'post_status', - 'post_type', - 'post_author', - 'post_date', - 'post_date_gmt', - 'post_modified', - 'post_modified_gmt', - 'post_parent', - 'menu_order', - 'comment_status', - 'ping_status', - ]; - - /** - * Restores a post revision. - * - * ## OPTIONS - * - * <revision_id> - * : The revision ID to restore. - * - * ## EXAMPLES - * - * # Restore a post revision - * $ wp post revision restore 123 - * Success: Restored revision 123. - * - * @subcommand restore - * - * @param array{0: string} $args Positional arguments. - */ - public function restore( $args ) { - $revision_id = (int) $args[0]; - - // Get the revision post - $revision = wp_get_post_revision( $revision_id ); - - /** - * Work around https://core.trac.wordpress.org/ticket/64643. - * @var int $revision_id - */ - - if ( ! $revision ) { - WP_CLI::error( "Invalid revision ID {$revision_id}." ); - } - - // Restore the revision - $restored_post_id = wp_restore_post_revision( $revision_id ); - - // wp_restore_post_revision() returns post ID on success, false on failure, or null if revision is same as current - if ( false === $restored_post_id ) { - WP_CLI::error( "Failed to restore revision {$revision_id}." ); - } elseif ( null === $restored_post_id ) { - WP_CLI::warning( "Revision {$revision_id} is the same as the current post. No action taken." ); - } else { - WP_CLI::success( "Restored revision {$revision_id}." ); - } - } - - /** - * Gets a post or revision object by ID. - * - * @param int $id The post or revision ID. - * @param string $name The name to use in error messages ('from' or 'to'). - * @return \WP_Post The post or revision object. - */ - private function get_post_or_revision( $id, $name ) { - $post = wp_get_post_revision( $id ); - - /** - * Work around https://core.trac.wordpress.org/ticket/64643. - * @var int $id - */ - - if ( ! $post instanceof \WP_Post ) { - // Try as a regular post - $post = get_post( $id ); - if ( ! $post instanceof \WP_Post ) { - WP_CLI::error( "Invalid '{$name}' ID {$id}." ); - } - } - - return $post; - } - - /** - * Shows the difference between two revisions. - * - * ## OPTIONS - * - * <from> - * : The 'from' revision ID or post ID. - * - * [<to>] - * : The 'to' revision ID or post ID. If not provided, compares with the current post. - * - * [--field=<field>] - * : Compare specific field(s). Default: post_content - * - * ## EXAMPLES - * - * # Show diff between two revisions - * $ wp post revision diff 123 456 - * - * # Show diff between a revision and the current post - * $ wp post revision diff 123 - * - * @subcommand diff - * - * @param array{0: string, 1?: string} $args Positional arguments. - * @param array{field?: string} $assoc_args Associative arguments. - */ - public function diff( $args, $assoc_args ) { - $from_id = (int) $args[0]; - $to_id = isset( $args[1] ) ? (int) $args[1] : null; - $field = Utils\get_flag_value( $assoc_args, 'field', 'post_content' ); - - // Get the 'from' revision or post - $from_revision = $this->get_post_or_revision( $from_id, 'from' ); - - // Get the 'to' revision or post - $to_revision = null; - if ( $to_id ) { - $to_revision = $this->get_post_or_revision( $to_id, 'to' ); - } elseif ( 'revision' === $from_revision->post_type ) { - // If no 'to' ID provided, use the parent post of the revision - $to_revision = get_post( $from_revision->post_parent ); - if ( ! $to_revision instanceof \WP_Post ) { - WP_CLI::error( "Could not find parent post for revision {$from_id}." ); - } - } else { - WP_CLI::error( "Please provide a 'to' revision ID when comparing posts." ); - } - - // Validate field - if ( ! in_array( $field, $this->valid_fields, true ) ) { - WP_CLI::error( "Invalid field '{$field}'. Valid fields: " . implode( ', ', $this->valid_fields ) ); - } - - // Get the field values - use isset to check if field exists on the object - if ( ! isset( $from_revision->{$field} ) ) { - WP_CLI::error( "Field '{$field}' not found on post/revision {$from_id}." ); - } - - // $to_revision is guaranteed to be non-null at this point due to earlier validation - if ( ! isset( $to_revision->{$field} ) ) { - $to_error_id = $to_id ?? $to_revision->ID; - WP_CLI::error( "Field '{$field}' not found on revision/post {$to_error_id}." ); - } - - $left_string = $from_revision->{$field}; - $right_string = $to_revision->{$field}; - - // Split content into lines for diff - $left_lines = explode( "\n", $left_string ); - $right_lines = explode( "\n", $right_string ); - - if ( ! class_exists( 'Text_Diff', false ) ) { - // @phpstan-ignore constant.notFound - require ABSPATH . WPINC . '/wp-diff.php'; - } - - // Create Text_Diff object - $text_diff = new \Text_Diff( 'auto', [ $left_lines, $right_lines ] ); - - // Check if there are any changes - if ( 0 === $text_diff->countAddedLines() && 0 === $text_diff->countDeletedLines() ) { - WP_CLI::success( 'No difference found.' ); - return; - } - - // Display header - WP_CLI::line( - WP_CLI::colorize( - sprintf( - '%%y--- %s (%s) - ID %d%%n', - $from_revision->post_title, - $from_revision->post_modified, - $from_revision->ID - ) - ) - ); - WP_CLI::line( - WP_CLI::colorize( - sprintf( - '%%y+++ %s (%s) - ID %d%%n', - $to_revision->post_title, - $to_revision->post_modified, - $to_revision->ID - ) - ) - ); - WP_CLI::line( '' ); - - // Render the diff using CLI-friendly format - $this->render_cli_diff( $text_diff ); - } - - /** - * Renders a diff in CLI-friendly format with colors. - * - * @param \Text_Diff $diff The diff object to render. - */ - private function render_cli_diff( $diff ) { - $edits = $diff->getDiff(); - - foreach ( $edits as $edit ) { - if ( $edit instanceof \Text_Diff_Op_copy ) { - // Unchanged lines - show in default color - foreach ( $edit->orig as $line ) { - WP_CLI::line( ' ' . $line ); - } - } elseif ( $edit instanceof \Text_Diff_Op_add ) { - // Added lines - show in green - foreach ( $edit->final as $line ) { - WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) ); - } - } elseif ( $edit instanceof \Text_Diff_Op_delete ) { - // Deleted lines - show in red - foreach ( $edit->orig as $line ) { - WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) ); - } - } elseif ( $edit instanceof \Text_Diff_Op_change ) { - // Changed lines - show deletions in red, additions in green - foreach ( $edit->orig as $line ) { - WP_CLI::line( WP_CLI::colorize( '%r- ' . $line . '%n' ) ); - } - foreach ( $edit->final as $line ) { - WP_CLI::line( WP_CLI::colorize( '%g+ ' . $line . '%n' ) ); - } - } - } - } - - /** - * Deletes old post revisions. - * - * ## OPTIONS - * - * [<post-id>...] - * : One or more post IDs to prune revisions for. If not provided, prunes revisions for all posts. - * - * [--latest=<limit>] - * : Keep only the latest N revisions per post. Older revisions will be deleted. - * - * [--earliest=<limit>] - * : Keep only the earliest N revisions per post. Newer revisions will be deleted. - * - * [--yes] - * : Skip confirmation prompt. - * - * ## EXAMPLES - * - * # Delete all but the latest 5 revisions for post 123 - * $ wp post revision prune 123 --latest=5 - * Success: Deleted 3 revisions for post 123. - * - * # Delete all but the latest 5 revisions for all posts - * $ wp post revision prune --latest=5 - * Success: Deleted 150 revisions across 30 posts. - * - * # Delete all but the earliest 2 revisions for posts 123 and 456 - * $ wp post revision prune 123 456 --earliest=2 - * Success: Deleted 5 revisions for post 123. - * Success: Deleted 3 revisions for post 456. - * - * @subcommand prune - */ - public function prune( $args, $assoc_args ) { - $latest = Utils\get_flag_value( $assoc_args, 'latest', null ); - $earliest = Utils\get_flag_value( $assoc_args, 'earliest', null ); - - // Validate flags - if ( null === $latest && null === $earliest ) { - WP_CLI::error( 'Please specify either --latest or --earliest flag.' ); - } - - if ( null !== $latest && null !== $earliest ) { - WP_CLI::error( 'Cannot specify both --latest and --earliest flags.' ); - } - - $limit = $latest ?? $earliest; - $keep_latest = null !== $latest; - - if ( ! is_numeric( $limit ) || (int) $limit < 1 ) { - WP_CLI::error( 'Limit must be a positive integer.' ); - } - - $limit = (int) $limit; - - // Get posts to process - if ( ! empty( $args ) ) { - $post_ids = array_map( 'intval', $args ); - } else { - // Get all posts that have revisions - global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $post_ids = $wpdb->get_col( - "SELECT DISTINCT post_parent FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_parent > 0" - ); - $post_ids = array_map( 'intval', $post_ids ); - } - - if ( empty( $post_ids ) ) { - WP_CLI::warning( 'No posts found with revisions.' ); - return; - } - - // Confirm deletion if processing multiple posts without --yes flag - if ( count( $post_ids ) > 1 && ! Utils\get_flag_value( $assoc_args, 'yes', false ) ) { - WP_CLI::confirm( - sprintf( - 'Are you sure you want to prune revisions for %d posts?', - count( $post_ids ) - ), - $assoc_args - ); - } - - $total_deleted = 0; - $posts_processed = 0; - - foreach ( $post_ids as $post_id ) { - $deleted = $this->prune_post_revisions( $post_id, $limit, $keep_latest ); - - if ( false === $deleted ) { - WP_CLI::warning( "Post {$post_id} does not exist or has no revisions." ); - continue; - } - - if ( $deleted > 0 ) { - ++$posts_processed; - $total_deleted += $deleted; - WP_CLI::success( "Deleted {$deleted} revision" . ( $deleted > 1 ? 's' : '' ) . " for post {$post_id}." ); - } elseif ( count( $post_ids ) === 1 ) { - WP_CLI::success( "No revisions to delete for post {$post_id}." ); - } - } - - if ( count( $post_ids ) > 1 ) { - if ( $total_deleted > 0 ) { - WP_CLI::success( - sprintf( - 'Deleted %d revision%s across %d post%s.', - $total_deleted, - $total_deleted > 1 ? 's' : '', - $posts_processed, - $posts_processed > 1 ? 's' : '' - ) - ); - } else { - WP_CLI::success( 'No revisions to delete.' ); - } - } - } - - /** - * Prunes revisions for a single post. - * - * @param int $post_id The post ID. - * @param int $limit Number of revisions to keep. - * @param bool $keep_latest Whether to keep the latest revisions (true) or earliest (false). - * @return int|false Number of revisions deleted, or false if post not found. - */ - private function prune_post_revisions( $post_id, $limit, $keep_latest ) { - $post = get_post( $post_id ); - - if ( ! $post ) { - return false; - } - - // Get all revisions for this post - $revisions = wp_get_post_revisions( $post_id, [ 'order' => 'ASC' ] ); - - if ( empty( $revisions ) ) { - return false; - } - - $revision_count = count( $revisions ); - - // If we have fewer or equal revisions than the limit, nothing to delete - if ( $revision_count <= $limit ) { - return 0; - } - - // Determine which revisions to delete - $revisions_array = array_values( $revisions ); - - if ( $keep_latest ) { - // Keep the latest N, delete the rest (from beginning) - $to_delete = array_slice( $revisions_array, 0, $revision_count - $limit ); - } else { - // Keep the earliest N, delete the rest (from end) - $to_delete = array_slice( $revisions_array, $limit ); - } - - $deleted = 0; - foreach ( $to_delete as $revision ) { - if ( $revision instanceof \WP_Post && wp_delete_post_revision( $revision->ID ) ) { - ++$deleted; - } - } - - return $deleted; - } -} diff --git a/src/Post_Term_Command.php b/src/Post_Term_Command.php index f27040ba7..4be4c34b2 100644 --- a/src/Post_Term_Command.php +++ b/src/Post_Term_Command.php @@ -1,42 +1,19 @@ <?php -use WP_CLI\CommandWithTerms; -use WP_CLI\Fetchers\Post as PostFetcher; - /** * Adds, updates, removes, and lists post terms. * * ## EXAMPLES * - * # Set category post term `test` to the post ID 123 + * # Set post terms * $ wp post term set 123 test category - * Success: Set term. - * - * # Set category post terms `test` and `apple` to the post ID 123 - * $ wp post term set 123 test apple category * Success: Set terms. - * - * # List category post terms for the post ID 123 - * $ wp post term list 123 category --fields=term_id,slug - * +---------+-------+ - * | term_id | slug | - * +---------+-------+ - * | 2 | apple | - * | 3 | test | - * +----------+------+ - * - * # Remove category post terms `test` and `apple` for the post ID 123 - * $ wp post term remove 123 category test apple - * Success: Removed terms. - * */ -class Post_Term_Command extends CommandWithTerms { +class Post_Term_Command extends \WP_CLI\CommandWithTerms { protected $obj_type = 'post'; - private $fetcher; - public function __construct() { - $this->fetcher = new PostFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\Post; } protected function get_object_type() { diff --git a/src/Post_Type_Command.php b/src/Post_Type_Command.php index 335477bc2..1dbe8c763 100644 --- a/src/Post_Type_Command.php +++ b/src/Post_Type_Command.php @@ -1,7 +1,4 @@ <?php - -use WP_CLI\Formatter; - /** * Retrieves details on the site's registered post types. * @@ -37,38 +34,6 @@ class Post_Type_Command extends WP_CLI_Command { 'capability_type', ); - /** - * Gets the post counts for each supplied post type. - * - * @param array $post_types Post types to fetch counts for. - * @return array Associative array of post counts keyed by post type. - */ - protected function get_counts( $post_types ) { - global $wpdb; - - if ( count( $post_types ) <= 0 ) { - return []; - } - - $query = $wpdb->prepare( - "SELECT `post_type`, COUNT(*) AS `count` - FROM $wpdb->posts - WHERE `post_type` IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ') - GROUP BY `post_type`', - $post_types - ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is already prepared above. - $counts = $wpdb->get_results( $query ); - - // Make sure there's a count for every item. - $counts = array_merge( - array_fill_keys( $post_types, 0 ), - wp_list_pluck( $counts, 'count', 'post_type' ) - ); - - return $counts; - } - /** * Lists registered post types. * @@ -97,7 +62,7 @@ protected function get_counts( $post_types ) { * * ## AVAILABLE FIELDS * - * These fields will be displayed by default for each post type: + * These fields will be displayed by default for each term: * * * name * * label @@ -106,9 +71,7 @@ protected function get_counts( $post_types ) { * * public * * capability_type * - * These fields are optionally available: - * - * * count + * There are no optionally available fields. * * ## EXAMPLES * @@ -137,22 +100,7 @@ protected function get_counts( $post_types ) { public function list_( $args, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); - $fields = $formatter->fields; - $types = get_post_types( $assoc_args, 'objects' ); - $counts = []; - - if ( count( $types ) > 0 && in_array( 'count', $fields, true ) ) { - $counts = $this->get_counts( wp_list_pluck( $types, 'name' ) ); - } - - $types = array_map( - function ( $type ) use ( $counts ) { - // @phpstan-ignore property.notFound - $type->count = isset( $counts[ $type->name ] ) ? $counts[ $type->name ] : 0; - return $type; - }, - $types - ); + $types = get_post_types( $assoc_args, 'objects' ); $formatter->display_items( $types ); } @@ -182,24 +130,6 @@ function ( $type ) use ( $counts ) { * - yaml * --- * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for the specified post type: - * - * * name - * * label - * * description - * * hierarchical - * * public - * * capability_type - * * labels - * * cap - * * supports - * - * These fields are optionally available: - * - * * count - * * ## EXAMPLES * * # Get details about the 'page' post type. @@ -214,27 +144,14 @@ public function get( $args, $assoc_args ) { } if ( empty( $assoc_args['fields'] ) ) { - $default_fields = array_merge( - $this->fields, - array( - 'labels', - 'cap', - 'supports', - ) - ); + $default_fields = array_merge( $this->fields, array( + 'labels', + 'cap' + ) ); $assoc_args['fields'] = $default_fields; } - $formatter = $this->get_formatter( $assoc_args ); - $fields = $formatter->fields; - $count = 0; - - if ( in_array( 'count', $fields, true ) ) { - $count = $this->get_counts( [ $post_type->name ] ); - $count = $count[ $post_type->name ]; - } - $data = array( 'name' => $post_type->name, 'label' => $post_type->label, @@ -244,13 +161,13 @@ public function get( $args, $assoc_args ) { 'capability_type' => $post_type->capability_type, 'labels' => $post_type->labels, 'cap' => $post_type->cap, - 'supports' => get_all_post_type_supports( $post_type->name ), - 'count' => $count, ); + + $formatter = $this->get_formatter( $assoc_args ); $formatter->display_item( $data ); } private function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->fields, 'post-type' ); + return new \WP_CLI\Formatter( $assoc_args, $this->fields, 'post-type' ); } } diff --git a/src/Signup_Command.php b/src/Signup_Command.php deleted file mode 100644 index 4125ceea8..000000000 --- a/src/Signup_Command.php +++ /dev/null @@ -1,339 +0,0 @@ -<?php - -use WP_CLI\CommandWithDBObject; -use WP_CLI\Utils; -use WP_CLI\Fetchers\Signup as SignupFetcher; - -/** - * Manages signups on a multisite installation. - * - * ## EXAMPLES - * - * # List signups. - * $ wp user signup list - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * | signup_id | user_login | user_email | registered | active | activation_key | - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * | 1 | bobuser | bobuser@example.com | 2024-03-13 05:46:53 | 1 | 7320b2f009266618 | - * | 2 | johndoe | johndoe@example.com | 2024-03-13 06:24:44 | 0 | 9068d859186cd0b5 | - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * - * # Activate signup. - * $ wp user signup activate 2 - * Signup 2 activated. Password: bZFSGsfzb9xs - * Success: Activated 1 of 1 signups. - * - * # Delete signup. - * $ wp user signup delete 3 - * Signup 3 deleted. - * Success: Deleted 1 of 1 signups. - * - * @package wp-cli - */ -class Signup_Command extends CommandWithDBObject { - - protected $obj_type = 'signup'; - - protected $obj_id_key = 'signup_id'; - - protected $obj_fields = [ - 'signup_id', - 'user_login', - 'user_email', - 'registered', - 'active', - 'activation_key', - ]; - - private $fetcher; - - public function __construct() { - $this->fetcher = new SignupFetcher(); - } - - /** - * Lists signups. - * - * ## OPTIONS - * - * [--<field>=<value>] - * : Filter the list by a specific field. - * - * [--field=<field>] - * : Prints the value of a single field for each signup. - * - * [--fields=<fields>] - * : Limit the output to specific object fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - ids - * - json - * - count - * - yaml - * --- - * - * [--per_page=<per_page>] - * : Limits the signups to the given number. Defaults to none. - * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for each signup: - * - * * signup_id - * * user_login - * * user_email - * * registered - * * active - * * activation_key - * - * These fields are optionally available: - * - * * domain - * * path - * * title - * * activated - * * meta - * - * ## EXAMPLES - * - * # List signup IDs. - * $ wp user signup list --field=signup_id - * 1 - * - * # List all signups. - * $ wp user signup list - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * | signup_id | user_login | user_email | registered | active | activation_key | - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * | 1 | bobuser | bobuser@example.com | 2024-03-13 05:46:53 | 1 | 7320b2f009266618 | - * | 2 | johndoe | johndoe@example.com | 2024-03-13 06:24:44 | 0 | 9068d859186cd0b5 | - * +-----------+------------+---------------------+---------------------+--------+------------------+ - * - * @subcommand list - * - * @package wp-cli - */ - public function list_( $args, $assoc_args ) { - global $wpdb; - - if ( isset( $assoc_args['fields'] ) ) { - $assoc_args['fields'] = explode( ',', $assoc_args['fields'] ); - } else { - $assoc_args['fields'] = $this->obj_fields; - } - - $signups = array(); - - /** - * @var string|null $per_page - */ - $per_page = Utils\get_flag_value( $assoc_args, 'per_page' ); - - $limit = $per_page ? $wpdb->prepare( 'LIMIT %d', (int) $per_page ) : ''; - - $query = "SELECT * FROM $wpdb->signups {$limit}"; - - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared properly above. - $results = $wpdb->get_results( $query, ARRAY_A ); - - if ( $results ) { - foreach ( $results as $item ) { - // Support features like --active=0. - foreach ( array_keys( $item ) as $field ) { - if ( isset( $assoc_args[ $field ] ) && $assoc_args[ $field ] !== $item[ $field ] ) { - continue 2; - } - } - - $signups[] = $item; - } - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); - - $formatter = $this->get_formatter( $assoc_args ); - - if ( 'ids' === $format ) { - WP_CLI::line( implode( ' ', wp_list_pluck( $signups, 'signup_id' ) ) ); - } else { - $formatter->display_items( $signups ); - } - } - - /** - * Gets details about a signup. - * - * ## OPTIONS - * - * <signup> - * : The signup ID, user login, user email, or activation key. - * - * [--field=<field>] - * : Instead of returning the whole signup, returns the value of a single field. - * - * [--fields=<fields>] - * : Limit the output to specific fields. Defaults to all fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * --- - * - * ## EXAMPLES - * - * # Get signup. - * $ wp user signup get 1 --field=user_login - * bobuser - * - * # Get signup and export to JSON file. - * $ wp user signup get bobuser --format=json > bobuser.json - * - * @package wp-cli - */ - public function get( $args, $assoc_args ) { - $signup = $this->fetcher->get_check( $args[0] ); - - if ( empty( $assoc_args['fields'] ) ) { - $assoc_args['fields'] = array_keys( (array) $signup ); - } - - $formatter = $this->get_formatter( $assoc_args ); - - $formatter->display_items( array( $signup ) ); - } - - /** - * Activates one or more signups. - * - * ## OPTIONS - * - * <signup>... - * : The signup ID, user login, user email, or activation key of the signup(s) to activate. - * - * ## EXAMPLES - * - * # Activate signup. - * $ wp user signup activate 2 - * Signup 2 activated. Password: bZFSGsfzb9xs - * Success: Activated 1 of 1 signups. - * - * @package wp-cli - */ - public function activate( $args, $assoc_args ) { - $signups = $this->fetcher->get_many( $args ); - - $successes = 0; - $errors = 0; - - foreach ( $signups as $signup ) { - $result = wpmu_activate_signup( $signup->activation_key ); - - if ( is_wp_error( $result ) ) { - WP_CLI::warning( "Failed activating signup {$signup->signup_id}." ); - ++$errors; - } else { - WP_CLI::log( "Signup {$signup->signup_id} activated. Password: {$result['password']}" ); - ++$successes; - } - } - - Utils\report_batch_operation_results( 'signup', 'activate', count( $args ), $successes, $errors ); - } - - /** - * Deletes one or more signups. - * - * ## OPTIONS - * - * [<signup>...] - * : The signup ID, user login, user email, or activation key of the signup(s) to delete. - * - * [--all] - * : If set, all signups will be deleted. - * - * ## EXAMPLES - * - * # Delete signup. - * $ wp user signup delete 3 - * Signup 3 deleted. - * Success: Deleted 1 of 1 signups. - * - * @package wp-cli - */ - public function delete( $args, $assoc_args ) { - $count = count( $args ); - - $all = Utils\get_flag_value( $assoc_args, 'all', false ); - - if ( ( 0 < $count && true === $all ) || ( 0 === $count && true !== $all ) ) { - WP_CLI::error( 'You need to specify either one or more signups or provide the --all flag.' ); - } - - if ( true === $all ) { - if ( ! $this->delete_all_signups() ) { - WP_CLI::error( 'Error deleting signups.' ); - } - - WP_CLI::success( 'Deleted all signups.' ); - WP_CLI::halt( 0 ); - } - - $signups = $this->fetcher->get_many( $args ); - - $successes = 0; - $errors = 0; - - foreach ( $signups as $signup ) { - if ( $this->delete_signup( $signup ) ) { - WP_CLI::log( "Signup {$signup->signup_id} deleted." ); - ++$successes; - } else { - WP_CLI::warning( "Failed deleting signup {$signup->signup_id}." ); - ++$errors; - } - } - - Utils\report_batch_operation_results( 'signup', 'delete', $count, $successes, $errors ); - } - - /** - * Deletes signup. - * - * @param object{signup_id: int|string} $signup - * @return bool True if success; otherwise false. - */ - private function delete_signup( $signup ) { - global $wpdb; - - $signup_id = $signup->signup_id; - - $result = $wpdb->delete( $wpdb->signups, array( 'signup_id' => $signup_id ), array( '%d' ) ); - - return $result ? true : false; - } - - /** - * Deletes all signup. - * - * @return bool True if success; otherwise false. - */ - private function delete_all_signups() { - global $wpdb; - - $results = $wpdb->query( 'DELETE FROM ' . $wpdb->signups ); - - return $results ? true : false; - } -} diff --git a/src/Site_Command.php b/src/Site_Command.php index 472000c9e..7de0cfc94 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -1,13 +1,5 @@ <?php -use WP_CLI\CommandWithDBObject; -use WP_CLI\ExitException; -use WP_CLI\Fetchers\Site as SiteFetcher; -use WP_CLI\Iterators\Table as TableIterator; -use WP_CLI\Utils; -use WP_CLI\Formatter; -use WP_CLI\Fetchers\User as UserFetcher; - /** * Creates, deletes, empties, moderates, and lists one or more sites on a multisite installation. * @@ -28,89 +20,98 @@ * Success: The site at 'http://www.example.com/example' was deleted. * * @package wp-cli - * - * @phpstan-type UserSite object{userblog_id: int, blogname: string, domain: string, path: string, site_id: int, siteurl: string, archived: int, spam: int, deleted: int} */ -class Site_Command extends CommandWithDBObject { +class Site_Command extends \WP_CLI\CommandWithDBObject { - protected $obj_type = 'site'; + protected $obj_type = 'site'; protected $obj_id_key = 'blog_id'; - private $fetcher; - public function __construct() { - $this->fetcher = new SiteFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\Site; } /** * Delete comments. */ - private function empty_comments() { + private function _empty_comments() { global $wpdb; - $wpdb->query( "TRUNCATE TABLE $wpdb->comments" ); - $wpdb->query( "TRUNCATE TABLE $wpdb->commentmeta" ); + // Empty comments and comment cache + $comment_ids = $wpdb->get_col( "SELECT comment_ID FROM $wpdb->comments" ); + foreach ( $comment_ids as $comment_id ) { + wp_cache_delete( $comment_id, 'comment' ); + wp_cache_delete( $comment_id, 'comment_meta' ); + } + $wpdb->query( "TRUNCATE $wpdb->comments" ); + $wpdb->query( "TRUNCATE $wpdb->commentmeta" ); } /** * Delete all posts. */ - private function empty_posts() { + private function _empty_posts() { global $wpdb; - $wpdb->query( "TRUNCATE TABLE $wpdb->posts" ); - $wpdb->query( "TRUNCATE TABLE $wpdb->postmeta" ); + // Empty posts and post cache + $posts_query = "SELECT ID FROM $wpdb->posts"; + $posts = new WP_CLI\Iterators\Query( $posts_query, 10000 ); + + $taxonomies = get_taxonomies(); + + while ( $posts->valid() ) { + $post_id = $posts->current()->ID; + + wp_cache_delete( $post_id, 'posts' ); + wp_cache_delete( $post_id, 'post_meta' ); + foreach ( $taxonomies as $taxonomy ) + wp_cache_delete( $post_id, "{$taxonomy}_relationships" ); + wp_cache_delete( $wpdb->blogid . '-' . $post_id, 'global-posts' ); + + $posts->next(); + } + $wpdb->query( "TRUNCATE $wpdb->posts" ); + $wpdb->query( "TRUNCATE $wpdb->postmeta" ); } /** * Delete terms, taxonomies, and tax relationships. */ - private function empty_taxonomies() { - /** - * @var \wpdb $wpdb - */ + private function _empty_taxonomies() { global $wpdb; - $taxonomies = $wpdb->get_col( "SELECT DISTINCT taxonomy FROM $wpdb->term_taxonomy" ); - if ( ! empty( $taxonomies ) ) { - $option_names = array_map( - static function ( $taxonomy ) { - return "{$taxonomy}_children"; - }, - $taxonomies - ); - $placeholders = implode( ', ', array_fill( 0, count( $option_names ), '%s' ) ); - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare - // @phpstan-ignore argument.type - $query = $wpdb->prepare( 'DELETE FROM ' . $wpdb->options . ' WHERE option_name IN ( ' . $placeholders . ' )', $option_names ); - // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare - if ( $query ) { - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is already prepared above. - $wpdb->query( $query ); - } + // Empty taxonomies and terms + $terms = $wpdb->get_results( "SELECT term_id, taxonomy FROM $wpdb->term_taxonomy" ); + $ids = array(); + $taxonomies = array(); + foreach ( (array) $terms as $term ) { + $taxonomies[] = $term->taxonomy; + $ids[] = $term->term_id; + wp_cache_delete( $term->term_id, $term->taxonomy ); } - $wpdb->query( "TRUNCATE TABLE $wpdb->terms" ); - $wpdb->query( "TRUNCATE TABLE $wpdb->term_taxonomy" ); - $wpdb->query( "TRUNCATE TABLE $wpdb->term_relationships" ); + $taxonomies = array_unique( $taxonomies ); + $cleaned = array(); + foreach ( $taxonomies as $taxonomy ) { + if ( isset( $cleaned[$taxonomy] ) ) + continue; + $cleaned[$taxonomy] = true; + + wp_cache_delete( 'all_ids', $taxonomy ); + wp_cache_delete( 'get', $taxonomy ); + delete_option( "{$taxonomy}_children" ); + } + $wpdb->query( "TRUNCATE $wpdb->terms" ); + $wpdb->query( "TRUNCATE $wpdb->term_taxonomy" ); + $wpdb->query( "TRUNCATE $wpdb->term_relationships" ); if ( ! empty( $wpdb->termmeta ) ) { - $wpdb->query( "TRUNCATE TABLE $wpdb->termmeta" ); + $wpdb->query( "TRUNCATE $wpdb->termmeta" ); } } - /** - * Delete all links by truncating the links table. - */ - private function empty_links() { - global $wpdb; - - $wpdb->query( "TRUNCATE TABLE {$wpdb->links}" ); - } - /** * Insert default terms. */ - private function insert_default_terms() { + private function _insert_default_terms() { global $wpdb; // Default category @@ -119,70 +120,29 @@ private function insert_default_terms() { /* translators: Default category slug */ $cat_slug = sanitize_title( _x( 'Uncategorized', 'Default category slug' ) ); - // @phpstan-ignore function.deprecated - if ( global_terms_enabled() ) { // phpcs:ignore WordPress.WP.DeprecatedFunctions.global_terms_enabledFound -- Required for backwards compatibility. + if ( global_terms_enabled() ) { $cat_id = $wpdb->get_var( $wpdb->prepare( "SELECT cat_ID FROM {$wpdb->sitecategories} WHERE category_nicename = %s", $cat_slug ) ); - if ( null === $cat_id ) { - $wpdb->insert( - $wpdb->sitecategories, - [ - 'cat_ID' => 0, - 'cat_name' => $cat_name, - 'category_nicename' => $cat_slug, - 'last_updated' => current_time( - 'mysql', - true - ), - ] - ); + if ( $cat_id == null ) { + $wpdb->insert( $wpdb->sitecategories, array('cat_ID' => 0, 'cat_name' => $cat_name, 'category_nicename' => $cat_slug, 'last_updated' => current_time('mysql', true)) ); $cat_id = $wpdb->insert_id; } - update_option( 'default_category', $cat_id ); + update_option('default_category', $cat_id); } else { $cat_id = 1; } - $wpdb->insert( - $wpdb->terms, - [ - 'term_id' => $cat_id, - 'name' => $cat_name, - 'slug' => $cat_slug, - 'term_group' => 0, - ] - ); - $wpdb->insert( - $wpdb->term_taxonomy, - [ - 'term_id' => $cat_id, - 'taxonomy' => 'category', - 'description' => '', - 'parent' => 0, - 'count' => 0, - ] - ); - } - - /** - * Reset option values to default. - */ - private function reset_options() { - // Reset Privacy Policy value to prevent error. - update_option( 'wp_page_for_privacy_policy', 0 ); - - // Reset sticky posts option. - update_option( 'sticky_posts', [] ); + $wpdb->insert( $wpdb->terms, array('term_id' => $cat_id, 'name' => $cat_name, 'slug' => $cat_slug, 'term_group' => 0) ); + $wpdb->insert( $wpdb->term_taxonomy, array('term_id' => $cat_id, 'taxonomy' => 'category', 'description' => '', 'parent' => 0, 'count' => 1)); } /** - * Empties a site of its content (posts, comments, terms, links, and meta). + * Empties a site of its content (posts, comments, terms, and meta). * - * Truncates posts, comments, terms, and links tables to empty a site of its + * Truncates posts, comments, and terms tables to empty a site of its * content. Doesn't affect site configuration (options) or users. * - * Flushes the object cache after emptying the site to ensure stale data - * is not served. On a Multisite installation, this will flush the cache - * for all sites. + * If running a persistent object cache, make sure to flush the cache + * after emptying the site, as the cache values will be invalid otherwise. * * To also empty custom database tables, you'll need to hook into command * execution: @@ -208,47 +168,38 @@ private function reset_options() { * ## EXAMPLES * * $ wp site empty - * Are you sure you want to empty the site at http://www.example.com of all posts, links, comments, and terms? [y/n] y + * Are you sure you want to empty the site at http://www.example.com of all posts, comments, and terms? [y/n] y * Success: The site at 'http://www.example.com' was emptied. * * @subcommand empty */ - public function empty_( $args, $assoc_args ) { + public function _empty( $args, $assoc_args ) { $upload_message = ''; - if ( Utils\get_flag_value( $assoc_args, 'uploads' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'uploads' ) ) { $upload_message = ', and delete its uploads directory'; } - WP_CLI::confirm( "Are you sure you want to empty the site at '" . site_url() . "' of all posts, links, comments, and terms" . $upload_message . '?', $assoc_args ); + WP_CLI::confirm( "Are you sure you want to empty the site at '" . site_url() . "' of all posts, comments, and terms" . $upload_message . "?", $assoc_args ); - $this->empty_posts(); - $this->empty_links(); - $this->empty_comments(); - $this->empty_taxonomies(); - $this->insert_default_terms(); - $this->reset_options(); - wp_cache_flush(); + $this->_empty_posts(); + $this->_empty_comments(); + $this->_empty_taxonomies(); + $this->_insert_default_terms(); if ( ! empty( $upload_message ) ) { $upload_dir = wp_upload_dir(); - $files = new RecursiveIteratorIterator( + $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $upload_dir['basedir'], RecursiveDirectoryIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); - $files_to_unlink = []; - $directories_to_delete = []; - $is_main_site = is_main_site(); - - /** - * @var \SplFileInfo $fileinfo - */ + $files_to_unlink = $directories_to_delete = array(); + $is_main_site = is_main_site(); foreach ( $files as $fileinfo ) { $realpath = $fileinfo->getRealPath(); // Don't clobber subsites when operating on the main site - $normalized_realpath = str_replace( '\\', '/', $realpath ); - if ( $is_main_site && false !== stripos( $normalized_realpath, '/sites/' ) ) { + if ( $is_main_site && false !== stripos( $realpath, '/sites/' ) ) { continue; } if ( $fileinfo->isDir() ) { @@ -257,15 +208,15 @@ public function empty_( $args, $assoc_args ) { $files_to_unlink[] = $realpath; } } - foreach ( $files_to_unlink as $file ) { + foreach( $files_to_unlink as $file ) { unlink( $file ); } - foreach ( $directories_to_delete as $directory ) { + foreach( $directories_to_delete as $directory ) { // Directory could be main sites directory '/sites' which may be non-empty. - @rmdir( $directory ); + @rmdir( $directory ); // @codingStandardsIgnoreLine } // May be non-empty if '/sites' still around. - @rmdir( $upload_dir['basedir'] ); + @rmdir( $upload_dir['basedir'] ); // @codingStandardsIgnoreLine } WP_CLI::success( "The site at '" . site_url() . "' was emptied." ); @@ -280,16 +231,13 @@ public function empty_( $args, $assoc_args ) { * : The id of the site to delete. If not provided, you must set the --slug parameter. * * [--slug=<slug>] - * : Path of the site to be deleted. Subdomain on subdomain installs, directory on subdirectory installs. + * : Path of the blog to be deleted. Subdomain on subdomain installs, directory on subdirectory installs. * * [--yes] * : Answer yes to the confirmation message. * * [--keep-tables] - * : Delete the blog from the list, but don't drop its tables. - * - * [--delete-tables-with-prefix] - * : Delete all tables with the site's database table prefix after deleting the site. + * : Delete the blog from the list, but don't drop it's tables. * * ## EXAMPLES * @@ -297,24 +245,13 @@ public function empty_( $args, $assoc_args ) { * Are you sure you want to delete the http://www.example.com/example site? [y/n] y * Success: The site at 'http://www.example.com/example' was deleted. */ - public function delete( $args, $assoc_args ) { - if ( ! is_multisite() ) { + function delete( $args, $assoc_args ) { + if ( !is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } - if ( Utils\get_flag_value( $assoc_args, 'keep-tables' ) && Utils\get_flag_value( $assoc_args, 'delete-tables-with-prefix' ) ) { - WP_CLI::error( "The '--keep-tables' and '--delete-tables-with-prefix' flags cannot be used together." ); - } - if ( isset( $assoc_args['slug'] ) ) { - $blog_id = get_id_from_blogname( $assoc_args['slug'] ); - if ( null === $blog_id ) { - WP_CLI::error( sprintf( 'Could not find site with slug \'%s\'.', $assoc_args['slug'] ) ); - } - if ( is_main_site( $blog_id ) ) { - WP_CLI::error( 'You cannot delete the root site.' ); - } - $blog = get_blog_details( $blog_id ); + $blog = get_blog_details( trim( $assoc_args['slug'], '/' ) ); } else { if ( empty( $args ) ) { WP_CLI::error( 'Need to specify a blog id.' ); @@ -335,182 +272,11 @@ public function delete( $args, $assoc_args ) { $site_url = trailingslashit( $blog->siteurl ); - WP_CLI::confirm( "Are you sure you want to delete the '{$site_url}' site?", $assoc_args ); - - $did_delete = wpmu_delete_blog( (int) $blog->blog_id, ! Utils\get_flag_value( $assoc_args, 'keep-tables' ) ); - if ( false === $did_delete ) { - WP_CLI::error( "The site at '{$site_url}' could not be deleted." ); - } - - if ( Utils\get_flag_value( $assoc_args, 'delete-tables-with-prefix' ) ) { - $this->drop_tables_with_prefix( (int) $blog->blog_id ); - } - - WP_CLI::success( "The site at '{$site_url}' was deleted." ); - } - - /** - * Drops all database tables for a site prefix. - * - * @param int $blog_id Site ID. - */ - private function drop_tables_with_prefix( $blog_id ) { - global $wpdb; - - $blog_prefix = $wpdb->get_blog_prefix( $blog_id ); - if ( is_main_site( $blog_id ) || $blog_prefix === $wpdb->base_prefix ) { - WP_CLI::error( 'You cannot drop tables for the root site.' ); - } - - $prefix_like = $wpdb->esc_like( $blog_prefix ) . '%'; - $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $prefix_like ) ); - - if ( empty( $tables ) ) { - return; - } - - $tables = array_map( - static function ( $table ) { - return '`' . str_replace( '`', '``', $table ) . '`'; - }, - $tables - ); - - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table identifiers are escaped and cannot be passed as placeholders. - $wpdb->query( 'DROP TABLE IF EXISTS ' . implode( ', ', $tables ) ); - } - - /** - * Gets details about a site in a multisite installation. - * - * ## OPTIONS - * - * <site> - * : Site ID or URL of the site to get. For subdirectory sites, use the full URL (e.g., http://example.com/subdir/). - * - * [--field=<field>] - * : Instead of returning the whole site, returns the value of a single field. - * - * [--fields=<fields>] - * : Limit the output to specific fields. Defaults to all fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for the site: - * - * * blog_id - * * url - * * last_updated - * * registered - * - * These fields are optionally available: - * - * * site_id - * * domain - * * path - * * public - * * archived - * * mature - * * spam - * * deleted - * * lang_id - * - * ## EXAMPLES - * - * # Get site by ID - * $ wp site get 1 - * +---------+-------------------------+---------------------+---------------------+ - * | blog_id | url | last_updated | registered | - * +---------+-------------------------+---------------------+---------------------+ - * | 1 | http://example.com/ | 2025-01-01 12:00:00 | 2025-01-01 12:00:00 | - * +---------+-------------------------+---------------------+---------------------+ - * - * # Get site URL by site ID - * $ wp site get 1 --field=url - * http://example.com/ - * - * # Get site ID by URL - * $ wp site get http://example.com/subdir/ --field=blog_id - * 2 - * - * # Delete a site by URL - * $ wp site delete $(wp site get http://example.com/subdir/ --field=blog_id) --yes - * Success: The site at 'http://example.com/subdir/' was deleted. - */ - public function get( $args, $assoc_args ) { - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - - $site_arg = $args[0]; - $site = null; - - // Check if the argument is a URL or a domain (non-numeric) - if ( ! is_numeric( $site_arg ) ) { - // Normalize URLs without a scheme for proper parsing. - $url_to_parse = $site_arg; - if ( false === strpos( $url_to_parse, '://' ) ) { - $url_to_parse = 'http://' . $url_to_parse; - } - - // Parse the URL to get domain and path - $url_parts = wp_parse_url( $url_to_parse ); - - if ( ! isset( $url_parts['host'] ) ) { - WP_CLI::error( "Invalid URL: {$site_arg}" ); - } - - $domain = $url_parts['host']; - $path = isset( $url_parts['path'] ) ? $url_parts['path'] : '/'; + WP_CLI::confirm( "Are you sure you want to delete the '$site_url' site?", $assoc_args ); - // Ensure path ends with / - if ( '/' !== substr( $path, -1 ) ) { - $path .= '/'; - } - - // Use WordPress's cached function to get the blog ID - $blog_id = get_blog_id_from_url( $domain, $path ); - - if ( ! $blog_id ) { - WP_CLI::error( "Could not find site with URL: {$site_arg}" ); - } - - $site = $this->fetcher->get_check( $blog_id ); - } else { - // Treat as site ID - $site = $this->fetcher->get_check( $site_arg ); - } - - // Get the site details and add URL - $site_data = get_object_vars( $site ); - $site_data['url'] = trailingslashit( get_home_url( $site->blog_id ) ); - - // Cast numeric fields to int for consistent output - $numeric_fields = [ 'blog_id', 'site_id', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' ]; - foreach ( $numeric_fields as $field ) { - if ( isset( $site_data[ $field ] ) && is_scalar( $site_data[ $field ] ) ) { - $site_data[ $field ] = (int) $site_data[ $field ]; - } - } + wpmu_delete_blog( $blog->blog_id, ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'keep-tables' ) ); - // Set default fields if not specified - if ( empty( $assoc_args['fields'] ) ) { - $assoc_args['fields'] = [ 'blog_id', 'url', 'last_updated', 'registered' ]; - } - - $formatter = $this->get_formatter( $assoc_args ); - $formatter->display_item( $site_data ); + WP_CLI::success( "The site at '$site_url' was deleted." ); } /** @@ -518,21 +284,14 @@ public function get( $args, $assoc_args ) { * * ## OPTIONS * - * [--slug=<slug>] + * --slug=<slug> * : Path for the new site. Subdomain on subdomain installs, directory on subdirectory installs. - * Required if --site-url is not provided. - * - * [--site-url=<url>] - * : Full URL for the new site. Use this to specify a custom domain instead of the auto-generated one. - * For subdomain installs, this allows you to use a different base domain (e.g., 'http://site.example.com' instead of 'http://site.main.example.com'). - * For subdirectory installs, this allows you to use a different path. - * If provided, --slug is optional and will be derived from the URL. If both --slug and --site-url are provided, --slug will be used as the base for internal operations (like user creation), while the domain/path from --site-url will be used for the actual site URL. * * [--title=<title>] * : Title of the new site. Default: prettified slug. * * [--email=<email>] - * : Email for admin user. User will be created if none exists. Assignment to super admin if not included. + * : Email for Admin user. User will be created if none exists. Assignement to Super Admin if not included. * * [--network_id=<network-id>] * : Network to associate new site with. Defaults to current network (typically 1). @@ -545,110 +304,33 @@ public function get( $args, $assoc_args ) { * * ## EXAMPLES * - * # Create a site with auto-generated domain * $ wp site create --slug=example * Success: Site 3 created: http://www.example.com/example/ - * - * # Create a site with a custom domain (subdomain multisite) - * $ wp site create --site-url=http://site.example.com - * Success: Site 4 created: http://site.example.com/ - * - * # Create a site with a custom subdirectory (subdirectory multisite) - * $ wp site create --site-url=http://example.com/custom/path/ - * Success: Site 5 created: http://example.com/custom/path/ */ - public function create( $args, $assoc_args ) { - if ( ! is_multisite() ) { + public function create( $_, $assoc_args ) { + if ( !is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } global $wpdb, $current_site; - // Check if either slug or site-url is provided - $has_slug = isset( $assoc_args['slug'] ); - $has_site_url = isset( $assoc_args['site-url'] ); - - if ( ! $has_slug && ! $has_site_url ) { - WP_CLI::error( 'Either --slug or --site-url must be provided.' ); - } - - // If site URL is provided, parse it to get domain and path - $custom_domain = null; - $custom_path = null; - $base = null; - - if ( $has_site_url ) { - $parsed_url = wp_parse_url( $assoc_args['site-url'] ); - if ( ! isset( $parsed_url['host'] ) ) { - WP_CLI::error( 'Invalid URL format. Please provide a valid URL (e.g., http://site.example.com).' ); - } - - // Validate the scheme if present - if ( isset( $parsed_url['scheme'] ) && ! in_array( $parsed_url['scheme'], [ 'http', 'https' ], true ) ) { - WP_CLI::error( 'Invalid URL scheme. Only http and https schemes are supported.' ); - } - - // Sanitize domain and path - $custom_domain = sanitize_text_field( $parsed_url['host'] ); - $custom_path = isset( $parsed_url['path'] ) ? sanitize_text_field( '/' . ltrim( $parsed_url['path'], '/' ) ) : '/'; - - // Ensure path ends with / - if ( '/' !== substr( $custom_path, -1 ) ) { - $custom_path .= '/'; - } - - // Derive base/slug from the URL if not explicitly provided - if ( ! $has_slug ) { - if ( is_subdomain_install() ) { - // For subdomain installs, use the first part of the domain as the base - $domain_parts = explode( '.', $custom_domain ); - $base = $domain_parts[0]; - - // Validate that the derived base is suitable for use as a slug - if ( empty( $base ) || is_numeric( $base ) ) { - WP_CLI::error( 'Could not derive a valid slug from the domain (numeric-only or empty slugs are not allowed). Please provide --slug explicitly.' ); - } - - // Sanitize and lowercase the derived base - $base = strtolower( $base ); - } else { - // For subdirectory installs, derive slug from the last part of the path. - $path_parts = array_filter( explode( '/', trim( $custom_path, '/' ) ) ); - $base = (string) array_pop( $path_parts ); - - // If base is empty (root path), require explicit slug. - if ( empty( $base ) ) { - WP_CLI::error( 'Could not derive a valid slug from the URL path. Please provide --slug explicitly.' ); - } - - // Sanitize and lowercase the derived base. - $base = strtolower( $base ); - } - } else { - $base = $assoc_args['slug']; - } - } else { - $base = $assoc_args['slug']; - } - - /** - * @var string $title - */ - $title = Utils\get_flag_value( $assoc_args, 'title', ucfirst( $base ) ); + $base = $assoc_args['slug']; + $title = \WP_CLI\Utils\get_flag_value( $assoc_args, 'title', ucfirst( $base ) ); $email = empty( $assoc_args['email'] ) ? '' : $assoc_args['email']; // Network - if ( ! empty( $assoc_args['network_id'] ) ) { - $network = $this->get_network( $assoc_args['network_id'] ); + if ( !empty( $assoc_args['network_id'] ) ) { + $network = $this->_get_network( $assoc_args['network_id'] ); if ( false === $network ) { - WP_CLI::error( "Network with id {$assoc_args['network_id']} does not exist." ); + WP_CLI::error( sprintf( 'Network with id %d does not exist.', $assoc_args['network_id'] ) ); } - } else { + } + else { $network = $current_site; } - $public = ! Utils\get_flag_value( $assoc_args, 'private' ); + $public = ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'private' ); // Sanitize if ( preg_match( '|^([a-zA-Z0-9-])+$|', $base ) ) { @@ -656,290 +338,91 @@ public function create( $args, $assoc_args ) { } // If not a subdomain install, make sure the domain isn't a reserved word - if ( ! is_subdomain_install() ) { - $subdirectory_reserved_names = $this->get_subdirectory_reserved_names(); - if ( in_array( $base, $subdirectory_reserved_names, true ) ) { + 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 ) ) { WP_CLI::error( 'The following words are reserved and cannot be used as blog names: ' . implode( ', ', $subdirectory_reserved_names ) ); } } - // Check for valid email, if not, use the first super admin found + // Check for valid email, if not, use the first Super Admin found // Probably a more efficient way to do this so we dont query for the // User twice if super admin $email = sanitize_email( $email ); - if ( empty( $email ) || ! is_email( $email ) ) { + if ( empty( $email ) || !is_email( $email ) ) { $super_admins = get_super_admins(); - $email = ''; - if ( ! empty( $super_admins ) && is_array( $super_admins ) ) { + $email = ''; + if ( !empty( $super_admins ) && is_array( $super_admins ) ) { // Just get the first one - $super_login = reset( $super_admins ); - $super_user = get_user_by( 'login', $super_login ); + $super_login = $super_admins[0]; + $super_user = get_user_by( 'login', $super_login ); if ( $super_user ) { $email = $super_user->user_email; } } } - if ( null !== $custom_domain ) { - // A custom site URL was provided. - $newdomain = $custom_domain; - $path = $custom_path; - - // For subdirectory installs, warn if the domain is different from the network's domain. - if ( ! is_subdomain_install() ) { - $network_domain = preg_replace( '|^www\.|', '', $current_site->domain ); - $custom_domain_normalized = preg_replace( '|^www\.|', '', $custom_domain ); - if ( $custom_domain_normalized !== $network_domain ) { - WP_CLI::warning( 'Using a different domain for a subdirectory multisite install may require additional configuration (such as domain mapping) to work properly.' ); - } - } - } elseif ( is_subdomain_install() ) { - // No custom site URL, use the slug to generate the domain/path for subdomain install. + if ( is_subdomain_install() ) { $newdomain = $base . '.' . preg_replace( '|^www\.|', '', $current_site->domain ); $path = $current_site->path; + $url = $newdomain; } else { - // No custom site URL, use the slug to generate the domain/path for subdirectory install. $newdomain = $current_site->domain; $path = $current_site->path . $base . '/'; + $url = $newdomain . $path; } $user_id = email_exists( $email ); - if ( ! $user_id ) { // Create a new user with a random password - $password = wp_generate_password( 24, false ); - $user_id = wpmu_create_user( $base, $password, $email ); - if ( false === $user_id ) { + if ( !$user_id ) { // Create a new user with a random password + $password = wp_generate_password( 12, false ); + $user_id = wpmu_create_user( $base, $password, $email ); + if ( false == $user_id ) { WP_CLI::error( "Can't create user." ); - } else { - User_Command::wp_new_user_notification( $user_id, $password ); + } + else { + wp_new_user_notification( $user_id, $password ); } } $wpdb->hide_errors(); $title = wp_slash( $title ); - $id = wpmu_create_blog( $newdomain, $path, $title, $user_id, [ 'public' => $public ], $network->id ); + $id = wpmu_create_blog( $newdomain, $path, $title, $user_id, array( 'public' => $public ), $network->id ); $wpdb->show_errors(); - if ( ! is_wp_error( $id ) ) { - if ( ! is_super_admin( $user_id ) && ! get_user_option( 'primary_blog', $user_id ) ) { + if ( !is_wp_error( $id ) ) { + if ( !is_super_admin( $user_id ) && !get_user_option( 'primary_blog', $user_id ) ) { update_user_option( $user_id, 'primary_blog', $id, true ); } - } else { + // Prevent mailing admins of new sites + // @TODO argument to pass in? + // $content_mail = sprintf(__( "New site created by WP Command Line Interface\n\nAddress: %2s\nName: %3s"), get_site_url($id), stripslashes($title)); + // wp_mail(get_site_option('admin_email'), sprintf(__('[%s] New Site Created'), $current_site->site_name), $content_mail, 'From: "Site Admin" <'.get_site_option( 'admin_email').'>'); + } + else { WP_CLI::error( $id->get_error_message() ); } - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $id ); + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { + WP_CLI::line( $id ); } else { $site_url = trailingslashit( get_site_url( $id ) ); - WP_CLI::success( "Site {$id} created: {$site_url}" ); - } - } - - /** - * Generate some sites. - * - * Creates a specified number of new sites. - * - * ## OPTIONS - * - * [--count=<number>] - * : How many sites to generates? - * --- - * default: 100 - * --- - * - * [--slug=<slug>] - * : Path for the new site. Subdomain on subdomain installs, directory on subdirectory installs. - * - * [--email=<email>] - * : Email for admin user. User will be created if none exists. Assignment to super admin if not included. - * - * [--network_id=<network-id>] - * : Network to associate new site with. Defaults to current network (typically 1). - * - * [--private] - * : If set, the new site will be non-public (not indexed) - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: progress - * options: - * - progress - * - ids - * --- - * - * ## EXAMPLES - * - * # Generate 10 sites. - * $ wp site generate --count=10 - * Generating sites 100% [================================================] 0:01 / 0:04 - */ - public function generate( $args, $assoc_args ) { - if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); - } - - global $wpdb, $current_site; - - $defaults = [ - 'count' => 100, - 'email' => '', - 'network_id' => 1, - 'slug' => 'site', - ]; - - $assoc_args = array_merge( $defaults, $assoc_args ); - - // Base. - $base = $assoc_args['slug']; - if ( preg_match( '|^([a-zA-Z0-9-])+$|', $base ) ) { - $base = strtolower( $base ); - } - - $is_subdomain_install = is_subdomain_install(); - // If not a subdomain install, make sure the domain isn't a reserved word - if ( ! $is_subdomain_install ) { - $subdirectory_reserved_names = $this->get_subdirectory_reserved_names(); - if ( in_array( $base, $subdirectory_reserved_names, true ) ) { - WP_CLI::error( 'The following words are reserved and cannot be used as blog names: ' . implode( ', ', $subdirectory_reserved_names ) ); - } - } - - // Network. - if ( ! empty( $assoc_args['network_id'] ) ) { - $network = $this->get_network( $assoc_args['network_id'] ); - if ( false === $network ) { - WP_CLI::error( "Network with id {$assoc_args['network_id']} does not exist." ); - } - } else { - $network = $current_site; - } - - // Public. - $public = ! Utils\get_flag_value( $assoc_args, 'private' ); - - // Limit. - $limit = $assoc_args['count']; - - // Email. - $email = sanitize_email( $assoc_args['email'] ); - if ( empty( $email ) || ! is_email( $email ) ) { - $super_admins = get_super_admins(); - $email = ''; - if ( ! empty( $super_admins ) && is_array( $super_admins ) ) { - $super_login = reset( $super_admins ); - $super_user = get_user_by( 'login', $super_login ); - if ( $super_user ) { - $email = $super_user->user_email; - } - } - } - - $user_id = email_exists( $email ); - if ( ! $user_id ) { - $password = wp_generate_password( 24, false ); - $user_id = wpmu_create_user( $base . '-admin', $password, $email ); - - if ( false === $user_id ) { - WP_CLI::error( "Can't create user." ); - } else { - User_Command::wp_new_user_notification( $user_id, $password ); - } - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); - - $notify = false; - if ( 'progress' === $format ) { - $notify = Utils\make_progress_bar( 'Generating sites', $limit ); - } - - for ( $index = 1; $index <= $limit; $index++ ) { - $current_base = $base . $index; - $title = ucfirst( $base ) . ' ' . $index; - - if ( $is_subdomain_install ) { - $new_domain = $current_base . '.' . preg_replace( '|^www\.|', '', $network->domain ); - $path = $network->path; - } else { - $new_domain = $network->domain; - $path = $network->path . $current_base . '/'; - } - - $wpdb->hide_errors(); - $title = wp_slash( $title ); - $id = wpmu_create_blog( $new_domain, $path, $title, $user_id, [ 'public' => $public ], $network->id ); - $wpdb->show_errors(); - if ( ! is_wp_error( $id ) ) { - if ( ! is_super_admin( $user_id ) && ! get_user_option( 'primary_blog', $user_id ) ) { - update_user_option( $user_id, 'primary_blog', $id, true ); - } - } else { - WP_CLI::error( $id->get_error_message() ); - } - - if ( 'progress' === $format ) { - $notify->tick(); - } else { - echo $id; - if ( $index < $limit - 1 ) { - echo ' '; - } - } - } - - if ( 'progress' === $format ) { - $notify->finish(); + WP_CLI::success( "Site $id created: $site_url" ); } } - /** - * Retrieves a list of reserved site on a sub-directory Multisite installation. - * - * Works on older WordPress versions where get_subdirectory_reserved_names() does not exist. - * - * @return string[] Array of reserved names. - */ - private function get_subdirectory_reserved_names() { - if ( function_exists( 'get_subdirectory_reserved_names' ) ) { - return get_subdirectory_reserved_names(); - } - - $names = array( - 'page', - 'comments', - 'blog', - 'files', - 'feed', - 'wp-admin', - 'wp-content', - 'wp-includes', - 'wp-json', - 'embed', - ); - - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Calling WordPress native hook. - return apply_filters( 'subdirectory_reserved_names', $names ); - } - /** * Gets network data for a given id. * * @param int $network_id * @return bool|array False if no network found with given id, array otherwise */ - private function get_network( $network_id ) { + private function _get_network( $network_id ) { global $wpdb; // Load network data - $networks = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM $wpdb->site WHERE id = %d", - $network_id - ) - ); + $networks = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM $wpdb->site WHERE id = %d", $network_id ) ); - if ( ! empty( $networks ) ) { + if ( !empty( $networks ) ) { // Only care about domain and path which are set here return $networks[0]; } @@ -957,19 +440,11 @@ private function get_network( $network_id ) { * * [--<field>=<value>] * : Filter by one or more fields (see "Available Fields" section). However, - * 'url' isn't an available filter, as it comes from 'home' in wp_options. - * Note: '--path' conflicts with the global parameter of the same name; use - * '--site-path' to filter by path instead. + * 'url' isn't an available filter, because it's created from domain + path. * * [--site__in=<value>] * : Only list the sites with these blog_id values (comma-separated). * - * [--site_user=<value>] - * : Only list the sites with this user. - * - * [--site-path=<path>] - * : Filter by path. Avoids conflict with the global `--path` parameter. - * * [--field=<field>] * : Prints the value of a single field for each site. * @@ -1019,8 +494,8 @@ private function get_network( $network_id ) { * * @subcommand list */ - public function list_( $args, $assoc_args ) { - if ( ! is_multisite() ) { + public function list_( $_, $assoc_args ) { + if ( !is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } @@ -1030,85 +505,52 @@ public function list_( $args, $assoc_args ) { $assoc_args['fields'] = preg_split( '/,[ \t]*/', $assoc_args['fields'] ); } - $defaults = [ + $defaults = array( 'format' => 'table', - 'fields' => [ 'blog_id', 'url', 'last_updated', 'registered' ], - ]; + 'fields' => array( 'blog_id', 'url', 'last_updated', 'registered' ), + ); $assoc_args = array_merge( $defaults, $assoc_args ); - $where = []; + $where = array(); $append = ''; - $site_cols = [ 'blog_id', 'last_updated', 'registered', 'site_id', 'domain', 'path', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' ]; - foreach ( $site_cols as $col ) { + $site_cols = array( 'blog_id', 'last_updated', 'registered', 'site_id', 'domain', 'path', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' ); + foreach( $site_cols as $col ) { if ( isset( $assoc_args[ $col ] ) ) { $where[ $col ] = $assoc_args[ $col ]; } } - if ( isset( $assoc_args['site-path'] ) ) { - $where['path'] = $assoc_args['site-path']; - } - if ( isset( $assoc_args['site__in'] ) ) { $where['blog_id'] = explode( ',', $assoc_args['site__in'] ); - $append = 'ORDER BY FIELD( blog_id, ' . implode( ',', array_map( 'intval', $where['blog_id'] ) ) . ' )'; + $append = "ORDER BY FIELD( blog_id, " . implode( ',', array_map( 'intval', $where['blog_id'] ) ) . " )"; } if ( isset( $assoc_args['network'] ) ) { $where['site_id'] = $assoc_args['network']; } - if ( isset( $assoc_args['site_user'] ) ) { - $user = ( new UserFetcher() )->get_check( $assoc_args['site_user'] ); - - if ( $user ) { - /** - * @phpstan-var UserSite[] $blogs - */ - $blogs = get_blogs_of_user( $user->ID ); - - foreach ( $blogs as $blog ) { - $where['blog_id'][] = $blog->userblog_id; - } - } - - if ( ! isset( $where['blog_id'] ) || empty( $where['blog_id'] ) ) { - $formatter = new Formatter( $assoc_args, [], 'site' ); - $formatter->display_items( [] ); - return; - } - - $append = 'ORDER BY FIELD( blog_id, ' . implode( ',', array_map( 'intval', $where['blog_id'] ) ) . ' )'; - } - - $iterator_args = [ - 'table' => $wpdb->blogs, - 'where' => $where, + $iterator_args = array( + 'table' => $wpdb->blogs, + 'where' => $where, 'append' => $append, - ]; - - $iterator = new TableIterator( $iterator_args ); - - /** - * @var iterable $iterator - */ - $iterator = Utils\iterator_map( - $iterator, - function ( $blog ) { - $blog->url = trailingslashit( get_home_url( $blog->blog_id ) ); - return $blog; - } ); + $it = new \WP_CLI\Iterators\Table( $iterator_args ); + + $it = \WP_CLI\Utils\iterator_map( $it, function( $blog ) { + $blog->url = trailingslashit( get_site_url( $blog->blog_id ) ); + return $blog; + } ); if ( ! empty( $assoc_args['format'] ) && 'ids' === $assoc_args['format'] ) { - $sites = iterator_to_array( $iterator ); - $ids = wp_list_pluck( $sites, 'blog_id' ); - $formatter = new Formatter( $assoc_args, null, 'site' ); + $sites = iterator_to_array( $it ); + $ids = wp_list_pluck( $sites, 'blog_id' ); + $formatter = new \WP_CLI\Formatter( $assoc_args, null, 'site' ); $formatter->display_items( $ids ); - } else { - $formatter = new Formatter( $assoc_args, null, 'site' ); - $formatter->display_items( $iterator ); + } + else { + $formatter = new \WP_CLI\Formatter( $assoc_args, null, 'site' ); + $formatter->display_items( $it ); } } @@ -1117,28 +559,16 @@ function ( $blog ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to archive. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to archive. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to archive. * * ## EXAMPLES * * $ wp site archive 123 * Success: Site 123 archived. - * - * $ wp site archive --slug=demo - * Success: Site 123 archived. */ - public function archive( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'archived', 1 ); + public function archive( $args ) { + $this->update_site_status( $args, 'archived', 1 ); } /** @@ -1146,28 +576,16 @@ public function archive( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to unarchive. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to unarchive. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to unarchive. * * ## EXAMPLES * * $ wp site unarchive 123 * Success: Site 123 unarchived. - * - * $ wp site unarchive --slug=demo - * Success: Site 123 unarchived. */ - public function unarchive( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'archived', 0 ); + public function unarchive( $args ) { + $this->update_site_status( $args, 'archived', 0 ); } /** @@ -1175,28 +593,16 @@ public function unarchive( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to activate. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be activated. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to activate. * * ## EXAMPLES * * $ wp site activate 123 * Success: Site 123 activated. - * - * $ wp site activate --slug=demo - * Success: Site 123 marked as activated. */ - public function activate( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'deleted', 0 ); + public function activate( $args ) { + $this->update_site_status( $args, 'deleted', 0 ); } /** @@ -1204,28 +610,16 @@ public function activate( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to deactivate. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be deactivated. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to deactivate. * * ## EXAMPLES * * $ wp site deactivate 123 * Success: Site 123 deactivated. - * - * $ wp site deactivate --slug=demo - * Success: Site 123 deactivated. */ - public function deactivate( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'deleted', 1 ); + public function deactivate( $args ) { + $this->update_site_status( $args, 'deleted', 1 ); } /** @@ -1233,25 +627,16 @@ public function deactivate( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to be marked as spam. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be marked as spam. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to be marked as spam. * * ## EXAMPLES * * $ wp site spam 123 * Success: Site 123 marked as spam. */ - public function spam( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'spam', 1 ); + public function spam( $args ) { + $this->update_site_status( $args, 'spam', 1 ); } /** @@ -1259,11 +644,8 @@ public function spam( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to remove from spam. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be removed from spam. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to remove from spam. * * ## EXAMPLES * @@ -1272,14 +654,8 @@ public function spam( $args, $assoc_args ) { * * @subcommand unspam */ - public function unspam( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'spam', 0 ); + public function unspam( $args ) { + $this->update_site_status( $args, 'spam', 0 ); } /** @@ -1287,28 +663,16 @@ public function unspam( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to set as mature. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be set as mature. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to set as mature. * * ## EXAMPLES * * $ wp site mature 123 * Success: Site 123 marked as mature. - * - * $ wp site mature --slug=demo - * Success: Site 123 marked as mature. */ - public function mature( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'mature', 1 ); + public function mature( $args ) { + $this->update_site_status( $args, 'mature', 1 ); } /** @@ -1316,28 +680,16 @@ public function mature( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to set as unmature. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be set as unmature. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to set as unmature. * * ## EXAMPLES * - * $ wp site unmature 123 - * Success: Site 123 marked as unmature. - * - * $ wp site unmature --slug=demo + * $ wp site general 123 * Success: Site 123 marked as unmature. */ - public function unmature( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'mature', 0 ); + public function unmature( $args ) { + $this->update_site_status( $args, 'mature', 0 ); } /** @@ -1345,30 +697,18 @@ public function unmature( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to set as public. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be set as public. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to set as public. * * ## EXAMPLES * * $ wp site public 123 * Success: Site 123 marked as public. * - * $ wp site public --slug=demo - * Success: Site 123 marked as public. - * * @subcommand public */ - public function set_public( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'public', 1 ); + public function set_public( $args ) { + $this->update_site_status( $args, 'public', 1 ); } /** @@ -1376,115 +716,60 @@ public function set_public( $args, $assoc_args ) { * * ## OPTIONS * - * [<id>...] - * : One or more IDs of sites to set as private. If not provided, you must set the --slug parameter. - * - * [--slug=<slug>] - * : Path of the site to be set as private. Subdomain on subdomain installs, directory on subdirectory installs. + * <id>... + * : One or more IDs of sites to set as private. * * ## EXAMPLES * * $ wp site private 123 * Success: Site 123 marked as private. * - * $ wp site private --slug=demo - * Success: Site 123 marked as private. - * * @subcommand private */ - public function set_private( $args, $assoc_args ) { - if ( ! $this->check_site_ids_and_slug( $args, $assoc_args ) ) { - return; - } - - $ids = $this->get_sites_ids( $args, $assoc_args ); - - $this->update_site_status( $ids, 'public', 0 ); + public function set_private( $args ) { + $this->update_site_status( $args, 'public', 0 ); } private function update_site_status( $ids, $pref, $value ) { - $value = (int) $value; - - $action = 'updated'; - - switch ( $pref ) { - case 'archived': - $action = $value ? 'archived' : 'unarchived'; - break; - case 'deleted': - $action = $value ? 'deactivated' : 'activated'; - break; - case 'mature': - $action = $value ? 'marked as mature' : 'marked as unmature'; - break; - case 'public': - $action = $value ? 'marked as public' : 'marked as private'; - break; - case 'spam': - $action = $value ? 'marked as spam' : 'removed from spam'; - break; + if ( $pref == 'archived' && $value == 1 ) { + $action = 'archived'; + } else if ( $pref == 'archived' && $value == 0) { + $action = 'unarchived'; + } else if ( $pref == 'deleted' && $value == 1 ) { + $action = 'deactivated'; + } else if ( $pref == 'deleted' && $value == 0 ) { + $action = 'activated'; + } else if ( $pref == 'spam' && $value == 1 ) { + $action = 'marked as spam'; + } else if ( $pref == 'spam' && $value == 0 ) { + $action = 'removed from spam'; + } else if ( $pref == 'public' && $value == 1 ) { + $action = 'marked as public'; + } else if ( $pref == 'public' && $value == 0 ) { + $action = 'marked as private'; + } else if ( $pref == 'mature' && $value == 1 ) { + $action = 'marked as mature'; + } else if ( $pref == 'mature' && $value == 0 ) { + $action = 'marked as unmature'; } foreach ( $ids as $site_id ) { $site = $this->fetcher->get_check( $site_id ); if ( is_main_site( $site->blog_id ) ) { - WP_CLI::warning( 'You are not allowed to change the main site.' ); + WP_CLI::warning( "You are not allowed to change the main site." ); continue; } - $old_value = (int) get_blog_status( $site->blog_id, $pref ); + $old_value = get_blog_status( $site->blog_id, $pref ); - if ( $value === $old_value ) { + if ( $value == $old_value ) { WP_CLI::warning( "Site {$site->blog_id} already {$action}." ); continue; } - update_blog_status( $site->blog_id, $pref, (string) $value ); + update_blog_status( $site->blog_id, $pref, $value ); WP_CLI::success( "Site {$site->blog_id} {$action}." ); } } - - /** - * Get an array of site IDs from the passed-in arguments or slug parameter. - * - * @param array $args Passed-in arguments. - * @param array $assoc_args Passed-in parameters. - * - * @return array Site IDs. - * @throws ExitException - */ - private function get_sites_ids( $args, $assoc_args ) { - /** - * @var string|false $slug - */ - $slug = Utils\get_flag_value( $assoc_args, 'slug', false ); - - if ( $slug ) { - $blog_id = get_id_from_blogname( trim( $slug, '/' ) ); - if ( null === $blog_id ) { - WP_CLI::error( sprintf( 'Could not find site with slug \'%s\'.', $slug ) ); - } - return [ $blog_id ]; - } - - return $args; - } - - /** - * Check that the site IDs or slug are provided. - * - * @param array $args Passed-in arguments. - * @param array $assoc_args Passed-in parameters. - * - * @return bool - */ - private function check_site_ids_and_slug( $args, $assoc_args ) { - if ( ( empty( $args ) && empty( $assoc_args['slug'] ) ) - || ( ! empty( $args ) && ! empty( $assoc_args['slug'] ) ) ) { - WP_CLI::error( 'Please specify one or more IDs of sites, or pass the slug for a single site using --slug.' ); - } - - return true; - } } diff --git a/src/Site_Meta_Command.php b/src/Site_Meta_Command.php deleted file mode 100644 index 8b86c34a6..000000000 --- a/src/Site_Meta_Command.php +++ /dev/null @@ -1,118 +0,0 @@ -<?php - -use WP_CLI\CommandWithMeta; -use WP_CLI\Fetchers\Site as SiteFetcher; - -/** - * Adds, updates, deletes, and lists site custom fields. - * - * ## EXAMPLES - * - * # Set site meta - * $ wp site meta set 123 bio "Mary is a WordPress developer." - * Success: Updated custom field 'bio'. - * - * # Get site meta - * $ wp site meta get 123 bio - * Mary is a WordPress developer. - * - * # Update site meta - * $ wp site meta update 123 bio "Mary is an awesome WordPress developer." - * Success: Updated custom field 'bio'. - * - * # Delete site meta - * $ wp site meta delete 123 bio - * Success: Deleted custom field. - */ -class Site_Meta_Command extends CommandWithMeta { - protected $meta_type = 'blog'; - - /** - * Check that the site ID exists - * - * @param string|int $object_id - * @return int|never - */ - protected function check_object_id( $object_id ) { - $fetcher = new SiteFetcher(); - $site = $fetcher->get_check( (string) $object_id ); - return $site->blog_id; - } - - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false The meta ID on success, false on failure. - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_site_meta( $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_site_meta( $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_site_meta( $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_site_meta( $object_id, $meta_key, $meta_value ); - } -} diff --git a/src/Site_Option_Command.php b/src/Site_Option_Command.php index c4e9e4b4f..3822631b8 100644 --- a/src/Site_Option_Command.php +++ b/src/Site_Option_Command.php @@ -1,11 +1,10 @@ <?php -use WP_CLI\Formatter; -use WP_CLI\Traverser\RecursiveDataStructureTraverser; +use WP_CLI\Entity\RecursiveDataStructureTraverser; use WP_CLI\Utils; /** - * Adds, updates, deletes, and lists site options in a multisite installation. + * Adds, updates, deletes, and lists site options in a multisite install. * * ## EXAMPLES * @@ -57,7 +56,7 @@ public function get( $args, $assoc_args ) { $value = get_site_option( $key ); if ( false === $value ) { - WP_CLI::halt( 1 ); + WP_CLI::halt(1); } WP_CLI::print_value( $value, $assoc_args ); @@ -72,7 +71,7 @@ public function get( $args, $assoc_args ) { * : The name of the site option to add. * * [<value>] - * : The value of the site option to add. If omitted, the value is read from STDIN. + * : The value of the site option to add. If ommited, the value is read from STDIN. * * [--format=<format>] * : The serialization format for the value. @@ -96,9 +95,9 @@ public function add( $args, $assoc_args ) { $value = WP_CLI::read_value( $value, $assoc_args ); if ( ! add_site_option( $key, $value ) ) { - WP_CLI::error( "Could not add site option '{$key}'. Does it already exist?" ); + WP_CLI::error( "Could not add site option '$key'. Does it already exist?" ); } else { - WP_CLI::success( "Added '{$key}' site option." ); + WP_CLI::success( "Added '$key' site option." ); } } @@ -147,7 +146,7 @@ public function add( $args, $assoc_args ) { * * ## EXAMPLES * - * # List all site options beginning with "i2f_" + * # List all site options begining with "i2f_" * $ wp site option list --search="i2f_*" * +-------------+--------------+ * | meta_key | meta_value | @@ -160,9 +159,9 @@ public function add( $args, $assoc_args ) { public function list_( $args, $assoc_args ) { global $wpdb; - $pattern = '%'; - $fields = [ 'meta_key', 'meta_value' ]; - $size_query = ',LENGTH(meta_value) AS `size_bytes`'; + $pattern = '%'; + $fields = array( 'meta_key', 'meta_value' ); + $size_query = ",LENGTH(meta_value) AS `size_bytes`"; if ( isset( $assoc_args['search'] ) ) { $pattern = self::esc_like( $assoc_args['search'] ); @@ -175,29 +174,26 @@ public function list_( $args, $assoc_args ) { $fields = explode( ',', $assoc_args['fields'] ); } - if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { - $fields = [ 'size_bytes' ]; - $size_query = ',SUM(LENGTH(meta_value)) AS `size_bytes`'; + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { + $fields = array( 'size_bytes' ); + $size_query = ",SUM(LENGTH(meta_value)) AS `size_bytes`"; } $query = $wpdb->prepare( - 'SELECT `meta_id`, `site_id`, `meta_key`,`meta_value`' - . $size_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Hard-coded partial query without user input. + "SELECT `meta_id`, `site_id`, `meta_key`,`meta_value`" . $size_query . " FROM `$wpdb->sitemeta` WHERE `meta_key` LIKE %s", $pattern ); - $site_id = Utils\get_flag_value( $assoc_args, 'site_id' ); - if ( $site_id ) { + if ( $site_id = Utils\get_flag_value( $assoc_args, 'site_id' ) ) { $query .= $wpdb->prepare( ' AND site_id=%d', $site_id ); } - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is already prepared above. $results = $wpdb->get_results( $query ); - if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) { WP_CLI::line( $results[0]->size_bytes ); } else { - $formatter = new Formatter( + $formatter = new \WP_CLI\Formatter( $assoc_args, $fields ); @@ -214,7 +210,7 @@ public function list_( $args, $assoc_args ) { * : The name of the site option to update. * * [<value>] - * : The new value. If omitted, the value is read from STDIN. + * : The new value. If ommited, the value is read from STDIN. * * [--format=<format>] * : The serialization format for the value. @@ -239,15 +235,17 @@ public function update( $args, $assoc_args ) { $value = WP_CLI::get_value_from_arg_or_stdin( $args, 1 ); $value = WP_CLI::read_value( $value, $assoc_args ); - $value = sanitize_option( $key, $value ); + $value = sanitize_option( $key, $value ); $old_value = sanitize_option( $key, get_site_option( $key ) ); if ( $value === $old_value ) { - WP_CLI::success( "Value passed for '{$key}' site option is unchanged." ); - } elseif ( update_site_option( $key, $value ) ) { - WP_CLI::success( "Updated '{$key}' site option." ); + WP_CLI::success( "Value passed for '$key' site option is unchanged." ); } else { - WP_CLI::error( "Could not update site option '{$key}'." ); + if ( update_site_option( $key, $value ) ) { + WP_CLI::success( "Updated '$key' site option." ); + } else { + WP_CLI::error( "Could not update site option '$key'." ); + } } } @@ -268,9 +266,9 @@ public function delete( $args ) { list( $key ) = $args; if ( ! delete_site_option( $key ) ) { - WP_CLI::error( "Could not delete '{$key}' site option. Does it exist?" ); + WP_CLI::error( "Could not delete '$key' site option. Does it exist?" ); } else { - WP_CLI::success( "Deleted '{$key}' site option." ); + WP_CLI::success( "Deleted '$key' site option." ); } } @@ -303,21 +301,18 @@ public function pluck( $args, $assoc_args ) { WP_CLI::halt( 1 ); } - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 1 ) - ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 1 ) ); $traverser = new RecursiveDataStructureTraverser( $value ); try { $value = $traverser->get( $key_path ); - } catch ( Exception $exception ) { + } catch ( \Exception $e ) { die( 1 ); } @@ -358,59 +353,59 @@ function ( $key ) { */ public function patch( $args, $assoc_args ) { list( $action, $key ) = $args; - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 2 ) - ); - - if ( 'delete' === $action ) { + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 2 ) ); + + if ( 'delete' == $action ) { $patch_value = null; + } elseif ( \WP_CLI\Entity\Utils::has_stdin() ) { + $stdin_value = WP_CLI::get_value_from_arg_or_stdin( $args, -1 ); + $patch_value = WP_CLI::read_value( trim( $stdin_value ), $assoc_args ); } else { - $stdin_value = Utils\has_stdin() - ? trim( WP_CLI::get_value_from_arg_or_stdin( $args, -1 ) ) - : null; - $patch_value = ! empty( $stdin_value ) - ? WP_CLI::read_value( $stdin_value, $assoc_args ) - : WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); + // Take the patch value as the last positional argument. Mutates $key_path to be 1 element shorter! + $patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); } /* Need to make a copy of $current_value here as it is modified by reference */ - $old_value = sanitize_option( $key, get_site_option( $key ) ); - $current_value = $old_value; - if ( is_object( $current_value ) ) { - $old_value = clone $current_value; - } + $old_value = $current_value = sanitize_option( $key, get_site_option( $key ) ); $traverser = new RecursiveDataStructureTraverser( $current_value ); try { $traverser->$action( $key_path, $patch_value ); - } catch ( Exception $exception ) { - WP_CLI::error( $exception->getMessage() ); + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); } $patched_value = sanitize_option( $key, $traverser->value() ); if ( $patched_value === $old_value ) { - WP_CLI::success( "Value passed for '{$key}' site option is unchanged." ); - } elseif ( update_site_option( $key, $patched_value ) ) { - WP_CLI::success( "Updated '{$key}' site option." ); + WP_CLI::success( "Value passed for '$key' site option is unchanged." ); } else { - WP_CLI::error( "Could not update site option '{$key}'." ); + if ( update_site_option( $key, $patched_value ) ) { + WP_CLI::success( "Updated '$key' site option." ); + } else { + WP_CLI::error( "Could not update site option '$key'." ); + } } } private static function esc_like( $old ) { - /** - * @var \wpdb $wpdb - */ global $wpdb; - return $wpdb->esc_like( $old ); + // Remove notices in 4.0 and support backwards compatibility + if( method_exists( $wpdb, 'esc_like' ) ) { + // 4.0 + $old = $wpdb->esc_like( $old ); + } else { + // 3.9 or less + $old = like_escape( esc_sql( $old ) ); + } + + return $old; } } diff --git a/src/Taxonomy_Command.php b/src/Taxonomy_Command.php index 8bae29616..c1fb418bc 100644 --- a/src/Taxonomy_Command.php +++ b/src/Taxonomy_Command.php @@ -1,8 +1,4 @@ <?php - -use WP_CLI\Formatter; -use WP_CLI\Utils; - /** * Retrieves information about registered taxonomies. * @@ -38,36 +34,14 @@ class Taxonomy_Command extends WP_CLI_Command { 'public', ); - /** - * Gets the term counts for each supplied taxonomy. - * - * @param array $taxonomies Taxonomies to fetch counts for. - * @return array Associative array of term counts keyed by taxonomy. - */ - protected function get_counts( $taxonomies ) { - global $wpdb; + public function __construct() { - if ( count( $taxonomies ) <= 0 ) { - return []; + if ( \WP_CLI\Utils\wp_version_compare( 3.7, '<' ) ) { + // remove description for wp <= 3.7 + $this->fields = array_values( array_diff( $this->fields, array( 'description' ) ) ); } - $query = $wpdb->prepare( - "SELECT `taxonomy`, COUNT(*) AS `count` - FROM $wpdb->term_taxonomy - WHERE `taxonomy` IN (" . implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) ) . ') - GROUP BY `taxonomy`', - $taxonomies - ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is already prepared above. - $counts = $wpdb->get_results( $query ); - - // Make sure there's a count for every item. - $counts = array_merge( - array_fill_keys( $taxonomies, 0 ), - wp_list_pluck( $counts, 'count', 'taxonomy' ) - ); - - return $counts; + parent::__construct(); } /** @@ -103,14 +77,10 @@ protected function get_counts( $taxonomies ) { * * name * * label * * description - * * object_type - * * show_tagcloud - * * hierarchical * * public + * * hierarchical * - * These fields are optionally available: - * - * * count + * There are no optionally available fields. * * ## EXAMPLES * @@ -138,40 +108,16 @@ protected function get_counts( $taxonomies ) { public function list_( $args, $assoc_args ) { $formatter = $this->get_formatter( $assoc_args ); - // Check if it's strict mode or not. - $strict = Utils\get_flag_value( $assoc_args, 'strict', false ); - - unset( $assoc_args['strict'] ); - if ( isset( $assoc_args['object_type'] ) ) { $assoc_args['object_type'] = array( $assoc_args['object_type'] ); - $taxonomy_object = $assoc_args['object_type']; - } else { - $taxonomy_object = get_post_types(); - } - - $fields = $formatter->fields; - $taxonomies = ( isset( $taxonomy_object ) && ! $strict ) - ? get_object_taxonomies( $taxonomy_object, 'objects' ) - : get_taxonomies( $assoc_args, 'objects' ); - - $counts = []; - - if ( count( $taxonomies ) > 0 && in_array( 'count', $fields, true ) ) { - $counts = $this->get_counts( wp_list_pluck( $taxonomies, 'name' ) ); } - $taxonomies = array_map( - function ( $taxonomy ) use ( $counts ) { - // @phpstan-ignore assign.propertyType - $taxonomy->object_type = implode( ', ', $taxonomy->object_type ); + $taxonomies = get_taxonomies( $assoc_args, 'objects' ); - // @phpstan-ignore property.notFound - $taxonomy->count = isset( $counts[ $taxonomy->name ] ) ? $counts[ $taxonomy->name ] : 0; - return $taxonomy; - }, - $taxonomies - ); + $taxonomies = array_map( function( $taxonomy ) { + $taxonomy->object_type = implode( ', ', $taxonomy->object_type ); + return $taxonomy; + }, $taxonomies ); $formatter->display_items( $taxonomies ); } @@ -201,24 +147,6 @@ function ( $taxonomy ) use ( $counts ) { * - yaml * --- * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for the specified taxonomy: - * - * * name - * * label - * * description - * * object_type - * * show_tagcloud - * * hierarchical - * * public - * * labels - * * cap - * - * These fields are optionally available: - * - * * count - * * ## EXAMPLES * * # Get details of `category` taxonomy. @@ -243,26 +171,14 @@ public function get( $args, $assoc_args ) { } if ( empty( $assoc_args['fields'] ) ) { - $default_fields = array_merge( - $this->fields, - array( - 'labels', - 'cap', - ) - ); + $default_fields = array_merge( $this->fields, array( + 'labels', + 'cap' + ) ); $assoc_args['fields'] = $default_fields; } - $formatter = $this->get_formatter( $assoc_args ); - $fields = $formatter->fields; - $count = 0; - - if ( in_array( 'count', $fields, true ) ) { - $count = $this->get_counts( [ $taxonomy->name ] ); - $count = $count[ $taxonomy->name ]; - } - $data = array( 'name' => $taxonomy->name, 'label' => $taxonomy->label, @@ -273,12 +189,13 @@ public function get( $args, $assoc_args ) { 'public' => $taxonomy->public, 'labels' => $taxonomy->labels, 'cap' => $taxonomy->cap, - 'count' => $count, ); + + $formatter = $this->get_formatter( $assoc_args ); $formatter->display_item( $data ); } private function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->fields, 'taxonomy' ); + return new \WP_CLI\Formatter( $assoc_args, $this->fields, 'taxonomy' ); } } diff --git a/src/Term_Command.php b/src/Term_Command.php index 7559df1b2..90349698e 100644 --- a/src/Term_Command.php +++ b/src/Term_Command.php @@ -1,12 +1,11 @@ <?php -use WP_CLI\Formatter; use WP_CLI\Utils; /** * Manages taxonomy terms and term meta, with create, delete, and list commands. * - * See reference for [taxonomies and their terms](https://wordpress.org/documentation/article/taxonomies). + * See reference for [taxonomies and their terms](https://codex.wordpress.org/Taxonomies). * * ## EXAMPLES * @@ -35,16 +34,11 @@ * Success: Updated category term count * Success: Updated post_tag term count * - * # Prune terms with 0 or 1 published posts - * $ wp term prune post_tag - * Deleted post_tag 15. - * Success: Pruned 1 of 5 terms. - * * @package wp-cli */ class Term_Command extends WP_CLI_Command { - private $fields = [ + private $fields = array( 'term_id', 'term_taxonomy_id', 'name', @@ -52,7 +46,7 @@ class Term_Command extends WP_CLI_Command { 'description', 'parent', 'count', - ]; + ); /** * Lists terms in a taxonomy. @@ -98,7 +92,6 @@ class Term_Command extends WP_CLI_Command { * * These fields are optionally available: * - * * term_group * * url * * ## EXAMPLES @@ -127,56 +120,46 @@ class Term_Command extends WP_CLI_Command { public function list_( $args, $assoc_args ) { foreach ( $args as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { - WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." ); + WP_CLI::error( "Taxonomy $taxonomy doesn't exist." ); } } $formatter = $this->get_formatter( $assoc_args ); - $defaults = [ + $defaults = array( 'hide_empty' => false, - ]; + ); $assoc_args = array_merge( $defaults, $assoc_args ); if ( ! empty( $assoc_args['term_id'] ) ) { - /** - * @var \WP_Term $term - */ - $term = get_term_by( 'id', $assoc_args['term_id'], $args[0] ); - $terms = [ $term ]; - } else { - $terms = get_terms( - array_merge( - $assoc_args, - [ - 'taxonomy' => $args, - ] - ) - ); - - // This should never happen because of the taxonomy_exists check above. - if ( is_wp_error( $terms ) ) { - WP_CLI::error( $terms ); + $term = get_term_by( 'id', $assoc_args['term_id'], $args[0] ); + $terms = array( $term ); + } else if ( ! empty( $assoc_args['include'] ) + && ! empty( $assoc_args['orderby'] ) + && 'include' === $assoc_args['orderby'] + && Utils\wp_version_compare( '4.7', '<' ) ) { + $terms = array(); + $term_ids = explode( ',', $assoc_args['include'] ); + foreach( $term_ids as $term_id ) { + $term = get_term_by( 'id', $term_id, $args[0] ); + if ( $term && ! is_wp_error( $term ) ) { + $terms[] = $term; + } else { + WP_CLI::warning( sprintf( "Invalid term %s.", $term_id ) ); + } } - - /** - * @var \WP_Term[] $terms - */ + } else { + $terms = get_terms( $args, $assoc_args ); } - $terms = array_map( - function ( $term ) { - $term->count = (int) $term->count; - $term->parent = (int) $term->parent; - - // @phpstan-ignore property.notFound - $term->url = get_term_link( $term ); - return $term; - }, - $terms - ); + $terms = array_map( function( $term ){ + $term->count = (int)$term->count; + $term->parent = (int)$term->parent; + $term->url = get_term_link( $term ); + return $term; + }, $terms ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { $terms = wp_list_pluck( $terms, 'term_id' ); echo implode( ' ', $terms ); } else { @@ -217,30 +200,32 @@ public function create( $args, $assoc_args ) { list( $taxonomy, $term ) = $args; - $defaults = [ + $defaults = array( 'slug' => sanitize_title( $term ), 'description' => '', 'parent' => 0, - ]; + ); $assoc_args = wp_parse_args( $assoc_args, $defaults ); - $porcelain = Utils\get_flag_value( $assoc_args, 'porcelain' ); + $porcelain = \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ); unset( $assoc_args['porcelain'] ); - $assoc_args = wp_slash( $assoc_args ); - $term = wp_slash( $term ); + // Compatibility for < WP 4.0 + if ( $assoc_args['parent'] > 0 && ! term_exists( (int) $assoc_args['parent'] ) ) { + WP_CLI::error( 'Parent term does not exist.' ); + } - /** - * @var string $term - */ - $result = wp_insert_term( $term, $taxonomy, $assoc_args ); + $assoc_args = wp_slash( $assoc_args ); + $term = wp_slash( $term ); + $ret = wp_insert_term( $term, $taxonomy, $assoc_args ); - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } elseif ( $porcelain ) { - WP_CLI::line( (string) $result['term_id'] ); + if ( is_wp_error( $ret ) ) { + WP_CLI::error( $ret->get_error_message() ); } else { - WP_CLI::success( "Created {$taxonomy} {$result['term_id']}." ); + if ( $porcelain ) + WP_CLI::line( $ret['term_id'] ); + else + WP_CLI::success( sprintf( "Created %s %d.", $taxonomy, $ret['term_id'] ) ); } } @@ -295,29 +280,18 @@ public function get( $args, $assoc_args ) { list( $taxonomy, $term ) = $args; - /** - * @var string $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); - - $term = get_term_by( $field, $term, $taxonomy ); + $term = get_term_by( Utils\get_flag_value( $assoc_args, 'by' ), $term, $taxonomy ); if ( ! $term ) { WP_CLI::error( "Term doesn't exist." ); } - // @phpstan-ignore property.notFound - if ( ! isset( $term->url ) ) { - // @phpstan-ignore property.notFound - $term->url = get_term_link( $term ); - } - if ( empty( $assoc_args['fields'] ) ) { - $term_array = get_object_vars( $term ); + $term_array = get_object_vars( $term ); $assoc_args['fields'] = array_keys( $term_array ); } - $term->count = (int) $term->count; + $term->count = (int) $term->count; $term->parent = (int) $term->parent; $formatter = $this->get_formatter( $assoc_args ); @@ -370,41 +344,36 @@ public function update( $args, $assoc_args ) { list( $taxonomy, $term ) = $args; - $defaults = [ + $defaults = array( 'name' => null, 'slug' => null, 'description' => null, 'parent' => null, - ]; + ); $assoc_args = wp_parse_args( $assoc_args, $defaults ); - foreach ( $assoc_args as $key => $value ) { - if ( null === $value ) { - unset( $assoc_args[ $key ] ); - } + foreach( $assoc_args as $key => $value ) { + if ( is_null( $value ) ) + unset( $assoc_args[$key] ); } $assoc_args = wp_slash( $assoc_args ); - /** - * @var string $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); + list( $taxonomy, $term ) = $args; - $term = get_term_by( $field, $term, $taxonomy ); + $term = get_term_by( Utils\get_flag_value( $assoc_args, 'by' ), $term, $taxonomy ); if ( ! $term ) { WP_CLI::error( "Term doesn't exist." ); } // Update term. - $result = wp_update_term( $term->term_id, $taxonomy, $assoc_args ); + $ret = wp_update_term( $term->term_id, $taxonomy, $assoc_args ); - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - } else { - WP_CLI::success( 'Term updated.' ); - } + if ( is_wp_error( $ret ) ) + WP_CLI::error( $ret->get_error_message() ); + else + WP_CLI::success( "Term updated." ); } /** @@ -448,25 +417,23 @@ public function update( $args, $assoc_args ) { * Deleted post_tag 161. * Success: Deleted 3 of 3 terms. */ - public function delete( $args, $assoc_args ) { + public function delete( $args, $assoc_args ) { $taxonomy = array_shift( $args ); - $successes = 0; - $errors = 0; + $successes = $errors = 0; foreach ( $args as $term_id ) { $term = $term_id; // Get term by specified argument otherwise get term by id. - $field = Utils\get_flag_value( $assoc_args, 'by' ); - if ( 'slug' === $field ) { + if ( 'slug' === ( $field = Utils\get_flag_value( $assoc_args, 'by' ) ) ) { // Get term by slug. $term = get_term_by( $field, $term, $taxonomy ); // If term not found, then show error message and skip the iteration. if ( ! $term ) { - WP_CLI::warning( "{$taxonomy} {$term_id} doesn't exist." ); + WP_CLI::warning( sprintf( "%s %s doesn't exist.", $taxonomy, $term_id ) ); continue; } @@ -475,16 +442,16 @@ public function delete( $args, $assoc_args ) { } - $result = wp_delete_term( $term_id, $taxonomy ); + $ret = wp_delete_term( $term_id, $taxonomy ); - if ( is_wp_error( $result ) ) { - WP_CLI::warning( $result ); - ++$errors; - } elseif ( $result ) { - WP_CLI::log( "Deleted {$taxonomy} {$term_id}." ); - ++$successes; + if ( is_wp_error( $ret ) ) { + WP_CLI::warning( $ret ); + $errors++; + } else if ( $ret ) { + WP_CLI::log( sprintf( "Deleted %s %d.", $taxonomy, $term_id ) ); + $successes++; } else { - WP_CLI::warning( "{$taxonomy} {$term_id} doesn't exist." ); + WP_CLI::warning( sprintf( "%s %s doesn't exist.", $taxonomy, $term_id ) ); } } Utils\report_batch_operation_results( 'term', 'delete', count( $args ), $successes, $errors ); @@ -538,75 +505,71 @@ public function generate( $args, $assoc_args ) { list ( $taxonomy ) = $args; - $defaults = [ - 'count' => 100, + $defaults = array( + 'count' => 100, 'max_depth' => 1, - ]; + ); - $final_args = array_merge( $defaults, $assoc_args ); - $count = $final_args['count']; - $max_depth = $final_args['max_depth']; + extract( array_merge( $defaults, $assoc_args ), EXTR_SKIP ); - if ( ! taxonomy_exists( $taxonomy ) ) { - WP_CLI::error( "'{$taxonomy}' is not a registered taxonomy." ); + if ( !taxonomy_exists( $taxonomy ) ) { + WP_CLI::error( sprintf( "'%s' is not a registered taxonomy.", $taxonomy ) ); } - /** - * @var \WP_Taxonomy $tax - */ - $tax = get_taxonomy( $taxonomy ); + $label = get_taxonomy( $taxonomy )->labels->singular_name; + $slug = sanitize_title_with_dashes( $label ); - $label = $tax->labels->singular_name; - $slug = sanitize_title_with_dashes( $label ); + $hierarchical = get_taxonomy( $taxonomy )->hierarchical; - $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'progress' ); $notify = false; if ( 'progress' === $format ) { - $notify = Utils\make_progress_bar( 'Generating terms', $count ); + $notify = \WP_CLI\Utils\make_progress_bar( 'Generating terms', $count ); } $previous_term_id = 0; - $current_parent = 0; - $current_depth = 1; + $current_parent = 0; + $current_depth = 1; - $max_id = (int) $wpdb->get_var( "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} ORDER BY term_taxonomy_id DESC LIMIT 1" ); + $max_id = (int) $wpdb->get_var( "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy ORDER BY term_taxonomy_id DESC LIMIT 1" ); $suspend_cache_invalidation = wp_suspend_cache_invalidation( true ); - $created = []; + $created = array(); - for ( $index = $max_id + 1; $index <= $max_id + $count; $index++ ) { + for ( $i = $max_id + 1; $i <= $max_id + $count; $i++ ) { - if ( $tax->hierarchical ) { + if ( $hierarchical ) { if ( $previous_term_id && $this->maybe_make_child() && $current_depth < $max_depth ) { $current_parent = $previous_term_id; - ++$current_depth; + $current_depth++; - } elseif ( $this->maybe_reset_depth() ) { + } else if ( $this->maybe_reset_depth() ) { $current_parent = 0; - $current_depth = 1; + $current_depth = 1; } + } - $args = [ + $args = array( 'parent' => $current_parent, - 'slug' => $slug . "-{$index}", - ]; + 'slug' => $slug . "-$i", + ); - $name = "{$label} {$index}"; + $name = "$label $i"; $term = wp_insert_term( $name, $taxonomy, $args ); if ( is_wp_error( $term ) ) { WP_CLI::warning( $term ); } else { - $created[] = $term['term_id']; + $created[] = $term['term_id']; $previous_term_id = $term['term_id']; if ( 'ids' === $format ) { echo $term['term_id']; - if ( $index < $max_id + $count ) { + if ( $i < $max_id + $count ) { echo ' '; } } @@ -656,253 +619,34 @@ public function generate( $args, $assoc_args ) { * Success: Updated post_format term count. */ public function recount( $args ) { - foreach ( $args as $taxonomy ) { + foreach( $args as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { - WP_CLI::warning( "Taxonomy {$taxonomy} does not exist." ); + WP_CLI::warning( sprintf( "Taxonomy %s does not exist.", $taxonomy ) ); } else { - $terms = get_terms( - [ - 'taxonomy' => $taxonomy, - 'hide_empty' => false, - ] - ); - - // This should never happen because of the taxonomy_exists check above. - if ( is_wp_error( $terms ) ) { - WP_CLI::warning( "Taxonomy {$taxonomy} does not exist." ); - continue; - } - - /** - * @var \WP_Term[] $terms - */ + $terms = get_terms( $taxonomy, array( 'hide_empty' => false ) ); $term_taxonomy_ids = wp_list_pluck( $terms, 'term_taxonomy_id' ); wp_update_term_count( $term_taxonomy_ids, $taxonomy ); - WP_CLI::success( "Updated {$taxonomy} term count." ); - } - } - } - - /** - * Removes terms with 0 or 1 published posts from one or more taxonomies. - * - * Useful for cleaning up large sites with many unused or barely-used terms. - * The term count is based on the number of published posts assigned to each - * term. - * - * ## OPTIONS - * - * <taxonomy>... - * : One or more taxonomies to prune. - * - * [--dry-run] - * : Preview the terms to be pruned, without actually deleting them. - * - * ## EXAMPLES - * - * # Prune post tags with 0 or 1 published posts. - * $ wp term prune post_tag - * Deleted post_tag 15. - * Success: Pruned 1 of 5 terms. - * - * # Dry run to preview which terms would be pruned. - * $ wp term prune post_tag --dry-run - * Would delete post_tag 15. - * Success: 1 post_tag term would be pruned. - * - * # Prune multiple taxonomies at once. - * $ wp term prune category post_tag - * Deleted category 8. - * Success: Pruned 1 of 3 terms. - * Deleted post_tag 15. - * Success: Pruned 1 of 5 terms. - */ - public function prune( $args, $assoc_args ) { - global $wpdb; - - $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false ); - - foreach ( $args as $taxonomy ) { - if ( ! taxonomy_exists( $taxonomy ) ) { - WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." ); - } - - $term_ids_to_prune = $wpdb->get_col( - $wpdb->prepare( - "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s AND count <= 1", - $taxonomy - ) - ); - - $total = count( $term_ids_to_prune ); - $successes = 0; - $errors = 0; - - foreach ( $term_ids_to_prune as $term_id ) { - if ( $dry_run ) { - WP_CLI::log( "Would delete {$taxonomy} {$term_id}." ); - ++$successes; - continue; - } - - $result = wp_delete_term( $term_id, $taxonomy ); - - if ( is_wp_error( $result ) ) { - WP_CLI::warning( $result ); - ++$errors; - } elseif ( $result ) { - WP_CLI::log( "Deleted {$taxonomy} {$term_id}." ); - ++$successes; - } else { - WP_CLI::warning( "Term {$term_id} in taxonomy {$taxonomy} doesn't exist." ); - ++$errors; - } + WP_CLI::success( sprintf( "Updated %s term count.", $taxonomy ) ); } - if ( $dry_run ) { - $term_word = Utils\pluralize( 'term', $successes ); - WP_CLI::success( "{$successes} {$taxonomy} {$term_word} would be pruned." ); - } else { - Utils\report_batch_operation_results( 'term', 'prune', $total, $successes, $errors ); - } } } - /** - * Migrate a term of a taxonomy to another taxonomy. - * - * ## OPTIONS - * - * <term> - * : Slug or ID of the term to migrate. - * - * [--by=<field>] - * : Explicitly handle the term value as a slug or id. - * --- - * default: id - * options: - * - slug - * - id - * --- - * - * [--from=<taxonomy>] - * : Taxonomy slug of the term to migrate. - * - * [--to=<taxonomy>] - * : Taxonomy slug to migrate to. - * - * ## EXAMPLES - * - * # Migrate a category's term (video) to tag taxonomy. - * $ wp term migrate 9190 --from=category --to=post_tag - * Term 'video' assigned to post 1155. - * Term 'video' migrated. - * Old instance of term 'video' removed from its original taxonomy. - * Success: Migrated the term 'video' from taxonomy 'category' to taxonomy 'post_tag' for 1 post. - */ - public function migrate( $args, $assoc_args ) { - $term_reference = $args[0]; - - /** - * @var string $original_taxonomy - */ - $original_taxonomy = Utils\get_flag_value( $assoc_args, 'from' ); - /** - * @var string $destination_taxonomy - */ - $destination_taxonomy = Utils\get_flag_value( $assoc_args, 'to' ); - - /** - * @var string $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); - - $term = get_term_by( $field, $term_reference, $original_taxonomy ); - - if ( ! $term ) { - WP_CLI::error( "Taxonomy term '{$term_reference}' for taxonomy '{$original_taxonomy}' doesn't exist." ); - } - - $tax = get_taxonomy( $original_taxonomy ); - - if ( ! $tax ) { - WP_CLI::error( "Taxonomy '{$original_taxonomy}' doesn't exist." ); - } - - $dest_tax = get_taxonomy( $destination_taxonomy ); - - if ( ! $dest_tax ) { - WP_CLI::error( "Taxonomy '{$destination_taxonomy}' doesn't exist." ); - } - - $id = wp_insert_term( - $term->name, - $destination_taxonomy, - [ - 'slug' => $term->slug, - 'parent' => 0, - 'description' => $term->description, - ] - ); - - if ( is_wp_error( $id ) ) { - WP_CLI::error( $id->get_error_message() ); - } - - /** - * @var string[] $post_ids - */ - $post_ids = get_objects_in_term( $term->term_id, $tax->name ); - $post_count = 0; - - foreach ( $post_ids as $post_id ) { - $type = get_post_type( (int) $post_id ); - if ( in_array( $type, $dest_tax->object_type, true ) ) { - $term_taxonomy_id = wp_set_object_terms( (int) $post_id, $id['term_id'], $destination_taxonomy, true ); - - if ( is_wp_error( $term_taxonomy_id ) ) { - WP_CLI::error( "Failed to assign the term '{$term->slug}' to the post {$post_id}. Reason: " . $term_taxonomy_id->get_error_message() ); - } - - WP_CLI::log( "Term '{$term->slug}' assigned to post {$post_id}." ); - ++$post_count; - } else { - WP_CLI::warning( "Term '{$term->slug}' not assigned to post {$post_id}. Post type '{$type}' is not registered with taxonomy '{$destination_taxonomy}'." ); - } - - clean_post_cache( (int) $post_id ); - } - - clean_term_cache( $term->term_id ); - - WP_CLI::log( "Term '{$term->slug}' migrated." ); - - $del = wp_delete_term( $term->term_id, $tax->name ); - - if ( is_wp_error( $del ) ) { - WP_CLI::error( "Failed to delete the term '{$term->slug}'. Reason: " . $del->get_error_message() ); - } - - WP_CLI::log( "Old instance of term '{$term->slug}' removed from its original taxonomy." ); - $post_plural = Utils\pluralize( 'post', $post_count ); - WP_CLI::success( "Migrated the term '{$term->slug}' from taxonomy '{$tax->name}' to taxonomy '{$destination_taxonomy}' for {$post_count} {$post_plural}." ); - } - private function maybe_make_child() { - // 50% chance of making child term. - return ( wp_rand( 1, 2 ) === 1 ); + // 50% chance of making child term + return ( mt_rand(1, 2) == 1 ); } private function maybe_reset_depth() { - // 10% chance of resetting to root depth. - return ( wp_rand( 1, 10 ) === 7 ); + // 10% chance of reseting to root depth + return ( mt_rand(1, 10) == 7 ); } private function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->fields, 'term' ); + return new \WP_CLI\Formatter( $assoc_args, $this->fields, 'term' ); } } diff --git a/src/Term_Meta_Command.php b/src/Term_Meta_Command.php index 88ec4270d..53e8fb81f 100644 --- a/src/Term_Meta_Command.php +++ b/src/Term_Meta_Command.php @@ -1,7 +1,5 @@ <?php -use WP_CLI\CommandWithMeta; - /** * Adds, updates, deletes, and lists term custom fields. * @@ -23,101 +21,20 @@ * $ wp term meta delete 123 bio * Success: Deleted custom field. */ -class Term_Meta_Command extends CommandWithMeta { +class Term_Meta_Command extends \WP_CLI\CommandWithMeta { protected $meta_type = 'term'; /** * Check that the term ID exists * - * @param string|int $object_id - * @return int|never + * @param int */ protected function check_object_id( $object_id ) { - $term = get_term( (int) $object_id ); - if ( ! $term || is_wp_error( $term ) ) { + $term = get_term( $object_id ); + if ( ! $term ) { WP_CLI::error( "Could not find the term with ID {$object_id}." ); } return $term->term_id; } - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false|WP_Error The meta ID on success, false on failure, WP_Error when term_id is ambiguous between taxonomies. - * - * @phpstan-ignore method.childReturnType - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_term_meta( $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool|WP_Error Meta ID if the key didn't exist, true on successful - * update, false on failure, WP_Error when term_id is ambiguous between taxonomies. - * - * @phpstan-ignore method.childReturnType - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_term_meta( $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_term_meta( $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_term_meta( $object_id, $meta_key, $meta_value ); - } } diff --git a/src/User_Application_Password_Command.php b/src/User_Application_Password_Command.php deleted file mode 100644 index 0dffef714..000000000 --- a/src/User_Application_Password_Command.php +++ /dev/null @@ -1,576 +0,0 @@ -<?php - -use WP_CLI\ExitException; -use WP_CLI\Fetchers\User as UserFetcher; -use WP_CLI\Formatter; -use WP_CLI\Utils; - -/** - * Creates, updates, deletes, lists and retrieves application passwords. - * - * ## EXAMPLES - * - * # List user application passwords and only show app name and password hash - * $ wp user application-password list 123 --fields=name,password - * +--------+------------------------------------+ - * | name | password | - * +--------+------------------------------------+ - * | myapp | $P$BVGeou1CUot114YohIemgpwxQCzb8O/ | - * +--------+------------------------------------+ - * - * # Get a specific application password and only show app name and created timestamp - * $ wp user application-password get 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --fields=name,created - * +--------+------------+ - * | name | created | - * +--------+------------+ - * | myapp | 1638395611 | - * +--------+------------+ - * - * # Create user application password - * $ wp user application-password create 123 myapp - * Success: Created application password. - * Password: ZG1bxdxdzjTwhsY8vK8l1C65 - * - * # Only print the password without any chrome - * $ wp user application-password create 123 myapp --porcelain - * ZG1bxdxdzjTwhsY8vK8l1C65 - * - * # Update an existing application password - * $ wp user application-password update 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --name=newappname - * Success: Updated application password. - * - * # Delete an existing application password - * $ wp user application-password delete 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - * Success: Deleted 1 of 1 application password. - * - * # Check if an application password for a given application exists - * $ wp user application-password exists 123 myapp - * $ echo $? - * 1 - * - * # Bash script for checking whether an application password exists and creating one if not - * if ! wp user application-password exists 123 myapp; then - * PASSWORD=$(wp user application-password create 123 myapp --porcelain) - * fi - */ -final class User_Application_Password_Command { - - /** - * List of application password fields. - * - * @var array<string> - */ - const APPLICATION_PASSWORD_FIELDS = [ - 'uuid', - 'app_id', - 'name', - 'password', - 'created', - 'last_used', - 'last_ip', - ]; - - /** - * Lists all application passwords associated with a user. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to get application passwords for. - * - * [--<field>=<value>] - * : Filter the list by a specific field. - * - * [--field=<field>] - * : Prints the value of a single field for each application password. - * - * [--fields=<fields>] - * : Limit the output to specific fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - count - * - yaml - * - ids - * --- - * - * [--orderby=<fields>] - * : Set orderby which field. - * --- - * default: created - * options: - * - uuid - * - app_id - * - name - * - password - * - created - * - last_used - * - last_ip - * --- - * - * [--order=<order>] - * : Set ascending or descending order. - * --- - * default: desc - * options: - * - asc - * - desc - * --- - * - * ## EXAMPLES - * - * # List user application passwords and only show app name and password hash - * $ wp user application-password list 123 --fields=name,password - * +--------+------------------------------------+ - * | name | password | - * +--------+------------------------------------+ - * | myapp | $P$BVGeou1CUot114YohIemgpwxQCzb8O/ | - * +--------+------------------------------------+ - * - * @subcommand list - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the user could not be found/parsed. - * @throws ExitException If the application passwords could not be retrieved. - */ - public function list_( $args, $assoc_args ) { - $args = $this->replace_login_with_user_id( $args ); - - list( $user_id ) = $args; - - $application_passwords = WP_Application_Passwords::get_user_application_passwords( $user_id ); - - if ( $application_passwords instanceof WP_Error ) { - WP_CLI::error( $application_passwords ); - } - - if ( empty( $application_passwords ) ) { - $application_passwords = []; - } - - $order = Utils\get_flag_value( $assoc_args, 'order' ); - $orderby = Utils\get_flag_value( $assoc_args, 'orderby' ); - - usort( - $application_passwords, - static function ( $a, $b ) use ( $orderby, $order ) { - // Sort array. - return 'asc' === $order - ? $a[ $orderby ] <=> $b[ $orderby ] - : $b[ $orderby ] <=> $a[ $orderby ]; - } - ); - - $fields = self::APPLICATION_PASSWORD_FIELDS; - - // Avoid confusion regarding the dash/underscore usage. - foreach ( [ 'app-id', 'last-used', 'last-ip' ] as $flag ) { - if ( array_key_exists( $flag, $assoc_args ) ) { - $underscored_flag = str_replace( '-', '_', $flag ); - $assoc_args[ $underscored_flag ] = $assoc_args[ $flag ]; - unset( $assoc_args[ $flag ] ); - } - } - - foreach ( $fields as $field ) { - if ( ! array_key_exists( $field, $assoc_args ) ) { - continue; - } - - $value = Utils\get_flag_value( $assoc_args, $field ); - - $application_passwords = array_filter( - $application_passwords, - static function ( $application_password ) use ( $field, $value ) { - return $application_password[ $field ] === $value; - } - ); - } - - if ( ! empty( $assoc_args['fields'] ) ) { - $fields = explode( ',', $assoc_args['fields'] ); - } - - $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); - - $formatter = new Formatter( $assoc_args, $fields ); - - if ( 'ids' === $format ) { - $formatter->display_items( wp_list_pluck( $application_passwords, 'uuid' ) ); - } else { - $formatter->display_items( $application_passwords ); - } - } - - /** - * Gets a specific application password. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to get the application password for. - * - * <uuid> - * : The universally unique ID of the application password. - * - * [--field=<field>] - * : Prints the value of a single field for the application password. - * - * [--fields=<fields>] - * : Limit the output to specific fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * --- - * - * ## EXAMPLES - * - * # Get a specific application password and only show app name and created timestamp - * $ wp user application-password get 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --fields=name,created - * +--------+------------+ - * | name | created | - * +--------+------------+ - * | myapp | 1638395611 | - * +--------+------------+ - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application passwords could not be retrieved. - */ - public function get( $args, $assoc_args ) { - $args = $this->replace_login_with_user_id( $args ); - - list( $user_id, $uuid ) = $args; - - $application_password = WP_Application_Passwords::get_user_application_password( $user_id, $uuid ); - - if ( $application_password instanceof WP_Error ) { - WP_CLI::error( $application_password ); - } - - if ( null === $application_password ) { - WP_CLI::error( 'No application password found for this user ID and UUID.' ); - } - - $fields = self::APPLICATION_PASSWORD_FIELDS; - - if ( ! empty( $assoc_args['fields'] ) ) { - $fields = explode( ',', $assoc_args['fields'] ); - } - - $formatter = new Formatter( $assoc_args, $fields ); - $formatter->display_items( [ $application_password ] ); - } - - /** - * Creates a new application password. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to create a new application password for. - * - * <app-name> - * : Unique name of the application to create an application password for. - * - * [--app-id=<app-id>] - * : Application ID to attribute to the application password. - * - * [--porcelain] - * : Output just the new password. - * - * ## EXAMPLES - * - * # Create user application password - * $ wp user application-password create 123 myapp - * Success: Created application password. - * Password: ZG1bxdxdzjTwhsY8vK8l1C65 - * - * # Only print the password without any chrome - * $ wp user application-password create 123 myapp --porcelain - * ZG1bxdxdzjTwhsY8vK8l1C65 - * - * # Create user application with a custom application ID for internal tracking - * $ wp user application-password create 123 myapp --app-id=42 --porcelain - * ZG1bxdxdzjTwhsY8vK8l1C65 - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application password could not be created. - */ - public function create( $args, $assoc_args ) { - $args = $this->replace_login_with_user_id( $args ); - - /** - * @var string $user_id - * @var string $app_name - */ - list( $user_id, $app_name ) = $args; - - /** - * @var string $app_id - */ - $app_id = Utils\get_flag_value( $assoc_args, 'app-id', '' ); - - $arguments = [ - 'name' => $app_name, - 'app_id' => $app_id, - ]; - - $result = WP_Application_Passwords::create_new_application_password( (int) $user_id, $arguments ); - - if ( $result instanceof WP_Error ) { - WP_CLI::error( $result ); - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain', false ) ) { - WP_CLI::line( $result[0] ); - WP_CLI::halt( 0 ); - } - - WP_CLI::success( 'Created application password.' ); - WP_CLI::line( "Password: {$result[0]}" ); - } - - /** - * Updates an existing application password. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to update the application password for. - * - * <uuid> - * : The universally unique ID of the application password. - * - * [--<field>=<value>] - * : Update the <field> with a new <value>. Currently supported fields: name. - * - * ## EXAMPLES - * - * # Update an existing application password - * $ wp user application-password update 123 6633824d-c1d7-4f79-9dd5-4586f734d69e --name=newappname - * Success: Updated application password. - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application password could not be created. - */ - public function update( $args, $assoc_args ) { - $args = $this->replace_login_with_user_id( $args ); - - list( $user_id, $uuid ) = $args; - - $result = WP_Application_Passwords::update_application_password( $user_id, $uuid, $assoc_args ); - - if ( $result instanceof WP_Error ) { - WP_CLI::error( $result ); - } - - WP_CLI::success( 'Updated application password.' ); - } - - /** - * Record usage of an application password. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to update the application password for. - * - * <uuid> - * : The universally unique ID of the application password. - * - * ## EXAMPLES - * - * # Record usage of an application password - * $ wp user application-password record-usage 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - * Success: Recorded application password usage. - * - * @subcommand record-usage - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application password could not be created. - */ - public function record_usage( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - $args = $this->replace_login_with_user_id( $args ); - - list( $user_id, $uuid ) = $args; - - $result = WP_Application_Passwords::record_application_password_usage( $user_id, $uuid ); - - if ( $result instanceof WP_Error ) { - WP_CLI::error( $result ); - } - - WP_CLI::success( 'Recorded application password usage.' ); - } - - /** - * Delete an existing application password. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to delete the application password for. - * - * [<uuid>...] - * : Comma-separated list of UUIDs of the application passwords to delete. - * - * [--all] - * : Delete all of the user's application password. - * - * ## EXAMPLES - * - * # Delete an existing application password - * $ wp user application-password delete 123 6633824d-c1d7-4f79-9dd5-4586f734d69e - * Success: Deleted 1 of 1 application password. - * - * # Delete all of the user's application passwords - * $ wp user application-password delete 123 --all - * Success: Deleted all application passwords. - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application password could not be created. - */ - public function delete( $args, $assoc_args ) { - $args = $this->replace_login_with_user_id( $args ); - - $user_id = array_shift( $args ); - $all = Utils\get_flag_value( $assoc_args, 'all', false ); - $count = count( $args ); - - if ( ( 0 < $count && true === $all ) || ( 0 === $count && true !== $all ) ) { - WP_CLI::error( 'You need to specify either one or more UUIDS or provide the --all flag' ); - } - - if ( true === $all ) { - $result = WP_Application_Passwords::delete_all_application_passwords( $user_id ); - - if ( $result instanceof WP_Error ) { - WP_CLI::error( $result ); - } - - WP_CLI::success( 'Deleted all application passwords.' ); - WP_CLI::halt( 0 ); - } - - $errors = 0; - foreach ( $args as $uuid ) { - $result = WP_Application_Passwords::delete_application_password( $user_id, $uuid ); - - if ( $result instanceof WP_Error ) { - WP_CLI::warning( "Failed to delete UUID {$uuid}: " . $result->get_error_message() ); - ++$errors; - } - } - - WP_CLI::success( - sprintf( - 'Deleted %d of %d application %s.', - $count - $errors, - $count, - Utils\pluralize( 'password', $count ) - ) - ); - - WP_CLI::halt( 0 === $errors ? 0 : 1 ); - } - - /** - * Checks whether an application password for a given application exists. - * - * ## OPTIONS - * - * <user> - * : The user login, user email, or user ID of the user to check the existence of an application password for. - * - * <app-name> - * : Name of the application to check the existence of an application password for. - * - * ## EXAMPLES - * - * # Check if an application password for a given application exists - * $ wp user application-password exists 123 myapp - * $ echo $? - * 1 - * - * # Bash script for checking whether an application password exists and creating one if not - * if ! wp user application-password exists 123 myapp; then - * PASSWORD=$(wp user application-password create 123 myapp --porcelain) - * fi - * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. - * @throws ExitException If the application password could not be created. - */ - public function exists( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - $args = $this->replace_login_with_user_id( $args ); - - list( $user_id, $app_name ) = $args; - - $result = $this->application_name_exists_for_user( $user_id, $app_name ); - - if ( $result instanceof WP_Error ) { - WP_CLI::error( $result ); - } - - WP_CLI::halt( $result ? 0 : 1 ); - } - - /** - * Replaces user_login value with user ID. - * - * @param array $args Associative array of arguments. - * @return array Associative array of arguments with the user login replaced with an ID. - * @throws ExitException If the user is not found. - */ - private function replace_login_with_user_id( $args ) { - $user = ( new UserFetcher() )->get_check( $args[0] ); - $args[0] = $user->ID; - - return $args; - } - - /** - * Checks if application name exists for the given user. - * - * This is a polyfill for WP_Application_Passwords::get_user_application_passwords(), which was only added for - * WordPress 5.7+, but we're already supporting application passwords for WordPress 5.6+. - * - * @param int $user_id User ID to check the application passwords for. - * @param string $app_name Application name to look for. - * @return bool - */ - private function application_name_exists_for_user( $user_id, $app_name ) { - if ( Utils\wp_version_compare( '5.7', '<' ) ) { - $passwords = WP_Application_Passwords::get_user_application_passwords( $user_id ); - - foreach ( $passwords as $password ) { - if ( strtolower( $password['name'] ) === strtolower( $app_name ) ) { - return true; - } - } - - return false; - } - - return WP_Application_Passwords::application_name_exists_for_user( $user_id, $app_name ); - } -} diff --git a/src/User_Command.php b/src/User_Command.php index 59d6f3d34..de54d57be 100644 --- a/src/User_Command.php +++ b/src/User_Command.php @@ -1,16 +1,11 @@ <?php -use WP_CLI\CommandWithDBObject; -use WP_CLI\Fetchers\User as UserFetcher; -use WP_CLI\Formatter; -use WP_CLI\Iterators\CSV as CsvIterator; -use WP_CLI\Utils; +use \WP_CLI\Utils; /** * Manages users, along with their roles, capabilities, and meta. * - * See references for [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities) - * and [WP User class](https://developer.wordpress.org/reference/classes/wp_user). + * See references for [Roles and Capabilities](https://codex.wordpress.org/Roles_and_Capabilities) and [WP User class](https://codex.wordpress.org/Class_Reference/WP_User). * * ## EXAMPLES * @@ -29,31 +24,29 @@ * * # Delete user 123 and reassign posts to user 567 * $ wp user delete 123 --reassign=567 - * Success: Removed user 123 from http://example.com. + * Success: Removed user 123 from http://example.com * * @package wp-cli - * - * @phpstan-import-type UserSite from Site_Command */ -class User_Command extends CommandWithDBObject { +class User_Command extends \WP_CLI\CommandWithDBObject { - protected $obj_type = 'user'; - protected $obj_fields = [ + protected $obj_type = 'user'; + protected $obj_fields = array( 'ID', 'user_login', 'display_name', 'user_email', 'user_registered', - 'roles', - ]; + 'roles' + ); - private $cap_fields = [ - 'name', - ]; - private $fetcher; + private $cap_fields = array( + 'name' + ); public function __construct() { - $this->fetcher = new UserFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\User; + $this->sitefetcher = new \WP_CLI\Fetchers\Site; } /** @@ -71,7 +64,7 @@ public function __construct() { * : Control output by one or more arguments of WP_User_Query(). * * [--network] - * : List all users in the network for multisite. Roles are not included when using this flag, as users can have different roles on different sites in a multisite network. + * : List all users in the network for multisite. * * [--field=<field>] * : Prints the value of a single field for each user. @@ -140,66 +133,46 @@ public function __construct() { */ public function list_( $args, $assoc_args ) { - if ( Utils\get_flag_value( $assoc_args, 'network' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'network' ) ) { if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); + WP_CLI::error( 'This is not a multisite install.' ); } $assoc_args['blog_id'] = 0; if ( isset( $assoc_args['fields'] ) ) { - $fields = explode( ',', $assoc_args['fields'] ); - $assoc_args['fields'] = array_diff( $fields, [ 'roles' ] ); + $fields = explode( ',', $assoc_args['fields'] ); + $assoc_args['fields'] = array_diff( $fields, array( 'roles' ) ); } else { - $assoc_args['fields'] = array_diff( $this->obj_fields, [ 'roles' ] ); + $assoc_args['fields'] = array_diff( $this->obj_fields, array( 'roles' ) ); } } $formatter = $this->get_formatter( $assoc_args ); - if ( in_array( $formatter->format, [ 'ids', 'count' ], true ) ) { + if ( in_array( $formatter->format, array( 'ids', 'count' ) ) ) { $assoc_args['fields'] = 'ids'; } else { $assoc_args['fields'] = 'all_with_meta'; } $assoc_args['count_total'] = false; - $assoc_args = self::process_csv_arguments_to_arrays( $assoc_args ); - - if ( ! empty( $assoc_args['role'] ) && 'none' === $assoc_args['role'] ) { - $norole_user_ids = wp_get_users_with_no_role(); - - if ( ! empty( $norole_user_ids ) ) { - $assoc_args['include'] = $norole_user_ids; - unset( $assoc_args['role'] ); - } - } - + $assoc_args = self::process_csv_arguments_to_arrays( $assoc_args ); $users = get_users( $assoc_args ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { echo implode( ' ', $users ); - } elseif ( 'count' === $formatter->format ) { + } else if ( 'count' === $formatter->format ) { $formatter->display_items( $users ); } else { - $iterator = Utils\iterator_map( - $users, - function ( $user ) { - if ( ! is_object( $user ) ) { - return $user; - } - - /** - * @var \WP_User $user - */ - - // @phpstan-ignore assign.propertyType - $user->roles = implode( ',', $user->roles ); - // @phpstan-ignore property.notFound - $user->url = get_author_posts_url( $user->ID, $user->user_nicename ); + $it = WP_CLI\Utils\iterator_map( $users, function ( $user ) { + if ( !is_object( $user ) ) return $user; - } - ); - $formatter->display_items( $iterator ); + $user->roles = implode( ',', $user->roles ); + $user->url = get_author_posts_url( $user->ID, $user->user_nicename ); + return $user; + } ); + + $formatter->display_items( $it ); } } @@ -238,8 +211,8 @@ function ( $user ) { * $ wp user get bob --format=json > bob.json */ public function get( $args, $assoc_args ) { - $user = $this->fetcher->get_check( $args[0] ); - $user_data = $user->to_array(); + $user = $this->fetcher->get_check( $args[0] ); + $user_data = $user->to_array(); $user_data['roles'] = implode( ', ', $user->roles ); $formatter = $this->get_formatter( $assoc_args ); @@ -271,72 +244,44 @@ public function get( $args, $assoc_args ) { * * # Delete user 123 and reassign posts to user 567 * $ wp user delete 123 --reassign=567 - * Success: Removed user 123 from http://example.com. + * Success: Removed user 123 from http://example.com * * # Delete all contributors and reassign their posts to user 2 * $ wp user delete $(wp user list --role=contributor --field=ID) --reassign=2 - * Success: Removed user 813 from http://example.com. - * Success: Removed user 578 from http://example.com. - * - * # Delete all contributors in batches of 100 (avoid error: argument list too long: wp) - * $ wp user delete $(wp user list --role=contributor --field=ID | head -n 100) + * Success: Removed user 813 from http://example.com + * Success: Removed user 578 from http://example.com */ public function delete( $args, $assoc_args ) { - $network = Utils\get_flag_value( $assoc_args, 'network' ) && is_multisite(); - - /** - * @var string|null $reassign - */ - $reassign = Utils\get_flag_value( $assoc_args, 'reassign' ); - - if ( null !== $reassign ) { - $reassign = (int) $reassign; - } + $network = \WP_CLI\Utils\get_flag_value( $assoc_args, 'network' ) && is_multisite(); + $reassign = \WP_CLI\Utils\get_flag_value( $assoc_args, 'reassign' ); if ( $network && $reassign ) { - WP_CLI::error( 'Reassigning content to a different user is not supported on multisite.' ); + WP_CLI::error('Reassigning content to a different user is not supported on multisite.'); } - $is_reassign_valid = ( $reassign && false === get_userdata( $reassign ) ) ? false : true; - - if ( ! $reassign ) { + if ( !$reassign ) { WP_CLI::confirm( '--reassign parameter not passed. All associated posts will be deleted. Proceed?', $assoc_args ); - } elseif ( ! $is_reassign_valid ) { - WP_CLI::confirm( '--reassign parameter is invalid. All associated posts will be deleted. Proceed?', $assoc_args ); } $users = $this->fetcher->get_many( $args ); - parent::_delete( - $users, - $assoc_args, - function ( $user ) use ( $network, $reassign ) { - $user_id = $user->ID; - - if ( $network ) { - $result = wpmu_delete_user( $user_id ); - $message = "Deleted user {$user_id}."; - } elseif ( is_multisite() && empty( $user->roles ) ) { - $message = "No roles found for user {$user_id} on " . home_url() . ', no users deleted.'; - return [ 'error', $message ]; - } else { - $result = wp_delete_user( $user_id, $reassign ); - $message = "Removed user {$user_id} from " . home_url() . '.'; - } - - if ( ! $result ) { - $message = "Failed deleting user {$user_id}."; - - if ( is_multisite() && is_super_admin( $user_id ) ) { - $message .= ' The user is a super admin.'; - } + parent::_delete( $users, $assoc_args, function ( $user ) use ( $network, $reassign ) { + $user_id = $user->ID; - return [ 'error', $message ]; - } + if ( $network ) { + $r = wpmu_delete_user( $user_id ); + $message = "Deleted user $user_id."; + } else { + $r = wp_delete_user( $user_id, $reassign ); + $message = "Removed user $user_id from " . home_url() . "."; + } - return [ 'success', $message ]; + if ( $r ) { + return array( 'success', $message ); + } else { + return array( 'error', "Failed deleting user $user_id." ); } - ); + } ); } /** @@ -369,6 +314,9 @@ function ( $user ) use ( $network, $reassign ) { * [--user_url=<url>] * : A string containing the user's URL for the user's web site. * + * [--user_email=<email>] + * : A string containing the user's email address. + * * [--nickname=<nickname>] * : The user's nickname, defaults to the user's username. * @@ -400,12 +348,9 @@ function ( $user ) use ( $network, $reassign ) { * # Create user without showing password upon success * $ wp user create ann ann@example.com --porcelain * 4 - * - * @param array{0: string, 1: string} $args Positional arguments. - * @param array{role?: string, user_pass?: string, user_registered?: string, display_name?: string, user_nicename?: string, user_url?: string, nickname?: string, first_name?: string, last_name?: string, description?: string, rich_editing?: string, send_email?: bool, porcelain?: bool} $assoc_args Associative arguments. */ public function create( $args, $assoc_args ) { - $user = new stdClass(); + $user = new stdClass; list( $user->user_login, $user->user_email ) = $args; @@ -415,60 +360,48 @@ public function create( $args, $assoc_args ) { WP_CLI::error( "The '{$user->user_login}' username is already registered." ); } - if ( ! is_email( $user->user_email ) ) { + if ( !is_email( $user->user_email ) ) { WP_CLI::error( "The '{$user->user_email}' email address is invalid." ); } - $user->user_registered = Utils\get_flag_value( + $user->user_registered = \WP_CLI\Utils\get_flag_value( $assoc_args, 'user_registered', - current_time( 'mysql', true ) + strftime( "%F %T", current_time('timestamp') ) ); - $user->display_name = Utils\get_flag_value( $assoc_args, 'display_name', false ); - - $user->nickname = Utils\get_flag_value( $assoc_args, 'nickname', false ); - - $user->first_name = Utils\get_flag_value( $assoc_args, 'first_name', false ); + $user->display_name = \WP_CLI\Utils\get_flag_value( $assoc_args, 'display_name', false ); - $user->last_name = Utils\get_flag_value( $assoc_args, 'last_name', false ); + $user->first_name = \WP_CLI\Utils\get_flag_value( $assoc_args, 'first_name', false ); - $user->description = Utils\get_flag_value( $assoc_args, 'description', false ); - - $user->user_url = Utils\get_flag_value( $assoc_args, 'user_url', false ); + $user->last_name = \WP_CLI\Utils\get_flag_value( $assoc_args, 'last_name', false ); if ( isset( $assoc_args['user_pass'] ) ) { $user->user_pass = $assoc_args['user_pass']; } else { - $user->user_pass = wp_generate_password( 24 ); - $generated_pass = true; + $user->user_pass = wp_generate_password(24); + $generated_pass = true; } - /** - * @var string $default_role - */ - $default_role = get_option( 'default_role' ); - - $user->role = Utils\get_flag_value( $assoc_args, 'role', $default_role ); + $user->role = \WP_CLI\Utils\get_flag_value( $assoc_args, 'role', get_option('default_role') ); self::validate_role( $user->role ); - if ( ! Utils\get_flag_value( $assoc_args, 'send-email' ) ) { + if ( ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'send-email' ) ) { add_filter( 'send_password_change_email', '__return_false' ); add_filter( 'send_email_change_email', '__return_false' ); } if ( is_multisite() ) { - $result = wpmu_validate_user_signup( $user->user_login, $user->user_email ); - if ( ! empty( $result['errors']->errors ) ) { - $error_message = implode( ' ', array_map( 'wp_strip_all_tags', $result['errors']->get_error_messages() ) ); - WP_CLI::error( $error_message ); + $ret = wpmu_validate_user_signup( $user->user_login, $user->user_email ); + if ( is_wp_error( $ret['errors'] ) && ! empty( $ret['errors']->errors ) ) { + WP_CLI::error( $ret['errors'] ); } $user_id = wpmu_create_user( $user->user_login, $user->user_pass, $user->user_email ); if ( ! $user_id ) { - WP_CLI::error( 'Unknown error creating new user.' ); + WP_CLI::error( "Unknown error creating new user." ); } $user->ID = $user_id; - $user_id = wp_update_user( $user ); + $user_id = wp_update_user( $user ); if ( is_wp_error( $user_id ) ) { WP_CLI::error( $user_id ); } @@ -481,22 +414,23 @@ public function create( $args, $assoc_args ) { $user_id = 'Unknown error creating new user.'; } WP_CLI::error( $user_id ); - } elseif ( false === $user->role ) { + } else { + if ( false === $user->role ) { delete_user_option( $user_id, 'capabilities' ); delete_user_option( $user_id, 'user_level' ); + } } - if ( Utils\get_flag_value( $assoc_args, 'send-email' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'send-email' ) ) { self::wp_new_user_notification( $user_id, $user->user_pass ); } - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( (string) $user_id ); + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { + WP_CLI::line( $user_id ); } else { - WP_CLI::success( "Created user {$user_id}." ); - if ( isset( $generated_pass ) ) { - WP_CLI::line( "Password: {$user->user_pass}" ); - } + WP_CLI::success( "Created user $user_id." ); + if ( isset( $generated_pass ) ) + WP_CLI::line( "Password: $user->user_pass" ); } } @@ -547,17 +481,11 @@ public function create( $args, $assoc_args ) { * --<field>=<value> * : One or more fields to update. For accepted fields, see wp_update_user(). * - * [--skip-email] - * : Don't send an email notification to the user. - * * ## EXAMPLES * * # Update user * $ wp user update 123 --display_name=Mary --user_pass=marypass * Success: Updated user 123. - * - * @param string[] $args Positional arguments. Users to update. - * @param array $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { if ( isset( $assoc_args['user_login'] ) ) { @@ -565,32 +493,13 @@ public function update( $args, $assoc_args ) { unset( $assoc_args['user_login'] ); } - if ( isset( $assoc_args['role'] ) ) { - self::validate_role( $assoc_args['role'], true ); - } - - $user_ids = []; + $user_ids = array(); foreach ( $this->fetcher->get_many( $args ) as $user ) { $user_ids[] = $user->ID; } - if ( empty( $user_ids ) ) { - WP_CLI::error( 'No valid users found.' ); - } - - $skip_email = Utils\get_flag_value( $assoc_args, 'skip-email' ); - if ( $skip_email ) { - add_filter( 'send_email_change_email', '__return_false' ); - add_filter( 'send_password_change_email', '__return_false' ); - } - $assoc_args = wp_slash( $assoc_args ); parent::_update( $user_ids, $assoc_args, 'wp_update_user' ); - - if ( $skip_email ) { - remove_filter( 'send_email_change_email', '__return_false' ); - remove_filter( 'send_password_change_email', '__return_false' ); - } } /** @@ -629,10 +538,10 @@ public function update( $args, $assoc_args ) { public function generate( $args, $assoc_args ) { global $blog_id; - $defaults = [ + $defaults = array( 'count' => 100, - 'role' => get_option( 'default_role' ), - ]; + 'role' => get_option('default_role'), + ); $assoc_args = array_merge( $defaults, $assoc_args ); $role = $assoc_args['role']; @@ -642,43 +551,38 @@ public function generate( $args, $assoc_args ) { } $user_count = count_users(); - $total = $user_count['total_users']; - $limit = $assoc_args['count'] + $total; + $total = $user_count['total_users']; + $limit = $assoc_args['count'] + $total; - $format = Utils\get_flag_value( $assoc_args, 'format', 'progress' ); + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'progress' ); $notify = false; if ( 'progress' === $format ) { - $notify = Utils\make_progress_bar( 'Generating users', $assoc_args['count'] ); + $notify = \WP_CLI\Utils\make_progress_bar( 'Generating users', $assoc_args['count'] ); } - for ( $index = $total; $index < $limit; $index++ ) { - $login = "user_{$blog_id}_{$index}"; - $name = "User {$index}"; - - $user_id = wp_insert_user( - [ - 'user_login' => $login, - 'user_pass' => $login, - 'nickname' => $name, - 'display_name' => $name, - 'role' => $role, - ] - ); + for ( $i = $total; $i < $limit; $i++ ) { + $login = sprintf( 'user_%d_%d', $blog_id, $i ); + $name = "User $i"; + + $user_id = wp_insert_user( array( + 'user_login' => $login, + 'user_pass' => $login, + 'nickname' => $name, + 'display_name' => $name, + 'role' => $role, + ) ); - if ( false === $role && ! is_wp_error( $user_id ) ) { + if ( false === $role ) { delete_user_option( $user_id, 'capabilities' ); delete_user_option( $user_id, 'user_level' ); } if ( 'progress' === $format ) { $notify->tick(); - } elseif ( 'ids' === $format ) { - if ( ! is_wp_error( $user_id ) ) { - echo $user_id; - } - - if ( $index < $limit - 1 ) { + } else if ( 'ids' === $format ) { + echo $user_id; + if ( $i < $limit - 1 ) { echo ' '; } } @@ -689,37 +593,6 @@ public function generate( $args, $assoc_args ) { } } - /** - * Verifies whether a user exists. - * - * Displays a success message if the user does exist. - * - * ## OPTIONS - * - * <id> - * : The ID of the user to check. - * - * ## EXAMPLES - * - * # The user exists. - * $ wp user exists 1337 - * Success: User with ID 1337 exists. - * $ echo $? - * 0 - * - * # The user does not exist. - * $ wp user exists 10000 - * $ echo $? - * 1 - */ - public function exists( $args ) { - if ( $this->fetcher->get( $args[0] ) ) { - WP_CLI::success( "User with ID {$args[0]} exists." ); - } else { - WP_CLI::halt( 1 ); - } - } - /** * Sets the user role. * @@ -742,16 +615,15 @@ public function exists( $args ) { public function set_role( $args, $assoc_args ) { $user = $this->fetcher->get_check( $args[0] ); - $role = $args[1] ?? get_option( 'default_role' ); + $role = \WP_CLI\Utils\get_flag_value( $args, 1, get_option('default_role') ); self::validate_role( $role ); // Multisite - if ( function_exists( 'add_user_to_blog' ) ) { + if ( function_exists( 'add_user_to_blog' ) ) add_user_to_blog( get_current_blog_id(), $user->ID, $role ); - } else { + else $user->set_role( $role ); - } WP_CLI::success( "Added {$user->user_login} ({$user->ID}) to " . site_url() . " as {$role}." ); } @@ -764,39 +636,26 @@ public function set_role( $args, $assoc_args ) { * <user> * : User ID, user email, or user login. * - * [<role>...] - * : Add the specified role(s) to the user. + * <role> + * : Add the specified role to the user. * * ## EXAMPLES * * $ wp user add-role 12 author * Success: Added 'author' role for johndoe (12). * - * $ wp user add-role 12 author editor - * Success: Added 'author', 'editor' roles for johndoe (12). - * * @subcommand add-role */ public function add_role( $args, $assoc_args ) { $user = $this->fetcher->get_check( $args[0] ); - $roles = $args; - array_shift( $roles ); + $role = $args[1]; - if ( empty( $roles ) ) { - WP_CLI::error( 'Please specify at least one role to add.' ); - } + self::validate_role( $role ); - foreach ( $roles as $role ) { - self::validate_role( $role ); - } + $user->add_role( $role ); - foreach ( $roles as $role ) { - $user->add_role( $role ); - } - $message = implode( "', '", $roles ); - $label = count( $roles ) > 1 ? 'roles' : 'role'; - WP_CLI::success( "Added '{$message}' {$label} for {$user->user_login} ({$user->ID})." ); + WP_CLI::success( sprintf( "Added '%s' role for %s (%d).", $role, $user->user_login, $user->ID ) ); } /** @@ -807,26 +666,13 @@ public function add_role( $args, $assoc_args ) { * <user> * : User ID, user email, or user login. * - * [<role>...] - * : Remove the specified role(s) from the user. If not passed, all roles are - * removed from the user; on multisite, this removes the user from the current - * site/blog. + * [<role>] + * : A specific role to remove. * * ## EXAMPLES * * $ wp user remove-role 12 author - * Success: Removed 'author' role from johndoe (12). - * - * $ wp user remove-role 12 author editor - * Success: Removed 'author', 'editor' roles from johndoe (12). - * - * # On single-site: removes all roles from the user - * $ wp user remove-role 12 - * Success: Removed all roles from johndoe (12) on http://example.com. - * - * # On multisite: removes the user from the current site/blog - * $ wp user remove-role 12 - * Success: Removed johndoe (12) from http://example.com. + * Success: Removed 'author' role for johndoe (12). * * @subcommand remove-role */ @@ -834,28 +680,21 @@ public function remove_role( $args, $assoc_args ) { $user = $this->fetcher->get_check( $args[0] ); if ( isset( $args[1] ) ) { - $roles = $args; - array_shift( $roles ); + $role = $args[1]; - foreach ( $roles as $role ) { - self::validate_role( $role ); - } + self::validate_role( $role ); - foreach ( $roles as $role ) { - $user->remove_role( $role ); - } - $message = implode( "', '", $roles ); - $label = count( $roles ) > 1 ? 'roles' : 'role'; - WP_CLI::success( "Removed '{$message}' {$label} from {$user->user_login} ({$user->ID})." ); + $user->remove_role( $role ); + + WP_CLI::success( sprintf( "Removed '%s' role for %s (%d).", $role, $user->user_login, $user->ID ) ); } else { - $site_url = site_url(); - if ( function_exists( 'remove_user_from_blog' ) ) { + // Multisite + if ( function_exists( 'remove_user_from_blog' ) ) remove_user_from_blog( $user->ID, get_current_blog_id() ); - WP_CLI::success( "Removed {$user->user_login} ({$user->ID}) from {$site_url}." ); - } else { + else $user->remove_all_caps(); - WP_CLI::success( "Removed all roles from {$user->user_login} ({$user->ID}) on {$site_url}." ); - } + + WP_CLI::success( "Removed {$user->user_login} ({$user->ID}) from " . site_url() . "." ); } } @@ -885,10 +724,10 @@ public function remove_role( $args, $assoc_args ) { public function add_cap( $args, $assoc_args ) { $user = $this->fetcher->get_check( $args[0] ); if ( $user ) { - $cap = $args[1]; + $cap = $args[1]; $user->add_cap( $cap ); - WP_CLI::success( "Added '{$cap}' capability for {$user->user_login} ({$user->ID})." ); + WP_CLI::success( sprintf( "Added '%s' capability for %s (%d).", $cap, $user->user_login, $user->ID ) ); } } @@ -903,9 +742,6 @@ public function add_cap( $args, $assoc_args ) { * <cap> * : The capability to be removed. * - * [--force] - * : Forcefully remove a capability. - * * ## EXAMPLES * * $ wp user remove-cap 11 publish_newsletters @@ -917,32 +753,21 @@ public function add_cap( $args, $assoc_args ) { * $ wp user remove-cap 11 nonexistent_cap * Error: No such 'nonexistent_cap' cap for supervisor (11). * - * $ wp user remove-cap 11 publish_newsletters --force - * Success: Removed 'publish_newsletters' cap for supervisor (11). - * * @subcommand remove-cap */ public function remove_cap( $args, $assoc_args ) { - /** - * @var \WP_User $user - */ $user = $this->fetcher->get_check( $args[0] ); if ( $user ) { $cap = $args[1]; if ( ! isset( $user->caps[ $cap ] ) ) { if ( isset( $user->allcaps[ $cap ] ) ) { - WP_CLI::error( "The '{$cap}' cap for {$user->user_login} ({$user->ID}) is inherited from a role." ); + WP_CLI::error( sprintf( "The '%s' cap for %s (%d) is inherited from a role.", $cap, $user->user_login, $user->ID ) ); } - WP_CLI::error( "No such '{$cap}' cap for {$user->user_login} ({$user->ID})." ); - } - - $user_roles = $user->roles; - if ( ! empty( $user_roles ) && in_array( $cap, $user_roles, true ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { - WP_CLI::error( "Aborting because a role has the same name as '{$cap}'. Use `wp user remove-cap {$user->ID} {$cap} --force` to proceed with the removal." ); + WP_CLI::error( sprintf( "No such '%s' cap for %s (%d).", $cap, $user->user_login, $user->ID ) ); } $user->remove_cap( $cap ); - WP_CLI::success( "Removed '{$cap}' cap for {$user->user_login} ({$user->ID})." ); + WP_CLI::success( sprintf( "Removed '%s' cap for %s (%d).", $cap, $user->user_login, $user->ID ) ); } } @@ -967,19 +792,6 @@ public function remove_cap( $args, $assoc_args ) { * - yaml * --- * - * [--origin=<origin>] - * : Render output in a particular format. - * --- - * default: all - * options: - * - all - * - user - * - role - * --- - * - * [--exclude-role-names] - * : Exclude capabilities that match role names from output. - * * ## EXAMPLES * * $ wp user list-caps 21 @@ -989,73 +801,39 @@ public function remove_cap( $args, $assoc_args ) { * @subcommand list-caps */ public function list_caps( $args, $assoc_args ) { - $user = $this->fetcher->get_check( $args[0] ); - $exclude_role_names = Utils\get_flag_value( $assoc_args, 'exclude-role-names' ); + $user = $this->fetcher->get_check( $args[0] ); - $active_user_cap_list = []; + if ( $user ) { + $user->get_role_caps(); - $user_roles = $user->roles; + $user_caps_list = $user->allcaps; - switch ( $assoc_args['origin'] ) { - case 'all': - $user->get_role_caps(); - $user_caps_list = $user->allcaps; + $active_user_cap_list = array(); - foreach ( $user_caps_list as $capability => $active ) { - if ( $exclude_role_names && in_array( $capability, $user_roles, true ) ) { - continue; - } - if ( $active ) { - $active_user_cap_list[] = $capability; - } - } - break; - case 'user': - /** - * @var array<string, int> $user_capabilities - */ - $user_capabilities = get_user_meta( $user->ID, 'wp_capabilities', true ); - - // Loop through each capability and only return the non-inherited ones - foreach ( $user_capabilities as $capability => $active ) { - if ( true === $active && ! in_array( $capability, $user_roles, true ) ) { - $active_user_cap_list[] = $capability; - } + foreach ( $user_caps_list as $cap => $active ) { + if ( $active ) { + $active_user_cap_list[] = $cap; } - break; - case 'role': - /** - * @var array<string, int> $user_capabilities - */ - $user_capabilities = get_user_meta( $user->ID, 'wp_capabilities', true ); - - // Loop through each capability and only return the inherited ones (including the role name) - foreach ( $user->get_role_caps() as $capability => $active ) { - if ( true === $active && ! isset( $user_capabilities[ $capability ] ) ) { - $active_user_cap_list[] = $capability; - } - if ( true === $active && ! $exclude_role_names && in_array( $capability, $user_roles, true ) ) { - $active_user_cap_list[] = $capability; - } - } - break; - } + } - if ( 'list' === $assoc_args['format'] ) { - foreach ( $active_user_cap_list as $cap ) { - WP_CLI::line( $cap ); + if ( 'list' === $assoc_args['format'] ) { + foreach ( $active_user_cap_list as $cap ) { + WP_CLI::line( $cap ); + } } - } else { - $output_caps = []; - foreach ( $active_user_cap_list as $cap ) { - $output_cap = new stdClass(); + else { + $output_caps = array(); + foreach ( $active_user_cap_list as $cap ) { + $output_cap = new stdClass; - $output_cap->name = $cap; + $output_cap->name = $cap; - $output_caps[] = $output_cap; + $output_caps[] = $output_cap; + } + $formatter = new \WP_CLI\Formatter( $assoc_args, $this->cap_fields ); + $formatter->display_items( $output_caps ); } - $formatter = new Formatter( $assoc_args, $this->cap_fields ); - $formatter->display_items( $output_caps ); + } } @@ -1080,9 +858,9 @@ public function list_caps( $args, $assoc_args ) { * * # Import users from local CSV file * $ wp user import-csv /path/to/users.csv - * Success: bobjones created. - * Success: newuser1 created. - * Success: existinguser created. + * Success: bobjones created + * Success: newuser1 created + * Success: existinguser created * * # Import users from remote CSV file * $ wp user import-csv http://example.com/users.csv @@ -1103,17 +881,17 @@ public function import_csv( $args, $assoc_args ) { $filename = $args[0]; if ( 0 === stripos( $filename, 'http://' ) || 0 === stripos( $filename, 'https://' ) ) { - $response = wp_remote_head( $filename ); - $response_code = (string) wp_remote_retrieve_response_code( $response ); - if ( in_array( (int) $response_code[0], [ 4, 5 ], true ) ) { + $response = wp_remote_head( $filename ); + $response_code = (string)wp_remote_retrieve_response_code( $response ); + if ( in_array( $response_code[0], array( 4, 5 ) ) ) { WP_CLI::error( "Couldn't access remote CSV file (HTTP {$response_code} response)." ); } } elseif ( '-' === $filename ) { - if ( ! Utils\has_stdin() ) { - WP_CLI::error( 'Unable to read content from STDIN.' ); + if ( ! WP_CLI\Entity\Utils::has_stdin() ) { + \WP_CLI::error( "Unable to read content from STDIN." ); } } elseif ( ! file_exists( $filename ) ) { - WP_CLI::error( "Missing file: {$filename}" ); + WP_CLI::error( sprintf( "Missing file: %s", $filename ) ); } // Don't send core's emails during the creation / update process @@ -1121,66 +899,41 @@ public function import_csv( $args, $assoc_args ) { add_filter( 'send_email_change_email', '__return_false' ); if ( '-' === $filename ) { - $file_object = new NoRewindIterator( new SplFileObject( 'php://stdin' ) ); + $file_object = new NoRewindIterator( new SplFileObject( "php://stdin" ) ); $file_object->setFlags( SplFileObject::READ_CSV ); - $csv_data = []; - $indexes = []; - - /** - * @var string[] $line - */ + $csv_data = array(); + $indexes = array(); foreach ( $file_object as $line ) { if ( empty( $line[0] ) ) { continue; - } - - if ( empty( $indexes ) ) { + } elseif ( empty( $indexes ) ) { $indexes = $line; continue; } - - /** - * @var array<string, string> $data - */ - $data = []; - foreach ( $indexes as $n => $key ) { $data[ $key ] = $line[ $n ]; } - $csv_data[] = $data; } - - if ( empty( $indexes ) && empty( $csv_data ) ) { - WP_CLI::error( 'Unable to read content from STDIN.' ); - } } else { - $csv_data = new CsvIterator( $filename ); + $csv_data = new \WP_CLI\Iterators\CSV( $filename ); } - /** - * @var string $default_role - */ - $default_role = get_option( 'default_role' ); - - /** - * @var array{ID: string, role: string, roles: string, user_pass: string, user_registered: string, display_name: string, user_login: string, user_email: string} $new_user - */ - foreach ( $csv_data as $new_user ) { - $defaults = [ - 'role' => $default_role, - 'user_pass' => wp_generate_password( 24 ), - 'user_registered' => current_time( 'mysql', true ), - 'display_name' => '', - ]; + foreach ( $csv_data as $i => $new_user ) { + $defaults = array( + 'role' => get_option('default_role'), + 'user_pass' => wp_generate_password(), + 'user_registered' => strftime( "%F %T", time() ), + 'display_name' => false, + ); $new_user = array_merge( $defaults, $new_user ); - $secondary_roles = []; + $secondary_roles = array(); if ( ! empty( $new_user['roles'] ) ) { - $roles = array_map( 'trim', explode( ',', $new_user['roles'] ) ); + $roles = array_map( 'trim', explode( ',', $new_user['roles'] ) ); $invalid_role = false; - foreach ( $roles as $role ) { - if ( null === get_role( $role ) ) { + foreach( $roles as $role ) { + if ( is_null( get_role( $role ) ) ) { WP_CLI::warning( "{$new_user['user_login']} has an invalid role." ); $invalid_role = true; break; @@ -1190,10 +943,10 @@ public function import_csv( $args, $assoc_args ) { continue; } $new_user['role'] = array_shift( $roles ); - $secondary_roles = $roles; - } elseif ( 'none' === $new_user['role'] ) { - $new_user['role'] = ''; - } elseif ( null === get_role( $new_user['role'] ) ) { + $secondary_roles = $roles; + } else if ( 'none' === $new_user['role'] ) { + $new_user['role'] = false; + } elseif ( is_null( get_role( $new_user['role'] ) ) ) { WP_CLI::warning( "{$new_user['user_login']} has an invalid role." ); continue; } @@ -1201,48 +954,42 @@ public function import_csv( $args, $assoc_args ) { // User already exists and we just need to add them to the site if they aren't already there $existing_user = get_user_by( 'email', $new_user['user_email'] ); - if ( ! $existing_user ) { + if ( !$existing_user ) { $existing_user = get_user_by( 'login', $new_user['user_login'] ); } - if ( $existing_user && Utils\get_flag_value( $assoc_args, 'skip-update' ) ) { + if ( $existing_user && \WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-update' ) ) { WP_CLI::log( "{$existing_user->user_login} exists and has been skipped." ); continue; - } + } else if ( $existing_user ) { - if ( $existing_user ) { $new_user['ID'] = $existing_user->ID; - $user_id = wp_update_user( $new_user ); + $user_id = wp_update_user( $new_user ); - if ( ! in_array( $existing_user->user_login, wp_list_pluck( $blog_users, 'user_login' ), true ) && is_multisite() && $new_user['role'] ) { + if ( !in_array( $existing_user->user_login, wp_list_pluck( $blog_users, 'user_login' ) ) && is_multisite() && $new_user['role'] ) { add_user_to_blog( get_current_blog_id(), $existing_user->ID, $new_user['role'] ); WP_CLI::log( "{$existing_user->user_login} added as {$new_user['role']}." ); } - if ( is_wp_error( $user_id ) ) { - WP_CLI::warning( $user_id ); - continue; - } - - // Create the user + // Create the user } else { unset( $new_user['ID'] ); // Unset else it will just return the ID if ( is_multisite() ) { - $result = wpmu_validate_user_signup( $new_user['user_login'], $new_user['user_email'] ); - if ( ! empty( $result['errors']->errors ) ) { - WP_CLI::warning( implode( ' ', array_map( 'wp_strip_all_tags', $result['errors']->get_error_messages() ) ) ); + $ret = wpmu_validate_user_signup( $new_user['user_login'], $new_user['user_email'] ); + if ( is_wp_error( $ret['errors'] ) && ! empty( $ret['errors']->errors ) ) { + WP_CLI::warning( $ret['errors'] ); continue; } $user_id = wpmu_create_user( $new_user['user_login'], $new_user['user_pass'], $new_user['user_email'] ); if ( ! $user_id ) { - WP_CLI::warning( 'Unknown error creating new user.' ); + WP_CLI::warning( "Unknown error creating new user." ); continue; } $new_user['ID'] = $user_id; - $user_id = wp_update_user( $new_user ); + $user_id = wp_update_user( $new_user ); if ( is_wp_error( $user_id ) ) { WP_CLI::warning( $user_id ); continue; @@ -1251,33 +998,29 @@ public function import_csv( $args, $assoc_args ) { $user_id = wp_insert_user( $new_user ); } - if ( is_wp_error( $user_id ) ) { - WP_CLI::warning( $user_id ); - continue; - } - - if ( Utils\get_flag_value( $assoc_args, 'send-email' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'send-email' ) ) { self::wp_new_user_notification( $user_id, $new_user['user_pass'] ); } } - if ( false === $new_user['role'] ) { + if ( is_wp_error( $user_id ) ) { + WP_CLI::warning( $user_id ); + continue; + + } else if ( $new_user['role'] === false ) { delete_user_option( $user_id, 'capabilities' ); delete_user_option( $user_id, 'user_level' ); } - /** - * @var \WP_User $user - */ $user = get_user_by( 'id', $user_id ); - foreach ( $secondary_roles as $secondary_role ) { + foreach( $secondary_roles as $secondary_role ) { $user->add_role( $secondary_role ); } - if ( ! empty( $existing_user ) ) { - WP_CLI::success( $new_user['user_login'] . ' updated.' ); + if ( !empty( $existing_user ) ) { + WP_CLI::success( $new_user['user_login'] . " updated." ); } else { - WP_CLI::success( $new_user['user_login'] . ' created.' ); + WP_CLI::success( $new_user['user_login'] . " created." ); } } } @@ -1293,142 +1036,93 @@ public function import_csv( $args, $assoc_args ) { * [--skip-email] * : Don't send an email notification to the affected user(s). * - * [--show-password] - * : Show the new password(s). - * - * [--porcelain] - * : Output only the new password(s). - * * ## EXAMPLES * * # Reset the password for two users and send them the change email. * $ wp user reset-password admin editor * Reset password for admin. * Reset password for editor. - * Success: Passwords reset for 2 users. - * - * # Reset and display the password. - * $ wp user reset-password editor --show-password - * Reset password for editor. - * Password: N6hAau0fXZMN#rLCIirdEGOh - * Success: Password reset for 1 user. - * - * # Reset the password for one user, displaying only the new password, and not sending the change email. - * $ wp user reset-password admin --skip-email --porcelain - * yV6BP*!d70wg - * - * # Reset password for all users. - * $ wp user reset-password $(wp user list --format=ids) - * Reset password for admin. - * Reset password for editor. - * Reset password for subscriber. - * Success: Passwords reset for 3 users. - * - * # Reset password for all users with a particular role. - * $ wp user reset-password $(wp user list --format=ids --role=administrator) - * Reset password for admin. - * Success: Password reset for 1 user. + * Success: Passwords reset. * * @subcommand reset-password */ public function reset_password( $args, $assoc_args ) { - $porcelain = Utils\get_flag_value( $assoc_args, 'porcelain' ); - $skip_email = (bool) Utils\get_flag_value( $assoc_args, 'skip-email' ); - $show_new_pass = Utils\get_flag_value( $assoc_args, 'show-password' ); - + $skip_email = Utils\get_flag_value( $assoc_args, 'skip-email' ); if ( $skip_email ) { add_filter( 'send_password_change_email', '__return_false' ); } - $fetcher = new UserFetcher(); - $users = $fetcher->get_many( $args ); - foreach ( $users as $user ) { - $new_pass = wp_generate_password( 24 ); - wp_update_user( - [ - 'ID' => $user->ID, - 'user_pass' => $new_pass, - ] - ); - if ( $porcelain ) { - WP_CLI::line( "$new_pass" ); - } else { - WP_CLI::log( "Reset password for {$user->user_login}." ); - if ( $show_new_pass ) { - WP_CLI::line( "Password: $new_pass" ); - } - } + $fetcher = new \WP_CLI\Fetchers\User; + $users = $fetcher->get_many( $args ); + foreach( $users as $user ) { + wp_update_user( array( 'ID' => $user->ID, 'user_pass' => wp_generate_password() ) ); + WP_CLI::log( "Reset password for {$user->user_login}." ); } - - $reset_user_count = count( $users ); - - if ( ! $porcelain ) { - if ( 1 === $reset_user_count ) { - WP_CLI::success( "Password reset for {$reset_user_count} user." ); - } elseif ( $reset_user_count > 1 ) { - WP_CLI::success( "Passwords reset for {$reset_user_count} users." ); - } else { - WP_CLI::error( 'No user found to reset password.' ); - } + if ( $skip_email ) { + remove_filter( 'send_password_change_email', '__return_false' ); } + WP_CLI::success( count( $users ) > 1 ? 'Passwords reset.' : 'Password reset.' ); } /** * Checks whether the role is valid * - * @param $role string - * @param $warn_user_only bool + * @param string */ - private static function validate_role( $role, $warn_user_only = false ) { + private static function validate_role( $role ) { - if ( ! empty( $role ) && null === get_role( $role ) ) { - if ( $warn_user_only ) { - WP_CLI::warning( "Role doesn't exist: {$role}" ); - } else { - WP_CLI::error( "Role doesn't exist: {$role}" ); - } + if ( ! empty( $role ) && is_null( get_role( $role ) ) ) { + WP_CLI::error( sprintf( "Role doesn't exist: %s", $role ) ); } + } /** - * Wrapper around wp_new_user_notification(). + * Accommodates three different behaviors for wp_new_user_notification() + * - 4.3.1 and above: expect second argument to be deprecated + * - 4.3: Second argument was repurposed as $notify + * - Below 4.3: Send the password in the notification * - * @param string|int $user_id - * @param mixed $password + * @param string $user_id + * @param string $password */ - public static function wp_new_user_notification( $user_id, $password ) { - wp_new_user_notification( (int) $user_id, null, 'both' ); + private static function wp_new_user_notification( $user_id, $password ) { + if ( \WP_CLI\Utils\wp_version_compare( '4.3.1', '>=' ) ) { + wp_new_user_notification( $user_id, null, 'both' ); + } else if ( \WP_CLI\Utils\wp_version_compare( '4.3', '>=' ) ) { + wp_new_user_notification( $user_id, 'both' ); + } else { + wp_new_user_notification( $user_id, $password ); + } } /** - * Marks one or more users as spam on multisite. + * Marks one or more users as spam. * * ## OPTIONS * - * <user>... - * : The user login, user email, or user ID of the user(s) to mark as spam. + * <id>... + * : One or more IDs of users to mark as spam. * * ## EXAMPLES * - * # Mark user as spam. * $ wp user spam 123 * User 123 marked as spam. - * Success: Spammed 1 of 1 users. + * Success: Spamed 1 of 1 users. */ public function spam( $args ) { $this->update_msuser_status( $args, 'spam', '1' ); } /** - * Removes one or more users from spam on multisite. + * Removes one or more users from spam. * * ## OPTIONS * - * <user>... - * : The user login, user email, or user ID of the user(s) to remove from spam. + * <id>... + * : One or more IDs of users to remove from spam. * * ## EXAMPLES * - * # Remove user from spam. * $ wp user unspam 123 * User 123 removed from spam. * Success: Unspamed 1 of 1 users. @@ -1444,30 +1138,36 @@ private function update_msuser_status( $user_ids, $pref, $value ) { // If site is not multisite, then stop execution. if ( ! is_multisite() ) { - WP_CLI::error( 'This is not a multisite installation.' ); + WP_CLI::error( 'This is not a multisite install.' ); } - $action = 'updated'; - $verb = 'update'; - - if ( 'spam' === $pref ) { - $action = (int) $value ? 'marked as spam' : 'removed from spam'; - $verb = (int) $value ? 'spam' : 'unspam'; + if ( 'spam' === $pref && '1' === $value ) { + $action = 'marked as spam'; + $verb = 'spam'; + } elseif ( 'spam' === $pref && '0' === $value ) { + $action = 'removed from spam'; + $verb = 'unspam'; } - $successes = 0; - $errors = 0; - $users = $this->fetcher->get_many( $user_ids ); + $successes = $errors = 0; + $users = $this->fetcher->get_many( $user_ids ); if ( count( $users ) < count( $user_ids ) ) { $errors = count( $user_ids ) - count( $users ); } - foreach ( $users as $user ) { - $user_id = $user->ID; + foreach ( $user_ids as $user_id ) { + + $user = get_userdata( $user_id ); + + // If no user found, then show warning. + if ( empty( $user ) ) { + WP_CLI::warning( sprintf( 'User %d doesn\'t exist.', esc_html( $user_id ) ) ); + continue; + } // Super admin should not be marked as spam. if ( is_super_admin( $user->ID ) ) { - WP_CLI::warning( "User cannot be modified. The user {$user->ID} is a network administrator." ); + WP_CLI::warning( sprintf( 'User cannot be modified. The user %d is a network administrator.', esc_html( $user->ID ) ) ); continue; } @@ -1478,79 +1178,23 @@ private function update_msuser_status( $user_ids, $pref, $value ) { } // Make that user's blog as spam too. + $blogs = get_blogs_of_user( $user_id, true ); + foreach ( (array) $blogs as $details ) { + $site = $this->sitefetcher->get_check( $details->site_id ); - /** - * @phpstan-var UserSite[] $blogs - */ - $blogs = (array) get_blogs_of_user( $user_id, true ); - foreach ( $blogs as $details ) { - // Only mark site as spam if not main site. - if ( ! is_main_site( $details->userblog_id, $details->site_id ) ) { + // Main blog shouldn't a spam ! + if ( $details->userblog_id != $site->blog_id ) { update_blog_status( $details->userblog_id, $pref, $value ); } } - if ( Utils\wp_version_compare( '5.3', '<' ) ) { - // @phpstan-ignore function.deprecated - update_user_status( $user_id, $pref, $value ); // phpcs:ignore WordPress.WP.DeprecatedFunctions.update_user_statusFound -- Fallback for older versions. - } else { - wp_update_user( - [ - 'ID' => $user_id, - $pref => $value, - ] - ); - } - + // Set status and show message. + update_user_status( $user_id, $pref, $value ); WP_CLI::log( "User {$user_id} {$action}." ); - ++$successes; + $successes++; } Utils\report_batch_operation_results( 'user', $verb, count( $user_ids ), $successes, $errors ); } - /** - * Checks if a user's password is valid or not. - * - * ## OPTIONS - * - * <user> - * : The user login, user email or user ID of the user to check credentials for. - * - * <user_pass> - * : A string that contains the plain text password for the user. - * - * [--escape-chars] - * : Escape password with `wp_slash()` to mimic the same behavior as `wp-login.php`. - * - * ## EXAMPLES - * - * # Check whether given credentials are valid; exit status 0 if valid, otherwise 1 - * $ wp user check-password admin adminpass - * $ echo $? - * 1 - * - * # Bash script for checking whether given credentials are valid or not - * if ! $(wp user check-password admin adminpass); then - * notify-send "Invalid Credentials"; - * fi - * - * @subcommand check-password - */ - public function check_password( $args, $assoc_args ) { - $escape_chars = Utils\get_flag_value( $assoc_args, 'escape-chars', false ); - - if ( ! $escape_chars && wp_slash( wp_unslash( $args[1] ) ) !== $args[1] ) { - WP_CLI::warning( 'Password contains characters that need to be escaped. Please escape them manually or use the `--escape-chars` option.' ); - } - - $user = $this->fetcher->get_check( $args[0] ); - $user_pass = $escape_chars ? wp_slash( $args[1] ) : $args[1]; - - if ( wp_check_password( $user_pass, $user->data->user_pass, $user->ID ) ) { - WP_CLI::halt( 0 ); - } else { - WP_CLI::halt( 1 ); - } - } } diff --git a/src/User_Meta_Command.php b/src/User_Meta_Command.php index 344488f65..27c9b1f78 100644 --- a/src/User_Meta_Command.php +++ b/src/User_Meta_Command.php @@ -1,8 +1,5 @@ <?php -use WP_CLI\CommandWithMeta; -use WP_CLI\Fetchers\User as UserFetcher; - /** * Adds, updates, deletes, and lists user custom fields. * @@ -30,13 +27,11 @@ * $ wp user meta delete 123 bio * Success: Deleted custom field. */ -class User_Meta_Command extends CommandWithMeta { +class User_Meta_Command extends \WP_CLI\CommandWithMeta { protected $meta_type = 'user'; - private $fetcher; - public function __construct() { - $this->fetcher = new UserFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\User; } /** @@ -84,9 +79,6 @@ public function __construct() { * - desc * --- * - * [--unserialize] - * : Unserialize meta_value output. - * * ## EXAMPLES * * # List user meta @@ -100,9 +92,6 @@ public function __construct() { * +---------+-----------------+--------------------------------+ * * @subcommand list - * - * @param array{0: string} $args Positional arguments. - * @param array{keys?: string, fields?: string, format: 'table'|'csv'|'json'|'count'|'yaml', orderby: 'id'|'meta_key'|'meta_value', order: 'asc'|'desc', unserialize?: bool} $assoc_args Associative arguments. */ public function list_( $args, $assoc_args ) { $args = $this->replace_login_with_user_id( $args ); @@ -136,13 +125,6 @@ public function list_( $args, $assoc_args ) { * # Get user meta * $ wp user meta get 123 bio * Mary is an WordPress developer. - * - * # Get the primary site of a user (for multisite) - * $ wp user meta get 2 primary_blog - * 3 - * - * @param array{0: string, 1: string} $args Positional arguments. - * @param array{format: 'table'|'csv'|'json'|'yaml'} $assoc_args Associative arguments. */ public function get( $args, $assoc_args ) { $args = $this->replace_login_with_user_id( $args ); @@ -168,8 +150,6 @@ public function get( $args, $assoc_args ) { * # Delete user meta * $ wp user meta delete 123 bio * Success: Deleted custom field. - * - * @param array<string> $args Positional arguments. */ public function delete( $args, $assoc_args ) { $args = $this->replace_login_with_user_id( $args ); @@ -191,22 +171,13 @@ public function delete( $args, $assoc_args ) { * : The new metadata value. * * [--format=<format>] - * : The serialization format for the value. - * --- - * default: plaintext - * options: - * - plaintext - * - json - * --- + * : The serialization format for the value. Default is plaintext. * * ## EXAMPLES * * # Add user meta * $ wp user meta add 123 bio "Mary is an WordPress developer." * Success: Added custom field. - * - * @param array<string> $args Positional arguments. - * @param array{format: 'plaintext'|'json'} $assoc_args Associative arguments. */ public function add( $args, $assoc_args ) { $args = $this->replace_login_with_user_id( $args ); @@ -228,13 +199,7 @@ public function add( $args, $assoc_args ) { * : The new metadata value. * * [--format=<format>] - * : The serialization format for the value. - * --- - * default: plaintext - * options: - * - plaintext - * - json - * --- + * : The serialization format for the value. Default is plaintext. * * ## EXAMPLES * @@ -243,104 +208,23 @@ public function add( $args, $assoc_args ) { * Success: Updated custom field 'bio'. * * @alias set - * - * @param array<string> $args Positional arguments. - * @param array{format: 'plaintext'|'json'} $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { $args = $this->replace_login_with_user_id( $args ); parent::update( $args, $assoc_args ); } - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false The meta ID on success, false on failure. - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_user_meta( $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_user_meta( $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_user_meta( $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_user_meta( $object_id, $meta_key, $meta_value ); - } - /** * Replaces user_login value with user ID * user meta is a special case that also supports user_login * - * @template T of array - * - * @param T $args - * @return T + * @param array + * @return array */ private function replace_login_with_user_id( $args ) { - $user = $this->fetcher->get_check( $args[0] ); + $user = $this->fetcher->get_check( $args[0] ); $args[0] = $user->ID; return $args; } + } diff --git a/src/User_Privacy_Request_Command.php b/src/User_Privacy_Request_Command.php deleted file mode 100644 index 8f82f3bfc..000000000 --- a/src/User_Privacy_Request_Command.php +++ /dev/null @@ -1,652 +0,0 @@ -<?php - -use WP_CLI\Formatter; -use WP_CLI\Utils; - -/** - * Manages user privacy requests (GDPR personal data export and erasure). - * - * ## EXAMPLES - * - * # List all privacy requests. - * $ wp user privacy-request list - * +----+-------------------+----------------------+-------------------+--------------------+ - * | ID | user_email | action_name | status | created_timestamp | - * +----+-------------------+----------------------+-------------------+--------------------+ - * | 1 | bob@example.com | export_personal_data | request-pending | 1713779524 | - * +----+-------------------+----------------------+-------------------+--------------------+ - * - * # Create a new data export request. - * $ wp user privacy-request create bob@example.com export_personal_data - * Success: Created privacy request 1. - * - * # Erase personal data for request 1. - * $ wp user privacy-request erase 1 - * Success: Erased personal data for request 1. - * - * # Export personal data for request 1. - * $ wp user privacy-request export 1 - * Success: Exported personal data to: /var/www/html/wp-content/uploads/wp-personal-data-exports/wp-personal-data-export-bob-example-com-1.zip - * - * # Mark request 1 as complete. - * $ wp user privacy-request complete 1 - * Success: Completed 1 of 1 privacy requests. - * - * # Delete request 1. - * $ wp user privacy-request delete 1 - * Success: Deleted 1 of 1 privacy requests. - * - * @package wp-cli - */ -final class User_Privacy_Request_Command { - - /** - * Default fields for displaying privacy requests. - * - * @var array<string> - */ - const REQUEST_FIELDS = [ - 'ID', - 'user_email', - 'action_name', - 'status', - 'created_timestamp', - ]; - - /** - * Lists privacy requests. - * - * ## OPTIONS - * - * [--action-type=<action-type>] - * : Filter the list by action type. - * --- - * options: - * - export_personal_data - * - remove_personal_data - * --- - * - * [--status=<status>] - * : Filter the list by request status. - * --- - * options: - * - request-pending - * - request-confirmed - * - request-failed - * - request-completed - * --- - * - * [--field=<field>] - * : Prints the value of a single field for each request. - * - * [--fields=<fields>] - * : Limit the output to specific object fields. - * - * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - ids - * - json - * - count - * - yaml - * --- - * - * ## AVAILABLE FIELDS - * - * These fields will be displayed by default for each request: - * - * * ID - * * user_email - * * action_name - * * status - * * created_timestamp - * - * These fields are optionally available: - * - * * user_id - * * confirmed_timestamp - * * completed_timestamp - * - * ## EXAMPLES - * - * # List all privacy requests. - * $ wp user privacy-request list - * +----+-------------------+----------------------+-------------------+--------------------+ - * | ID | user_email | action_name | status | created_timestamp | - * +----+-------------------+----------------------+-------------------+--------------------+ - * | 1 | bob@example.com | export_personal_data | request-pending | 1713779524 | - * +----+-------------------+----------------------+-------------------+--------------------+ - * - * # List only export requests. - * $ wp user privacy-request list --action-type=export_personal_data - * - * # List only completed requests. - * $ wp user privacy-request list --status=request-completed - * - * # List request IDs only. - * $ wp user privacy-request list --format=ids - * 1 2 - * - * @subcommand list - * - * @param list<string> $args Positional arguments. - * @param array{action-type?: string, status?: string, field?: string, fields?: string, format?: string} $assoc_args Associative arguments. - */ - public function list_( $args, $assoc_args ) { - $query_args = [ - 'post_type' => 'user_request', - 'posts_per_page' => -1, - 'post_status' => 'any', - 'orderby' => 'ID', - 'order' => 'ASC', - ]; - - $action_type = Utils\get_flag_value( $assoc_args, 'action-type' ); - if ( $action_type ) { - $query_args['post_name__in'] = [ sanitize_key( $action_type ) ]; - } - - $status = Utils\get_flag_value( $assoc_args, 'status' ); - if ( $status ) { - $query_args['post_status'] = sanitize_key( $status ); - } - - $posts = get_posts( $query_args ); - $requests = array_map( [ $this, 'get_request_data' ], $posts ); - $requests = array_filter( $requests ); - - $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); - - if ( empty( $assoc_args['fields'] ) ) { - $assoc_args['fields'] = self::REQUEST_FIELDS; - } - - $formatter = new Formatter( $assoc_args, self::REQUEST_FIELDS ); - - if ( 'ids' === $format ) { - $ids = wp_list_pluck( $requests, 'ID' ); - if ( ! empty( $ids ) ) { - WP_CLI::line( implode( ' ', $ids ) ); - } - } else { - $formatter->display_items( $requests ); - } - } - - /** - * Creates a privacy request for a user. - * - * ## OPTIONS - * - * <email> - * : The email address of the user to create the request for. - * - * <action-type> - * : The type of personal data request. - * --- - * options: - * - export_personal_data - * - remove_personal_data - * --- - * - * [--status=<status>] - * : The initial status of the request. - * --- - * default: pending - * options: - * - pending - * - confirmed - * --- - * - * [--send-email] - * : If set, sends a confirmation email to the user. - * - * [--porcelain] - * : Output just the new request ID. - * - * ## EXAMPLES - * - * # Create a new data export request with pending status. - * $ wp user privacy-request create bob@example.com export_personal_data - * Success: Created privacy request 1. - * - * # Create a confirmed data erasure request. - * $ wp user privacy-request create bob@example.com remove_personal_data --status=confirmed - * Success: Created privacy request 2. - * - * # Get just the new request ID. - * $ wp user privacy-request create bob@example.com export_personal_data --porcelain - * 3 - * - * @param array{string, string} $args Positional arguments. - * @param array{status?: string, send-email?: bool, porcelain?: bool} $assoc_args Associative arguments. - */ - public function create( $args, $assoc_args ) { - list( $email_address, $action_name ) = $args; - - $status = Utils\get_flag_value( $assoc_args, 'status', 'pending' ); - - $request_id = wp_create_user_request( $email_address, $action_name, [], $status ); - - if ( is_wp_error( $request_id ) ) { - WP_CLI::error( $request_id ); - } - - // The $status parameter for wp_create_user_request() was added in WP 5.7.0. - // For older versions, manually update the post status when 'confirmed' was requested. - if ( 'confirmed' === $status ) { - wp_update_post( - [ - 'ID' => $request_id, - 'post_status' => 'request-confirmed', - ] - ); - } - - if ( Utils\get_flag_value( $assoc_args, 'send-email', false ) ) { - $send_result = wp_send_user_request( $request_id ); - if ( is_wp_error( $send_result ) ) { - WP_CLI::error( $send_result ); - } - } - - if ( Utils\get_flag_value( $assoc_args, 'porcelain', false ) ) { - WP_CLI::line( (string) $request_id ); - return; - } - - WP_CLI::success( "Created privacy request {$request_id}." ); - } - - /** - * Deletes one or more privacy requests. - * - * ## OPTIONS - * - * <request-id>... - * : One or more IDs of the privacy requests to delete. - * - * ## EXAMPLES - * - * # Delete privacy request 1. - * $ wp user privacy-request delete 1 - * Privacy request 1 deleted. - * Success: Deleted 1 of 1 privacy requests. - * - * # Delete multiple privacy requests. - * $ wp user privacy-request delete 1 2 3 - * Privacy request 1 deleted. - * Privacy request 2 deleted. - * Privacy request 3 deleted. - * Success: Deleted 3 of 3 privacy requests. - * - * @param list<string> $args Positional arguments. - * @param array{} $assoc_args Associative arguments. - */ - public function delete( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - $successes = 0; - $errors = 0; - - foreach ( $args as $request_id ) { - $request_id = (int) $request_id; - $request = $this->get_request( $request_id ); - - if ( ! $request ) { - WP_CLI::warning( "Could not find privacy request with ID {$request_id}." ); - ++$errors; - continue; - } - - $result = wp_delete_post( $request_id, true ); - - if ( ! $result ) { - WP_CLI::warning( "Failed deleting privacy request {$request_id}." ); - ++$errors; - } else { - WP_CLI::log( "Privacy request {$request_id} deleted." ); - ++$successes; - } - } - - $count = count( $args ); - Utils\report_batch_operation_results( 'privacy request', 'delete', $count, $successes, $errors ); - } - - /** - * Erases personal data for a given privacy request. - * - * Runs all registered data erasers for the email address associated with the - * request, then marks the request as completed. - * - * ## OPTIONS - * - * <request-id> - * : The ID of the remove_personal_data privacy request to process. - * - * ## EXAMPLES - * - * # Erase personal data for request 1. - * $ wp user privacy-request erase 1 - * Success: Erased personal data for request 1. - * - * @param array{string} $args Positional arguments. - * @param array{} $assoc_args Associative arguments. - */ - public function erase( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - list( $request_id ) = $args; - $request_id = (int) $request_id; - - $request = $this->get_request_check( $request_id ); - - if ( 'remove_personal_data' !== $request->action_name ) { - WP_CLI::error( "Request {$request_id} is not a 'remove_personal_data' request." ); - } - - $email_address = $request->email; - $erasers = apply_filters( 'wp_privacy_personal_data_erasers', [] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - $messages = []; - - foreach ( $erasers as $eraser_key => $eraser ) { - if ( ! isset( $eraser['callback'] ) || ! is_callable( $eraser['callback'] ) ) { - WP_CLI::warning( "Eraser '{$eraser_key}' does not have a valid callback." ); - continue; - } - - $page = 1; - do { - $response = call_user_func( $eraser['callback'], $email_address, $page ); - - if ( ! is_array( $response ) ) { - WP_CLI::error( "Eraser '{$eraser_key}' did not return an array." ); - } - - if ( ! array_key_exists( 'items_removed', $response ) ) { - WP_CLI::error( "Expected items_removed key in response array from '{$eraser_key}' eraser." ); - } - - if ( ! array_key_exists( 'items_retained', $response ) ) { - WP_CLI::error( "Expected items_retained key in response array from '{$eraser_key}' eraser." ); - } - - if ( ! array_key_exists( 'messages', $response ) ) { - WP_CLI::error( "Expected messages key in response array from '{$eraser_key}' eraser." ); - } - - if ( ! is_array( $response['messages'] ) ) { - WP_CLI::error( "Expected messages key to reference an array in response array from '{$eraser_key}' eraser." ); - } - - if ( ! array_key_exists( 'done', $response ) ) { - WP_CLI::error( "Expected done flag in response array from '{$eraser_key}' eraser." ); - } - - if ( ! empty( $response['messages'] ) ) { - $messages = array_merge( $messages, $response['messages'] ); - } - - $done = (bool) $response['done']; - ++$page; - } while ( ! $done ); - } - - $result = _wp_privacy_completed_request( $request_id ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( "Failed completing privacy request {$request_id}: " . $result->get_error_message() ); - } - - foreach ( $messages as $message ) { - if ( is_scalar( $message ) ) { - WP_CLI::log( (string) $message ); - } - } - - WP_CLI::success( "Erased personal data for request {$request_id}." ); - } - - /** - * Exports personal data for a given privacy request. - * - * Runs all registered data exporters for the email address associated with - * the request, generates a ZIP file containing the data, then marks the - * request as completed. - * - * ## OPTIONS - * - * <request-id> - * : The ID of the export_personal_data privacy request to process. - * - * ## EXAMPLES - * - * # Export personal data for request 1. - * $ wp user privacy-request export 1 - * Success: Exported personal data to: /var/www/html/wp-content/uploads/wp-personal-data-exports/wp-personal-data-export-bob-example-com-1.zip - * - * @param array{string} $args Positional arguments. - * @param array{} $assoc_args Associative arguments. - */ - public function export( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - list( $request_id ) = $args; - $request_id = (int) $request_id; - - $request = $this->get_request_check( $request_id ); - - if ( 'export_personal_data' !== $request->action_name ) { - WP_CLI::error( "Request {$request_id} is not an 'export_personal_data' request." ); - } - - $email_address = $request->email; - $exporters = apply_filters( 'wp_privacy_personal_data_exporters', [] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - $groups = []; - - foreach ( $exporters as $exporter_key => $exporter ) { - if ( ! isset( $exporter['callback'] ) || ! is_callable( $exporter['callback'] ) ) { - WP_CLI::warning( "Exporter '{$exporter_key}' does not have a valid callback." ); - continue; - } - - $page = 1; - do { - $response = call_user_func( $exporter['callback'], $email_address, $page ); - - if ( ! is_array( $response ) ) { - WP_CLI::error( "Exporter '{$exporter_key}' did not return an array." ); - } - - if ( ! array_key_exists( 'data', $response ) ) { - WP_CLI::error( "Expected data in response array from exporter '{$exporter_key}'." ); - } - - if ( ! is_array( $response['data'] ) ) { - WP_CLI::error( "Expected data array in response array from exporter '{$exporter_key}'." ); - } - - if ( ! array_key_exists( 'done', $response ) ) { - WP_CLI::error( "Expected done (boolean) in response array from exporter '{$exporter_key}'." ); - } - - if ( ! empty( $response['data'] ) && is_array( $response['data'] ) ) { - foreach ( $response['data'] as $export_datum ) { - if ( ! is_array( $export_datum ) ) { - continue; - } - if ( ! isset( $export_datum['group_id'], $export_datum['item_id'] ) ) { - continue; - } - if ( ! is_scalar( $export_datum['group_id'] ) || ! is_scalar( $export_datum['item_id'] ) ) { - continue; - } - $group_id = (string) $export_datum['group_id']; - $item_id = (string) $export_datum['item_id']; - - if ( ! isset( $groups[ $group_id ] ) ) { - $groups[ $group_id ] = [ - 'group_label' => isset( $export_datum['group_label'] ) && is_scalar( $export_datum['group_label'] ) ? (string) $export_datum['group_label'] : '', - 'group_description' => isset( $export_datum['group_description'] ) && is_scalar( $export_datum['group_description'] ) ? (string) $export_datum['group_description'] : '', - 'items' => [], - ]; - } - if ( ! isset( $groups[ $group_id ]['items'][ $item_id ] ) ) { - $groups[ $group_id ]['items'][ $item_id ] = []; - } - if ( isset( $export_datum['data'] ) && is_array( $export_datum['data'] ) ) { - $groups[ $group_id ]['items'][ $item_id ] = array_merge( $groups[ $group_id ]['items'][ $item_id ], $export_datum['data'] ); - } - } - } - - $done = (bool) $response['done']; - ++$page; - } while ( ! $done ); - } - - update_post_meta( $request_id, '_export_data_grouped', $groups ); - - // Files were moved in WP 5.3.0, see https://core.trac.wordpress.org/ticket/43895. - if ( file_exists( ABSPATH . 'wp-admin/includes/privacy-tools.php' ) ) { - require_once ABSPATH . 'wp-admin/includes/privacy-tools.php'; - } else { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - wp_privacy_generate_personal_data_export_file( $request_id ); - - // WP 5.5+ stores only the basename in '_export_file_name'; older versions - // store the full absolute path in '_export_file_path'. - $raw_path = get_post_meta( $request_id, '_export_file_path', true ); - $file_path = is_string( $raw_path ) ? $raw_path : ''; - - if ( '' === $file_path ) { - $raw_name = get_post_meta( $request_id, '_export_file_name', true ); - $file_name = is_string( $raw_name ) ? $raw_name : ''; - if ( '' !== $file_name ) { - $file_path = wp_privacy_exports_dir() . $file_name; - } - } - - if ( '' === $file_path ) { - WP_CLI::error( 'Failed to generate the personal data export file.' ); - } - - $result = _wp_privacy_completed_request( $request_id ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( "Failed completing privacy request {$request_id}: " . $result->get_error_message() ); - } - - WP_CLI::success( 'Exported personal data to: ' . $file_path ); - } - - /** - * Marks one or more privacy requests as completed. - * - * ## OPTIONS - * - * <request-id>... - * : One or more IDs of the privacy requests to complete. - * - * ## EXAMPLES - * - * # Mark request 1 as completed. - * $ wp user privacy-request complete 1 - * Privacy request 1 completed. - * Success: Completed 1 of 1 privacy requests. - * - * # Mark multiple requests as completed. - * $ wp user privacy-request complete 1 2 - * Privacy request 1 completed. - * Privacy request 2 completed. - * Success: Completed 2 of 2 privacy requests. - * - * @param list<string> $args Positional arguments. - * @param array{} $assoc_args Associative arguments. - */ - public function complete( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed - $successes = 0; - $errors = 0; - - foreach ( $args as $request_id ) { - $request_id = (int) $request_id; - $request = $this->get_request( $request_id ); - - if ( ! $request ) { - WP_CLI::warning( "Could not find privacy request with ID {$request_id}." ); - ++$errors; - continue; - } - - $result = _wp_privacy_completed_request( $request_id ); - - if ( is_wp_error( $result ) ) { - WP_CLI::warning( "Failed completing privacy request {$request_id}: " . $result->get_error_message() ); - ++$errors; - } else { - update_post_meta( $request_id, '_wp_user_request_completed_timestamp', time() ); - WP_CLI::log( "Privacy request {$request_id} completed." ); - ++$successes; - } - } - - $count = count( $args ); - Utils\report_batch_operation_results( 'privacy request', 'complete', $count, $successes, $errors ); - } - - /** - * Gets a user privacy request object. - * - * @param int $request_id Request ID. - * @return WP_User_Request|false The request if found; false otherwise. - */ - private function get_request( $request_id ) { - if ( function_exists( 'wp_get_user_request' ) ) { - return wp_get_user_request( $request_id ); - } - - return wp_get_user_request_data( $request_id ); // phpcs:ignore WordPress.WP.DeprecatedFunctions.wp_get_user_request_dataFound -- Fallback for WP < 5.4. // @phpstan-ignore function.deprecated - } - - /** - * Gets a user privacy request object or exits with an error. - * - * @param int $request_id Request ID. - * @return WP_User_Request The request. - */ - private function get_request_check( $request_id ) { - $request = $this->get_request( $request_id ); - - if ( ! $request ) { - WP_CLI::error( "Could not find privacy request with ID {$request_id}." ); - } - - return $request; - } - - /** - * Converts a WP_Post (user_request post type) to an associative array for display. - * - * @param WP_Post $post Post object of type user_request. - * @return array<string, mixed>|false Array of request data, or false on failure. - */ - private function get_request_data( $post ) { - $request = $this->get_request( $post->ID ); - - if ( ! $request ) { - return false; - } - - return [ - 'ID' => $request->ID, - 'user_id' => $request->user_id, - 'user_email' => $request->email, - 'action_name' => $request->action_name, - 'status' => $request->status, - 'created_timestamp' => $request->created_timestamp, - 'confirmed_timestamp' => $request->confirmed_timestamp, - 'completed_timestamp' => $request->completed_timestamp, - ]; - } -} diff --git a/src/User_Session_Command.php b/src/User_Session_Command.php index 7e4262484..04e29c917 100644 --- a/src/User_Session_Command.php +++ b/src/User_Session_Command.php @@ -1,9 +1,5 @@ <?php -use WP_CLI\Fetchers\User as UserFetcher; -use WP_CLI\Formatter; -use WP_CLI\Utils; - /** * Destroys and lists a user's sessions. * @@ -22,18 +18,16 @@ */ class User_Session_Command extends WP_CLI_Command { - private $fields = [ + private $fields = array( 'token', 'login_time', 'expiration_time', 'ip', 'ua', - ]; - - private $fetcher; + ); public function __construct() { - $this->fetcher = new UserFetcher(); + $this->fetcher = new \WP_CLI\Fetchers\User; } /** @@ -71,8 +65,8 @@ public function __construct() { */ public function destroy( $args, $assoc_args ) { $user = $this->fetcher->get_check( $args[0] ); - $token = $args[1] ?? null; - $all = (bool) Utils\get_flag_value( $assoc_args, 'all', false ); + $token = \WP_CLI\Utils\get_flag_value( $args, 1, null ); + $all = \WP_CLI\Utils\get_flag_value( $assoc_args, 'all', false ); $manager = WP_Session_Tokens::get_instance( $user->ID ); if ( $token && $all ) { @@ -91,7 +85,7 @@ public function destroy( $args, $assoc_args ) { if ( empty( $sessions ) ) { WP_CLI::success( 'No sessions to destroy.' ); } - $last = end( $sessions ); + $last = end( $sessions ); $token = $last['token']; } @@ -102,7 +96,7 @@ public function destroy( $args, $assoc_args ) { $this->destroy_session( $manager, $token ); $remaining = count( $manager->get_all() ); - WP_CLI::success( "Destroyed session. {$remaining} remaining." ); + WP_CLI::success( sprintf( 'Destroyed session. %s remaining.', $remaining ) ); } /** @@ -163,7 +157,7 @@ public function list_( $args, $assoc_args ) { $manager = WP_Session_Tokens::get_instance( $user->ID ); $sessions = $this->get_all_sessions( $manager ); - if ( 'ids' === $formatter->format ) { + if ( 'ids' == $formatter->format ) { echo implode( ' ', array_keys( $sessions ) ); } else { $formatter->display_items( $sessions ); @@ -173,38 +167,26 @@ public function list_( $args, $assoc_args ) { protected function get_all_sessions( WP_Session_Tokens $manager ) { // Make the private session data accessible to WP-CLI $get_sessions = new ReflectionMethod( $manager, 'get_sessions' ); - if ( PHP_VERSION_ID < 80100 ) { - // @phpstan-ignore method.deprecated - $get_sessions->setAccessible( true ); - } - - /** - * @var array<array{token: string|int, login_time: string, login: int, expiration_time: string, expiration: int}> $sessions - */ + $get_sessions->setAccessible( true ); $sessions = $get_sessions->invoke( $manager ); - array_walk( - $sessions, - function ( &$session, $token ) { - $session['token'] = $token; - $session['login_time'] = date( 'Y-m-d H:i:s', $session['login'] ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date - $session['expiration_time'] = date( 'Y-m-d H:i:s', $session['expiration'] ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date - } - ); + array_walk( $sessions, function( & $session, $token ) { + $session['token'] = $token; + $session['login_time'] = date( 'Y-m-d H:i:s', $session['login'] ); + $session['expiration_time'] = date( 'Y-m-d H:i:s', $session['expiration'] ); + } ); return $sessions; } protected function destroy_session( WP_Session_Tokens $manager, $token ) { $update_session = new ReflectionMethod( $manager, 'update_session' ); - if ( PHP_VERSION_ID < 80100 ) { - // @phpstan-ignore method.deprecated - $update_session->setAccessible( true ); - } + $update_session->setAccessible( true ); return $update_session->invoke( $manager, $token, null ); } private function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->fields ); + return new \WP_CLI\Formatter( $assoc_args, $this->fields ); } + } diff --git a/src/User_Term_Command.php b/src/User_Term_Command.php index 3ecc9bb45..ed72c8129 100644 --- a/src/User_Term_Command.php +++ b/src/User_Term_Command.php @@ -1,7 +1,5 @@ <?php -use WP_CLI\CommandWithTerms; - /** * Adds, updates, removes, and lists user terms. * @@ -11,6 +9,6 @@ * $ wp user term set 123 test category * Success: Set terms. */ -class User_Term_Command extends CommandWithTerms { +class User_Term_Command extends \WP_CLI\CommandWithTerms { protected $obj_type = 'user'; } diff --git a/src/WP_CLI/CommandWithDBObject.php b/src/WP_CLI/CommandWithDBObject.php index 767a0bb27..f8655a287 100644 --- a/src/WP_CLI/CommandWithDBObject.php +++ b/src/WP_CLI/CommandWithDBObject.php @@ -2,17 +2,12 @@ namespace WP_CLI; -use WP_CLI; -use WP_CLI_Command; -use WP_CLI\Utils; -use WP_Error; - /** * Base class for WP-CLI commands that deal with database objects. * * @package wp-cli */ -abstract class CommandWithDBObject extends WP_CLI_Command { +abstract class CommandWithDBObject extends \WP_CLI_Command { /** * @var string $object_type WordPress' expected name for the object. @@ -27,15 +22,15 @@ abstract class CommandWithDBObject extends WP_CLI_Command { /** * @var array $obj_fields Default fields to display for each object. */ - protected $obj_fields; + protected $obj_fields = null; /** * Create a given database object. * Exits with status. * - * @param array $args Arguments passed to command. Generally unused. - * @param array $assoc_args Parameters passed to command to be passed to callback. - * @param callable $callback Function used to create object. + * @param array $args Arguments passed to command. Generally unused. + * @param array $assoc_args Parameters passed to command to be passed to callback. + * @param string $callback Function used to create object. */ protected function _create( $args, $assoc_args, $callback ) { unset( $assoc_args[ $this->obj_id_key ] ); @@ -43,47 +38,44 @@ protected function _create( $args, $assoc_args, $callback ) { $obj_id = $callback( $assoc_args ); if ( is_wp_error( $obj_id ) ) { - WP_CLI::error( $obj_id ); + \WP_CLI::error( $obj_id ); } - if ( Utils\get_flag_value( $assoc_args, 'porcelain' ) ) { - WP_CLI::line( $obj_id ); - } else { - WP_CLI::success( "Created {$this->obj_type} {$obj_id}." ); - } + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'porcelain' ) ) + \WP_CLI::line( $obj_id ); + else + \WP_CLI::success( "Created $this->obj_type $obj_id." ); } /** * Update a given database object. * Exits with status. * - * @param array $args Collection of one or more object ids to update. - * @param array $assoc_args Fields => values to update on each object. - * @param callable $callback Function used to update object. + * @param array $args Collection of one or more object ids to update. + * @param array $assoc_args Fields => values to update on each object. + * @param string $callback Function used to update object. */ protected function _update( $args, $assoc_args, $callback ) { $status = 0; if ( empty( $assoc_args ) ) { - WP_CLI::error( 'Need some fields to update.' ); + \WP_CLI::error( "Need some fields to update." ); } - if ( Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { wp_defer_term_counting( true ); } foreach ( $args as $obj_id ) { - $params = array_merge( $assoc_args, [ $this->obj_id_key => $obj_id ] ); - - $status = $this->success_or_failure( - $this->wp_error_to_resp( - $callback( $params ), - "Updated {$this->obj_type} {$obj_id}." - ) - ); + $params = array_merge( $assoc_args, array( $this->obj_id_key => $obj_id ) ); + + $status = $this->success_or_failure( $this->wp_error_to_resp( + $callback( $params ), + "Updated $this->obj_type $obj_id." + ) ); } - if ( Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { wp_defer_term_counting( false ); } @@ -97,7 +89,7 @@ protected function _update( $args, $assoc_args, $callback ) { * @return array */ protected static function process_csv_arguments_to_arrays( $assoc_args ) { - foreach ( $assoc_args as $k => $v ) { + foreach( $assoc_args as $k => $v ) { if ( false !== strpos( $k, '__' ) ) { $assoc_args[ $k ] = explode( ',', $v ); } @@ -111,21 +103,21 @@ protected static function process_csv_arguments_to_arrays( $assoc_args ) { * * @param array $args Collection of one or more object ids to delete. * @param array $assoc_args Any arguments needed for the callback function. - * @param callable $callback Function used to delete object. + * @param string $callback Function used to delete object. */ protected function _delete( $args, $assoc_args, $callback ) { $status = 0; - if ( Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { wp_defer_term_counting( true ); } foreach ( $args as $obj_id ) { - $result = $callback( $obj_id, $assoc_args ); - $status = $this->success_or_failure( $result ); + $r = $callback( $obj_id, $assoc_args ); + $status = $this->success_or_failure( $r ); } - if ( Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'defer-term-counting' ) ) { wp_defer_term_counting( false ); } @@ -135,30 +127,31 @@ protected function _delete( $args, $assoc_args, $callback ) { /** * Format callback response to consistent format. * - * @param WP_Error|true $response Response from CRUD callback. - * @param string $success_msg + * @param WP_Error|true $r Response from CRUD callback. + * @param string $success_msg * @return array */ - protected function wp_error_to_resp( $response, $success_msg ) { - return is_wp_error( $response ) - ? [ 'error', $response->get_error_message() ] - : [ 'success', $success_msg ]; + protected function wp_error_to_resp( $r, $success_msg ) { + if ( is_wp_error( $r ) ) + return array( 'error', $r->get_error_message() ); + else + return array( 'success', $success_msg ); } /** * Display success or warning based on response; return proper exit code. * - * @param array $response Formatted from a CRUD callback. + * @param array $r Formatted from a CRUD callback. * @return int $status */ - protected function success_or_failure( $response ) { - list( $type, $msg ) = $response; + protected function success_or_failure( $r ) { + list( $type, $msg ) = $r; - if ( 'success' === $type ) { - WP_CLI::success( $msg ); + if ( 'success' == $type ) { + \WP_CLI::success( $msg ); $status = 0; } else { - WP_CLI::warning( $msg ); + \WP_CLI::warning( $msg ); $status = 1; } @@ -169,7 +162,7 @@ protected function success_or_failure( $response ) { * Get Formatter object based on supplied parameters. * * @param array $assoc_args Parameters passed to command. Determines formatting. - * @return Formatter + * @return \WP_CLI\Formatter */ protected function get_formatter( &$assoc_args ) { @@ -182,6 +175,19 @@ protected function get_formatter( &$assoc_args ) { } else { $fields = $this->obj_fields; } - return new Formatter( $assoc_args, $fields, $this->obj_type ); + return new \WP_CLI\Formatter( $assoc_args, $fields, $this->obj_type ); + } + + /** + * Given a callback, display the URL for one or more objects. + * + * @param array $args One or more object references. + * @param string $callback Function to get URL for the object. + */ + protected function _url( $args, $callback ) { + foreach ( $args as $obj_id ) { + $object = $this->fetcher->get_check( $obj_id ); + \WP_CLI::line( $callback( $object->{$this->obj_id_key} ) ); + } } } diff --git a/src/WP_CLI/CommandWithMeta.php b/src/WP_CLI/CommandWithMeta.php index d32391998..d69e6f986 100644 --- a/src/WP_CLI/CommandWithMeta.php +++ b/src/WP_CLI/CommandWithMeta.php @@ -2,18 +2,15 @@ namespace WP_CLI; -use Exception; use WP_CLI; -use WP_CLI_Command; -use WP_CLI\Traverser\RecursiveDataStructureTraverser; -use WP_CLI\Utils; +use WP_CLI\Entity\RecursiveDataStructureTraverser; /** * Base class for WP-CLI commands that deal with metadata * * @package wp-cli */ -abstract class CommandWithMeta extends WP_CLI_Command { +abstract class CommandWithMeta extends \WP_CLI_Command { protected $meta_type; @@ -32,16 +29,7 @@ abstract class CommandWithMeta extends WP_CLI_Command { * : Limit the output to specific row fields. Defaults to id,meta_key,meta_value. * * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * - count - * --- + * : Accepted values: table, csv, json, count. Default: table * * [--orderby=<fields>] * : Set orderby which field. @@ -61,48 +49,40 @@ abstract class CommandWithMeta extends WP_CLI_Command { * - asc * - desc * --- - * - * [--unserialize] - * : Unserialize meta_value output. - * * @subcommand list - * - * @param array{0: string} $args Positional arguments.. - * @param array{keys?: string, fields?: string, format: 'table'|'csv'|'json'|'yaml'|'count', orderby: 'id'|'meta_key'|'meta_value', order: 'asc'|'desc', unserialize?: bool} $assoc_args Associative arguments. */ public function list_( $args, $assoc_args ) { list( $object_id ) = $args; - $keys = ! empty( $assoc_args['keys'] ) ? explode( ',', $assoc_args['keys'] ) : []; + $keys = ! empty( $assoc_args['keys'] ) ? explode( ',', $assoc_args['keys'] ) : array(); $object_id = $this->check_object_id( $object_id ); - $metadata = $this->get_metadata( $object_id ); + $metadata = get_metadata( $this->meta_type, $object_id ); if ( ! $metadata ) { - $metadata = []; + $metadata = array(); } - $items = []; - foreach ( $metadata as $key => $values ) { + $items = array(); + foreach( $metadata as $key => $values ) { // Skip if not requested - if ( ! empty( $keys ) && ! in_array( $key, $keys, true ) ) { + if ( ! empty( $keys ) && ! in_array( $key, $keys ) ) { continue; } - foreach ( $values as $item_value ) { + foreach( $values as $item_value ) { - if ( Utils\get_flag_value( $assoc_args, 'unserialize' ) ) { - $item_value = maybe_unserialize( (string) $item_value ); - } + $item_value = maybe_unserialize( $item_value ); - $items[] = (object) [ + $items[] = (object) array( "{$this->meta_type}_id" => $object_id, 'meta_key' => $key, 'meta_value' => $item_value, - ]; + ); } + } $order = Utils\get_flag_value( $assoc_args, 'order' ); @@ -110,17 +90,14 @@ public function list_( $args, $assoc_args ) { if ( 'id' !== $orderby ) { - usort( - $items, - function ( $a, $b ) use ( $orderby, $order ) { - // Sort array. - return 'asc' === $order - ? $a->$orderby <=> $b->$orderby - : $b->$orderby <=> $a->$orderby; - } - ); + usort( $items, function ( $a, $b ) use ( $orderby, $order ) { + // Sort array. + return 'asc' === $order + ? $a->$orderby > $b->$orderby + : $a->$orderby < $b->$orderby; + }); - } elseif ( 'desc' === $order ) { // Sort by default descending. + } elseif ( 'id' === $orderby && 'desc' === $order ) { // Sort by default descending. krsort( $items ); } @@ -130,8 +107,9 @@ function ( $a, $b ) use ( $orderby, $order ) { $fields = $this->get_fields(); } - $formatter = new Formatter( $assoc_args, $fields, $this->meta_type ); + $formatter = new \WP_CLI\Formatter( $assoc_args, $fields, $this->meta_type ); $formatter->display_items( $items ); + } /** @@ -145,33 +123,18 @@ function ( $a, $b ) use ( $orderby, $order ) { * <key> * : The name of the meta field to get. * - * [--single] - * : Whether to return a single value. - * * [--format=<format>] - * : Get value in a particular format. - * --- - * default: var_export - * options: - * - var_export - * - json - * - yaml - * --- - * - * @param array{0: string, 1: string} $args Positional arguments. - * @param array{single?: bool, format: 'table'|'csv'|'json'|'yaml'} $assoc_args Associative arguments. + * : Accepted values: table, json. Default: table */ public function get( $args, $assoc_args ) { list( $object_id, $meta_key ) = $args; $object_id = $this->check_object_id( $object_id ); - $single = (bool) Utils\get_flag_value( $assoc_args, 'single', true ); - $value = $this->get_metadata( $object_id, $meta_key, $single ); + $value = get_metadata( $this->meta_type, $object_id, $meta_key, true ); - if ( '' === $value ) { - die( 1 ); - } + if ( '' === $value ) + die(1); WP_CLI::print_value( $value, $assoc_args ); } @@ -192,14 +155,11 @@ public function get( $args, $assoc_args ) { * * [--all] * : Delete all meta for the object. - * - * @param array<string> $args Positional arguments. - * @param array{all?: bool} $assoc_args Associative arguments. */ public function delete( $args, $assoc_args ) { list( $object_id ) = $args; - $meta_key = ! empty( $args[1] ) ? $args[1] : ''; + $meta_key = ! empty( $args[1] ) ? $args[1] : ''; $meta_value = ! empty( $args[2] ) ? $args[2] : ''; if ( empty( $meta_key ) && ! Utils\get_flag_value( $assoc_args, 'all' ) ) { @@ -210,8 +170,8 @@ public function delete( $args, $assoc_args ) { if ( Utils\get_flag_value( $assoc_args, 'all' ) ) { $errors = false; - foreach ( $this->get_metadata( $object_id ) as $meta_key => $values ) { - $success = $this->delete_metadata( $object_id, $meta_key ); + foreach( get_metadata( $this->meta_type, $object_id ) as $meta_key => $values ) { + $success = delete_metadata( $this->meta_type, $object_id, $meta_key ); if ( $success ) { WP_CLI::log( "Deleted '{$meta_key}' custom field." ); } else { @@ -225,11 +185,11 @@ public function delete( $args, $assoc_args ) { WP_CLI::success( 'Deleted all custom fields.' ); } } else { - $success = $this->delete_metadata( $object_id, $meta_key, $meta_value ); + $success = delete_metadata( $this->meta_type, $object_id, $meta_key, $meta_value ); if ( $success ) { - WP_CLI::success( 'Deleted custom field.' ); + WP_CLI::success( "Deleted custom field." ); } else { - WP_CLI::error( 'Failed to delete custom field.' ); + WP_CLI::error( "Failed to delete custom field." ); } } } @@ -256,9 +216,6 @@ public function delete( $args, $assoc_args ) { * - plaintext * - json * --- - * - * @param array<string> $args Positional arguments. - * @param array{format: 'plaintext'|'json'} $assoc_args Associative arguments. */ public function add( $args, $assoc_args ) { list( $object_id, $meta_key ) = $args; @@ -269,12 +226,12 @@ public function add( $args, $assoc_args ) { $object_id = $this->check_object_id( $object_id ); $meta_value = wp_slash( $meta_value ); - $success = $this->add_metadata( $object_id, $meta_key, $meta_value ); + $success = add_metadata( $this->meta_type, $object_id, $meta_key, $meta_value ); if ( $success ) { - WP_CLI::success( 'Added custom field.' ); + WP_CLI::success( "Added custom field." ); } else { - WP_CLI::error( 'Failed to add custom field.' ); + WP_CLI::error( "Failed to add custom field." ); } } @@ -302,9 +259,6 @@ public function add( $args, $assoc_args ) { * --- * * @alias set - * - * @param array<string> $args Positional arguments. - * @param array{format: 'plaintext'|'json'} $assoc_args Associative arguments. */ public function update( $args, $assoc_args ) { list( $object_id, $meta_key ) = $args; @@ -314,24 +268,23 @@ public function update( $args, $assoc_args ) { $object_id = $this->check_object_id( $object_id ); - /** - * @var array|string $meta_value - */ $meta_value = sanitize_meta( $meta_key, $meta_value, $this->meta_type ); - $old_value = sanitize_meta( $meta_key, $this->get_metadata( $object_id, $meta_key, true ), $this->meta_type ); + $old_value = sanitize_meta( $meta_key, get_metadata( $this->meta_type, $object_id, $meta_key, true ), $this->meta_type ); if ( $meta_value === $old_value ) { - WP_CLI::success( "Value passed for custom field '{$meta_key}' is unchanged." ); + WP_CLI::success( "Value passed for custom field '$meta_key' is unchanged." ); } else { $meta_value = wp_slash( $meta_value ); - $success = $this->update_metadata( $object_id, $meta_key, $meta_value ); + $success = update_metadata( $this->meta_type, $object_id, $meta_key, $meta_value ); if ( $success ) { - WP_CLI::success( "Updated custom field '{$meta_key}'." ); + WP_CLI::success( "Updated custom field '$meta_key'." ); } else { - WP_CLI::error( "Failed to update custom field '{$meta_key}'." ); + WP_CLI::error( "Failed to update custom field '$meta_key'." ); } + } + } /** @@ -359,24 +312,21 @@ public function update( $args, $assoc_args ) { */ public function pluck( $args, $assoc_args ) { list( $object_id, $meta_key ) = $args; - $object_id = $this->check_object_id( $object_id ); - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 2 ) - ); + $object_id = $this->check_object_id( $object_id ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 2 ) ); - $value = $this->get_metadata( $object_id, $meta_key, true ); + $value = get_metadata( $this->meta_type, $object_id, $meta_key, true ); $traverser = new RecursiveDataStructureTraverser( $value ); try { $value = $traverser->get( $key_path ); - } catch ( Exception $exception ) { + } catch ( \Exception $e ) { die( 1 ); } @@ -420,160 +370,72 @@ function ( $key ) { */ public function patch( $args, $assoc_args ) { list( $action, $object_id, $meta_key ) = $args; - $object_id = $this->check_object_id( $object_id ); - $key_path = array_map( - function ( $key ) { - if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) { - return (int) $key; - } - return $key; - }, - array_slice( $args, 3 ) - ); + $object_id = $this->check_object_id( $object_id ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 3 ) ); - if ( 'delete' === $action ) { + if ( 'delete' == $action ) { $patch_value = null; + } elseif ( Entity\Utils::has_stdin() ) { + $stdin_value = WP_CLI::get_value_from_arg_or_stdin( $args, -1 ); + $patch_value = WP_CLI::read_value( trim( $stdin_value ), $assoc_args ); } else { - $stdin_value = Utils\has_stdin() - ? trim( WP_CLI::get_value_from_arg_or_stdin( $args, -1 ) ) - : null; - $patch_value = ! empty( $stdin_value ) - ? WP_CLI::read_value( $stdin_value, $assoc_args ) - : WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); + // Take the patch value as the last positional argument. Mutates $key_path to be 1 element shorter! + $patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); } /* Need to make a copy of $current_meta_value here as it is modified by reference */ - $current_meta_value = sanitize_meta( $meta_key, $this->get_metadata( $object_id, $meta_key, true ), $this->meta_type ); - $old_meta_value = $current_meta_value; - if ( is_object( $current_meta_value ) ) { - $old_meta_value = clone $current_meta_value; - } + $current_meta_value = $old_meta_value = sanitize_meta( $meta_key, get_metadata( $this->meta_type, $object_id, $meta_key, true ), $this->meta_type ); $traverser = new RecursiveDataStructureTraverser( $current_meta_value ); try { $traverser->$action( $key_path, $patch_value ); - } catch ( Exception $exception ) { - WP_CLI::error( $exception->getMessage() ); + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); } - /** - * @var array|string $patched_meta_value - */ $patched_meta_value = sanitize_meta( $meta_key, $traverser->value(), $this->meta_type ); if ( $patched_meta_value === $old_meta_value ) { - WP_CLI::success( "Value passed for custom field '{$meta_key}' is unchanged." ); + WP_CLI::success( "Value passed for custom field '$meta_key' is unchanged." ); } else { $slashed = wp_slash( $patched_meta_value ); - $success = $this->update_metadata( $object_id, $meta_key, $slashed ); + $success = update_metadata( $this->meta_type, $object_id, $meta_key, $slashed ); if ( $success ) { - WP_CLI::success( "Updated custom field '{$meta_key}'." ); + WP_CLI::success( "Updated custom field '$meta_key'." ); } else { - WP_CLI::error( "Failed to update custom field '{$meta_key}'." ); + WP_CLI::error( "Failed to update custom field '$meta_key'." ); } } } - /** - * Wrapper method for add_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param bool $unique Optional, default is false. Whether the - * specified metadata key should be unique for the - * object. If true, and the object already has a - * value for the specified metadata key, no change - * will be made. - * - * @return int|false The meta ID on success, false on failure. - */ - protected function add_metadata( $object_id, $meta_key, $meta_value, $unique = false ) { - return add_metadata( $this->meta_type, $object_id, $meta_key, $meta_value, $unique ); - } - - /** - * Wrapper method for update_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Metadata key to use. - * @param mixed $meta_value Metadata value. Must be serializable if - * non-scalar. - * @param mixed $prev_value Optional. If specified, only update existing - * metadata entries with the specified value. - * Otherwise, update all entries. - * - * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. - */ - protected function update_metadata( $object_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_metadata( $this->meta_type, $object_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Wrapper method for get_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object the metadata is for. - * @param string $meta_key Optional. Metadata key. If not specified, - * retrieve all metadata for the specified object. - * @param bool $single Optional, default is false. If true, return only - * the first value of the specified meta_key. This - * parameter has no effect if meta_key is not - * specified. - * - * @return mixed Single metadata value, or array of values. - * - * @phpstan-return ($single is true ? string : $meta_key is "" ? array<array<string>> : array<string>) - */ - protected function get_metadata( $object_id, $meta_key = '', $single = false ) { - // @phpstan-ignore return.type - return get_metadata( $this->meta_type, $object_id, $meta_key, $single ); - } - - /** - * Wrapper method for delete_metadata that can be overridden in sub classes. - * - * @param int $object_id ID of the object metadata is for - * @param string $meta_key Metadata key - * @param mixed $meta_value Optional. Metadata value. Must be serializable - * if non-scalar. If specified, only delete - * metadata entries with this value. Otherwise, - * delete all entries with the specified meta_key. - * Pass `null, `false`, or an empty string to skip - * this check. For backward compatibility, it is - * not possible to pass an empty string to delete - * those entries with an empty string for a value. - * - * @return bool True on successful delete, false on failure. - */ - protected function delete_metadata( $object_id, $meta_key, $meta_value = '' ) { - return delete_metadata( $this->meta_type, $object_id, $meta_key, $meta_value, false ); - } - /** * Get the fields for this object's meta * * @return array */ private function get_fields() { - return [ + return array( "{$this->meta_type}_id", 'meta_key', 'meta_value', - ]; + ); } /** * Check that the object ID exists * - * @param string|int $object_id - * @return int + * @param int */ protected function check_object_id( $object_id ) { // Needs to be set in subclass - return (int) $object_id; + return $object_id; } + } diff --git a/src/WP_CLI/CommandWithTerms.php b/src/WP_CLI/CommandWithTerms.php index a70347233..f030fcae7 100644 --- a/src/WP_CLI/CommandWithTerms.php +++ b/src/WP_CLI/CommandWithTerms.php @@ -3,7 +3,6 @@ namespace WP_CLI; use WP_CLI; -use WP_CLI_Command; use WP_CLI\Utils; /** @@ -11,7 +10,7 @@ * * @package wp-cli */ -abstract class CommandWithTerms extends WP_CLI_Command { +abstract class CommandWithTerms extends \WP_CLI_Command { /** * @var string $object_type WordPress' expected name for the object. @@ -26,18 +25,16 @@ abstract class CommandWithTerms extends WP_CLI_Command { /** * @var array $obj_fields Default fields to display for each object. */ - protected $obj_fields = [ - 'term_id', - 'name', - 'slug', - 'taxonomy', - ]; + protected $obj_fields = array( + "term_id", + "name", + "slug", + "taxonomy" + ); /** * List all terms associated with an object. * - * ## OPTIONS - * * <id> * : ID for the object. * @@ -51,17 +48,7 @@ abstract class CommandWithTerms extends WP_CLI_Command { * : Limit the output to specific row fields. * * [--format=<format>] - * : Render output in a particular format. - * --- - * default: table - * options: - * - table - * - csv - * - json - * - yaml - * - count - * - ids - * --- + * : Accepted values: table, csv, json, count, ids. Default: table * * ## AVAILABLE FIELDS * @@ -84,14 +71,14 @@ abstract class CommandWithTerms extends WP_CLI_Command { */ public function list_( $args, $assoc_args ) { - $defaults = [ - 'format' => 'table', - ]; + $defaults = array( + 'format' => 'table', + ); $assoc_args = array_merge( $defaults, $assoc_args ); $object_id = array_shift( $args ); $taxonomy_names = $args; - $taxonomy_args = []; + $taxonomy_args = array(); $this->set_obj_id( $object_id ); @@ -99,23 +86,15 @@ public function list_( $args, $assoc_args ) { $this->taxonomy_exists( $taxonomy ); } - if ( 'ids' === $assoc_args['format'] ) { + if ( $assoc_args['format'] == 'ids' ) { $taxonomy_args['fields'] = 'ids'; } $items = wp_get_object_terms( $object_id, $taxonomy_names, $taxonomy_args ); - // This should never happen because of the taxonomy_exists check above. - if ( is_wp_error( $items ) ) { - WP_CLI::error( $items ); - } - - /** - * @var \WP_Term[] $items - */ - $formatter = $this->get_formatter( $assoc_args ); $formatter->display_items( $items ); + } @@ -131,12 +110,11 @@ public function list_( $args, $assoc_args ) { * : The name of the term's taxonomy. * * [<term>...] - * : The slug of the term or terms to be removed from the object. + * : The name of the term or terms to be removed from the object. * * [--by=<field>] * : Explicitly handle the term value as a slug or id. * --- - * default: slug * options: * - slug * - id @@ -146,23 +124,19 @@ public function list_( $args, $assoc_args ) { * : Remove all terms from the object. */ public function remove( $args, $assoc_args ) { - $object_id = array_shift( $args ); - $taxonomy = array_shift( $args ); - $terms = $args; + $object_id = array_shift( $args ); + $taxonomy = array_shift( $args ); + $terms = $args; $this->set_obj_id( $object_id ); $this->taxonomy_exists( $taxonomy ); - /** - * @var string|null $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); - if ( $field ) { + if ( $field = Utils\get_flag_value( $assoc_args, 'by' ) ) { $terms = $this->prepare_terms( $field, $terms, $taxonomy ); } - if ( (bool) Utils\get_flag_value( $assoc_args, 'all' ) ) { + if ( Utils\get_flag_value( $assoc_args, 'all' ) ) { // No need to specify terms while removing all terms. if ( $terms ) { @@ -176,46 +150,41 @@ public function remove( $args, $assoc_args ) { if ( 'category' === $taxonomy ) { // Set default category to post. - - /** - * @var string $default_category - */ - $default_category = get_option( 'default_category' ); - $default_category = (int) $default_category; + $default_category = (int) get_option( 'default_category' ); $default_category = ( ! empty( $default_category ) ) ? $default_category : 1; - $default_category = wp_set_object_terms( $object_id, [ $default_category ], $taxonomy, true ); + $default_category = wp_set_object_terms( $object_id, array( $default_category ), $taxonomy, true ); - if ( is_wp_error( $default_category ) ) { + if ( ! is_wp_error( $default_category ) ) { + $message = 'Removed all terms and set default term.'; + } else { WP_CLI::error( 'Failed to set default term.' ); } - - $message = 'Removed all terms and set default term.'; } - if ( is_wp_error( $result ) ) { + if ( ! is_wp_error( $result ) ) { + WP_CLI::success( $message ); + } else { WP_CLI::error( 'Failed to remove all terms.' ); } + return; - WP_CLI::success( $message ); + } else { - return; - } + // Abort if no terms are specified. + if ( ! $terms ) { + WP_CLI::error( 'Please specify one or more terms, or use --all.' ); + } - // Abort if no terms are specified. - if ( ! $terms ) { - WP_CLI::error( 'Please specify one or more terms, or use --all.' ); + // Remove term from post. + $result = wp_remove_object_terms( $object_id, $terms, $taxonomy ); } - // Remove term from post. - $result = wp_remove_object_terms( $object_id, $terms, $taxonomy ); - $label = count( $terms ) > 1 ? 'terms' : 'term'; - - if ( is_wp_error( $result ) ) { + if ( ! is_wp_error( $result ) ) { + WP_CLI::success( "Removed {$label}." ); + } else { WP_CLI::error( "Failed to remove {$label}." ); } - - WP_CLI::success( "Removed {$label}." ); } /** @@ -223,8 +192,6 @@ public function remove( $args, $assoc_args ) { * * Append the term to the existing set of terms on the object. * - * ## OPTIONS - * * <id> * : The ID of the object. * @@ -237,26 +204,21 @@ public function remove( $args, $assoc_args ) { * [--by=<field>] * : Explicitly handle the term value as a slug or id. * --- - * default: slug * options: * - slug * - id * --- */ public function add( $args, $assoc_args ) { - $object_id = array_shift( $args ); - $taxonomy = array_shift( $args ); - $terms = $args; + $object_id = array_shift( $args ); + $taxonomy = array_shift( $args ); + $terms = $args; $this->set_obj_id( $object_id ); $this->taxonomy_exists( $taxonomy ); - /** - * @var string|null $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); - if ( $field ) { + if ( $field = Utils\get_flag_value( $assoc_args, 'by' ) ) { $terms = $this->prepare_terms( $field, $terms, $taxonomy ); } $result = wp_set_object_terms( $object_id, $terms, $taxonomy, true ); @@ -274,8 +236,6 @@ public function add( $args, $assoc_args ) { * * Replaces existing terms on the object. * - * ## OPTIONS - * * <id> * : The ID of the object. * @@ -288,26 +248,21 @@ public function add( $args, $assoc_args ) { * [--by=<field>] * : Explicitly handle the term value as a slug or id. * --- - * default: slug * options: * - slug * - id * --- */ public function set( $args, $assoc_args ) { - $object_id = array_shift( $args ); - $taxonomy = array_shift( $args ); - $terms = $args; + $object_id = array_shift( $args ); + $taxonomy = array_shift( $args ); + $terms = $args; $this->set_obj_id( $object_id ); $this->taxonomy_exists( $taxonomy ); - /** - * @var string|null $field - */ - $field = Utils\get_flag_value( $assoc_args, 'by' ); - if ( $field ) { + if ( $field = Utils\get_flag_value( $assoc_args, 'by' ) ) { $terms = $this->prepare_terms( $field, $terms, $taxonomy ); } $result = wp_set_object_terms( $object_id, $terms, $taxonomy, false ); @@ -329,7 +284,7 @@ protected function taxonomy_exists( $taxonomy ) { $taxonomy_names = get_object_taxonomies( $this->get_object_type() ); - if ( ! in_array( $taxonomy, $taxonomy_names, true ) ) { + if ( ! in_array( $taxonomy, $taxonomy_names ) ) { WP_CLI::error( "Invalid taxonomy {$taxonomy}." ); } } @@ -343,8 +298,8 @@ protected function taxonomy_exists( $taxonomy ) { */ protected function prepare_terms( $field, $terms, $taxonomy ) { if ( 'id' === $field ) { - $new_terms = []; - foreach ( $terms as $term_id ) { + $new_terms = array(); + foreach( $terms as $i => $term_id ) { $term = get_term_by( 'term_id', $term_id, $taxonomy ); if ( $term ) { $new_terms[] = $term->slug; @@ -391,6 +346,7 @@ protected function get_object_type() { * @return WP_CLI\Formatter */ protected function get_formatter( &$assoc_args ) { - return new Formatter( $assoc_args, $this->obj_fields, $this->obj_type ); + return new WP_CLI\Formatter( $assoc_args, $this->obj_fields, $this->obj_type ); } } + diff --git a/src/WP_CLI/Entity/NonExistentKeyException.php b/src/WP_CLI/Entity/NonExistentKeyException.php new file mode 100644 index 000000000..be007d195 --- /dev/null +++ b/src/WP_CLI/Entity/NonExistentKeyException.php @@ -0,0 +1,22 @@ +<?php + +namespace WP_CLI\Entity; + +class NonExistentKeyException extends \OutOfBoundsException { + /* @var \WP_CLI\Entity\RecursiveDataStructureTraverser */ + protected $traverser; + + /** + * @param \WP_CLI\Entity\RecursiveDataStructureTraverser $traverser + */ + public function set_traverser( $traverser ) { + $this->traverser = $traverser; + } + + /** + * @return \WP_CLI\Entity\RecursiveDataStructureTraverser + */ + public function get_traverser() { + return $this->traverser; + } +} diff --git a/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php b/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php new file mode 100644 index 000000000..a7b7c93f6 --- /dev/null +++ b/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php @@ -0,0 +1,177 @@ +<?php + +namespace WP_CLI\Entity; + +class RecursiveDataStructureTraverser { + + /** + * @var mixed The data to traverse set by reference. + */ + protected $data; + + /** + * @var null|string The key the data belongs to in the parent's data. + */ + protected $key; + + /** + * @var null|static The parent instance of the traverser. + */ + protected $parent; + + /** + * RecursiveDataStructureTraverser constructor. + * + * @param mixed $data The data to read/manipulate by reference. + * @param string|int $key The key/property the data belongs to. + * @param static $parent + */ + public function __construct( &$data, $key = null, $parent = null ) { + $this->data =& $data; + $this->key = $key; + $this->parent = $parent; + } + + /** + * Get the nested value at the given key path. + * + * @param string|int|array $key_path + * + * @return static + */ + public function get( $key_path ) { + return $this->traverse_to( (array) $key_path )->value(); + } + + /** + * Get the current data. + * + * @return mixed + */ + public function value() { + return $this->data; + } + + /** + * Update a nested value at the given key path. + * + * @param string|int|array $key_path + * @param mixed $value + */ + public function update( $key_path, $value ) { + $this->traverse_to( (array) $key_path )->set_value( $value ); + } + + /** + * Update the current data with the given value. + * + * This will mutate the variable which was passed into the constructor + * as the data is set and traversed by reference. + * + * @param mixed $value + */ + public function set_value( $value ) { + $this->data = $value; + } + + /** + * Unset the value at the given key path. + * + * @param $key_path + */ + public function delete( $key_path ) { + $this->traverse_to( (array) $key_path )->unset_on_parent(); + } + + /** + * Define a nested value while creating keys if they do not exist. + * + * @param array $key_path + * @param mixed $value + */ + public function insert( $key_path, $value ) { + try { + $this->update( $key_path, $value ); + } catch ( NonExistentKeyException $e ) { + $e->get_traverser()->create_key(); + $this->insert( $key_path, $value ); + } + } + + /** + * Delete the key on the parent's data that references this data. + */ + public function unset_on_parent() { + $this->parent->delete_by_key( $this->key ); + } + + /** + * Delete the given key from the data. + * + * @param $key + */ + public function delete_by_key( $key ) { + if ( is_array( $this->data ) ) { + unset( $this->data[ $key ] ); + } else { + unset( $this->data->$key ); + } + } + + /** + * Get an instance of the traverser for the given hierarchical key. + * + * @param array $key_path Hierarchical key path within the current data to traverse to. + * + * @throws NonExistentKeyException + * + * @return static + */ + public function traverse_to( array $key_path ) { + $current = array_shift( $key_path ); + + if ( null === $current ) { + return $this; + } + + if ( ! $this->exists( $current ) ) { + $exception = new NonExistentKeyException( "No data exists for key \"$current\"" ); + $exception->set_traverser( new static( $this->data, $current, $this->parent ) ); + throw $exception; + } + + foreach ( $this->data as $key => &$key_data ) { + if ( $key === $current ) { + $traverser = new static( $key_data, $key, $this ); + return $traverser->traverse_to( $key_path ); + } + } + } + + /** + * Create the key on the current data. + * + * @throws \UnexpectedValueException + */ + protected function create_key() { + if ( is_array( $this->data ) ) { + $this->data[ $this->key ] = null; + } elseif ( is_object( $this->data ) ) { + $this->data->{$this->key} = null; + } else { + throw new \UnexpectedValueException( sprintf( 'Cannot create key "%s" on data type %s', $this->key, gettype( $this->data ) ) ); + } + } + + /** + * Check if the given key exists on the current data. + * + * @param string $key + * + * @return bool + */ + public function exists( $key ) { + return ( is_array( $this->data ) && array_key_exists( $key, $this->data ) ) || + ( is_object( $this->data ) && property_exists( $this->data, $key ) ); + } +} diff --git a/src/WP_CLI/Entity/Utils.php b/src/WP_CLI/Entity/Utils.php new file mode 100644 index 000000000..7c2d92d6e --- /dev/null +++ b/src/WP_CLI/Entity/Utils.php @@ -0,0 +1,22 @@ +<?php + +namespace WP_CLI\Entity; + +class Utils { + + /** + * Check whether any input is passed to STDIN. + * + * @return bool + */ + public static function has_stdin() { + $handle = fopen( 'php://stdin', 'r' ); + $read = array( $handle ); + $write = null; + $except = null; + $streams = stream_select( $read, $write, $except, 0 ); + fclose( $handle ); + + return 1 === $streams; + } +} diff --git a/src/WP_CLI/Fetchers/Comment.php b/src/WP_CLI/Fetchers/Comment.php new file mode 100644 index 000000000..0cf0f9a0c --- /dev/null +++ b/src/WP_CLI/Fetchers/Comment.php @@ -0,0 +1,32 @@ +<?php + +namespace WP_CLI\Fetchers; + +/** + * Fetch a WordPress comment based on one of its attributes. + */ +class Comment extends Base { + + /** + * @var string $msg Error message to use when invalid data is provided + */ + protected $msg = "Could not find the comment with ID %d."; + + /** + * Get a comment object by ID + * + * @param int $arg + * @return object|false + */ + public function get( $arg ) { + $comment_id = (int) $arg; + $comment = get_comment( $comment_id ); + + if ( is_null( $comment ) ) { + return false; + } + + return $comment; + } +} + diff --git a/src/WP_CLI/Fetchers/Post.php b/src/WP_CLI/Fetchers/Post.php new file mode 100644 index 000000000..20b7d9855 --- /dev/null +++ b/src/WP_CLI/Fetchers/Post.php @@ -0,0 +1,25 @@ +<?php + +namespace WP_CLI\Fetchers; + +/** + * Fetch a WordPress post based on one of its attributes. + */ +class Post extends Base { + + /** + * @var string $msg Error message to use when invalid data is provided + */ + protected $msg = "Could not find the post with ID %d."; + + /** + * Get a post object by ID + * + * @param int $arg + * @return WP_Post|false + */ + public function get( $arg ) { + return get_post( $arg ); + } +} + diff --git a/src/WP_CLI/Fetchers/Site.php b/src/WP_CLI/Fetchers/Site.php new file mode 100644 index 000000000..981e52b41 --- /dev/null +++ b/src/WP_CLI/Fetchers/Site.php @@ -0,0 +1,45 @@ +<?php + +namespace WP_CLI\Fetchers; + +/** + * Fetch a WordPress site based on one of its attributes. + */ +class Site extends Base { + + /** + * @var string $msg Error message to use when invalid data is provided + */ + protected $msg = "Could not find the site with ID %d."; + + /** + * Get a site object by ID + * + * @param int $site_id + * @return object|false + */ + public function get( $site_id ) { + return $this->_get_site( $site_id ); + } + + /** + * Get site (blog) data for a given id. + * + * @param int $site_id + * @return bool|array False if no site found with given id, array otherwise + */ + private function _get_site( $site_id ) { + global $wpdb; + + // Load site data + $site = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM $wpdb->blogs WHERE blog_id = %d", $site_id ) ); + + if ( !empty( $site ) ) { + // Only care about domain and path which are set here + return $site; + } + + return false; + } +} diff --git a/src/WP_CLI/Fetchers/User.php b/src/WP_CLI/Fetchers/User.php new file mode 100644 index 000000000..eed7c4a8c --- /dev/null +++ b/src/WP_CLI/Fetchers/User.php @@ -0,0 +1,38 @@ +<?php + +namespace WP_CLI\Fetchers; + +/** + * Fetch a WordPress user based on one of its attributes. + */ +class User extends Base { + + /** + * @var string $msg Error message to use when invalid data is provided + */ + protected $msg = "Invalid user ID, email or login: '%s'"; + + /** + * Get a user object by one of its identifying attributes + * + * @param mixed $id_email_or_login + * @return WP_User|false + */ + public function get( $id_email_or_login ) { + + if ( is_numeric( $id_email_or_login ) ) { + $user = get_user_by( 'id', $id_email_or_login ); + } elseif ( is_email( $id_email_or_login ) ) { + $user = get_user_by( 'email', $id_email_or_login ); + // Logins can be emails + if ( ! $user ) { + $user = get_user_by( 'login', $id_email_or_login ); + } + } else { + $user = get_user_by( 'login', $id_email_or_login ); + } + + return $user; + } +} + diff --git a/tests/Block_Processor_HelperTest.php b/tests/Block_Processor_HelperTest.php deleted file mode 100644 index 10ef7ce7b..000000000 --- a/tests/Block_Processor_HelperTest.php +++ /dev/null @@ -1,1115 +0,0 @@ -<?php -/** - * Tests for Block_Processor_Helper class. - * - * @package WP_CLI\Entity - */ - -namespace WP_CLI\Entity\Tests; - -use PHPUnit\Framework\TestCase; -use WP_CLI\Entity\Block_Processor_Helper; - -/** - * Test the Block_Processor_Helper class. - * - * These tests verify the helper methods work correctly with the - * WP_Block_Processor polyfill. - */ -class Block_Processor_HelperTest extends TestCase { - - /** - * Sample block content for testing. - * - * @var string - */ - private $sample_content; - - /** - * Set up test fixtures. - */ - protected function setUp(): void { - parent::setUp(); - - // Create sample content with multiple blocks. - $this->sample_content = implode( - '', - [ - '<!-- wp:paragraph --><p>First paragraph</p><!-- /wp:paragraph -->', - '<!-- wp:heading {"level":2} --><h2>My Heading</h2><!-- /wp:heading -->', - '<!-- wp:paragraph --><p>Second paragraph</p><!-- /wp:paragraph -->', - '<!-- wp:image {"id":123} /-->', - ] - ); - } - - // ========================================================================= - // Tests for parse_all() - // ========================================================================= - - /** - * Test parse_all returns correct structure for simple content. - */ - public function test_parse_all_returns_correct_structure() { - $content = '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - $this->assertArrayHasKey( 'attrs', $blocks[0] ); - $this->assertArrayHasKey( 'innerBlocks', $blocks[0] ); - $this->assertArrayHasKey( 'innerHTML', $blocks[0] ); - $this->assertArrayHasKey( 'innerContent', $blocks[0] ); - } - - /** - * Test parse_all handles multiple blocks. - */ - public function test_parse_all_handles_multiple_blocks() { - $blocks = Block_Processor_Helper::parse_all( $this->sample_content ); - - $this->assertCount( 4, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - $this->assertSame( 'core/heading', $blocks[1]['blockName'] ); - $this->assertSame( 'core/paragraph', $blocks[2]['blockName'] ); - $this->assertSame( 'core/image', $blocks[3]['blockName'] ); - } - - /** - * Test parse_all handles nested blocks. - */ - public function test_parse_all_handles_nested_blocks() { - $content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --></div><!-- /wp:group -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/group', $blocks[0]['blockName'] ); - $this->assertCount( 1, $blocks[0]['innerBlocks'] ); - $this->assertSame( 'core/paragraph', $blocks[0]['innerBlocks'][0]['blockName'] ); - } - - /** - * Test parse_all handles void blocks. - */ - public function test_parse_all_handles_void_blocks() { - $content = '<!-- wp:separator /--><!-- wp:spacer {"height":"50px"} /-->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 2, $blocks ); - $this->assertSame( 'core/separator', $blocks[0]['blockName'] ); - $this->assertSame( 'core/spacer', $blocks[1]['blockName'] ); - } - - /** - * Test parse_all returns empty array for empty content. - */ - public function test_parse_all_returns_empty_for_empty_content() { - $blocks = Block_Processor_Helper::parse_all( '' ); - - $this->assertSame( [], $blocks ); - } - - /** - * Test parse_all parses attributes correctly. - */ - public function test_parse_all_parses_attributes() { - $content = '<!-- wp:heading {"level":3,"textAlign":"center"} --><h3>Title</h3><!-- /wp:heading -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 3, $blocks[0]['attrs']['level'] ); - $this->assertSame( 'center', $blocks[0]['attrs']['textAlign'] ); - } - - // ========================================================================= - // Tests for get_at_index() - // ========================================================================= - - /** - * Test get_at_index returns correct block. - */ - public function test_get_at_index_returns_correct_block() { - $block = Block_Processor_Helper::get_at_index( $this->sample_content, 1 ); - - $this->assertNotNull( $block ); - $this->assertSame( 'core/heading', $block['blockName'] ); - } - - /** - * Test get_at_index returns first block at index 0. - */ - public function test_get_at_index_returns_first_block() { - $block = Block_Processor_Helper::get_at_index( $this->sample_content, 0 ); - - $this->assertNotNull( $block ); - $this->assertSame( 'core/paragraph', $block['blockName'] ); - } - - /** - * Test get_at_index returns last block. - */ - public function test_get_at_index_returns_last_block() { - $block = Block_Processor_Helper::get_at_index( $this->sample_content, 3 ); - - $this->assertNotNull( $block ); - $this->assertSame( 'core/image', $block['blockName'] ); - } - - /** - * Test get_at_index returns null for invalid index. - */ - public function test_get_at_index_returns_null_for_invalid_index() { - $block = Block_Processor_Helper::get_at_index( $this->sample_content, 10 ); - - $this->assertNull( $block ); - } - - /** - * Test get_at_index returns null for negative index. - */ - public function test_get_at_index_returns_null_for_negative_index() { - $block = Block_Processor_Helper::get_at_index( $this->sample_content, -1 ); - - $this->assertNull( $block ); - } - - /** - * Test get_at_index returns null for empty content. - */ - public function test_get_at_index_returns_null_for_empty_content() { - $block = Block_Processor_Helper::get_at_index( '', 0 ); - - $this->assertNull( $block ); - } - - // ========================================================================= - // Tests for count_by_type() - // ========================================================================= - - /** - * Test count_by_type returns correct counts. - */ - public function test_count_by_type_returns_correct_counts() { - $counts = Block_Processor_Helper::count_by_type( $this->sample_content ); - - $this->assertSame( 2, $counts['core/paragraph'] ); - $this->assertSame( 1, $counts['core/heading'] ); - $this->assertSame( 1, $counts['core/image'] ); - } - - /** - * Test count_by_type with nested blocks excluded by default. - */ - public function test_count_by_type_excludes_nested_by_default() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - $counts = Block_Processor_Helper::count_by_type( $content ); - - $this->assertSame( 1, $counts['core/group'] ); - $this->assertArrayNotHasKey( 'core/paragraph', $counts ); - } - - /** - * Test count_by_type with nested blocks included. - */ - public function test_count_by_type_includes_nested_when_requested() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - $counts = Block_Processor_Helper::count_by_type( $content, true ); - - $this->assertSame( 1, $counts['core/group'] ); - $this->assertSame( 1, $counts['core/paragraph'] ); - } - - /** - * Test count_by_type returns empty array for empty content. - */ - public function test_count_by_type_returns_empty_for_empty_content() { - $counts = Block_Processor_Helper::count_by_type( '' ); - - $this->assertSame( [], $counts ); - } - - /** - * Test count_by_type returns empty array for plain HTML. - */ - public function test_count_by_type_returns_empty_for_plain_html() { - $counts = Block_Processor_Helper::count_by_type( '<p>Just HTML</p>' ); - - $this->assertSame( [], $counts ); - } - - // ========================================================================= - // Tests for has_block() - // ========================================================================= - - /** - * Test has_block returns true when block exists. - */ - public function test_has_block_returns_true_when_exists() { - $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'core/heading' ) ); - } - - /** - * Test has_block works with shorthand block names. - */ - public function test_has_block_works_with_shorthand() { - $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'heading' ) ); - $this->assertTrue( Block_Processor_Helper::has_block( $this->sample_content, 'paragraph' ) ); - } - - /** - * Test has_block returns false when block doesn't exist. - */ - public function test_has_block_returns_false_when_missing() { - $this->assertFalse( Block_Processor_Helper::has_block( $this->sample_content, 'core/quote' ) ); - } - - /** - * Test has_block returns false for empty content. - */ - public function test_has_block_returns_false_for_empty_content() { - $this->assertFalse( Block_Processor_Helper::has_block( '', 'paragraph' ) ); - } - - /** - * Test has_block finds nested blocks. - */ - public function test_has_block_finds_nested_blocks() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - - $this->assertTrue( Block_Processor_Helper::has_block( $content, 'paragraph' ) ); - } - - // ========================================================================= - // Tests for get_block_count() - // ========================================================================= - - /** - * Test get_block_count returns correct count. - */ - public function test_get_block_count_returns_correct_count() { - $count = Block_Processor_Helper::get_block_count( $this->sample_content ); - - $this->assertSame( 4, $count ); - } - - /** - * Test get_block_count excludes nested by default. - */ - public function test_get_block_count_excludes_nested_by_default() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - $count = Block_Processor_Helper::get_block_count( $content ); - - $this->assertSame( 1, $count ); - } - - /** - * Test get_block_count includes nested when requested. - */ - public function test_get_block_count_includes_nested_when_requested() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - $count = Block_Processor_Helper::get_block_count( $content, true ); - - $this->assertSame( 2, $count ); - } - - /** - * Test get_block_count returns 0 for empty content. - */ - public function test_get_block_count_returns_zero_for_empty() { - $count = Block_Processor_Helper::get_block_count( '' ); - - $this->assertSame( 0, $count ); - } - - // ========================================================================= - // Tests for has_blocks() - // ========================================================================= - - /** - * Test has_blocks returns true when blocks exist. - */ - public function test_has_blocks_returns_true_when_exists() { - $this->assertTrue( Block_Processor_Helper::has_blocks( $this->sample_content ) ); - } - - /** - * Test has_blocks returns false for empty content. - */ - public function test_has_blocks_returns_false_for_empty() { - $this->assertFalse( Block_Processor_Helper::has_blocks( '' ) ); - } - - /** - * Test has_blocks returns false for plain HTML. - */ - public function test_has_blocks_returns_false_for_plain_html() { - $this->assertFalse( Block_Processor_Helper::has_blocks( '<p>Just HTML</p>' ) ); - } - - // ========================================================================= - // Tests for get_block_types() - // ========================================================================= - - /** - * Test get_block_types returns unique types. - */ - public function test_get_block_types_returns_unique_types() { - $types = Block_Processor_Helper::get_block_types( $this->sample_content ); - - $this->assertContains( 'core/paragraph', $types ); - $this->assertContains( 'core/heading', $types ); - $this->assertContains( 'core/image', $types ); - $this->assertCount( 3, $types ); - } - - /** - * Test get_block_types returns empty array for empty content. - */ - public function test_get_block_types_returns_empty_for_empty() { - $types = Block_Processor_Helper::get_block_types( '' ); - - $this->assertSame( [], $types ); - } - - // ========================================================================= - // Tests for extract_matching() - // ========================================================================= - - /** - * Test extract_matching finds blocks by type. - */ - public function test_extract_matching_finds_blocks_by_type() { - $blocks = Block_Processor_Helper::extract_matching( - $this->sample_content, - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params. - function ( $type, $attrs ) { - return 'core/paragraph' === $type; - } - ); - - $this->assertCount( 2, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - $this->assertSame( 'core/paragraph', $blocks[1]['blockName'] ); - } - - /** - * Test extract_matching finds blocks by attribute. - */ - public function test_extract_matching_finds_blocks_by_attribute() { - $blocks = Block_Processor_Helper::extract_matching( - $this->sample_content, - function ( $type, $attrs ) { - return isset( $attrs['level'] ) && 2 === $attrs['level']; - } - ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/heading', $blocks[0]['blockName'] ); - } - - /** - * Test extract_matching respects limit. - */ - public function test_extract_matching_respects_limit() { - $blocks = Block_Processor_Helper::extract_matching( - $this->sample_content, - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params. - function ( $type, $attrs ) { - return 'core/paragraph' === $type; - }, - 1 - ); - - $this->assertCount( 1, $blocks ); - } - - /** - * Test extract_matching returns empty for no matches. - */ - public function test_extract_matching_returns_empty_for_no_matches() { - $blocks = Block_Processor_Helper::extract_matching( - $this->sample_content, - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Callback signature requires both params. - function ( $type, $attrs ) { - return 'core/quote' === $type; - } - ); - - $this->assertSame( [], $blocks ); - } - - // ========================================================================= - // Tests for get_block_span() - // ========================================================================= - - /** - * Test get_block_span returns correct offsets. - */ - public function test_get_block_span_returns_correct_offsets() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $span = Block_Processor_Helper::get_block_span( $content, 0 ); - - $this->assertNotNull( $span ); - $this->assertArrayHasKey( 'start', $span ); - $this->assertArrayHasKey( 'end', $span ); - $this->assertSame( 0, $span['start'] ); - $this->assertSame( strlen( $content ), $span['end'] ); - } - - /** - * Test get_block_span returns null for invalid index. - */ - public function test_get_block_span_returns_null_for_invalid_index() { - $span = Block_Processor_Helper::get_block_span( $this->sample_content, 100 ); - - $this->assertNull( $span ); - } - - /** - * Test get_block_span returns null for empty content. - */ - public function test_get_block_span_returns_null_for_empty() { - $span = Block_Processor_Helper::get_block_span( '', 0 ); - - $this->assertNull( $span ); - } - - // ========================================================================= - // Tests for strip_inner_html() - // ========================================================================= - - /** - * Test strip_inner_html removes innerHTML. - */ - public function test_strip_inner_html_removes_content() { - $blocks = [ - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerBlocks' => [], - 'innerHTML' => '<p>Test</p>', - 'innerContent' => [ '<p>Test</p>' ], - ], - ]; - - $stripped = Block_Processor_Helper::strip_inner_html( $blocks ); - - $this->assertArrayNotHasKey( 'innerHTML', $stripped[0] ); - $this->assertArrayNotHasKey( 'innerContent', $stripped[0] ); - $this->assertSame( 'core/paragraph', $stripped[0]['blockName'] ); - } - - /** - * Test strip_inner_html works recursively on nested blocks. - */ - public function test_strip_inner_html_works_recursively() { - $blocks = [ - [ - 'blockName' => 'core/group', - 'attrs' => [], - 'innerHTML' => '<div></div>', - 'innerContent' => [ '<div>', '</div>' ], - 'innerBlocks' => [ - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerHTML' => '<p>Inner</p>', - 'innerContent' => [ '<p>Inner</p>' ], - 'innerBlocks' => [], - ], - ], - ], - ]; - - $stripped = Block_Processor_Helper::strip_inner_html( $blocks ); - - $this->assertArrayNotHasKey( 'innerHTML', $stripped[0] ); - $this->assertArrayNotHasKey( 'innerHTML', $stripped[0]['innerBlocks'][0] ); - } - - // ========================================================================= - // Tests for filter_empty_blocks() - // ========================================================================= - - /** - * Test filter_empty_blocks removes null blockName entries. - */ - public function test_filter_empty_blocks_removes_empty() { - $blocks = [ - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - ], - [ - 'blockName' => null, - 'attrs' => [], - ], - [ - 'blockName' => 'core/heading', - 'attrs' => [], - ], - [ - 'blockName' => '', - 'attrs' => [], - ], - ]; - - $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks ); - - $this->assertCount( 2, $filtered ); - $this->assertSame( 'core/paragraph', $filtered[0]['blockName'] ); - $this->assertSame( 'core/heading', $filtered[1]['blockName'] ); - } - - /** - * Test filter_empty_blocks re-indexes array. - */ - public function test_filter_empty_blocks_reindexes() { - $blocks = [ - 0 => [ 'blockName' => null ], - 1 => [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - ], - ]; - - $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks ); - - $this->assertArrayHasKey( 0, $filtered ); - $this->assertArrayNotHasKey( 1, $filtered ); - } - - // ========================================================================= - // Edge case tests - // ========================================================================= - - /** - * Test handling of deeply nested blocks. - */ - public function test_deeply_nested_blocks() { - $content = '<!-- wp:group --><!-- wp:group --><!-- wp:group --><!-- wp:paragraph --><p>Deep</p><!-- /wp:paragraph --><!-- /wp:group --><!-- /wp:group --><!-- /wp:group -->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/group', $blocks[0]['blockName'] ); - - // Navigate to deepest block. - $inner = $blocks[0]['innerBlocks'][0]['innerBlocks'][0]['innerBlocks'][0]; - $this->assertSame( 'core/paragraph', $inner['blockName'] ); - } - - /** - * Test handling of blocks with complex JSON attributes. - */ - public function test_complex_json_attributes() { - $content = '<!-- wp:gallery {"ids":[1,2,3],"columns":3,"linkTo":"media","nested":{"key":"value"}} /-->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( [ 1, 2, 3 ], $blocks[0]['attrs']['ids'] ); - $this->assertSame( 3, $blocks[0]['attrs']['columns'] ); - $this->assertSame( 'media', $blocks[0]['attrs']['linkTo'] ); - $this->assertSame( [ 'key' => 'value' ], $blocks[0]['attrs']['nested'] ); - } - - /** - * Test handling of custom namespaced blocks. - */ - public function test_custom_namespace_blocks() { - $content = '<!-- wp:my-plugin/custom-block {"option":"value"} /-->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'my-plugin/custom-block', $blocks[0]['blockName'] ); - $this->assertSame( 'value', $blocks[0]['attrs']['option'] ); - } - - // ========================================================================= - // Serialization Round-Trip Tests (Task 1) - // ========================================================================= - - /** - * Normalize block structure for comparison (remove keys that may differ). - * - * @param array $blocks Blocks to normalize. - * @return array Normalized blocks. - */ - private function normalize_for_comparison( array $blocks ) { - return array_map( - function ( $block ) { - return [ - 'blockName' => $block['blockName'], - 'attrs' => $block['attrs'] ?? [], - 'innerBlocks' => isset( $block['innerBlocks'] ) - ? $this->normalize_for_comparison( $block['innerBlocks'] ) - : [], - ]; - }, - $blocks - ); - } - - /** - * Serialize an array of blocks to string (test helper). - * - * @param array $blocks Blocks to serialize. - * @return string Serialized content. - */ - private function serialize_blocks_for_test( array $blocks ) { - $output = ''; - foreach ( $blocks as $block ) { - $output .= $this->serialize_block_for_test( $block ); - } - return $output; - } - - /** - * Serialize a single block to string (test helper). - * - * @param array $block Block to serialize. - * @return string Serialized content. - */ - private function serialize_block_for_test( array $block ) { - $block_name = $block['blockName'] ?? null; - - if ( empty( $block_name ) ) { - return $block['innerHTML'] ?? ''; - } - - $name = $block_name; - if ( 0 === strpos( $name, 'core/' ) ) { - $name = substr( $name, 5 ); - } - - $attrs = ''; - if ( ! empty( $block['attrs'] ) ) { - $attrs = ' ' . json_encode( $block['attrs'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); - } - - if ( empty( $block['innerContent'] ) ) { - return "<!-- wp:{$name}{$attrs} /-->"; - } - - $output = "<!-- wp:{$name}{$attrs} -->"; - $inner_index = 0; - foreach ( $block['innerContent'] as $chunk ) { - if ( null === $chunk ) { - if ( isset( $block['innerBlocks'][ $inner_index ] ) ) { - $output .= $this->serialize_block_for_test( $block['innerBlocks'][ $inner_index ] ); - ++$inner_index; - } - } else { - $output .= $chunk; - } - } - $output .= "<!-- /wp:{$name} -->"; - - return $output; - } - - /** - * Test that parsed blocks can be serialized and re-parsed consistently. - */ - public function test_serialization_roundtrip_simple_paragraph() { - $original = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $parsed = Block_Processor_Helper::parse_all( $original ); - $serialized = $this->serialize_blocks_for_test( $parsed ); - $reparsed = Block_Processor_Helper::parse_all( $serialized ); - - $this->assertEquals( - $this->normalize_for_comparison( $parsed ), - $this->normalize_for_comparison( $reparsed ) - ); - } - - /** - * Test serialization round-trip with attributes. - */ - public function test_serialization_roundtrip_with_attributes() { - $original = '<!-- wp:heading {"level":3,"textAlign":"center"} --><h3 class="has-text-align-center">Title</h3><!-- /wp:heading -->'; - $parsed = Block_Processor_Helper::parse_all( $original ); - $serialized = $this->serialize_blocks_for_test( $parsed ); - $reparsed = Block_Processor_Helper::parse_all( $serialized ); - - $this->assertEquals( - $this->normalize_for_comparison( $parsed ), - $this->normalize_for_comparison( $reparsed ) - ); - } - - /** - * Test serialization round-trip with nested blocks. - */ - public function test_serialization_roundtrip_nested_blocks() { - $original = '<!-- wp:group {"className":"test"} --><div class="wp-block-group test"><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --></div><!-- /wp:group -->'; - $parsed = Block_Processor_Helper::parse_all( $original ); - $serialized = $this->serialize_blocks_for_test( $parsed ); - $reparsed = Block_Processor_Helper::parse_all( $serialized ); - - $this->assertEquals( - $this->normalize_for_comparison( $parsed ), - $this->normalize_for_comparison( $reparsed ) - ); - } - - /** - * Test serialization round-trip with void blocks. - */ - public function test_serialization_roundtrip_void_blocks() { - $original = '<!-- wp:separator {"className":"is-style-wide"} /-->'; - $parsed = Block_Processor_Helper::parse_all( $original ); - $serialized = $this->serialize_blocks_for_test( $parsed ); - $reparsed = Block_Processor_Helper::parse_all( $serialized ); - - $this->assertEquals( - $this->normalize_for_comparison( $parsed ), - $this->normalize_for_comparison( $reparsed ) - ); - } - - /** - * Test serialization round-trip with multiple blocks. - */ - public function test_serialization_roundtrip_multiple_blocks() { - $original = '<!-- wp:paragraph --><p>One</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Two</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Three</h2><!-- /wp:heading -->'; - $parsed = Block_Processor_Helper::parse_all( $original ); - $serialized = $this->serialize_blocks_for_test( $parsed ); - $reparsed = Block_Processor_Helper::parse_all( $serialized ); - - $this->assertEquals( - $this->normalize_for_comparison( $parsed ), - $this->normalize_for_comparison( $reparsed ) - ); - } - - // ========================================================================= - // Malformed Content Handling Tests (Task 2) - // ========================================================================= - - /** - * Test handling of unclosed block (missing closer). - */ - public function test_parse_all_handles_unclosed_block() { - $content = '<!-- wp:paragraph --><p>No closing tag'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - // Should not throw, may return empty or partial. - $this->assertIsArray( $blocks ); - } - - /** - * Test handling of orphaned closer (no opener). - */ - public function test_parse_all_handles_orphaned_closer() { - $content = '<p>Some text</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - /** - * Test handling of mismatched block types. - */ - public function test_parse_all_handles_mismatched_blocks() { - $content = '<!-- wp:paragraph --><p>Text</p><!-- /wp:heading -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - /** - * Test handling of invalid JSON in attributes. - */ - public function test_parse_all_handles_invalid_json_attrs() { - $content = '<!-- wp:paragraph {"broken: json} --><p>Test</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - /** - * Test handling of truncated block comment. - */ - public function test_parse_all_handles_truncated_comment() { - $content = '<!-- wp:paragr'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - $this->assertCount( 0, $blocks ); - } - - /** - * Test handling of empty block name. - */ - public function test_parse_all_handles_empty_block_name() { - $content = '<!-- wp: --><p>Test</p><!-- /wp: -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - /** - * Test handling of block with only whitespace content. - */ - public function test_parse_all_handles_whitespace_only_content() { - $content = "<!-- wp:paragraph --> \n\n <!-- /wp:paragraph -->"; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - } - - /** - * Test handling of deeply nested unclosed blocks. - */ - public function test_parse_all_handles_nested_unclosed() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Unclosed nesting'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - // ========================================================================= - // Unicode and Special Character Tests (Task 3) - // ========================================================================= - - /** - * Test handling of emoji in attributes. - */ - public function test_parse_all_handles_emoji_in_attrs() { - $content = '<!-- wp:paragraph {"emoji":"🎉🚀💻"} --><p>Test</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( '🎉🚀💻', $blocks[0]['attrs']['emoji'] ); - } - - /** - * Test handling of Chinese characters in attributes. - */ - public function test_parse_all_handles_chinese_chars() { - $content = '<!-- wp:paragraph {"text":"中文测试"} --><p>中文测试</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( '中文测试', $blocks[0]['attrs']['text'] ); - } - - /** - * Test handling of RTL characters (Arabic/Hebrew). - */ - public function test_parse_all_handles_rtl_chars() { - $content = '<!-- wp:paragraph {"text":"مرحبا بالعالم"} --><p>مرحبا بالعالم</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'مرحبا بالعالم', $blocks[0]['attrs']['text'] ); - } - - /** - * Test handling of escaped quotes in attributes. - */ - public function test_parse_all_handles_escaped_quotes() { - $content = '<!-- wp:paragraph {"text":"He said \"hello\""} --><p>Test</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'He said "hello"', $blocks[0]['attrs']['text'] ); - } - - /** - * Test handling of newlines in attributes. - */ - public function test_parse_all_handles_newlines_in_attrs() { - $content = '<!-- wp:paragraph {"text":"Line1\\nLine2"} --><p>Test</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertStringContainsString( "\n", $blocks[0]['attrs']['text'] ); - } - - /** - * Test handling of HTML entities in content. - */ - public function test_parse_all_handles_html_entities() { - $content = '<!-- wp:paragraph --><p><script>alert("xss")</script></p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertStringContainsString( '<script>', $blocks[0]['innerHTML'] ); - } - - /** - * Test handling of null bytes (should be stripped or handled). - */ - public function test_parse_all_handles_null_bytes() { - $content = "<!-- wp:paragraph --><p>Test\x00with\x00nulls</p><!-- /wp:paragraph -->"; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertIsArray( $blocks ); - } - - // ========================================================================= - // Deep Nesting Tests (Task 4) - // ========================================================================= - - /** - * Test handling of 10-level deep nesting. - */ - public function test_parse_all_handles_10_level_nesting() { - $depth = 10; - $content = str_repeat( '<!-- wp:group --><div class="wp-block-group">', $depth ); - $content .= '<!-- wp:paragraph --><p>Deep content</p><!-- /wp:paragraph -->'; - $content .= str_repeat( '</div><!-- /wp:group -->', $depth ); - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/group', $blocks[0]['blockName'] ); - - // Navigate to deepest block. - $current = $blocks[0]; - for ( $i = 1; $i < $depth; $i++ ) { - $this->assertNotEmpty( $current['innerBlocks'] ); - $current = $current['innerBlocks'][0]; - } - $this->assertSame( 'core/paragraph', $current['innerBlocks'][0]['blockName'] ); - } - - /** - * Test handling of 50-level deep nesting (stress test). - */ - public function test_parse_all_handles_50_level_nesting() { - $depth = 50; - $content = str_repeat( '<!-- wp:group -->', $depth ); - $content .= '<!-- wp:paragraph --><p>Very deep</p><!-- /wp:paragraph -->'; - $content .= str_repeat( '<!-- /wp:group -->', $depth ); - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - } - - /** - * Test handling of wide nesting (many siblings at each level). - */ - public function test_parse_all_handles_wide_nesting() { - $siblings = 20; - $content = '<!-- wp:group --><div class="wp-block-group">'; - for ( $i = 0; $i < $siblings; $i++ ) { - $content .= "<!-- wp:paragraph --><p>Paragraph {$i}</p><!-- /wp:paragraph -->"; - } - $content .= '</div><!-- /wp:group -->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertCount( $siblings, $blocks[0]['innerBlocks'] ); - } - - /** - * Test handling of mixed nesting patterns. - */ - public function test_parse_all_handles_mixed_nesting() { - $content = '<!-- wp:columns --><div class="wp-block-columns">'; - $content .= '<!-- wp:column --><div class="wp-block-column">'; - $content .= '<!-- wp:group --><div class="wp-block-group">'; - $content .= '<!-- wp:paragraph --><p>Col1</p><!-- /wp:paragraph -->'; - $content .= '</div><!-- /wp:group -->'; - $content .= '</div><!-- /wp:column -->'; - $content .= '<!-- wp:column --><div class="wp-block-column">'; - $content .= '<!-- wp:heading --><h2>Col2</h2><!-- /wp:heading -->'; - $content .= '</div><!-- /wp:column -->'; - $content .= '</div><!-- /wp:columns -->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/columns', $blocks[0]['blockName'] ); - $this->assertCount( 2, $blocks[0]['innerBlocks'] ); // 2 columns. - } - - // ========================================================================= - // Freeform Content Handling Tests (Task 8) - // ========================================================================= - - /** - * Test that parse_all skips pure freeform HTML content. - */ - public function test_parse_all_skips_freeform_html() { - $content = '<p>Just HTML, no block markers</p>'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 0, $blocks ); - } - - /** - * Test that parse_all skips freeform content between blocks. - */ - public function test_parse_all_skips_freeform_between_blocks() { - $content = '<!-- wp:paragraph --><p>Block 1</p><!-- /wp:paragraph -->'; - $content .= '<p>Freeform between</p>'; - $content .= '<!-- wp:paragraph --><p>Block 2</p><!-- /wp:paragraph -->'; - - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 2, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - $this->assertSame( 'core/paragraph', $blocks[1]['blockName'] ); - } - - /** - * Test that parse_all skips freeform content before first block. - */ - public function test_parse_all_skips_leading_freeform() { - $content = '<p>Leading freeform</p><!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph -->'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - } - - /** - * Test that parse_all skips freeform content after last block. - */ - public function test_parse_all_skips_trailing_freeform() { - $content = '<!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph --><p>Trailing freeform</p>'; - $blocks = Block_Processor_Helper::parse_all( $content ); - - $this->assertCount( 1, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - } - - /** - * Document: filter_empty_blocks removes freeform blocks from array. - */ - public function test_filter_empty_blocks_removes_freeform() { - $blocks = [ - [ - 'blockName' => null, - 'innerHTML' => '<p>Freeform</p>', - ], - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - ], - [ - 'blockName' => null, - 'innerHTML' => 'Whitespace', - ], - [ - 'blockName' => 'core/heading', - 'attrs' => [], - ], - ]; - - $filtered = Block_Processor_Helper::filter_empty_blocks( $blocks ); - - $this->assertCount( 2, $filtered ); - $this->assertSame( 'core/paragraph', $filtered[0]['blockName'] ); - $this->assertSame( 'core/heading', $filtered[1]['blockName'] ); - } -} diff --git a/tests/Compat/PolyfillsTest.php b/tests/Compat/PolyfillsTest.php deleted file mode 100644 index a4ed64271..000000000 --- a/tests/Compat/PolyfillsTest.php +++ /dev/null @@ -1,127 +0,0 @@ -<?php -/** - * Tests for PHP function polyfills. - * - * @package WP_CLI\Entity\Compat - */ - -namespace WP_CLI\Entity\Tests\Compat; - -use PHPUnit\Framework\TestCase; - -/** - * Test the PHP function polyfills. - * - * Note: These tests verify the polyfill behavior. On PHP 8.0+, the native - * functions will be used instead, but they should behave identically. - */ -class PolyfillsTest extends TestCase { - - /** - * Test str_ends_with with matching suffix. - */ - public function test_str_ends_with_returns_true_for_matching_suffix() { - $this->assertTrue( str_ends_with( 'hello world', 'world' ) ); - $this->assertTrue( str_ends_with( 'hello world', 'd' ) ); - $this->assertTrue( str_ends_with( 'hello world', 'hello world' ) ); - } - - /** - * Test str_ends_with with non-matching suffix. - */ - public function test_str_ends_with_returns_false_for_non_matching_suffix() { - $this->assertFalse( str_ends_with( 'hello world', 'hello' ) ); - $this->assertFalse( str_ends_with( 'hello world', 'World' ) ); // Case sensitive. - $this->assertFalse( str_ends_with( 'hello world', 'xyz' ) ); - } - - /** - * Test str_ends_with with empty needle. - */ - public function test_str_ends_with_returns_true_for_empty_needle() { - $this->assertTrue( str_ends_with( 'hello world', '' ) ); - $this->assertTrue( str_ends_with( '', '' ) ); - } - - /** - * Test str_ends_with with empty haystack. - */ - public function test_str_ends_with_handles_empty_haystack() { - $this->assertFalse( str_ends_with( '', 'hello' ) ); - } - - /** - * Test str_ends_with with needle longer than haystack. - */ - public function test_str_ends_with_handles_needle_longer_than_haystack() { - $this->assertFalse( str_ends_with( 'hi', 'hello world' ) ); - } - - /** - * Test str_ends_with with special characters. - */ - public function test_str_ends_with_handles_special_characters() { - $this->assertTrue( str_ends_with( '<!-- wp:paragraph -->', '-->' ) ); - $this->assertTrue( str_ends_with( '<!-- wp:paragraph -->', '<!-- wp:paragraph -->' ) ); - $this->assertTrue( str_ends_with( 'test<!-', '<!-' ) ); - $this->assertTrue( str_ends_with( 'test<', '<' ) ); - } - - /** - * Test str_starts_with with matching prefix. - */ - public function test_str_starts_with_returns_true_for_matching_prefix() { - $this->assertTrue( str_starts_with( 'hello world', 'hello' ) ); - $this->assertTrue( str_starts_with( 'hello world', 'h' ) ); - $this->assertTrue( str_starts_with( 'hello world', 'hello world' ) ); - } - - /** - * Test str_starts_with with non-matching prefix. - */ - public function test_str_starts_with_returns_false_for_non_matching_prefix() { - $this->assertFalse( str_starts_with( 'hello world', 'world' ) ); - $this->assertFalse( str_starts_with( 'hello world', 'Hello' ) ); // Case sensitive. - } - - /** - * Test str_starts_with with empty needle. - */ - public function test_str_starts_with_returns_true_for_empty_needle() { - $this->assertTrue( str_starts_with( 'hello world', '' ) ); - $this->assertTrue( str_starts_with( '', '' ) ); - } - - /** - * Test str_contains with matching substring. - */ - public function test_str_contains_returns_true_for_matching_substring() { - $this->assertTrue( str_contains( 'hello world', 'world' ) ); - $this->assertTrue( str_contains( 'hello world', 'hello' ) ); - $this->assertTrue( str_contains( 'hello world', 'o w' ) ); - $this->assertTrue( str_contains( 'hello world', 'hello world' ) ); - } - - /** - * Test str_contains with non-matching substring. - */ - public function test_str_contains_returns_false_for_non_matching_substring() { - $this->assertFalse( str_contains( 'hello world', 'xyz' ) ); - $this->assertFalse( str_contains( 'hello world', 'World' ) ); // Case sensitive. - } - - /** - * Test str_contains with empty needle. - */ - public function test_str_contains_returns_true_for_empty_needle() { - $this->assertTrue( str_contains( 'hello world', '' ) ); - $this->assertTrue( str_contains( '', '' ) ); - } - - /** - * Test str_contains with empty haystack. - */ - public function test_str_contains_handles_empty_haystack() { - $this->assertFalse( str_contains( '', 'hello' ) ); - } -} diff --git a/tests/Compat/WP_Block_ProcessorTest.php b/tests/Compat/WP_Block_ProcessorTest.php deleted file mode 100644 index c5d70731c..000000000 --- a/tests/Compat/WP_Block_ProcessorTest.php +++ /dev/null @@ -1,621 +0,0 @@ -<?php -/** - * Tests for WP_Block_Processor polyfill. - * - * @package WP_CLI\Entity\Compat - */ - -namespace WP_CLI\Entity\Tests\Compat; - -use PHPUnit\Framework\TestCase; -use WP_Block_Processor; -use WP_HTML_Span; - -/** - * Test the WP_Block_Processor polyfill class. - * - * These tests verify that the polyfill behaves identically to the native - * WordPress implementation. - */ -class WP_Block_ProcessorTest extends TestCase { - - /** - * Test next_block finds a simple paragraph block. - */ - public function test_next_block_finds_paragraph() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $this->assertTrue( $processor->next_block() ); - $this->assertSame( 'core/paragraph', $processor->get_block_type() ); - } - - /** - * Test next_block finds void blocks. - */ - public function test_next_block_finds_void_block() { - $content = '<!-- wp:spacer {"height":"50px"} /-->'; - $processor = new WP_Block_Processor( $content ); - - $this->assertTrue( $processor->next_block() ); - $this->assertSame( 'core/spacer', $processor->get_block_type() ); - $this->assertSame( WP_Block_Processor::VOID, $processor->get_delimiter_type() ); - } - - /** - * Test next_block with specific block type filter. - */ - public function test_next_block_with_type_filter() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->'; - $processor = new WP_Block_Processor( $content ); - - $this->assertTrue( $processor->next_block( 'heading' ) ); - $this->assertSame( 'core/heading', $processor->get_block_type() ); - } - - /** - * Test next_block returns false when no blocks found. - */ - public function test_next_block_returns_false_for_no_blocks() { - $content = '<p>Just regular HTML</p>'; - $processor = new WP_Block_Processor( $content ); - - $this->assertFalse( $processor->next_block() ); - } - - /** - * Test next_block skips closers. - */ - public function test_next_block_skips_closers() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- wp:heading --><h2>Title</h2><!-- /wp:heading -->'; - $processor = new WP_Block_Processor( $content ); - - // First block is paragraph. - $this->assertTrue( $processor->next_block() ); - $this->assertSame( 'core/paragraph', $processor->get_block_type() ); - - // Second block is heading (skips the paragraph closer). - $this->assertTrue( $processor->next_block() ); - $this->assertSame( 'core/heading', $processor->get_block_type() ); - - // No more blocks. - $this->assertFalse( $processor->next_block() ); - } - - /** - * Test get_block_type returns null for freeform content. - */ - public function test_get_block_type_returns_null_for_freeform() { - $content = 'Just text'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_token(); - - $this->assertNull( $processor->get_block_type() ); - $this->assertTrue( $processor->is_html() ); - } - - /** - * Test get_block_type normalizes implicit core namespace. - */ - public function test_get_block_type_normalizes_namespace() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - - $this->assertSame( 'core/paragraph', $processor->get_block_type() ); - } - - /** - * Test get_block_type preserves custom namespace. - */ - public function test_get_block_type_preserves_custom_namespace() { - $content = '<!-- wp:my-plugin/custom-block /-->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - - $this->assertSame( 'my-plugin/custom-block', $processor->get_block_type() ); - } - - /** - * Test extract_full_block_and_advance returns correct structure. - */ - public function test_extract_full_block_returns_correct_structure() { - $content = '<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - $block = $processor->extract_full_block_and_advance(); - - $this->assertIsArray( $block ); - $this->assertSame( 'core/paragraph', $block['blockName'] ); - $this->assertIsArray( $block['attrs'] ); - $this->assertIsArray( $block['innerBlocks'] ); - $this->assertArrayHasKey( 'innerHTML', $block ); - $this->assertArrayHasKey( 'innerContent', $block ); - $this->assertSame( '<p>Hello World</p>', $block['innerHTML'] ); - } - - /** - * Test extract_full_block_and_advance handles nested blocks. - */ - public function test_extract_full_block_handles_nested_blocks() { - $content = '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --></div><!-- /wp:group -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - $block = $processor->extract_full_block_and_advance(); - - $this->assertSame( 'core/group', $block['blockName'] ); - $this->assertCount( 1, $block['innerBlocks'] ); - $this->assertSame( 'core/paragraph', $block['innerBlocks'][0]['blockName'] ); - } - - /** - * Test allocate_and_return_parsed_attributes parses JSON correctly. - */ - public function test_allocate_and_return_parsed_attributes_parses_json() { - $content = '<!-- wp:heading {"level":2,"textAlign":"center"} --><h2>Title</h2><!-- /wp:heading -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - $attrs = $processor->allocate_and_return_parsed_attributes(); - - $this->assertIsArray( $attrs ); - $this->assertSame( 2, $attrs['level'] ); - $this->assertSame( 'center', $attrs['textAlign'] ); - } - - /** - * Test allocate_and_return_parsed_attributes returns null for void blocks without attrs. - */ - public function test_allocate_and_return_parsed_attributes_returns_null_without_json() { - $content = '<!-- wp:separator /-->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - $attrs = $processor->allocate_and_return_parsed_attributes(); - - $this->assertNull( $attrs ); - } - - /** - * Test get_span returns correct byte offsets. - */ - public function test_get_span_returns_correct_offsets() { - $content = 'Before<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - $span = $processor->get_span(); - - $this->assertInstanceOf( WP_HTML_Span::class, $span ); - $this->assertSame( 6, $span->start ); // After "Before". - $this->assertGreaterThan( 0, $span->length ); - } - - /** - * Test get_depth tracks nesting correctly. - */ - public function test_get_depth_tracks_nesting() { - $content = '<!-- wp:group --><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --><!-- /wp:group -->'; - $processor = new WP_Block_Processor( $content ); - - // Before anything. - $this->assertSame( 0, $processor->get_depth() ); - - // After entering group. - $processor->next_block(); - $this->assertSame( 1, $processor->get_depth() ); - - // After entering paragraph. - $processor->next_block(); - $this->assertSame( 2, $processor->get_depth() ); - } - - /** - * Test get_breadcrumbs returns open block hierarchy. - */ - public function test_get_breadcrumbs_returns_hierarchy() { - $content = '<!-- wp:group --><!-- wp:columns --><!-- wp:column --><!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- /wp:column --><!-- /wp:columns --><!-- /wp:group -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); // group - $processor->next_block(); // columns - $processor->next_block(); // column - $processor->next_block(); // paragraph - - $breadcrumbs = $processor->get_breadcrumbs(); - - $this->assertSame( - array( 'core/group', 'core/columns', 'core/column', 'core/paragraph' ), - $breadcrumbs - ); - } - - /** - * Test is_block_type with wildcard. - */ - public function test_is_block_type_with_wildcard() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - - $this->assertTrue( $processor->is_block_type( '*' ) ); - } - - /** - * Test is_block_type with shorthand name. - */ - public function test_is_block_type_with_shorthand() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - - $this->assertTrue( $processor->is_block_type( 'paragraph' ) ); - $this->assertTrue( $processor->is_block_type( 'core/paragraph' ) ); - } - - /** - * Test opens_block detects block openers. - */ - public function test_opens_block_detects_openers() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_block(); - - $this->assertTrue( $processor->opens_block() ); - $this->assertTrue( $processor->opens_block( 'paragraph' ) ); - $this->assertFalse( $processor->opens_block( 'heading' ) ); - } - - /** - * Test get_delimiter_type returns correct types. - */ - public function test_get_delimiter_type_returns_correct_types() { - $content = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_delimiter(); - $this->assertSame( WP_Block_Processor::OPENER, $processor->get_delimiter_type() ); - - $processor->next_delimiter(); - $this->assertSame( WP_Block_Processor::CLOSER, $processor->get_delimiter_type() ); - } - - /** - * Test normalize_block_type static method. - */ - public function test_normalize_block_type() { - $this->assertSame( 'core/paragraph', WP_Block_Processor::normalize_block_type( 'paragraph' ) ); - $this->assertSame( 'core/paragraph', WP_Block_Processor::normalize_block_type( 'core/paragraph' ) ); - $this->assertSame( 'my/block', WP_Block_Processor::normalize_block_type( 'my/block' ) ); - } - - /** - * Test empty content handling. - */ - public function test_empty_content_handling() { - $processor = new WP_Block_Processor( '' ); - - $this->assertFalse( $processor->next_block() ); - $this->assertFalse( $processor->next_token() ); - } - - /** - * Test multiple blocks iteration. - */ - public function test_multiple_blocks_iteration() { - $content = '<!-- wp:paragraph --><p>One</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Two</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>Three</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $count = 0; - while ( $processor->next_block() ) { - ++$count; - } - - $this->assertSame( 3, $count ); - } - - /** - * Test deeply nested blocks. - */ - public function test_deeply_nested_blocks() { - $content = '<!-- wp:group --><!-- wp:group --><!-- wp:group --><!-- wp:paragraph --><p>Deep</p><!-- /wp:paragraph --><!-- /wp:group --><!-- /wp:group --><!-- /wp:group -->'; - $processor = new WP_Block_Processor( $content ); - - // Navigate to the deepest block. - $processor->next_block(); // First group. - $processor->next_block(); // Second group. - $processor->next_block(); // Third group. - $processor->next_block(); // Paragraph. - - $this->assertSame( 4, $processor->get_depth() ); - $this->assertSame( 'core/paragraph', $processor->get_block_type() ); - } - - /** - * Test freeform HTML content detection. - */ - public function test_freeform_html_content() { - $content = '<p>Freeform HTML</p><!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - // First token should be HTML. - $processor->next_token(); - $this->assertTrue( $processor->is_html() ); - - // Second token should be the paragraph opener. - $processor->next_token(); - $this->assertFalse( $processor->is_html() ); - $this->assertSame( 'core/paragraph', $processor->get_block_type() ); - } - - /** - * Test is_non_whitespace_html distinguishes content. - */ - public function test_is_non_whitespace_html() { - $content = "\n\n<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->\n\n"; - $processor = new WP_Block_Processor( $content ); - - // First token is whitespace HTML. - $processor->next_token(); - $this->assertTrue( $processor->is_html() ); - $this->assertFalse( $processor->is_non_whitespace_html() ); - } - - /** - * Test get_html_content returns correct content. - */ - public function test_get_html_content() { - $content = '<p>Freeform</p><!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph -->'; - $processor = new WP_Block_Processor( $content ); - - $processor->next_token(); - $this->assertSame( '<p>Freeform</p>', $processor->get_html_content() ); - } - - // ========================================================================= - // Additional Polyfill Verification Tests (Task 9) - // ========================================================================= - - /** - * Test next_block with type parameter filters correctly. - */ - public function test_next_block_type_filter_with_namespace() { - $content = '<!-- wp:paragraph --><p>Skip</p><!-- /wp:paragraph -->'; - $content .= '<!-- wp:heading --><h2>Find</h2><!-- /wp:heading -->'; - - $processor = new WP_Block_Processor( $content ); - - // Should skip paragraph and find heading. - $this->assertTrue( $processor->next_block( 'core/heading' ) ); - $this->assertSame( 'core/heading', $processor->get_block_type() ); - } - - /** - * Test next_block with shorthand type name. - */ - public function test_next_block_type_filter_shorthand() { - $content = '<!-- wp:paragraph --><p>Skip</p><!-- /wp:paragraph -->'; - $content .= '<!-- wp:heading --><h2>Find</h2><!-- /wp:heading -->'; - - $processor = new WP_Block_Processor( $content ); - - // Shorthand should also work. - $this->assertTrue( $processor->next_block( 'heading' ) ); - $this->assertSame( 'core/heading', $processor->get_block_type() ); - } - - /** - * Test get_breadcrumbs returns correct path. - */ - public function test_get_breadcrumbs_nested() { - $content = '<!-- wp:group --><!-- wp:columns --><!-- wp:column --><!-- wp:paragraph --><p>Deep</p><!-- /wp:paragraph --><!-- /wp:column --><!-- /wp:columns --><!-- /wp:group -->'; - - $processor = new WP_Block_Processor( $content ); - - // Navigate to paragraph. - while ( $processor->next_block() ) { - if ( 'core/paragraph' === $processor->get_block_type() ) { - break; - } - } - - $breadcrumbs = $processor->get_breadcrumbs(); - - $this->assertContains( 'core/group', $breadcrumbs ); - $this->assertContains( 'core/columns', $breadcrumbs ); - $this->assertContains( 'core/column', $breadcrumbs ); - } - - /** - * Test is_block_type with custom namespace. - */ - public function test_is_block_type_custom_namespace() { - $content = '<!-- wp:my-plugin/custom-block /-->'; - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $this->assertTrue( $processor->is_block_type( 'my-plugin/custom-block' ) ); - $this->assertFalse( $processor->is_block_type( 'other-plugin/custom-block' ) ); - $this->assertFalse( $processor->is_block_type( 'my-plugin/other-block' ) ); - } - - /** - * Test extract_full_block_and_advance returns correct structure. - */ - public function test_extract_full_block_structure() { - $content = '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center">Test</p><!-- /wp:paragraph -->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $block = $processor->extract_full_block_and_advance(); - - $this->assertArrayHasKey( 'blockName', $block ); - $this->assertArrayHasKey( 'attrs', $block ); - $this->assertArrayHasKey( 'innerBlocks', $block ); - $this->assertArrayHasKey( 'innerHTML', $block ); - $this->assertArrayHasKey( 'innerContent', $block ); - - $this->assertSame( 'core/paragraph', $block['blockName'] ); - $this->assertSame( 'center', $block['attrs']['align'] ); - $this->assertEmpty( $block['innerBlocks'] ); - $this->assertStringContainsString( 'Test', $block['innerHTML'] ); - } - - /** - * Test extract_full_block_and_advance with nested blocks. - */ - public function test_extract_full_block_with_nested() { - $content = '<!-- wp:group --><div><!-- wp:paragraph --><p>Inner</p><!-- /wp:paragraph --></div><!-- /wp:group -->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $block = $processor->extract_full_block_and_advance(); - - $this->assertSame( 'core/group', $block['blockName'] ); - $this->assertCount( 1, $block['innerBlocks'] ); - $this->assertSame( 'core/paragraph', $block['innerBlocks'][0]['blockName'] ); - } - - /** - * Test handling of complex nested attributes. - */ - public function test_complex_nested_attributes() { - $content = '<!-- wp:gallery {"ids":[1,2,3],"nested":{"deep":{"value":true}}} /-->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $attrs = $processor->allocate_and_return_parsed_attributes(); - - $this->assertSame( [ 1, 2, 3 ], $attrs['ids'] ); - $this->assertSame( true, $attrs['nested']['deep']['value'] ); - } - - /** - * Test that opener detection works correctly. - */ - public function test_opens_block_with_specific_type() { - $content = '<!-- wp:heading {"level":2} --><h2>Title</h2><!-- /wp:heading -->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $this->assertTrue( $processor->opens_block() ); - $this->assertTrue( $processor->opens_block( 'heading' ) ); - $this->assertTrue( $processor->opens_block( 'core/heading' ) ); - $this->assertFalse( $processor->opens_block( 'paragraph' ) ); - } - - /** - * Test multiple sequential blocks with extraction. - */ - public function test_multiple_blocks_extraction() { - $content = '<!-- wp:paragraph --><p>One</p><!-- /wp:paragraph -->'; - $content .= '<!-- wp:heading --><h2>Two</h2><!-- /wp:heading -->'; - $content .= '<!-- wp:paragraph --><p>Three</p><!-- /wp:paragraph -->'; - - $processor = new WP_Block_Processor( $content ); - $blocks = []; - - while ( $processor->next_block() ) { - $blocks[] = $processor->extract_full_block_and_advance(); - } - - $this->assertCount( 3, $blocks ); - $this->assertSame( 'core/paragraph', $blocks[0]['blockName'] ); - $this->assertSame( 'core/heading', $blocks[1]['blockName'] ); - $this->assertSame( 'core/paragraph', $blocks[2]['blockName'] ); - } - - /** - * Test depth tracking with multiple nested levels. - */ - public function test_depth_tracking_complex() { - $content = '<!-- wp:group --><!-- wp:columns --><!-- wp:column --><!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph --><!-- /wp:column --><!-- /wp:columns --><!-- /wp:group -->'; - - $processor = new WP_Block_Processor( $content ); - $max_depth = 0; - - while ( $processor->next_block() ) { - $depth = $processor->get_depth(); - if ( $depth > $max_depth ) { - $max_depth = $depth; - } - } - - $this->assertSame( 4, $max_depth ); - } - - /** - * Test void block with complex attributes. - */ - public function test_void_block_with_complex_attrs() { - $content = '<!-- wp:image {"id":123,"sizeSlug":"large","linkDestination":"media","className":"is-style-rounded"} /-->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $this->assertSame( WP_Block_Processor::VOID, $processor->get_delimiter_type() ); - - $attrs = $processor->allocate_and_return_parsed_attributes(); - - $this->assertSame( 123, $attrs['id'] ); - $this->assertSame( 'large', $attrs['sizeSlug'] ); - $this->assertSame( 'media', $attrs['linkDestination'] ); - $this->assertSame( 'is-style-rounded', $attrs['className'] ); - } - - /** - * Test handling of Unicode in block content. - */ - public function test_unicode_content_handling() { - $content = '<!-- wp:paragraph {"text":"日本語テスト"} --><p>日本語テスト</p><!-- /wp:paragraph -->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $attrs = $processor->allocate_and_return_parsed_attributes(); - - $this->assertSame( '日本語テスト', $attrs['text'] ); - } - - /** - * Test span offsets for block in middle of content. - */ - public function test_span_offset_middle_block() { - $prefix = 'Some text before '; - $block = '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->'; - $suffix = ' some text after'; - $content = $prefix . $block . $suffix; - - $processor = new WP_Block_Processor( $content ); - $processor->next_block(); - - $span = $processor->get_span(); - - $this->assertSame( strlen( $prefix ), $span->start ); - } - - /** - * Test is_non_whitespace_html with actual content. - */ - public function test_is_non_whitespace_html_with_content() { - $content = '<p>Real content</p><!-- wp:paragraph --><p>Block</p><!-- /wp:paragraph -->'; - - $processor = new WP_Block_Processor( $content ); - $processor->next_token(); - - $this->assertTrue( $processor->is_html() ); - $this->assertTrue( $processor->is_non_whitespace_html() ); - } -} diff --git a/tests/Compat/WP_HTML_SpanTest.php b/tests/Compat/WP_HTML_SpanTest.php deleted file mode 100644 index 8bf8b7d29..000000000 --- a/tests/Compat/WP_HTML_SpanTest.php +++ /dev/null @@ -1,73 +0,0 @@ -<?php -/** - * Tests for WP_HTML_Span polyfill. - * - * @package WP_CLI\Entity\Compat - */ - -namespace WP_CLI\Entity\Tests\Compat; - -use PHPUnit\Framework\TestCase; -use WP_HTML_Span; - -/** - * Test the WP_HTML_Span polyfill class. - */ -class WP_HTML_SpanTest extends TestCase { - - /** - * Test constructor sets properties correctly. - */ - public function test_constructor_sets_start_and_length() { - $span = new WP_HTML_Span( 10, 25 ); - - $this->assertSame( 10, $span->start ); - $this->assertSame( 25, $span->length ); - } - - /** - * Test constructor with zero values. - */ - public function test_constructor_with_zero_values() { - $span = new WP_HTML_Span( 0, 0 ); - - $this->assertSame( 0, $span->start ); - $this->assertSame( 0, $span->length ); - } - - /** - * Test constructor with large values. - */ - public function test_constructor_with_large_values() { - $span = new WP_HTML_Span( 1000000, 5000000 ); - - $this->assertSame( 1000000, $span->start ); - $this->assertSame( 5000000, $span->length ); - } - - /** - * Test properties are public and accessible. - */ - public function test_properties_are_public() { - $span = new WP_HTML_Span( 5, 10 ); - - // Properties should be directly accessible. - $span->start = 20; - $span->length = 30; - - $this->assertSame( 20, $span->start ); - $this->assertSame( 30, $span->length ); - } - - /** - * Test use case: extracting substring from document. - */ - public function test_use_case_extract_substring() { - $document = '<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->'; - $span = new WP_HTML_Span( 21, 18 ); // "<p>Hello World</p>" - - $extracted = substr( $document, $span->start, $span->length ); - - $this->assertSame( '<p>Hello World</p>', $extracted ); - } -} diff --git a/tests/RecursiveDataStructureTraverserTest.php b/tests/RecursiveDataStructureTraverserTest.php new file mode 100644 index 000000000..635e57ba4 --- /dev/null +++ b/tests/RecursiveDataStructureTraverserTest.php @@ -0,0 +1,149 @@ +<?php + +namespace WP_CLI\Entity\Tests; + +use WP_CLI\Entity\RecursiveDataStructureTraverser; + +class RecursiveDataStructureTraverserTest extends \PHPUnit_Framework_TestCase { + + /** @test */ + function it_can_get_a_top_level_array_value() { + $array = array( + 'foo' => 'bar', + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + + $this->assertEquals( 'bar', $traverser->get( 'foo' ) ); + } + + /** @test */ + function it_can_get_a_top_level_object_value() { + $object = (object) array( + 'foo' => 'bar', + ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + + $this->assertEquals( 'bar', $traverser->get( 'foo' ) ); + } + + /** @test */ + function it_can_get_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => array( + 'baz' => 'value' + ), + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + + $this->assertEquals( 'value', $traverser->get( array( 'foo', 'bar', 'baz' ) ) ); + } + + /** @test */ + function it_can_get_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + + $this->assertEquals( 'baz', $traverser->get( array( 'foo', 'bar' ) ) ); + } + + /** @test */ + function it_can_set_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + $this->assertEquals( 'baz', $array['foo']['bar'] ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->update( array( 'foo', 'bar' ), 'new' ); + + $this->assertEquals( 'new', $array['foo']['bar'] ); + } + + /** @test */ + function it_can_set_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + $this->assertEquals( 'baz', $object->foo->bar ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + $traverser->update( array( 'foo', 'bar' ), 'new' ); + + $this->assertEquals( 'new', $object->foo->bar ); + } + + /** @test */ + function it_can_delete_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + $this->assertArrayHasKey( 'bar', $array['foo'] ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->delete( array( 'foo', 'bar' ) ); + + $this->assertArrayNotHasKey( 'bar', $array['foo'] ); + } + + /** @test */ + function it_can_delete_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + $this->assertObjectHasAttribute( 'bar', $object->foo ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + $traverser->delete( array( 'foo', 'bar' ) ); + + $this->assertObjectNotHasAttribute( 'bar', $object->foo ); + } + + /** @test */ + function it_can_insert_a_key_into_a_nested_array() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->insert( array( 'foo', 'new' ), 'new value' ); + + $this->assertArrayHasKey( 'new', $array['foo'] ); + $this->assertEquals( 'new value', $array['foo']['new'] ); + } + + /** @test */ + function it_throws_an_exception_when_attempting_to_create_a_key_on_an_invalid_type() { + $data = 'a string'; + $traverser = new RecursiveDataStructureTraverser( $data ); + + try { + $traverser->insert( array( 'key' ), 'value' ); + } catch ( \Exception $e ) { + $this->assertSame( 'a string', $data ); + return; + } + + $this->fail( 'Failed to assert that an exception was thrown when inserting a key into a string.' ); + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 8a813c5fa..000000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,25 +0,0 @@ -<?php -/** - * PHPUnit bootstrap file for entity-command tests. - * - * These tests run WITHOUT WordPress - they test the polyfills and helper - * classes in isolation. For integration tests with WordPress, use Behat. - * - * @package WP_CLI\Entity - */ - -// Load Composer autoloader. -$autoloader = dirname( __DIR__ ) . '/vendor/autoload.php'; -if ( file_exists( $autoloader ) ) { - require_once $autoloader; -} - -// Load the polyfills - these are what we're testing. -// Note: We load polyfills.php directly to ensure our polyfill functions are defined -// even if the native PHP functions exist. -require_once dirname( __DIR__ ) . '/src/Compat/polyfills.php'; -require_once dirname( __DIR__ ) . '/src/Compat/WP_HTML_Span.php'; -require_once dirname( __DIR__ ) . '/src/Compat/WP_Block_Processor.php'; - -// Load the Block_Processor_Helper class. -require_once dirname( __DIR__ ) . '/src/Block_Processor_Helper.php'; diff --git a/utils/behat-tags.php b/utils/behat-tags.php new file mode 100644 index 000000000..f65fb7d75 --- /dev/null +++ b/utils/behat-tags.php @@ -0,0 +1,85 @@ +<?php +/** + * Generate a list of tags to skip during the test run. + * + * Require a minimum version of WordPress: + * + * @require-wp-4.0 + * Scenario: Core translation CRUD + * + * Then use in bash script: + * + * BEHAT_TAGS=$(php behat-tags.php) + * vendor/bin/behat --format progress $BEHAT_TAGS + */ + +function version_tags( $prefix, $current, $operator = '<' ) { + if ( ! $current ) + return array(); + + exec( "grep '@{$prefix}-[0-9\.]*' -h -o features/*.feature | uniq", $existing_tags ); + + $skip_tags = array(); + + foreach ( $existing_tags as $tag ) { + $compare = str_replace( "@{$prefix}-", '', $tag ); + if ( version_compare( $current, $compare, $operator ) ) { + $skip_tags[] = $tag; + } + } + + return $skip_tags; +} + +$wp_version = getenv( 'WP_VERSION' ); +$wp_version_reqs = array(); +// Only apply @require-wp tags when WP_VERSION isn't 'latest', 'nightly' or 'trunk'. +// 'latest', 'nightly' and 'trunk' are expected to work with all features. +if ( $wp_version && ! in_array( $wp_version, array( 'latest', 'nightly', 'trunk' ), true ) ) { + $wp_version_reqs = array_merge( + version_tags( 'require-wp', $wp_version, '<' ), + version_tags( 'less-than-wp', $wp_version, '>=' ) + ); +} else { + // But make sure @less-than-wp tags always exist for those special cases. (Note: @less-than-wp-latest etc won't work and shouldn't be used). + $wp_version_reqs = array_merge( $wp_version_reqs, version_tags( 'less-than-wp', '9999', '>=' ) ); +} + +$skip_tags = array_merge( + $wp_version_reqs, + version_tags( 'require-php', PHP_VERSION, '<' ), + version_tags( 'less-than-php', PHP_VERSION, '>=' ) // Note: this was '>' prior to WP-CLI 1.5.0 but the change is unlikely to cause BC issues as usually compared against major.minor only. +); + +# Skip Github API tests if `GITHUB_TOKEN` not available because of rate limiting. See https://github.com/wp-cli/wp-cli/issues/1612 +if ( ! getenv( 'GITHUB_TOKEN' ) ) { + $skip_tags[] = '@github-api'; +} + +# Skip tests known to be broken. +$skip_tags[] = '@broken'; + +# Require PHP extension, eg 'imagick'. +function extension_tags() { + $extension_tags = array(); + exec( "grep '@require-extension-[A-Za-z_]*' -h -o features/*.feature | uniq", $extension_tags ); + + $skip_tags = array(); + + $substr_start = strlen( '@require-extension-' ); + foreach ( $extension_tags as $tag ) { + $extension = substr( $tag, $substr_start ); + if ( ! extension_loaded( $extension ) ) { + $skip_tags[] = $tag; + } + } + + return $skip_tags; +} + +$skip_tags = array_merge( $skip_tags, extension_tags() ); + +if ( !empty( $skip_tags ) ) { + echo '--tags=~' . implode( '&&~', $skip_tags ); +} +