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 3c03f7ec..23f96c85 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.36", @@ -24,7 +25,8 @@ "squizlabs/php_codesniffer": "^3.13", "tinify/tinify": "dev-create-key", "wp-coding-standards/wpcs": "^3.4", - "dealerdirect/phpcodesniffer-composer-installer": "^1.2" + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", + "coenjacobs/mozart": "^1.2" }, "scripts": { "post-install-cmd": "bin/post-install", @@ -38,5 +40,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 e41ac70a..f61501de 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": "cc56189048f01abb5f7cbde318ca14a4", + "content-hash": "6fe319fe58ce6543c153bcbc25b38ca9", "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.8.0", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "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.8.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-09-25T15:24:19+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.1", @@ -375,157 +637,345 @@ "time": "2015-05-11T14:41:42+00:00" }, { - "name": "mikey179/vfsstream", - "version": "v1.6.12", + "name": "league/flysystem", + "version": "3.36.0", "source": { "type": "git", - "url": "https://github.com/bovigo/vfsStream.git", - "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", - "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "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.36.0" }, - "time": "2024-08-29T18:43:31+00:00" + "time": "2026-09-02T08:00:27+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.14.0", + "name": "league/mime-type-detection", + "version": "1.17.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", - "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { - "php": "^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.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^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": "2026-08-11T10:17:44+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.9.0", @@ -1372,6 +1873,59 @@ ], "time": "2026-08-11T06:25:15+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", @@ -2462,6 +3016,756 @@ ], "time": "2026-08-06T00:17:32+00:00" }, + { + "name": "symfony/console", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3a1973458b153f566a8d7499bc06ece323d60063" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063", + "reference": "3a1973458b153f566a8d7499bc06ece323d60063", + "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.19" + }, + "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-09-13T10:38:41+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.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe", + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe", + "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.19" + }, + "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-09-10T19:14:10+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.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "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.19" + }, + "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-09-11T14:46:58+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/src/class-tiny-background-queue.php b/src/class-tiny-background-queue.php new file mode 100644 index 00000000..dd6c688f --- /dev/null +++ b/src/class-tiny-background-queue.php @@ -0,0 +1,391 @@ +settings = $settings; + parent::__construct(); + } + + /** + * Queue attachments and start working through them. + * + * Whatever an earlier run left behind is forgotten first. + * + * @param int[] $ids Attachments to optimize. + * @return bool Whether the run was started. + */ + public function start( array $ids ) { + delete_site_option( $this->get_status_key() ); + + delete_post_meta_by_key( self::META_KEY_STATUS ); + delete_post_meta_by_key( self::META_KEY_RESULT ); + + $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) ); + foreach ( $ids as $id ) { + add_post_meta( $id, self::META_KEY_STATUS, self::STATUS_QUEUED, true ); + } + + for ( $i = 0; $i < self::WORKERS; $i++ ) { + $this->dispatch(); + } + + return true; + } + + /** + * Whether a run still has attachments queued or one being optimized. + * + * Not is_active(): that also counts a cancel flag no loopback request has + * cleared yet, and a run is over once it is cancelled. + * + * @return bool + */ + public function is_running() { + return $this->is_queued() || count( $this->taken_slots() ) > 0; + } + + /** + * Whether every worker slot is taken. + * + * The library asks this before it starts another process, so it keeps + * starting them until all slots are taken. + * + * @return bool + */ + public function is_processing() { + return count( $this->taken_slots() ) >= self::WORKERS; + } + + /** + * Take a free worker slot. + * + * @param bool $reset_start_time Whether this process starts its time limit. + */ + public function lock_process( $reset_start_time = true ) { + if ( $reset_start_time ) { + $this->start_time = time(); + } + + $free = array_diff( range( 1, self::WORKERS ), $this->taken_slots() ); + $this->slot = $free ? reset( $free ) : self::WORKERS; + + set_site_transient( $this->slot_key( $this->slot ), microtime(), $this->queue_lock_time ); + } + + /** + * Give the worker slot back. + * + * @return $this + */ + protected function unlock_process() { + delete_site_transient( $this->slot_key( $this->slot ) ); + return $this; + } + + /** + * @return int[] + */ + private function taken_slots() { + return array_filter( + range( 1, self::WORKERS ), + function ( $slot ) { + return (bool) get_site_transient( $this->slot_key( $slot ) ); + } + ); + } + + private function slot_key( $slot ) { + return $this->identifier . '_process_lock_' . $slot; + } + + /** + * Where the given attachments stand in the current run. + * + * @param int[] $ids Attachment IDs. + * @return array Keyed by + * attachment ID. The status is null when the attachment is not part + * of the run; the result is only there once it is done or failed. + */ + public function get_results( array $ids ) { + $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) ); + + update_meta_cache( 'post', $ids ); + + $results = array(); + foreach ( $ids as $id ) { + $status = get_post_meta( $id, self::META_KEY_STATUS, true ); + $finished = self::STATUS_DONE === $status || self::STATUS_FAILED === $status; + + $result = $finished ? get_post_meta( $id, self::META_KEY_RESULT, true ) : null; + + $results[ $id ] = array( + 'status' => '' === $status ? null : $status, + 'result' => $finished ? (array) $result : null, + ); + } + + return $results; + } + + /** + * The next queued attachment, as a batch of one. + * + * Everything in the library that asks whether there is work left comes + * through here, so an empty queue has to be an empty array: a batch without + * data would still count as work. + * + * @param int $limit Number of batches; there is never more than one. + * @return stdClass[] + */ + public function get_batches( $limit = 0 ) { + global $wpdb; + + $id = $wpdb->get_var( + $wpdb->prepare( + "SELECT post_id FROM $wpdb->postmeta + WHERE meta_key = %s AND meta_value = %s + ORDER BY post_id DESC + LIMIT 1", + self::META_KEY_STATUS, + self::STATUS_QUEUED + ) + ); + + if ( is_null( $id ) ) { + return array(); + } + + $batch = new stdClass(); + $batch->key = $this->identifier . '_batch_postmeta'; + $batch->data = array( intval( $id ) ); + + return array( $batch ); + } + + /** + * Nothing to store: an attachment's status records its progress. + * + * @param string $key Batch key. + * @param array $data Batch data. + * @return $this + */ + public function update( $key, $data ) { + return $this; + } + + /** + * Nothing to remove: a batch is used up once its attachment is no longer + * queued. + * + * @param string $key Batch key. + * @return $this + */ + public function delete( $key ) { + return $this; + } + + /** + * Dequeues every attachment and reset queue state + */ + public function delete_all() { + delete_metadata( 'post', 0, self::META_KEY_STATUS, self::STATUS_QUEUED, true ); + delete_site_option( $this->get_status_key() ); + $this->cancelled(); + } + + /** + * Optimize a queued attachment. + * + * The attachment always leaves the queue, whatever happens: one that stayed + * queued would be handed out again and again. + * + * @param int $item Attachment ID. + * @return false The library can forget the item. + */ + protected function task( $item ) { + $id = intval( $item ); + + /* Another process may have claimed it since the batch was read. */ + $claimed = update_post_meta( + $id, + self::META_KEY_STATUS, + self::STATUS_PROCESSING, + self::STATUS_QUEUED + ); + + if ( ! $claimed ) { + return false; + } + + try { + $result = $this->compress( $id ); + $status = $result['failed'] > 0 ? self::STATUS_FAILED : self::STATUS_DONE; + } catch ( Exception $e ) { + $result = array( + 'success' => 0, + 'failed' => 1, + 'message' => $e->getMessage(), + 'size_change' => 0, + ); + $status = self::STATUS_FAILED; + } + + update_post_meta( $id, self::META_KEY_RESULT, $result ); + update_post_meta( $id, self::META_KEY_STATUS, $status ); + + + $compressor = $this->settings->get_compressor(); + if ( $compressor && $compressor->limit_reached() ) { + $this->cancel(); + } + + return false; + } + + /** + * Compress an attachment and say what that came to. + * + * @param int $id Attachment ID. + * @return array{success: int, failed: int, message: string|null, size_change: int} + */ + private function compress( $id ) { + $active_sizes = $this->settings->get_sizes(); + $active_tinify_sizes = $this->settings->get_active_tinify_sizes(); + + /* Statistics are worked out once per instance, so before needs its own. */ + $before = new Tiny_Image( $this->settings, $id ); + $before = $before->get_statistics( $active_sizes, $active_tinify_sizes ); + + $tiny_image = new Tiny_Image( $this->settings, $id ); + + Tiny_Logger::debug( + 'compress from background queue', + array( + 'image_id' => $id, + ) + ); + + $result = $tiny_image->compress(); + wp_update_attachment_metadata( $id, $tiny_image->get_wp_metadata() ); + + $after = $tiny_image->get_statistics( $active_sizes, $active_tinify_sizes ); + + return array( + 'success' => isset( $result['success'] ) ? intval( $result['success'] ) : 0, + 'failed' => isset( $result['failed'] ) ? intval( $result['failed'] ) : 0, + 'message' => $tiny_image->get_latest_error(), + 'size_change' => $after['compressed_total_size'] - $before['compressed_total_size'], + ); + } + + /** + * Do not wait for the loopback request, as spawn_cron() does not: it runs + * the queue and does not answer until it stops. + * + * @return array + */ + protected function get_post_args() { + $args = parent::get_post_args(); + $args['timeout'] = 0.01; + return $args; + } + /** + * ID the library passes to its hooks. + * + * The library's own version checks the nonce on every AJAX request and + * needs WordPress 4.9. Nothing in the plugin listens to the chain ID, so a + * fresh one per request is enough. + * + * @return string + */ + public function get_chain_id() { + if ( empty( $this->started_chain_id ) ) { + $this->started_chain_id = wp_generate_password( 32, false ); + } + + return $this->started_chain_id; + } +} diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index 735d8328..83b11a43 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -22,10 +22,13 @@ class Tiny_Plugin extends Tiny_WP_Base { const MEDIA_COLUMN = self::NAME; const DATETIME_FORMAT = 'Y-m-d G:i:s'; - private static $version; - private $settings; - private $twig; + /** + * Background process + * + * @var Tiny_Background_Queue + */ + private $bulk_queue; public static function jpeg_quality() { return 85; @@ -42,6 +45,7 @@ public function __construct() { parent::__construct(); $this->settings = new Tiny_Settings(); new Tiny_Conversion( $this->settings ); + $this->bulk_queue = new Tiny_Background_Queue( $this->settings ); } public function set_compressor( $compressor ) { @@ -100,8 +104,18 @@ public function ajax_init() { ); add_action( - 'wp_ajax_tiny_compress_image_for_bulk', - $this->get_method( 'compress_image_for_bulk' ) + '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( @@ -304,10 +318,6 @@ public function enqueue_scripts( $hook ) { 'No action taken', 'tiny-compress-images' ), - 'L10nDuplicate' => __( - 'Image was already processed', - 'tiny-compress-images' - ), 'L10nBulkAction' => __( 'Compress Images', 'tiny-compress-images' ), 'L10nBulkMarkCompressed' => __( 'Mark as Compressed', @@ -335,6 +345,11 @@ public function enqueue_scripts( $hook ) { 'L10nInternalError' => __( 'Internal error', 'tiny-compress-images' ), 'L10nOutOf' => __( 'out of', 'tiny-compress-images' ), 'L10nWaiting' => __( 'Waiting', 'tiny-compress-images' ), + 'L10nBackgroundNotice' => __( + // phpcs:ignore Generic.Files.LineLength + 'Optimization continues in the background. You can close this page and come back later.', + 'tiny-compress-images' + ), ) ); @@ -594,47 +609,21 @@ public function compress_image_from_library() { exit(); } - public function compress_image_for_bulk() { - $response = $this->validate_ajax_attachment_request(); - if ( isset( $response['error'] ) ) { - echo json_encode( $response ); - exit(); - } - - list($id, $metadata) = $response['data']; - $tiny_image_before = new Tiny_Image( $this->settings, $id, $metadata ); - $image_statistics_before = $tiny_image_before->get_statistics( - $this->settings->get_sizes(), - $this->settings->get_active_tinify_sizes() - ); - $size_before = $image_statistics_before['compressed_total_size']; - - $tiny_image = new Tiny_Image( $this->settings, $id, $metadata ); - - Tiny_Logger::debug( - 'compress from bulk', - array( - 'image_id' => $id, - ) - ); - - $result = $tiny_image->compress(); + /** + * What the bulk optimization page shows about an image once it is done. + * + * @param Tiny_Image $tiny_image The image as it is after compressing. + * @param array $result What compressing it came to: at least + * 'success', 'failed', 'message' and + * 'size_change'. + * @return array + */ + private function bulk_image_result( $tiny_image, array $result ) { $image_statistics = $tiny_image->get_statistics( $this->settings->get_sizes(), $this->settings->get_active_tinify_sizes() ); - wp_update_attachment_metadata( $id, $tiny_image->get_wp_metadata() ); - - // Nonce verified in validate_ajax_attachment_request(). - // phpcs:disable WordPress.Security.NonceVerification.Missing - $current_library_size = isset( $_POST['current_size'] ) ? - intval( wp_unslash( $_POST['current_size'] ) ) - : 0; - // phpcs:enable WordPress.Security.NonceVerification.Missing - $size_after = $image_statistics['compressed_total_size']; - $new_library_size = $current_library_size + $size_after - $size_before; - $result['message'] = $tiny_image->get_latest_error(); $result['image_sizes_compressed'] = $image_statistics['image_sizes_compressed']; $result['image_sizes_converted'] = $image_statistics['image_sizes_converted']; $result['image_sizes_optimized'] = $image_statistics['image_sizes_optimized']; @@ -649,10 +638,10 @@ public function compress_image_for_bulk() { 1 ); - $result['savings'] = $tiny_image->get_savings( $image_statistics ); - $result['status'] = $this->settings->get_status(); - $result['thumbnail'] = wp_get_attachment_image( - $id, + $result['savings'] = $tiny_image->get_savings( $image_statistics ); + $result['status'] = $this->settings->get_status(); + $result['thumbnail'] = wp_get_attachment_image( + $tiny_image->get_id(), array( '30', '30' ), true, array( @@ -660,12 +649,95 @@ public function compress_image_for_bulk() { 'alt' => '', ) ); - $result['size_change'] = $size_after - $size_before; - $result['human_readable_library_size'] = size_format( $new_library_size, 2 ); - echo json_encode( $result ); + return $result; + } - exit(); + /** + * Retrieves images available for optimization and + * queues them for optimization + */ + public function ajax_bulk_queue_start() { + check_ajax_referer( 'tiny-compress', '_nonce' ); + if ( ! current_user_can( 'upload_files' ) ) { + wp_die( -1, 403 ); + } + + /* A run that is still going is followed, not restarted. */ + if ( $this->bulk_queue->is_running() ) { + wp_send_json_success(); + } + + $stats = Tiny_Bulk_Optimization::get_optimization_statistics( $this->settings ); + $ids = wp_list_pluck( $stats['available-for-optimization'], 'ID' ); + $this->bulk_queue->start( $ids ); + + wp_send_json_success(); + } + + /** + * Where the images the bulk optimization page is waiting for stand. + * + * Finished images come with the same details the page gets when it + * compresses an image itself, so it can show them the same way. + */ + public function ajax_bulk_queue_status() { + check_ajax_referer( 'tiny-compress', '_nonce' ); + if ( ! current_user_can( 'upload_files' ) ) { + wp_die( -1, 403 ); + } + + $ids = isset( $_POST['ids'] ) ? + explode( ',', sanitize_text_field( wp_unslash( $_POST['ids'] ) ) ) + : array(); + $library_size = isset( $_POST['current_size'] ) ? + intval( wp_unslash( $_POST['current_size'] ) ) + : 0; + + $running = $this->bulk_queue->is_running(); + + $items = array(); + foreach ( $this->bulk_queue->get_results( $ids ) as $id => $item ) { + if ( is_array( $item['result'] ) ) { + $item['result'] = $this->bulk_image_result( + new Tiny_Image( $this->settings, $id ), + array_merge( + array( + 'success' => 0, + 'failed' => 0, + 'message' => null, + 'size_change' => 0, + ), + $item['result'] + ) + ); + + /* The page applies the results in order, each on top of the last. */ + $library_size += $item['result']['size_change']; + + $item['result']['human_readable_library_size'] = size_format( $library_size, 2 ); + } + + $items[ $id ] = $item; + } + + wp_send_json_success( + array( + 'running' => $running, + 'items' => $items, + ) + ); + } + + public function ajax_bulk_queue_cancel() { + check_ajax_referer( 'tiny-compress', '_nonce' ); + if ( ! current_user_can( 'upload_files' ) ) { + wp_die( -1, 403 ); + } + + $this->bulk_queue->cancel(); + + wp_send_json_success(); } public function ajax_optimization_statistics() { @@ -815,6 +887,7 @@ public function render_bulk_optimization_page() { $remaining_credits = $this->settings->get_remaining_credits(); $is_on_free_plan = $this->settings->is_on_free_plan(); $email_address = $this->settings->get_email_address(); + $bulk_running = $this->bulk_queue->is_running(); include __DIR__ . '/views/bulk-optimization.php'; } diff --git a/src/js/bulk-optimization.js b/src/js/bulk-optimization.js index 4f4fd6a1..a09b8f38 100644 --- a/src/js/bulk-optimization.js +++ b/src/js/bulk-optimization.js @@ -1,6 +1,7 @@ (function() { - const processedItems = []; - var parallelCompressions = 5; + const POLL_INTERVAL = 2000; + const POLL_WINDOW = 10; + let pending = []; function updateProgressBar(successFullCompressions) { var totalToOptimize = parseInt(jQuery('div#compression-progress-bar').data('number-to-optimize'), 10); @@ -68,30 +69,10 @@ } } - function bulkOptimizationCallback(error, data, items, i) { - if (window.optimizationCancelled) { - handleCancellation(); - } - + function bulkOptimizationCallback(data, items, i) { var row = jQuery('#optimization-items tr').eq(parseInt(i, 10)+1); - if (error) { - row.addClass('failed'); - row.find('.status').html(tinyCompress.L10nInternalError + '
' + error.toString()); - row.find('.status').attr('title', error.toString()); - row.find('.status').attr('data-status', 'error'); - data = {}; - } else if (data == null) { - row.addClass('failed'); - row.find('.status').html(tinyCompress.L10nError); - row.find('.status').attr('data-status', 'error'); - data = {}; - } else if (data.error) { - row.addClass('failed'); - row.find('.status').html(tinyCompress.L10nError + '
' + data.error); - row.find('.status').attr('title', data.error); - row.find('.status').attr('data-status', 'error'); - } else if (data.failed > 0) { + if (data.failed > 0) { row.addClass('failed'); row.find('.status').html('' + tinyCompress.L10nLatestError + ': ' + data.message + ''); row.find('.status').attr('title', data.message); @@ -122,88 +103,99 @@ row.find('.initial-size').html(data.initial_total_size); row.find('.optimized-size').html(data.optimized_total_size); row.find('.savings').html(data.savings); - - var totalToOptimize = jQuery('td.status[data-status="waiting"],td.status[data-status="compressing"]').length; - var nextImage; - - if (jQuery('td.status[data-status="waiting"]').length > 0) { - nextImage = jQuery('tr.media-item').index(jQuery('td.status[data-status="waiting"]:first').parents('tr')); - } - if (nextImage !== undefined && items[nextImage]) { - if (!window.optimizationCancelled) { - drawSomeRows(items, 1); - } - bulkOptimizeItem(items, nextImage); - } else if (totalToOptimize === 0) { - var message = jQuery('

'); - message.find('p').html(tinyCompress.L10nAllDone); - message.insertAfter(jQuery('#tiny-bulk-optimization h1')); - jQuery('div#optimization-spinner').css('display', 'none'); - jQuery('div#bulk-optimization-actions').hide(); - jQuery('div.progress').css('animation', 'none'); - } } - function bulkOptimizeItem(items, i) { - if (window.optimizationCancelled) { - return; - } - - const itemID = items[i].ID; - if (processedItems.includes(itemID)) { - const row = jQuery('#optimization-items tr').eq(parseInt(i, 10) + 1); - row.find('.status') - .attr('data-status', 'skipped-duplicate') - .html('' + tinyCompress.L10nDuplicate); + function finishOptimization(message) { + if (message) { + const notice = jQuery('

'); + notice.find('p').text(message); + notice.insertAfter(jQuery('#tiny-bulk-optimization h2')); } + jQuery('div#optimization-spinner').css('display', 'none'); + handleCancellation(); + } - var row = jQuery('#optimization-items tr').eq(parseInt(i, 10)+1); - row.find('.status').removeClass('todo'); - row.find('.status').html('' + tinyCompress.L10nCompressing).attr('data-status', 'compressing'); - jQuery.ajax({ - url: ajaxurl, - type: 'POST', - dataType: 'json', - data: { - _nonce: tinyCompress.nonce, - action: 'tiny_compress_image_for_bulk', - id: itemID, - current_size: window.currentLibraryBytes - }, - success: function(data) { bulkOptimizationCallback(null, data, items, i); }, - error: function(xhr, textStatus, errorThrown) { bulkOptimizationCallback(errorThrown, null, items, i, parallelCompressions); } + // The queue works through the images in the order they are listed, so only + // the first few waiting ones need asking about. + function pollStatus(items) { + const batch = pending.slice(0, POLL_WINDOW); + drawSomeRows(items, batch[batch.length - 1] + 1); + jQuery.post(ajaxurl, { + _nonce: tinyCompress.nonce, + action: 'tiny_bulk_queue_status', + ids: batch.map(function(i) { return items[i].ID; }).join(','), + current_size: window.currentLibraryBytes + }, function(response) { + const data = response.data; + let finished = 0; + const waiting = batch.filter(function(i) { + const item = data.items[items[i].ID] || {}; + if (item.status === 'done' || item.status === 'failed') { + bulkOptimizationCallback(item.result, items, i); + finished++; + } else if (data.running && (item.status === 'queued' || item.status === 'processing')) { + return true; + } else { + jQuery('#optimization-items tr').eq(i + 1).find('.status').html(tinyCompress.L10nCancelled).attr('data-status', 'cancelled'); + } + return false; + }); + pending = waiting.concat(pending.slice(batch.length)); + + if (pending.length > 0 && (data.running || finished > 0)) { + setTimeout(pollStatus, POLL_INTERVAL, items); + } else { + finishOptimization(window.optimizationCancelled ? null : tinyCompress.L10nAllDone); + } + }, 'json').fail(function(xhr) { + if (xhr.status === 403) { + finishOptimization(tinyCompress.L10nInternalError); + } else { + setTimeout(pollStatus, POLL_INTERVAL, items); + } }); - processedItems.push(itemID); - jQuery('#tiny-progress span').html(i + 1); } - function prepareBulkOptimization(items) { + function prepareBulkOptimization(items, running) { window.allBulkOptimizationItems = items; updateProgressBar(0); + if (running) { + startBulkOptimization(items, true); + } } - function startBulkOptimization(items) { + function startBulkOptimization(items, running) { window.optimizationCancelled = false; window.totalRowsDrawn = 0; window.currentLibraryBytes = parseInt(jQuery('#optimized-library-size').data('bytes'), 10); - processedItems.splice(0, processedItems.length); + pending = items.map(function(item, i) { return i; }); + jQuery('div#bulk-optimization-actions input').removeClass('visible'); + jQuery('div#bulk-optimization-actions input#id-optimizing').addClass('visible'); + jQuery('div#bulk-optimization-actions p.optimization-buttons_notice').text(tinyCompress.L10nBackgroundNotice); jQuery('div.progress').css('animation', 'progress-bar 80s linear infinite'); jQuery('div#optimization-spinner').css('display', 'inline-block'); updateProgressBar(0); - drawSomeRows(items, 5 + parallelCompressions); - for (var i = 0; i < parallelCompressions; i++) { - if (items.length >= i+1) { - bulkOptimizeItem(items, i); - } + if (running) { + pollStatus(items); + return; } + + jQuery.post(ajaxurl, { + _nonce: tinyCompress.nonce, + action: 'tiny_bulk_queue_start' + }, function() { + pollStatus(items); + }, 'json').fail(function() { + finishOptimization(tinyCompress.L10nInternalError); + }); } - function drawSomeRows(items, rowsToDraw) { + function drawSomeRows(items, end) { const list = jQuery('#optimization-items tbody'); const start = window.totalRowsDrawn; - const end = Math.min(start + rowsToDraw, items.length); + end = Math.min(end, items.length); for (let i = start; i < end; i++) { const tableRow = ` @@ -215,15 +207,15 @@ `; list.append(tableRow); } - window.totalRowsDrawn = end; + window.totalRowsDrawn = Math.max(start, end); } function cancelOptimization() { window.optimizationCancelled = true; jQuery('div#optimization-spinner').css('display', 'none'); - jQuery(jQuery('#optimization-items tr td.status.todo')).html(tinyCompress.L10nCancelled).attr('data-status', 'cancelled'); jQuery('div#bulk-optimization-actions input').removeClass('visible'); jQuery('div#bulk-optimization-actions input#id-cancelling').addClass('visible'); + jQuery.post(ajaxurl, { _nonce: tinyCompress.nonce, action: 'tiny_bulk_queue_cancel' }); } jQuery('.tiny-bulk-optimization .upgrade-account-notice a#hide-warning').click(function() { @@ -233,8 +225,6 @@ jQuery('div#bulk-optimization-actions input').click(function() { if ((jQuery(this).attr('id') === 'id-start') && jQuery(this).hasClass('visible')) { - jQuery('div#bulk-optimization-actions input#id-start').removeClass('visible'); - jQuery('div#bulk-optimization-actions input#id-optimizing').addClass('visible'); startBulkOptimization(window.allBulkOptimizationItems); } if ((jQuery(this).attr('id') === 'id-cancel') && jQuery(this).hasClass('visible')) { 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-form.php b/src/views/bulk-optimization-form.php index 2373d63d..e3e8c119 100644 --- a/src/views/bulk-optimization-form.php +++ b/src/views/bulk-optimization-form.php @@ -8,5 +8,5 @@ submit_button( esc_attr__( 'Cancelling', 'tiny-compress-images' ) . '...', 'button-primary button-hero red', 'id-cancelling', false ); ?>
-

+

diff --git a/src/views/bulk-optimization.php b/src/views/bulk-optimization.php index 31ad62dc..3ba98ec4 100644 --- a/src/views/bulk-optimization.php +++ b/src/views/bulk-optimization.php @@ -9,6 +9,7 @@ * @var string $email_address * @var bool $is_on_free_plan * @var int $remaining_credits + * @var bool $bulk_running * @var Tiny_Plugin $this */ @@ -326,7 +327,7 @@ ?> diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 7802daa9..8d0319a9 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-background-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';