diff --git a/bin/post-install b/bin/post-install index c8983048..fc1b03fb 100755 --- a/bin/post-install +++ b/bin/post-install @@ -1,4 +1,8 @@ #!/bin/bash +set -e + rm -rf src/vendor mkdir src/vendor cp -R vendor/tinify/tinify/lib src/vendor/tinify + +vendor/bin/mozart compose diff --git a/composer.json b/composer.json index 35a13517..a6a12967 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ ], "require": { "php": ">=5.3.0", - "composer/installers": "~1.0" + "composer/installers": "~1.0", + "deliciousbrains/wp-background-processing": "^1.4" }, "require-dev": { "phpunit/phpunit": "9.6.22", @@ -24,7 +25,8 @@ "squizlabs/php_codesniffer": "^3.13", "tinify/tinify": "dev-create-key", "wp-coding-standards/wpcs": "^3.2", - "dealerdirect/phpcodesniffer-composer-installer": "^1.2" + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", + "coenjacobs/mozart": "^1.2" }, "scripts": { "post-install-cmd": "bin/post-install", @@ -35,5 +37,26 @@ "composer/installers": true, "dealerdirect/phpcodesniffer-composer-installer": true } + }, + "extra": { + "mozart": { + "dep_namespace": "Tinify\\Vendor\\", + "dep_directory": "/src/vendor/prefixed/", + "classmap_directory": "/src/vendor/prefixed/", + "classmap_prefix": "Tiny_Vendor_", + "packages": [ + "deliciousbrains/wp-background-processing" + ], + "override_autoload": { + "deliciousbrains/wp-background-processing": { + "classmap": [ + "wp-async-request.php", + "wp-background-process.php" + ] + } + }, + "delete_vendor_directories": false, + "generate_autoloader": false + } } } diff --git a/composer.lock b/composer.lock index a7163e93..e5de7d40 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be6c57ccedfea4345a2cbe6b2ba232c2", + "content-hash": "47fde150fa7a26105d38bc67d7c0ce03", "packages": [ { "name": "composer/installers", @@ -156,9 +156,271 @@ } ], "time": "2021-09-13T08:19:44+00:00" + }, + { + "name": "deliciousbrains/wp-background-processing", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/deliciousbrains/wp-background-processing.git", + "reference": "7ca7cc3504333db3a291bbab7f1917124fba4816" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/deliciousbrains/wp-background-processing/zipball/7ca7cc3504333db3a291bbab7f1917124fba4816", + "reference": "7ca7cc3504333db3a291bbab7f1917124fba4816", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpcompatibility/phpcompatibility-wp": "*", + "phpunit/phpunit": "^8.0", + "spryker/code-sniffer": "^0.17.18", + "wp-coding-standards/wpcs": "^2.3", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "coenjacobs/mozart": "Easily wrap this library with your own prefix, to prevent collisions when multiple plugins use this library" + }, + "type": "library", + "autoload": { + "classmap": [ + "classes/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Delicious Brains", + "email": "nom@deliciousbrains.com" + } + ], + "description": "WP Background Processing can be used to fire off non-blocking asynchronous requests or as a background processing tool, allowing you to queue tasks.", + "support": { + "issues": "https://github.com/deliciousbrains/wp-background-processing/issues", + "source": "https://github.com/deliciousbrains/wp-background-processing/tree/1.4.0" + }, + "time": "2024-12-17T14:04:30+00:00" } ], "packages-dev": [ + { + "name": "coenjacobs/mozart", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/coenjacobs/mozart.git", + "reference": "5e20f299238c54d39f619a46dc2e063af526cbc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coenjacobs/mozart/zipball/5e20f299238c54d39f619a46dc2e063af526cbc8", + "reference": "5e20f299238c54d39f619a46dc2e063af526cbc8", + "shasum": "" + }, + "require": { + "composer/class-map-generator": "^1.7", + "league/flysystem": "^3.0", + "league/flysystem-local": "^3.0", + "netresearch/jsonmapper": "^4.4 || ^5.0", + "nikic/php-parser": "^5.0", + "php": "^8.2", + "symfony/console": "^6.4 || ^7.4", + "symfony/finder": "^6.4 || ^7.4" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "mheap/phpunit-github-actions-printer": "^1.5", + "phpcompatibility/php-compatibility": "^10.1", + "phpmd/phpmd": "^2.15", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpunit/phpunit": "^11.0", + "squizlabs/php_codesniffer": "^4.0" + }, + "bin": [ + "bin/mozart" + ], + "type": "library", + "autoload": { + "psr-4": { + "CoenJacobs\\Mozart\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Coen Jacobs", + "email": "coenjacobs@gmail.com" + } + ], + "description": "Composes all dependencies as a package inside a WordPress plugin", + "support": { + "issues": "https://github.com/coenjacobs/mozart/issues", + "source": "https://github.com/coenjacobs/mozart/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/coenjacobs", + "type": "github" + } + ], + "time": "2026-05-01T10:47:09+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-05-05T09:17:07+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, { "name": "dealerdirect/phpcodesniffer-composer-installer", "version": "v1.2.0", @@ -375,157 +637,345 @@ "time": "2015-05-11T14:41:42+00:00" }, { - "name": "mikey179/vfsstream", - "version": "v1.6.12", + "name": "league/flysystem", + "version": "3.35.3", "source": { "type": "git", - "url": "https://github.com/bovigo/vfsStream.git", - "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", - "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { - "php": ">=7.1.0" + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" }, "require-dev": { - "phpunit/phpunit": "^7.5||^8.5||^9.6", - "yoast/phpunit-polyfills": "^2.0" + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.6.x-dev" - } - }, "autoload": { - "psr-0": { - "org\\bovigo\\vfs\\": "src/main/php" + "psr-4": { + "League\\Flysystem\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Frank Kleine", - "homepage": "http://frankkleine.de/", - "role": "Developer" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "Virtual file system to mock the real file system in unit tests.", - "homepage": "http://vfs.bovigo.org/", + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], "support": { - "issues": "https://github.com/bovigo/vfsStream/issues", - "source": "https://github.com/bovigo/vfsStream/tree/master", - "wiki": "https://github.com/bovigo/vfsStream/wiki" + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2024-08-29T18:43:31+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { - "name": "mockery/mockery", - "version": "0.9.11", + "name": "league/flysystem-local", + "version": "3.35.3", "source": { "type": "git", - "url": "https://github.com/mockery/mockery.git", - "reference": "be9bf28d8e57d67883cba9fcadfcff8caab667f8" + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/be9bf28d8e57d67883cba9fcadfcff8caab667f8", - "reference": "be9bf28d8e57d67883cba9fcadfcff8caab667f8", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "~1.1", - "lib-pcre": ">=7.0", - "php": ">=5.3.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.9.x-dev" - } - }, "autoload": { - "psr-0": { - "Mockery": "library/" + "psr-4": { + "League\\Flysystem\\Local\\": "" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Pádraic Brady", - "email": "padraic.brady@gmail.com", - "homepage": "http://blog.astrumfutura.com" - }, - { - "name": "Dave Marshall", - "email": "dave.marshall@atstsolutions.co.uk", - "homepage": "http://davedevelopment.co.uk" + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" } ], - "description": "Mockery is a simple yet flexible PHP mock object framework for use in unit testing with PHPUnit, PHPSpec or any other testing framework. Its core goal is to offer a test double framework with a succinct API capable of clearly defining all possible object operations and interactions using a human readable Domain Specific Language (DSL). Designed as a drop in alternative to PHPUnit's phpunit-mock-objects library, Mockery is easy to integrate with PHPUnit and can operate alongside phpunit-mock-objects without the World ending.", - "homepage": "http://github.com/padraic/mockery", + "description": "Local filesystem adapter for Flysystem.", "keywords": [ - "BDD", - "TDD", - "library", - "mock", - "mock objects", - "mockery", - "stub", - "test", - "test double", - "testing" + "Flysystem", + "file", + "files", + "filesystem", + "local" ], "support": { - "issues": "https://github.com/mockery/mockery/issues", - "source": "https://github.com/mockery/mockery/tree/0.9" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2019-02-12T16:07:13+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.0", + "name": "league/mime-type-detection", + "version": "1.17.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.12", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5||^8.5||^9.6", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "support": { + "issues": "https://github.com/bovigo/vfsStream/issues", + "source": "https://github.com/bovigo/vfsStream/tree/master", + "wiki": "https://github.com/bovigo/vfsStream/wiki" + }, + "time": "2024-08-29T18:43:31+00:00" + }, + { + "name": "mockery/mockery", + "version": "0.9.11", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "be9bf28d8e57d67883cba9fcadfcff8caab667f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/be9bf28d8e57d67883cba9fcadfcff8caab667f8", + "reference": "be9bf28d8e57d67883cba9fcadfcff8caab667f8", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "~1.1", + "lib-pcre": ">=7.0", + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.9.x-dev" + } + }, + "autoload": { + "psr-0": { + "Mockery": "library/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "http://blog.astrumfutura.com" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "http://davedevelopment.co.uk" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework for use in unit testing with PHPUnit, PHPSpec or any other testing framework. Its core goal is to offer a test double framework with a succinct API capable of clearly defining all possible object operations and interactions using a human readable Domain Specific Language (DSL). Designed as a drop in alternative to PHPUnit's phpunit-mock-objects library, Mockery is easy to integrate with PHPUnit and can operate alongside phpunit-mock-objects without the World ending.", + "homepage": "http://github.com/padraic/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "issues": "https://github.com/mockery/mockery/issues", + "source": "https://github.com/mockery/mockery/tree/0.9" + }, + "time": "2019-02-12T16:07:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", + "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" ], "psr-4": { "DeepCopy\\": "src/DeepCopy/" @@ -555,6 +1005,57 @@ ], "time": "2025-02-12T12:17:51+00:00" }, + { + "name": "netresearch/jsonmapper", + "version": "v5.0.1", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" + }, + "time": "2026-02-22T16:28:03+00:00" + }, { "name": "nikic/php-parser", "version": "v5.4.0", @@ -1381,6 +1882,59 @@ ], "time": "2024-12-05T13:48:26+00:00" }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, { "name": "sebastian/cli-parser", "version": "1.0.2", @@ -2423,6 +2977,756 @@ ], "time": "2025-11-04T16:30:35+00:00" }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:09:28+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.3", diff --git a/src/class-tiny-bulk-queue.php b/src/class-tiny-bulk-queue.php new file mode 100644 index 00000000..d545cca1 --- /dev/null +++ b/src/class-tiny-bulk-queue.php @@ -0,0 +1,903 @@ +settings = $settings; + parent::__construct(); + } + + /* --------------------------------------------------------------------- + * Starting and stopping a run + * ------------------------------------------------------------------ */ + + /** + * Start optimizing. + * + * @param int[]|null $ids Attachments to optimize, or null for the whole library. + * @return array The state the run starts out with. + */ + public function start( $ids = null ) { + /* + A cancel or pause only records a flag; the handler that clears it runs + in the loopback request. If that request never arrived the flag is + still set, and it would stop this run before it claimed anything. + Starting is an explicit instruction, so clear it here. */ + delete_site_option( $this->get_status_key() ); + + $this->clear_queue_meta(); + + $mode = is_array( $ids ) ? 'selection' : 'all'; + + if ( 'selection' === $mode ) { + $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); + foreach ( $ids as $id ) { + add_post_meta( $id, self::META_STATUS, self::STATUS_PENDING, true ); + } + } + + self::save_run( + array_merge( + self::empty_run(), + array( + 'status' => 'running', + 'mode' => $mode, + 'started_at' => time(), + ) + ) + ); + + if ( $this->is_queue_empty() ) { + $this->complete(); + return $this->get_progress(); + } + + $unreachable = $this->loopback_error(); + + if ( ! is_null( $unreachable ) ) { + $run = self::get_run(); + $run['status'] = 'unreachable'; + $run['error_message'] = $unreachable; + self::save_run( $run ); + + return $this->get_progress(); + } + + $this->dispatch(); + + return $this->get_progress(); + } + + /** + * Check that WordPress can reach its own admin-ajax.php. + * + * @return string|null Why the loopback failed, or null when it works. + */ + protected function loopback_error() { + $url = $this->get_query_url(); + + $response = wp_remote_post( + $url, + array( + 'timeout' => 10, + 'blocking' => true, + /* An action nobody handles: WordPress answers without side effects. */ + 'body' => array( 'action' => $this->identifier . '_ping' ), + 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), + ) + ); + + if ( is_wp_error( $response ) ) { + return sprintf( '%s (%s)', $response->get_error_message(), $url ); + } + + $code = intval( wp_remote_retrieve_response_code( $response ) ); + + /* Password protected staging sites answer, but never run the handler. */ + if ( 401 === $code || 403 === $code ) { + return sprintf( 'HTTP %d (%s)', $code, $url ); + } + + return null; + } + + /** + * Stop the current run and leave the results of what did run in place. + */ + public function stop() { + if ( $this->is_active() ) { + $this->cancel(); + } else { + $this->cancelled(); + } + } + + /** + * Progress of the current run. + * + * @return array + */ + public function get_progress() { + $run = self::get_run(); + $counts = $this->status_counts(); + + $queued = array_sum( $counts ); + $total = $queued; + + if ( 'all' === $run['mode'] ) { + /* + Everything that has not been given a status yet is still waiting, + so the library total is the denominator. */ + $total = max( $queued, $this->image_attachment_count() ); + } + + $run['counts'] = $counts; + $run['total'] = $total; + $run['processed'] = $counts[ self::STATUS_DONE ] + + $counts[ self::STATUS_FAILED ] + + $counts[ self::STATUS_SKIPPED ] + + $counts[ self::STATUS_CANCELLED ]; + $run['optimized'] = $counts[ self::STATUS_DONE ]; + $run['failed'] = $counts[ self::STATUS_FAILED ]; + $run['skipped'] = $counts[ self::STATUS_SKIPPED ]; + + $run['is_processing'] = $this->is_processing(); + $run['is_queued'] = ! $this->is_queue_empty(); + $run['is_active'] = $this->is_active(); + + /* + A run that is neither queued nor holding the lock, and that has not + reported progress for a while, is never going to finish on its own: + say so rather than leave the page spinning forever. */ + if ( 'running' === $run['status'] && ! $run['is_active'] ) { + $last = $run['updated_at'] ? $run['updated_at'] : $run['started_at']; + + if ( $last && ( time() - $last ) > self::STALL_AFTER ) { + $run['status'] = 'stalled'; + } + } + + return $run; + } + + /** + * Replaces the library's check against its batch rows in the options table. + * + * @return bool + */ + protected function is_queue_empty() { + $run = self::get_run(); + + /* A run that was cancelled or finished leaves nothing claimable. */ + if ( 'running' !== $run['status'] ) { + return true; + } + + $counts = $this->status_counts(); + + if ( $counts[ self::STATUS_PENDING ] > 0 || $counts[ self::STATUS_PROCESSING ] > 0 ) { + return false; + } + + if ( 'all' === $run['mode'] ) { + /* + Attachments without a status row have not been looked at yet. + Comparing counts avoids the NOT EXISTS scan on every poll. */ + return $this->image_attachment_count() <= array_sum( $counts ); + } + + return true; + } + + /** + * Take the next attachments off the queue. + * + * Claiming is a compare and set on the status: update_post_meta only writes + * when the row still reads 'pending', so two processes cannot walk away with + * the same image. + * + * @param int $limit Maximum number of attachments to claim. + * @return int[] Attachment IDs claimed by this process. + */ + protected function claim_batch( $limit ) { + global $wpdb; + + $this->reclaim_stale(); + + $run = self::get_run(); + if ( 'all' === $run['mode'] ) { + $this->mark_next_pending( $limit ); + } + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_id FROM $wpdb->postmeta + WHERE meta_key = %s AND meta_value = %s + LIMIT %d", + self::META_STATUS, + self::STATUS_PENDING, + $limit + ) + ); + + $claimed = array(); + foreach ( $ids as $id ) { + $id = intval( $id ); + + if ( intval( get_post_meta( $id, self::META_ATTEMPTS, true ) ) >= self::MAX_ATTEMPTS ) { + update_post_meta( $id, self::META_STATUS, self::STATUS_FAILED ); + continue; + } + + $claimed_by_us = update_post_meta( + $id, + self::META_STATUS, + self::STATUS_PROCESSING, + self::STATUS_PENDING + ); + + if ( ! $claimed_by_us ) { + /* Another process got there first. */ + continue; + } + + update_post_meta( $id, self::META_CLAIMED, time() ); + $claimed[] = $id; + } + + return $claimed; + } + + /** + * Give a chunk of not yet considered attachments a pending status. + * + * This is the only query that has to look at wp_posts, and it runs with a + * small limit once per batch rather than once per run over the whole library. + * + * @param int $limit Maximum number of attachments to mark. + */ + protected function mark_next_pending( $limit ) { + global $wpdb; + + $mimes = self::mime_types(); + $placeholders = implode( ', ', array_fill( 0, count( $mimes ), '%s' ) ); + + $query = "SELECT p.ID FROM $wpdb->posts p + WHERE p.post_type = 'attachment' + AND p.post_mime_type IN ($placeholders) + AND NOT EXISTS ( + SELECT 1 FROM $wpdb->postmeta m + WHERE m.post_id = p.ID AND m.meta_key = %s + ) + ORDER BY p.ID DESC + LIMIT %d"; + + $args = array_merge( $mimes, array( self::META_STATUS, $limit ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Only the generated list of %s placeholders is interpolated. + $ids = $wpdb->get_col( $wpdb->prepare( $query, $args ) ); + + foreach ( $ids as $id ) { + add_post_meta( intval( $id ), self::META_STATUS, self::STATUS_PENDING, true ); + } + } + + /** + * Hand back claims from processes that died mid image. + */ + protected function reclaim_stale() { + global $wpdb; + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT status.post_id FROM $wpdb->postmeta status + INNER JOIN $wpdb->postmeta claimed + ON claimed.post_id = status.post_id AND claimed.meta_key = %s + WHERE status.meta_key = %s + AND status.meta_value = %s + AND CAST(claimed.meta_value AS UNSIGNED) < %d + LIMIT %d", + self::META_CLAIMED, + self::META_STATUS, + self::STATUS_PROCESSING, + time() - self::CLAIM_TIMEOUT, + self::BATCH_SIZE + ) + ); + + foreach ( $ids as $id ) { + $this->release( intval( $id ) ); + } + } + + /** + * Put a claimed attachment back on the queue. + * + * @param int $id Attachment ID. + */ + protected function release( $id ) { + update_post_meta( $id, self::META_STATUS, self::STATUS_PENDING ); + } + + /** + * How many attachments sit in each status. + * + * @return array + */ + protected function status_counts() { + global $wpdb; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_value AS status, COUNT(*) AS total + FROM $wpdb->postmeta + WHERE meta_key = %s + GROUP BY meta_value", + self::META_STATUS + ), + ARRAY_A + ); + + $counts = array_fill_keys( self::statuses(), 0 ); + + foreach ( (array) $rows as $row ) { + if ( isset( $counts[ $row['status'] ] ) ) { + $counts[ $row['status'] ] = intval( $row['total'] ); + } + } + + return $counts; + } + + /** + * Number of images in the library the plugin can optimize. + * + * @return int + */ + protected function image_attachment_count() { + global $wpdb; + + $mimes = self::mime_types(); + $placeholders = implode( ', ', array_fill( 0, count( $mimes ), '%s' ) ); + + $query = "SELECT COUNT(*) FROM $wpdb->posts + WHERE post_type = 'attachment' AND post_mime_type IN ($placeholders)"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Only the generated list of %s placeholders is interpolated. + return intval( $wpdb->get_var( $wpdb->prepare( $query, $mimes ) ) ); + } + + /** + * Forget the queue, keeping the compression results themselves. + */ + protected function clear_queue_meta() { + global $wpdb; + + $keys = self::meta_keys(); + $placeholders = implode( ', ', array_fill( 0, count( $keys ), '%s' ) ); + + do { + $select = "SELECT DISTINCT post_id FROM $wpdb->postmeta + WHERE meta_key IN ($placeholders) + LIMIT 500"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Only the generated list of %s placeholders is interpolated. + $ids = $wpdb->get_col( $wpdb->prepare( $select, $keys ) ); + + if ( empty( $ids ) ) { + return; + } + + $ids = array_map( 'intval', $ids ); + $in = implode( ', ', $ids ); + + $delete = "DELETE FROM $wpdb->postmeta + WHERE meta_key IN ($placeholders) AND post_id IN ($in)"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Only generated %s placeholders and a list of integers are interpolated. + $wpdb->query( $wpdb->prepare( $delete, $keys ) ); + + foreach ( $ids as $id ) { + wp_cache_delete( $id, 'post_meta' ); + } + } while ( true ); + } + + /* --------------------------------------------------------------------- + * Processing + * ------------------------------------------------------------------ */ + + /** + * Work through the queue. + * + * The library's own loop reads and rewrites a batch row in the options + * table; this one claims attachments instead. Everything around it, the + * lock, the limits and the decision to hand over to the next process, is + * kept the same. + */ + protected function handle() { + $this->lock_process(); + + /** + * Number of seconds to sleep between batches. Defaults to 0 seconds. + * + * @param int $seconds + */ + $throttle = max( + 0, + apply_filters( $this->identifier . '_seconds_between_batches', 0 ) + ); + + $worked = false; + + do { + $batch = $this->claim_batch( self::BATCH_SIZE ); + + foreach ( $batch as $index => $id ) { + $worked = true; + $this->task( $id ); + + sleep( $throttle ); + + if ( ! $this->should_continue() ) { + /* + Hand back what this process will not get to, so the next + one can pick it up right away instead of waiting for + the claim to go stale. */ + foreach ( array_slice( $batch, $index + 1 ) as $unclaimed ) { + $this->release( $unclaimed ); + } + break 2; + } + } + } while ( ! empty( $batch ) && $this->should_continue() ); + + $this->unlock_process(); + + if ( $this->is_queue_empty() ) { + $this->complete(); + } elseif ( $worked ) { + $this->dispatch(); + } + + /* + Nothing claimed and nothing finished means the only thing left is a + claim held by another process. Leave it to the cron health check + rather than spinning up another chain immediately. */ + + return $this->maybe_wp_die(); + } + + /** + * Optimize a single attachment. + * + * Always returns false: the queue lives in postmeta, so there is nothing to + * hand back to the library's batch. + * + * @param mixed $item Attachment ID. + * @return false + */ + protected function task( $item ) { + $id = intval( $item ); + $attempts = intval( get_post_meta( $id, self::META_ATTEMPTS, true ) ) + 1; + + update_post_meta( $id, self::META_ATTEMPTS, $attempts ); + + if ( ! $this->is_supported_attachment( $id ) ) { + $this->finish( + $id, + self::STATUS_SKIPPED, + array( 'title' => get_the_title( $id ) ) + ); + return false; + } + + $active_sizes = $this->settings->get_sizes(); + $active_tinify_sizes = $this->settings->get_active_tinify_sizes(); + + /* + Compressing mutates the image, so the size it started at is read from + a separate instance, the same way the AJAX handler does it. */ + $before = new Tiny_Image( $this->settings, $id ); + $stats_before = $before->get_statistics( $active_sizes, $active_tinify_sizes ); + $size_before = $stats_before['compressed_total_size']; + + $tiny_image = new Tiny_Image( $this->settings, $id ); + + Tiny_Logger::debug( + 'compress from bulk queue', + array( + 'image_id' => $id, + ) + ); + + try { + $result = $tiny_image->compress(); + } catch ( Exception $e ) { + $this->fail( $id, $attempts, $tiny_image->get_name(), $e->getMessage() ); + return false; + } + + wp_update_attachment_metadata( $id, $tiny_image->get_wp_metadata() ); + + $stats = $tiny_image->get_statistics( $active_sizes, $active_tinify_sizes ); + $optimized = isset( $result['success'] ) ? intval( $result['success'] ) : 0; + + if ( ! empty( $result['failed'] ) ) { + $this->fail( $id, $attempts, $tiny_image->get_name(), $tiny_image->get_latest_error() ); + return false; + } + + $this->finish( + $id, + $optimized > 0 ? self::STATUS_DONE : self::STATUS_SKIPPED, + array( + 'title' => $tiny_image->get_name(), + 'sizes_compressed' => $stats['image_sizes_compressed'], + 'sizes_converted' => $stats['image_sizes_converted'], + 'initial_size' => size_format( $stats['initial_total_size'], 1 ), + 'optimized_size' => size_format( $stats['compressed_total_size'], 1 ), + 'savings' => $tiny_image->get_savings( $stats ), + 'thumbnail' => wp_get_attachment_image( + $id, + array( '30', '30' ), + true, + array( + 'class' => 'pinkynail', + 'alt' => '', + ) + ), + 'size_change' => $stats['compressed_total_size'] - $size_before, + ) + ); + + return false; + } + + /** + * Record the outcome of an attachment that was processed. + * + * @param int $id Attachment ID. + * @param string $status One of the STATUS_ constants. + * @param array $entry Details for the table of recent results. + */ + protected function finish( $id, $status, array $entry = array() ) { + update_post_meta( $id, self::META_STATUS, $status ); + delete_post_meta( $id, self::META_ERROR ); + + $this->log( + array_merge( + $entry, + array( + 'id' => $id, + 'status' => $status, + ) + ) + ); + } + + /** + * Record a failure, and queue the attachment again if it has attempts left. + * + * One bad image cannot poison the run: it is retried a few times and then + * left alone while the rest of the library carries on. + * + * @param int $id Attachment ID. + * @param int $attempts Attempts made so far, including this one. + * @param string $title Attachment name, for display. + * @param string $message Why it failed. + */ + protected function fail( $id, $attempts, $title, $message ) { + update_post_meta( $id, self::META_ERROR, (string) $message ); + + if ( $attempts < self::MAX_ATTEMPTS ) { + $this->release( $id ); + return; + } + + update_post_meta( $id, self::META_STATUS, self::STATUS_FAILED ); + + $this->log( + array( + 'id' => $id, + 'title' => $title, + 'status' => self::STATUS_FAILED, + 'message' => $message, + ) + ); + } + + /* --------------------------------------------------------------------- + * Run boundaries + * ------------------------------------------------------------------ */ + + /** + * Mark the run as finished once the queue has drained. + */ + protected function complete() { + parent::complete(); + + $run = self::get_run(); + if ( 'running' === $run['status'] ) { + $run['status'] = 'done'; + $run['finished_at'] = time(); + self::save_run( $run ); + } + } + + /** + * Drop what is still queued, keeping the results of what already ran. + * + * Replaces the library's batch cleanup. Anything still waiting or claimed + * becomes 'cancelled' so the counts on the page stay meaningful. + */ + public function delete_all() { + global $wpdb; + + do { + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_id FROM $wpdb->postmeta + WHERE meta_key = %s AND meta_value IN ( %s, %s ) + LIMIT 500", + self::META_STATUS, + self::STATUS_PENDING, + self::STATUS_PROCESSING + ) + ); + + if ( empty( $ids ) ) { + break; + } + + $ids = array_map( 'intval', $ids ); + $in = implode( ', ', $ids ); + + $update = "UPDATE $wpdb->postmeta SET meta_value = %s + WHERE meta_key = %s AND post_id IN ($in)"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of integers is interpolated. + $wpdb->query( $wpdb->prepare( $update, self::STATUS_CANCELLED, self::META_STATUS ) ); + + foreach ( $ids as $id ) { + wp_cache_delete( $id, 'post_meta' ); + } + } while ( true ); + + delete_site_option( $this->get_status_key() ); + + $this->cancelled(); + } + + /** + * Mark the run as cancelled. + */ + protected function cancelled() { + parent::cancelled(); + + $run = self::get_run(); + $run['status'] = 'cancelled'; + $run['finished_at'] = time(); + self::save_run( $run ); + } + + /* --------------------------------------------------------------------- + * Library plumbing + * ------------------------------------------------------------------ */ + + /** + * Where the loopback request that keeps the queue moving is sent. + * + * Mirrors the WORDPRESS_HOST escape hatch used for compression on upload: + * in the development containers the site URL carries the port published on + * the host, which the container itself cannot reach. + * + * @return string + */ + protected function get_query_url() { + $host = getenv( 'WORDPRESS_HOST' ); + + if ( false !== $host ) { + return $host . '/wp-admin/admin-ajax.php'; + } + + return parent::get_query_url(); + } + + /** + * ID tying together the loopback requests of a single run. + * @return string + */ + public function get_chain_id() { + if ( wp_doing_ajax() && ! check_ajax_referer( $this->identifier, 'nonce', false ) ) { + if ( empty( $this->started_chain_id ) ) { + $this->started_chain_id = wp_generate_uuid4(); + } + + return $this->started_chain_id; + } + + return parent::get_chain_id(); + } + + /* --------------------------------------------------------------------- + * Helpers + * ------------------------------------------------------------------ */ + + private function is_supported_attachment( $id ) { + if ( 'attachment' !== get_post_type( $id ) ) { + return false; + } + + return in_array( get_post_mime_type( $id ), self::mime_types(), true ); + } + + private static function mime_types() { + return array( 'image/jpeg', 'image/png', 'image/webp' ); + } + + private static function meta_keys() { + return array( + self::META_STATUS, + self::META_ATTEMPTS, + self::META_CLAIMED, + self::META_ERROR, + ); + } + + private static function statuses() { + return array( + self::STATUS_PENDING, + self::STATUS_PROCESSING, + self::STATUS_DONE, + self::STATUS_FAILED, + self::STATUS_SKIPPED, + self::STATUS_CANCELLED, + ); + } + + /** + * Keep the last handful of results for the table on the page. + * + * Display only: the numbers the page shows come from postmeta, this is just + * the detail that would be expensive to recompute for every poll. + * + * @param array $entry Result of processing one attachment. + */ + private function log( array $entry ) { + $run = self::get_run(); + + $run['updated_at'] = time(); + $run['log'][] = array_merge( + array( + 'id' => 0, + 'title' => '', + 'status' => self::STATUS_SKIPPED, + 'message' => null, + 'sizes_compressed' => 0, + 'sizes_converted' => 0, + 'initial_size' => null, + 'optimized_size' => null, + 'savings' => 0, + 'thumbnail' => '', + 'size_change' => 0, + ), + $entry + ); + + if ( count( $run['log'] ) > self::LOG_SIZE ) { + $run['log'] = array_slice( $run['log'], -self::LOG_SIZE ); + } + + self::save_run( $run ); + } + + public static function get_run() { + $run = get_site_option( self::RUN_OPTION ); + + if ( ! is_array( $run ) ) { + return self::empty_run(); + } + + return array_merge( self::empty_run(), $run ); + } + + private static function save_run( array $run ) { + update_site_option( self::RUN_OPTION, $run ); + } + + private static function empty_run() { + return array( + 'status' => 'idle', + 'mode' => 'all', + 'error_message' => null, + 'started_at' => null, + 'updated_at' => null, + 'finished_at' => null, + 'log' => array(), + ); + } +} diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index ff0708e0..acbf78fe 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -26,6 +26,7 @@ class Tiny_Plugin extends Tiny_WP_Base { private $settings; private $twig; + private $bulk_queue; public static function jpeg_quality() { return 85; @@ -42,6 +43,7 @@ public function __construct() { parent::__construct(); $this->settings = new Tiny_Settings(); new Tiny_Conversion( $this->settings ); + $this->bulk_queue = new Tiny_Bulk_Queue( $this->settings ); } public function set_compressor( $compressor ) { @@ -109,6 +111,21 @@ public function ajax_init() { $this->get_method( 'ajax_optimization_statistics' ) ); + add_action( + 'wp_ajax_tiny_bulk_queue_start', + $this->get_method( 'ajax_bulk_queue_start' ) + ); + + add_action( + 'wp_ajax_tiny_bulk_queue_status', + $this->get_method( 'ajax_bulk_queue_status' ) + ); + + add_action( + 'wp_ajax_tiny_bulk_queue_cancel', + $this->get_method( 'ajax_bulk_queue_cancel' ) + ); + add_action( 'wp_ajax_tiny_get_compression_status', $this->get_method( 'ajax_compression_status' ) @@ -217,6 +234,14 @@ public function admin_menu() { 'tiny-bulk-optimization', $this->get_method( 'render_bulk_optimization_page' ) ); + + add_media_page( + __( 'Bulk Optimization V2', 'tiny-compress-images' ), + esc_html__( 'Bulk TinyPNG V2', 'tiny-compress-images' ), + 'upload_files', + 'tiny-bulk-optimization-queue', + $this->get_method( 'render_bulk_optimization_queue_page' ) + ); } public function add_plugin_links( $current_links ) { @@ -328,6 +353,10 @@ public function enqueue_scripts( $hook ) { 'L10nError' => __( 'Error', 'tiny-compress-images' ), 'L10nLatestError' => __( 'Latest error', 'tiny-compress-images' ), 'L10nInternalError' => __( 'Internal error', 'tiny-compress-images' ), + 'L10nQueueUnreachable' => __( + 'Could not reach this site to start background processing', + 'tiny-compress-images' + ), 'L10nOutOf' => __( 'out of', 'tiny-compress-images' ), 'L10nWaiting' => __( 'Waiting', 'tiny-compress-images' ), ) @@ -360,6 +389,25 @@ public function enqueue_scripts( $hook ) { wp_enqueue_script( self::NAME . '_tiny_bulk_optimization' ); } + + if ( 'media_page_tiny-bulk-optimization-queue' == $hook ) { + wp_enqueue_style( + self::NAME . '_tiny_bulk_optimization', + plugins_url( '/css/bulk-optimization.css', __FILE__ ), + array(), + self::version() + ); + + wp_register_script( + self::NAME . '_tiny_bulk_optimization_queue', + plugins_url( '/js/bulk-optimization-queue.js', __FILE__ ), + array( self::NAME . '_admin' ), + self::version(), + true + ); + + wp_enqueue_script( self::NAME . '_tiny_bulk_optimization_queue' ); + } } public function process_attachment( $metadata, $attachment_id ) { @@ -672,6 +720,73 @@ public function ajax_optimization_statistics() { exit(); } + private function validate_bulk_queue_request() { + if ( ! check_ajax_referer( 'tiny-compress', '_nonce', false ) ) { + return false; + } + return current_user_can( 'upload_files' ); + } + + /** + * Hand the library to the background queue in one request. + * + * Nothing is enqueued up front: the queue lives in postmeta, so starting a + * run is a reset and a dispatch no matter how large the library is. Passing + * ids optimizes just those attachments instead of everything. + */ + public function ajax_bulk_queue_start() { + if ( ! $this->validate_bulk_queue_request() ) { + echo json_encode( array( 'error' => __( 'Not allowed', 'tiny-compress-images' ) ) ); + exit(); + } + + /* + Only a run that is genuinely under way blocks a new one. Asking the + library whether it is "active" would also count a cancel that never + reached its handler, which would make the button do nothing. */ + $progress = $this->bulk_queue->get_progress(); + if ( 'running' === $progress['status'] ) { + echo json_encode( $progress ); + exit(); + } + + // Nonce verified in validate_bulk_queue_request(). + // phpcs:disable WordPress.Security.NonceVerification.Missing + $requested = isset( $_POST['ids'] ) ? + sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Missing + $ids = null; + + if ( '' !== $requested ) { + $ids = array_filter( array_map( 'intval', explode( ',', $requested ) ) ); + } + + echo json_encode( $this->bulk_queue->start( $ids ) ); + exit(); + } + + public function ajax_bulk_queue_status() { + if ( ! $this->validate_bulk_queue_request() ) { + echo json_encode( array( 'error' => __( 'Not allowed', 'tiny-compress-images' ) ) ); + exit(); + } + + echo json_encode( $this->bulk_queue->get_progress() ); + exit(); + } + + public function ajax_bulk_queue_cancel() { + if ( ! $this->validate_bulk_queue_request() ) { + echo json_encode( array( 'error' => __( 'Not allowed', 'tiny-compress-images' ) ) ); + exit(); + } + + $this->bulk_queue->stop(); + + echo json_encode( $this->bulk_queue->get_progress() ); + exit(); + } + public function ajax_compression_status() { $response = $this->validate_ajax_attachment_request(); @@ -813,6 +928,19 @@ public function render_bulk_optimization_page() { include __DIR__ . '/views/bulk-optimization.php'; } + public function render_bulk_optimization_queue_page() { + /* This makes sure that up to date information is retrieved from the API. */ + $this->settings->get_compressor()->get_status(); + + /* + No library scan here. The queue counts come out of postmeta, so this + page renders in the same time on a library of ten or ten thousand. */ + $progress = $this->bulk_queue->get_progress(); + $remaining_credits = $this->settings->get_remaining_credits(); + + include __DIR__ . '/views/bulk-optimization-queue.php'; + } + public function add_dashboard_widget() { wp_enqueue_style( self::NAME . '_chart', diff --git a/src/js/bulk-optimization-queue.js b/src/js/bulk-optimization-queue.js new file mode 100644 index 00000000..848ad911 --- /dev/null +++ b/src/js/bulk-optimization-queue.js @@ -0,0 +1,203 @@ +/* + * Bulk Optimization Queue + * + * Starting sends one request that hands the library to the background queue; + * from then on this script only polls for progress. The counts it renders come + * from the queue status stored on each attachment, so they survive a reload, + * a cancelled run, or a process that died halfway. + */ +(() => { + const POLL_INTERVAL = 3000; + + const ICON_SUCCESS = ''; + const ICON_ERROR = ''; + const ICON_ALERT = ''; + + const renderedIds = new Set(); + let pollTimer = null; + let el = null; + + async function request(action) { + try { + const response = await fetch(ajaxurl, { + method: 'POST', + credentials: 'same-origin', + body: new URLSearchParams({ action, _nonce: tinyCompress.nonce }), + }); + return response.ok ? await response.json() : null; + } catch { + return null; + } + } + + function statusLabel(state) { + const label = { + running: `${tinyCompress.L10nCompressing}…`, + done: tinyCompress.L10nAllDone, + cancelled: tinyCompress.L10nCancelled, + stalled: tinyCompress.L10nInternalError, + unreachable: tinyCompress.L10nQueueUnreachable, + }[state.status]; + + if (!label) { + return ''; + } + + // The loopback error is the only thing that says why nothing is happening. + return state.error_message ? `${label}: ${state.error_message}` : label; + } + + function fillStatus(cell, item) { + if (item.status === 'done') { + let html = ''; + if (item.sizes_compressed > 0) { + html += `

${ICON_SUCCESS} ${item.sizes_compressed} ${tinyCompress.L10nCompressed}

`; + } + if (item.sizes_converted > 0) { + html += `

${ICON_SUCCESS} ${item.sizes_converted} ${tinyCompress.L10nConverted}

`; + } + cell.innerHTML = html; + return; + } + + if (item.status === 'failed') { + cell.innerHTML = `${ICON_ERROR} ${tinyCompress.L10nError}`; + if (item.message) { + cell.append(document.createElement('br'), item.message); + } + return; + } + + cell.innerHTML = `${ICON_ALERT} ${tinyCompress.L10nNoActionTaken}`; + } + + function addCell(row, className) { + const cell = row.insertCell(); + cell.className = className; + return cell; + } + + function buildRow(item) { + const row = document.createElement('tr'); + row.className = 'media-item'; + + addCell(row, 'thumbnail').innerHTML = item.thumbnail || ''; + addCell(row, 'column-primary name').textContent = item.title || item.id; + addCell(row, 'column-author initial-size').textContent = item.initial_size || '-'; + addCell(row, 'column-author optimized-size').textContent = item.optimized_size || '-'; + addCell(row, 'column-author savings').textContent = item.savings ? `${item.savings}%` : '-'; + + const status = addCell(row, 'column-author status'); + status.dataset.status = item.status; + fillStatus(status, item); + + return row; + } + + function renderLog(log) { + const fresh = log.filter((item) => !renderedIds.has(item.id)); + if (fresh.length === 0) { + return; + } + + const batch = document.createDocumentFragment(); + for (const item of fresh.reverse()) { + renderedIds.add(item.id); + batch.append(buildRow(item)); + } + el.tbody.prepend(batch); + } + + function render(state) { + // A permission failure comes back as a bare {error: ...} with no status. + if (!state || !state.status) { + return; + } + + const counts = state.counts || {}; + const total = parseInt(state.total, 10) || 0; + const processed = parseInt(state.processed, 10) || 0; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + el.total.textContent = total; + el.processed.textContent = processed; + el.percentage.textContent = `(${percentage}%)`; + el.progressBar.style.width = `${percentage}%`; + + el.optimized.textContent = counts.done || 0; + el.skipped.textContent = counts.skipped || 0; + el.failed.textContent = counts.failed || 0; + el.processing.textContent = counts.processing || 0; + // Everything not yet given a status is waiting too, not just the marked rows. + el.pending.textContent = Math.max(0, total - processed - (counts.processing || 0)); + + el.state.textContent = statusLabel(state); + + renderLog(state.log || []); + + const running = state.status === 'running'; + el.start.disabled = running; + el.cancel.disabled = !running; + + if (running) { + schedulePoll(); + } else { + stopPolling(); + } + } + + function schedulePoll() { + if (pollTimer) { + return; + } + pollTimer = setTimeout(async () => { + pollTimer = null; + const state = await request('tiny_bulk_queue_status'); + if (state) { + render(state); + } else { + schedulePoll(); + } + }, POLL_INTERVAL); + } + + function stopPolling() { + clearTimeout(pollTimer); + pollTimer = null; + } + + async function start() { + el.start.disabled = true; + // Resets the table: a new run reports on a fresh set of attachments. + renderedIds.clear(); + el.tbody.replaceChildren(); + render(await request('tiny_bulk_queue_start')); + } + + async function cancel() { + el.cancel.disabled = true; + render(await request('tiny_bulk_queue_cancel')); + } + + window.tinyBulkQueue = (state) => { + el = { + start: document.getElementById('tiny-queue-start'), + cancel: document.getElementById('tiny-queue-cancel'), + total: document.getElementById('tiny-queue-total'), + processed: document.getElementById('tiny-queue-processed'), + percentage: document.getElementById('tiny-queue-percentage'), + optimized: document.getElementById('tiny-queue-optimized'), + skipped: document.getElementById('tiny-queue-skipped'), + failed: document.getElementById('tiny-queue-failed'), + processing: document.getElementById('tiny-queue-processing'), + pending: document.getElementById('tiny-queue-pending'), + state: document.getElementById('tiny-queue-state'), + progressBar: document.querySelector('#tiny-queue-progress-bar #progress-size'), + tbody: document.querySelector('#tiny-queue-items tbody'), + }; + + el.start.addEventListener('click', start); + el.cancel.addEventListener('click', cancel); + render(state); + }; +})(); diff --git a/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-async-request.php b/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-async-request.php new file mode 100644 index 00000000..75891cd3 --- /dev/null +++ b/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-async-request.php @@ -0,0 +1,183 @@ +identifier = $this->prefix . '_' . $this->action; + add_action('wp_ajax_' . $this->identifier, array($this, 'maybe_handle')); + add_action('wp_ajax_nopriv_' . $this->identifier, array($this, 'maybe_handle')); + } + /** + * Set data used during the request. + * + * @param array $data Data. + * + * @return $this + */ + public function data($data) + { + $this->data = $data; + return $this; + } + /** + * Dispatch the async request. + * + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. + */ + public function dispatch() + { + $url = add_query_arg($this->get_query_args(), $this->get_query_url()); + $args = $this->get_post_args(); + return wp_remote_post(esc_url_raw($url), $args); + } + /** + * Get query args. + * + * @return array + */ + protected function get_query_args() + { + if (property_exists($this, 'query_args')) { + return $this->query_args; + } + $args = array('action' => $this->identifier, 'nonce' => wp_create_nonce($this->identifier)); + /** + * Filters the query arguments used during an async request. + * + * @param array $args + */ + return apply_filters($this->identifier . '_query_args', $args); + } + /** + * Get query URL. + * + * @return string + */ + protected function get_query_url() + { + if (property_exists($this, 'query_url')) { + return $this->query_url; + } + $url = admin_url('admin-ajax.php'); + /** + * Filters the query URL used during an async request. + * + * @param string $url + */ + return apply_filters($this->identifier . '_query_url', $url); + } + /** + * Get post args. + * + * @return array + */ + protected function get_post_args() + { + if (property_exists($this, 'post_args')) { + return $this->post_args; + } + $args = array( + 'timeout' => 5, + 'blocking' => false, + 'body' => $this->data, + 'cookies' => $_COOKIE, + // Passing cookies ensures request is performed as initiating user. + 'sslverify' => apply_filters('https_local_ssl_verify', false), + ); + /** + * Filters the post arguments used during an async request. + * + * @param array $args + */ + return apply_filters($this->identifier . '_post_args', $args); + } + /** + * Maybe handle a dispatched request. + * + * Check for correct nonce and pass to handler. + * + * @return void|mixed + */ + public function maybe_handle() + { + // Don't lock up other requests while processing. + session_write_close(); + check_ajax_referer($this->identifier, 'nonce'); + $this->handle(); + return $this->maybe_wp_die(); + } + /** + * Should the process exit with wp_die? + * + * @param mixed $return What to return if filter says don't die, default is null. + * + * @return void|mixed + */ + protected function maybe_wp_die($return = null) + { + /** + * Should wp_die be used? + * + * @return bool + */ + if (apply_filters($this->identifier . '_wp_die', true)) { + wp_die(); + } + return $return; + } + /** + * Handle a dispatched request. + * + * Override this method to perform any actions required + * during the async request. + */ + abstract protected function handle(); +} \ No newline at end of file diff --git a/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-background-process.php b/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-background-process.php new file mode 100644 index 00000000..c0f4bd10 --- /dev/null +++ b/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-background-process.php @@ -0,0 +1,857 @@ +allowed_batch_data_classes || true !== $allowed_batch_data_classes) { + $this->allowed_batch_data_classes = $allowed_batch_data_classes; + } + $this->cron_hook_identifier = $this->identifier . '_cron'; + $this->cron_interval_identifier = $this->identifier . '_cron_interval'; + add_action($this->cron_hook_identifier, array($this, 'handle_cron_healthcheck')); + add_filter('cron_schedules', array($this, 'schedule_cron_healthcheck')); + // Ensure dispatch query args included extra data. + add_filter($this->identifier . '_query_args', array($this, 'filter_dispatch_query_args')); + } + /** + * Schedule the cron healthcheck and dispatch an async request to start processing the queue. + * + * @access public + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. + */ + public function dispatch() + { + if ($this->is_processing()) { + // Process already running. + return false; + } + /** + * Filter fired before background process dispatches its next process. + * + * @param bool $cancel Should the dispatch be cancelled? Default false. + * @param string $chain_id The background process chain ID. + */ + $cancel = apply_filters($this->identifier . '_pre_dispatch', false, $this->get_chain_id()); + if ($cancel) { + return false; + } + // Schedule the cron healthcheck. + $this->schedule_event(); + // Perform remote post. + return parent::dispatch(); + } + /** + * Push to the queue. + * + * Note, save must be called in order to persist queued items to a batch for processing. + * + * @param mixed $data Data. + * + * @return $this + */ + public function push_to_queue($data) + { + $this->data[] = $data; + return $this; + } + /** + * Save the queued items for future processing. + * + * @return $this + */ + public function save() + { + $key = $this->generate_key(); + if (!empty($this->data)) { + update_site_option($key, $this->data); + } + // Clean out data so that new data isn't prepended with closed session's data. + $this->data = array(); + return $this; + } + /** + * Update a batch's queued items. + * + * @param string $key Key. + * @param array $data Data. + * + * @return $this + */ + public function update($key, $data) + { + if (!empty($data)) { + update_site_option($key, $data); + } + return $this; + } + /** + * Delete a batch of queued items. + * + * @param string $key Key. + * + * @return $this + */ + public function delete($key) + { + delete_site_option($key); + return $this; + } + /** + * Delete entire job queue. + */ + public function delete_all() + { + $batches = $this->get_batches(); + foreach ($batches as $batch) { + $this->delete($batch->key); + } + delete_site_option($this->get_status_key()); + $this->cancelled(); + } + /** + * Cancel job on next batch. + */ + public function cancel() + { + update_site_option($this->get_status_key(), self::STATUS_CANCELLED); + // Just in case the job was paused at the time. + $this->dispatch(); + } + /** + * Has the process been cancelled? + * + * @return bool + */ + public function is_cancelled() + { + return $this->get_status() === self::STATUS_CANCELLED; + } + /** + * Called when background process has been cancelled. + */ + protected function cancelled() + { + do_action($this->identifier . '_cancelled', $this->get_chain_id()); + } + /** + * Pause job on next batch. + */ + public function pause() + { + update_site_option($this->get_status_key(), self::STATUS_PAUSED); + } + /** + * Has the process been paused? + * + * @return bool + */ + public function is_paused() + { + return $this->get_status() === self::STATUS_PAUSED; + } + /** + * Called when background process has been paused. + */ + protected function paused() + { + do_action($this->identifier . '_paused', $this->get_chain_id()); + } + /** + * Resume job. + */ + public function resume() + { + delete_site_option($this->get_status_key()); + $this->schedule_event(); + $this->dispatch(); + $this->resumed(); + } + /** + * Called when background process has been resumed. + */ + protected function resumed() + { + do_action($this->identifier . '_resumed', $this->get_chain_id()); + } + /** + * Is queued? + * + * @return bool + */ + public function is_queued() + { + return !$this->is_queue_empty(); + } + /** + * Is the tool currently active, e.g. starting, working, paused or cleaning up? + * + * @return bool + */ + public function is_active() + { + return $this->is_queued() || $this->is_processing() || $this->is_paused() || $this->is_cancelled(); + } + /** + * Generate key for a batch. + * + * Generates a unique key based on microtime. Queue items are + * given a unique key so that they can be merged upon save. + * + * @param int $length Optional max length to trim key to, defaults to 64 characters. + * @param string $key Optional string to append to identifier before hash, defaults to "batch". + * + * @return string + */ + protected function generate_key($length = 64, $key = 'batch') + { + $unique = md5(microtime() . wp_rand()); + $prepend = $this->identifier . '_' . $key . '_'; + return substr($prepend . $unique, 0, $length); + } + /** + * Get the status key. + * + * @return string + */ + protected function get_status_key() + { + return $this->identifier . '_status'; + } + /** + * Get the status value for the process. + * + * @return int + */ + protected function get_status() + { + global $wpdb; + if (is_multisite()) { + $status = $wpdb->get_var($wpdb->prepare("SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = %s AND site_id = %d LIMIT 1", $this->get_status_key(), get_current_network_id())); + } else { + $status = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $this->get_status_key())); + } + return absint($status); + } + /** + * Maybe process a batch of queued items. + * + * Checks whether data exists within the queue and that + * the process is not already running. + */ + public function maybe_handle() + { + // Don't lock up other requests while processing. + session_write_close(); + check_ajax_referer($this->identifier, 'nonce'); + // Background process already running. + if ($this->is_processing()) { + return $this->maybe_wp_die(); + } + // Cancel requested. + if ($this->is_cancelled()) { + $this->clear_scheduled_event(); + $this->delete_all(); + return $this->maybe_wp_die(); + } + // Pause requested. + if ($this->is_paused()) { + $this->clear_scheduled_event(); + $this->paused(); + return $this->maybe_wp_die(); + } + // No data to process. + if ($this->is_queue_empty()) { + return $this->maybe_wp_die(); + } + $this->handle(); + return $this->maybe_wp_die(); + } + /** + * Is queue empty? + * + * @return bool + */ + protected function is_queue_empty() + { + return empty($this->get_batch()); + } + /** + * Is process running? + * + * Check whether the current process is already running + * in a background process. + * + * @return bool + * + * @deprecated 1.1.0 Superseded. + * @see is_processing() + */ + protected function is_process_running() + { + return $this->is_processing(); + } + /** + * Is the background process currently running? + * + * @return bool + */ + public function is_processing() + { + if (get_site_transient($this->identifier . '_process_lock')) { + // Process already running. + return true; + } + return false; + } + /** + * Lock process. + * + * Lock the process so that multiple instances can't run simultaneously. + * Override if applicable, but the duration should be greater than that + * defined in the time_exceeded() method. + * + * @param bool $reset_start_time Optional, default true. + */ + public function lock_process($reset_start_time = true) + { + if ($reset_start_time) { + $this->start_time = time(); + // Set start time of current process. + } + $lock_duration = property_exists($this, 'queue_lock_time') ? $this->queue_lock_time : 60; + // 1 minute + $lock_duration = apply_filters($this->identifier . '_queue_lock_time', $lock_duration); + $microtime = microtime(); + $locked = set_site_transient($this->identifier . '_process_lock', $microtime, $lock_duration); + /** + * Action to note whether the background process managed to create its lock. + * + * The lock is used to signify that a process is running a task and no other + * process should be allowed to run the same task until the lock is released. + * + * @param bool $locked Whether the lock was successfully created. + * @param string $microtime Microtime string value used for the lock. + * @param int $lock_duration Max number of seconds that the lock will live for. + * @param string $chain_id Current background process chain ID. + */ + do_action($this->identifier . '_process_locked', $locked, $microtime, $lock_duration, $this->get_chain_id()); + } + /** + * Unlock process. + * + * Unlock the process so that other instances can spawn. + * + * @return $this + */ + protected function unlock_process() + { + $unlocked = delete_site_transient($this->identifier . '_process_lock'); + /** + * Action to note whether the background process managed to release its lock. + * + * The lock is used to signify that a process is running a task and no other + * process should be allowed to run the same task until the lock is released. + * + * @param bool $unlocked Whether the lock was released. + * @param string $chain_id Current background process chain ID. + */ + do_action($this->identifier . '_process_unlocked', $unlocked, $this->get_chain_id()); + return $this; + } + /** + * Get batch. + * + * @return stdClass Return the first batch of queued items. + */ + protected function get_batch() + { + return array_reduce($this->get_batches(1), static function ($carry, $batch) { + return $batch; + }, array()); + } + /** + * Get batches. + * + * @param int $limit Number of batches to return, defaults to all. + * + * @return array of stdClass + */ + public function get_batches($limit = 0) + { + global $wpdb; + if (empty($limit) || !is_int($limit)) { + $limit = 0; + } + $table = $wpdb->options; + $column = 'option_name'; + $key_column = 'option_id'; + $value_column = 'option_value'; + if (is_multisite()) { + $table = $wpdb->sitemeta; + $column = 'meta_key'; + $key_column = 'meta_id'; + $value_column = 'meta_value'; + } + $key = $wpdb->esc_like($this->identifier . '_batch_') . '%'; + $sql = ' + SELECT * + FROM ' . $table . ' + WHERE ' . $column . ' LIKE %s + ORDER BY ' . $key_column . ' ASC + '; + $args = array($key); + if (!empty($limit)) { + $sql .= ' LIMIT %d'; + $args[] = $limit; + } + $items = $wpdb->get_results($wpdb->prepare( + $sql, + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $args + )); + $batches = array(); + if (!empty($items)) { + $allowed_classes = $this->allowed_batch_data_classes; + $batches = array_map(static function ($item) use ($column, $value_column, $allowed_classes) { + $batch = new stdClass(); + $batch->key = $item->{$column}; + $batch->data = static::maybe_unserialize($item->{$value_column}, $allowed_classes); + return $batch; + }, $items); + } + return $batches; + } + /** + * Handle a dispatched request. + * + * Pass each queue item to the task handler, while remaining + * within server memory and time limit constraints. + */ + protected function handle() + { + $this->lock_process(); + /** + * Number of seconds to sleep between batches. Defaults to 0 seconds, minimum 0. + * + * @param int $seconds + */ + $throttle_seconds = max(0, apply_filters($this->identifier . '_seconds_between_batches', apply_filters($this->prefix . '_seconds_between_batches', 0))); + do { + $batch = $this->get_batch(); + foreach ($batch->data as $key => $value) { + $task = $this->task($value); + if (false !== $task) { + $batch->data[$key] = $task; + } else { + unset($batch->data[$key]); + } + // Keep the batch up to date while processing it. + if (!empty($batch->data)) { + $this->update($batch->key, $batch->data); + } + // Let the server breathe a little. + sleep($throttle_seconds); + // Batch limits reached, or pause or cancel requested. + if (!$this->should_continue()) { + break; + } + } + // Delete current batch if fully processed. + if (empty($batch->data)) { + $this->delete($batch->key); + } + } while (!$this->is_queue_empty() && $this->should_continue()); + $this->unlock_process(); + // Start next batch or complete process. + if (!$this->is_queue_empty()) { + $this->dispatch(); + } else { + $this->complete(); + } + return $this->maybe_wp_die(); + } + /** + * Memory exceeded? + * + * Ensures the batch process never exceeds 90% + * of the maximum WordPress memory. + * + * @return bool + */ + protected function memory_exceeded() + { + $memory_limit = $this->get_memory_limit() * 0.9; + // 90% of max memory + $current_memory = memory_get_usage(true); + $return = false; + if ($current_memory >= $memory_limit) { + $return = true; + } + return apply_filters($this->identifier . '_memory_exceeded', $return); + } + /** + * Get memory limit in bytes. + * + * @return int + */ + protected function get_memory_limit() + { + if (function_exists('ini_get')) { + $memory_limit = ini_get('memory_limit'); + } else { + // Sensible default. + $memory_limit = '128M'; + } + if (!$memory_limit || -1 === intval($memory_limit)) { + // Unlimited, set to 32GB. + $memory_limit = '32000M'; + } + return wp_convert_hr_to_bytes($memory_limit); + } + /** + * Time limit exceeded? + * + * Ensures the batch never exceeds a sensible time limit. + * A timeout limit of 30s is common on shared hosting. + * + * @return bool + */ + protected function time_exceeded() + { + $finish = $this->start_time + apply_filters($this->identifier . '_default_time_limit', 20); + // 20 seconds + $return = false; + if (time() >= $finish) { + $return = true; + } + return apply_filters($this->identifier . '_time_exceeded', $return); + } + /** + * Complete processing. + * + * Override if applicable, but ensure that the below actions are + * performed, or, call parent::complete(). + */ + protected function complete() + { + delete_site_option($this->get_status_key()); + // Remove the cron healthcheck job from the cron schedule. + $this->clear_scheduled_event(); + $this->completed(); + } + /** + * Called when background process has completed. + */ + protected function completed() + { + do_action($this->identifier . '_completed', $this->get_chain_id()); + } + /** + * Get the cron healthcheck interval in minutes. + * + * Default is 5 minutes, minimum is 1 minute. + * + * @return int + */ + public function get_cron_interval() + { + $interval = 5; + if (property_exists($this, 'cron_interval')) { + $interval = $this->cron_interval; + } + $interval = apply_filters($this->cron_interval_identifier, $interval); + return is_int($interval) && 0 < $interval ? $interval : 5; + } + /** + * Schedule the cron healthcheck job. + * + * @access public + * + * @param mixed $schedules Schedules. + * + * @return mixed + */ + public function schedule_cron_healthcheck($schedules) + { + $interval = $this->get_cron_interval(); + if (1 === $interval) { + $display = __('Every Minute'); + } else { + $display = sprintf(__('Every %d Minutes'), $interval); + } + // Adds an "Every NNN Minute(s)" schedule to the existing cron schedules. + $schedules[$this->cron_interval_identifier] = array('interval' => MINUTE_IN_SECONDS * $interval, 'display' => $display); + return $schedules; + } + /** + * Handle cron healthcheck event. + * + * Restart the background process if not already running + * and data exists in the queue. + */ + public function handle_cron_healthcheck() + { + if ($this->is_processing()) { + // Background process already running. + exit; + } + if ($this->is_queue_empty()) { + // No data to process. + $this->clear_scheduled_event(); + exit; + } + $this->dispatch(); + } + /** + * Schedule the cron healthcheck event. + */ + protected function schedule_event() + { + if (!wp_next_scheduled($this->cron_hook_identifier)) { + wp_schedule_event(time() + $this->get_cron_interval() * MINUTE_IN_SECONDS, $this->cron_interval_identifier, $this->cron_hook_identifier); + } + } + /** + * Clear scheduled cron healthcheck event. + */ + protected function clear_scheduled_event() + { + $timestamp = wp_next_scheduled($this->cron_hook_identifier); + if ($timestamp) { + wp_unschedule_event($timestamp, $this->cron_hook_identifier); + } + } + /** + * Cancel the background process. + * + * Stop processing queue items, clear cron job and delete batch. + * + * @deprecated 1.1.0 Superseded. + * @see cancel() + */ + public function cancel_process() + { + $this->cancel(); + } + /** + * Perform task with queued item. + * + * Override this method to perform any actions required on each + * queue item. Return the modified item for further processing + * in the next pass through. Or, return false to remove the + * item from the queue. + * + * @param mixed $item Queue item to iterate over. + * + * @return mixed + */ + abstract protected function task($item); + /** + * Maybe unserialize data, but not if an object. + * + * @param mixed $data Data to be unserialized. + * @param bool|array $allowed_classes Array of class names that can be unserialized. + * + * @return mixed + */ + protected static function maybe_unserialize($data, $allowed_classes) + { + if (is_serialized($data)) { + $options = array(); + if (is_bool($allowed_classes) || is_array($allowed_classes)) { + $options['allowed_classes'] = $allowed_classes; + } + return @unserialize($data, $options); + // @phpcs:ignore + } + return $data; + } + /** + * Should any processing continue? + * + * @return bool + */ + public function should_continue() + { + /** + * Filter whether the current background process should continue running the task + * if there is data to be processed. + * + * If the processing time or memory limits have been exceeded, the value will be false. + * If pause or cancel have been requested, the value will be false. + * + * It is very unlikely that you would want to override a false value with true. + * + * If false is returned here, it does not necessarily mean background processing is + * complete. If there is batch data still to be processed and pause or cancel have not + * been requested, it simply means this background process should spawn a new process + * for the chain to continue processing and then close itself down. + * + * @param bool $continue Should the current process continue processing the task? + * @param string $chain_id The current background process chain's ID. + * + * @return bool + */ + return apply_filters($this->identifier . '_should_continue', !($this->time_exceeded() || $this->memory_exceeded() || $this->is_paused() || $this->is_cancelled()), $this->get_chain_id()); + } + /** + * Get the string used to identify this type of background process. + * + * @return string + */ + public function get_identifier() + { + return $this->identifier; + } + /** + * Return the current background process chain's ID. + * + * If the chain's ID hasn't been set before this function is first used, + * and hasn't been passed as a query arg during dispatch, + * the chain ID will be generated before being returned. + * + * @return string + */ + public function get_chain_id() + { + if (empty($this->chain_id) && wp_doing_ajax()) { + check_ajax_referer($this->identifier, 'nonce'); + if (!empty($_GET[$this->get_chain_id_arg_name()])) { + $chain_id = sanitize_key($_GET[$this->get_chain_id_arg_name()]); + if (wp_is_uuid($chain_id)) { + $this->chain_id = $chain_id; + return $this->chain_id; + } + } + } + if (empty($this->chain_id)) { + $this->chain_id = wp_generate_uuid4(); + } + return $this->chain_id; + } + /** + * Filters the query arguments used during an async request. + * + * @param array $args Current query args. + * + * @return array + */ + public function filter_dispatch_query_args($args) + { + $args[$this->get_chain_id_arg_name()] = $this->get_chain_id(); + return $args; + } + /** + * Get the query arg name used for passing the chain ID to new processes. + * + * @return string + */ + private function get_chain_id_arg_name() + { + static $chain_id_arg_name; + if (!empty($chain_id_arg_name)) { + return $chain_id_arg_name; + } + /** + * Filter the query arg name used for passing the chain ID to new processes. + * + * If you encounter problems with using the default query arg name, you can + * change it with this filter. + * + * @param string $chain_id_arg_name Default "chain_id". + * + * @return string + */ + $chain_id_arg_name = apply_filters($this->identifier . '_chain_id_arg_name', self::CHAIN_ID_ARG_NAME); + if (!is_string($chain_id_arg_name) || empty($chain_id_arg_name)) { + $chain_id_arg_name = self::CHAIN_ID_ARG_NAME; + } + return $chain_id_arg_name; + } +} \ No newline at end of file diff --git a/src/views/bulk-optimization-queue.php b/src/views/bulk-optimization-queue.php new file mode 100644 index 00000000..128eaf43 --- /dev/null +++ b/src/views/bulk-optimization-queue.php @@ -0,0 +1,104 @@ + + +
+

+ +

+ + + +

+ +
+
+
+ 0 + / + 0 + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
00000
+ +

+ array( + 'href' => array(), + ), + ) + ), + '' . + esc_html__( 'here', 'tiny-compress-images' ) . '' + ) + ?> +

+ +

+ + + + + + + + + + + + +
+ + + + + + + +
+ + +
diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 37ccc7e0..57309f0f 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -19,6 +19,11 @@ require dirname( __FILE__ ) . '/src/class-tiny-diagnostics.php'; require dirname( __FILE__ ) . '/src/class-tiny-compress.php'; require dirname( __FILE__ ) . '/src/class-tiny-bulk-optimization.php'; +require dirname( __FILE__ ) . + '/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-async-request.php'; +require dirname( __FILE__ ) . + '/src/vendor/prefixed/deliciousbrains/wp-background-processing/classes/wp-background-process.php'; +require dirname( __FILE__ ) . '/src/class-tiny-bulk-queue.php'; require dirname( __FILE__ ) . '/src/class-tiny-image-size.php'; require dirname( __FILE__ ) . '/src/class-tiny-image.php'; require dirname( __FILE__ ) . '/src/class-tiny-settings.php';