diff --git a/README.md b/README.md index c27b3c8..6f10d92 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,15 @@ When providing translations to website visitors, you can configure which languag Each object that is translated will store its cached translation in the `wp_postmeta` table within the database. This caching mechanism ensures that translations are efficiently reused, reducing unnecessary API requests to DeepL and saving costs. -- **Serving Cached Translations:** If a cached translation is newer than the `post_modified` date of the object, the cached version is served. +- **Serving Cached Translations:** Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. - **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. +- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: texts missing from the cache are translated by DeepL and returned together with the cached texts, without storing the result. When the rate limit is reached, only the cached texts are returned. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. ### Admin: Cache Metabox -A metabox labeled **Yard DeepL** is displayed on the edit screen of supported post types (default: `page`). It provides two options: +A metabox labeled **Yard DeepL** is displayed on the edit screen of supported post types (default: all public post types except `attachment`). It provides two options: - **Disable translation cache:** When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. - **Clear translation cache:** When checked and the post is saved, all cached translations for this object are deleted. @@ -40,10 +40,11 @@ A metabox labeled **Yard DeepL** is displayed on the edit screen of supported po A **Translation cache** column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. - **Green badge per language** (e.g. `NL`, `EN-US`): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +- **Yellow badge with a count per language** (e.g. `EN-US 3`): a cached translation exists, but that many visitor requests received only part of their texts because the rest is missing from the cache. Hovering the badge shows the full count as a tooltip. An editor visiting the page translates the missing texts, stores them and resets the count. - **Grey badge with a count per language** (e.g. `EN-US 42`): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. - **—**: the post has never been requested for translation, or caching is disabled for this post. -The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the **Disable translation cache** option enabled are excluded from the column entirely. +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. The same applies to the incomplete response count, which is also removed when the cache is cleared. Posts with the **Disable translation cache** option enabled are excluded from the column entirely. ## External Services @@ -91,7 +92,7 @@ When making requests to the API, ensure that the nonce is included in the reques #### Rate Limiting -To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. +To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **9 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. #### Cache Capability @@ -143,11 +144,21 @@ xhr.send( data ); | Filter | Default | Description | | -------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `yard::deepl/cache_capability` | `'edit_posts'` | The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. | -| `yard::deepl/cache_metabox_post_types` | `['page']` | Post types on which the Yard DeepL cache metabox is displayed. | +| `yard::deepl/cache_metabox_post_types` | public types | Post types on which the Yard DeepL cache metabox is displayed. | | ~~`yard::deepl/disable_cache_metabox_post_types`~~ | — | **Deprecated.** Use `yard::deepl/cache_metabox_post_types` instead. | ## Changelog +### NEXT + +- Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot +- Change: users without the cache capability receive texts missing from the cache from DeepL without storing them; when rate limited they receive the cached texts only +- Change: raise the rate limit from 3 to 9 requests per 60 seconds +- Fix: requests from users with the cache capability no longer count towards the visitor rate limit +- Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts +- Fix: clearing the translation cache also resets the uncached call count +- Change: the cache metabox and translation cache column are shown on all public post types except `attachment` by default + ### 2.1.0 (Aug 03, 2026) - Add: configurable DeepL API base URL setting, defaulting to `https://api.deepl.com/v2/translate` diff --git a/assets/css/admin-columns.css b/assets/css/admin-columns.css index c8949b2..37bec44 100644 --- a/assets/css/admin-columns.css +++ b/assets/css/admin-columns.css @@ -21,6 +21,11 @@ color: #fff; } +.ydpl-cache-badge--incomplete { + background: #dba617; + color: #1d2327; +} + .ydpl-cache-badge--uncached { background: #f0f0f1; color: #3c434a; diff --git a/composer.json b/composer.json index 62bfa98..9544196 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "brianhenryie/strauss": "^0.15.0", "friendsofphp/php-cs-fixer": "^3.0", "symfony/var-dumper": "^5.1", - "wp-coding-standards/wpcs": "^3.0" + "wp-coding-standards/wpcs": "^3.0", + "pestphp/pest": "^1.23" }, "extra": { "strauss": { @@ -73,13 +74,15 @@ "vendor/bin/strauss" ], "phpcs": "./vendor/squizlabs/php_codesniffer/bin/phpcs", - "phpcbf": "./vendor/squizlabs/php_codesniffer/bin/phpcbf" + "phpcbf": "./vendor/squizlabs/php_codesniffer/bin/phpcbf", + "test": "./vendor/bin/pest" }, "config": { "allow-plugins": { "composer/installers": true, "php-http/discovery": true, - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "pestphp/pest-plugin": true } } } diff --git a/composer.lock b/composer.lock index 96afdc0..61d8abd 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": "7e3ce4dbe45335d7c6ded7270692dffc", + "content-hash": "760846e42b25dce8b4d682aeca150461", "packages": [ { "name": "laravel/serializable-closure", @@ -1367,6 +1367,77 @@ ], "time": "2024-08-06T10:04:20+00:00" }, + { + "name": "filp/whoops", + "version": "2.18.6", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/7d0a2cde892089bb09372f4366e9b960eb3001a9", + "reference": "7d0a2cde892089bb09372f4366e9b960eb3001a9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.6" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2026-10-03T17:03:57+00:00" + }, { "name": "friendsofphp/php-cs-fixer", "version": "v3.65.0", @@ -1890,20 +1961,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -1938,15 +2009,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "myclabs/php-enum", @@ -2013,20 +2084,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2041,7 +2111,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -2065,9 +2135,262 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v6.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "f05978827b9343cba381ca05b8c7deee346b6015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f05978827b9343cba381ca05b8c7deee346b6015", + "reference": "f05978827b9343cba381ca05b8c7deee346b6015", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.14.5", + "php": "^8.0.0", + "symfony/console": "^6.0.2" + }, + "require-dev": { + "brianium/paratest": "^6.4.1", + "laravel/framework": "^9.26.1", + "laravel/pint": "^1.1.1", + "nunomaduro/larastan": "^1.0.3", + "nunomaduro/mock-final-classes": "^1.1.0", + "orchestra/testbench": "^7.7", + "phpunit/phpunit": "^9.5.23", + "spatie/ignition": "^1.4.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-develop": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2023-01-03T12:54:54+00:00" + }, + { + "name": "pestphp/pest", + "version": "v1.23.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "5c56ad8772b89611c72a07e23f6e30aa29dc677a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5c56ad8772b89611c72a07e23f6e30aa29dc677a", + "reference": "5c56ad8772b89611c72a07e23f6e30aa29dc677a", + "shasum": "" + }, + "require": { + "nunomaduro/collision": "^5.11.0|^6.4.0", + "pestphp/pest-plugin": "^1.1.0", + "php": "^7.3 || ^8.0", + "phpunit/phpunit": "^9.6.10" + }, + "require-dev": { + "illuminate/console": "^8.83.27", + "illuminate/support": "^8.83.27", + "laravel/dusk": "^6.25.2", + "pestphp/pest-dev-tools": "^1.0.0", + "pestphp/pest-plugin-parallel": "^1.2.1" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Environment" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + }, + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v1.23.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2023-07-12T19:42:47+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "606c5f79c6a339b49838ffbee0151ca519efe378" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/606c5f79c6a339b49838ffbee0151ca519efe378", + "reference": "606c5f79c6a339b49838ffbee0151ca519efe378", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1.0 || ^2.0.0", + "php": "^7.3 || ^8.0" + }, + "conflict": { + "pestphp/pest": "<1.0" + }, + "require-dev": { + "composer/composer": "^2.4.2", + "pestphp/pest": "^1.22.1", + "pestphp/pest-dev-tools": "^1.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager", + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2022-09-18T13:18:17+00:00" }, { "name": "phar-io/manifest", @@ -2674,27 +2997,27 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.21", + "version": "9.6.38", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa" + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", - "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8b5f757e8e92071bfdbfa93943f94f84c4e31007", + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -2705,11 +3028,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -2757,23 +3080,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.21" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.38" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-09-19T10:50:18+00:00" + "time": "2026-10-05T05:44:26+00:00" }, { "name": "psr/cache", @@ -3674,16 +3989,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -3736,15 +4051,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -3934,16 +4261,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", "shasum": "" }, "require": { @@ -3999,28 +4326,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2026-08-11T04:55:59+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -4063,15 +4402,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -4244,16 +4595,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "c85be6922b7fd365942b986b9a50397d65407611" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", "shasum": "" }, "require": { @@ -4295,15 +4646,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2026-08-11T05:25:24+00:00" }, { "name": "sebastian/resource-operations", @@ -4819,16 +5182,16 @@ }, { "name": "symfony/cache-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/df6a1a44c890faded49a5fca33c2d5c5fd3c2197", - "reference": "df6a1a44c890faded49a5fca33c2d5c5fd3c2197", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -4837,12 +5200,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -4875,7 +5238,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -4886,25 +5249,29 @@ "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": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/console", - "version": "v6.4.15", + "version": "v6.4.47", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "url": "https://api.github.com/repos/symfony/console/zipball/0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", "shasum": "" }, "require": { @@ -4969,7 +5336,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.15" + "source": "https://github.com/symfony/console/tree/v6.4.47" }, "funding": [ { @@ -4980,25 +5347,29 @@ "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": "2024-11-06T14:19:14+00:00" + "time": "2026-09-18T10:23:48+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -5006,12 +5377,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -5036,7 +5407,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5047,12 +5418,16 @@ "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": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", @@ -5136,16 +5511,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5154,12 +5529,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -5192,7 +5567,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5203,12 +5578,16 @@ "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": "2024-04-18T09:32:20+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", @@ -5409,16 +5788,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -5433,8 +5812,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5468,7 +5847,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -5479,25 +5858,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -5509,8 +5892,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5546,7 +5929,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -5557,25 +5940,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -5587,8 +5974,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5627,7 +6014,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -5638,28 +6025,33 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5671,8 +6063,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5707,7 +6099,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -5718,16 +6110,20 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -5745,8 +6141,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5783,7 +6179,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" }, "funding": [ { @@ -5794,6 +6190,10 @@ "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" @@ -5803,16 +6203,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -5821,8 +6221,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -5863,7 +6263,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -5874,12 +6274,16 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php81", @@ -6020,16 +6424,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.0", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -6042,12 +6446,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -6083,7 +6487,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -6094,12 +6498,16 @@ "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": "2024-04-18T09:32:20+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stopwatch", @@ -6165,16 +6573,16 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", "shasum": "" }, "require": { @@ -6188,7 +6596,6 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", "symfony/http-client": "^5.4|^6.0|^7.0", "symfony/intl": "^6.2|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -6231,7 +6638,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.46" }, "funding": [ { @@ -6242,12 +6649,16 @@ "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": "2024-11-13T13:31:12+00:00" + "time": "2026-09-11T09:03:57+00:00" }, { "name": "symfony/var-dumper", @@ -6417,16 +6828,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -6455,7 +6866,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -6463,7 +6874,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -6541,5 +6952,5 @@ "php": "^8.0" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/dist/admin-columns.asset.php b/dist/admin-columns.asset.php index f74de63..bb0a903 100644 --- a/dist/admin-columns.asset.php +++ b/dist/admin-columns.asset.php @@ -1 +1 @@ - array(), 'version' => '1d877b8f32d5c2ebba6e'); + array(), 'version' => '0921bcaa4b4ca4ac535b'); diff --git a/dist/admin-columns.css b/dist/admin-columns.css index 18ae8fd..8d7fb5d 100644 --- a/dist/admin-columns.css +++ b/dist/admin-columns.css @@ -1 +1 @@ -.ydpl-cache-badges{display:flex;flex-wrap:wrap;gap:3px}.ydpl-cache-badge{align-items:center;border-radius:3px;display:inline-flex;font-size:10px;font-weight:600;gap:4px;letter-spacing:.03em;line-height:1;padding:3px 6px}.ydpl-cache-badge--cached{background:#00a32a;color:#fff}.ydpl-cache-badge--uncached{background:#f0f0f1;color:#3c434a}.ydpl-badge-count{font-size:9px;font-weight:400;opacity:.7}.ydpl-cache-status--none{color:#8c8f94} +.ydpl-cache-badges{display:flex;flex-wrap:wrap;gap:3px}.ydpl-cache-badge{align-items:center;border-radius:3px;display:inline-flex;font-size:10px;font-weight:600;gap:4px;letter-spacing:.03em;line-height:1;padding:3px 6px}.ydpl-cache-badge--cached{background:#00a32a;color:#fff}.ydpl-cache-badge--incomplete{background:#dba617;color:#1d2327}.ydpl-cache-badge--uncached{background:#f0f0f1;color:#3c434a}.ydpl-badge-count{font-size:9px;font-weight:400;opacity:.7}.ydpl-cache-status--none{color:#8c8f94} diff --git a/dist/editor.asset.php b/dist/editor.asset.php index c31ee19..8712eb1 100644 --- a/dist/editor.asset.php +++ b/dist/editor.asset.php @@ -1 +1 @@ - array(), 'version' => '045a517d31b6755cceb1'); + array(), 'version' => '1e80dfdc79e5d8f97eba'); diff --git a/languages/yard-deepl-nl_NL.mo b/languages/yard-deepl-nl_NL.mo index 1f1d237..26d7fae 100644 Binary files a/languages/yard-deepl-nl_NL.mo and b/languages/yard-deepl-nl_NL.mo differ diff --git a/languages/yard-deepl-nl_NL.po b/languages/yard-deepl-nl_NL.po index a5a63e7..7e191a2 100644 --- a/languages/yard-deepl-nl_NL.po +++ b/languages/yard-deepl-nl_NL.po @@ -229,8 +229,15 @@ msgstr "Geen talen geconfigureerd" msgid "No cached translations" msgstr "Geen gecachede vertalingen" +#. Translators: %d is the number of visitor requests that received only part of their texts from the cache. +#: src/Providers/AdminColumnsServiceProvider.php:90 +msgid "%d incomplete response" +msgid_plural "%d incomplete responses" +msgstr[0] "%d onvolledig antwoord" +msgstr[1] "%d onvolledige antwoorden" + #. Translators: %d is the number of uncached DeepL API calls made for this post and language. -#: src/Providers/AdminColumnsServiceProvider.php:94 +#: src/Providers/AdminColumnsServiceProvider.php:109 msgid "%d uncached call" msgid_plural "%d uncached calls" msgstr[0] "%d aanroep zonder cache" diff --git a/languages/yard-deepl.pot b/languages/yard-deepl.pot index 5bbc018..28fe3db 100644 --- a/languages/yard-deepl.pot +++ b/languages/yard-deepl.pot @@ -232,8 +232,15 @@ msgstr "" msgid "No cached translations" msgstr "" +#. Translators: %d is the number of visitor requests that received only part of their texts from the cache. +#: src/Providers/AdminColumnsServiceProvider.php:90 +msgid "%d incomplete response" +msgid_plural "%d incomplete responses" +msgstr[0] "" +msgstr[1] "" + #. Translators: %d is the number of uncached DeepL API calls made for this post and language. -#: src/Providers/AdminColumnsServiceProvider.php:94 +#: src/Providers/AdminColumnsServiceProvider.php:109 msgid "%d uncached call" msgid_plural "%d uncached calls" msgstr[0] "" diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..eb25b65 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests/Unit + + + diff --git a/readme.txt b/readme.txt index 5c23e83..36a35f8 100644 --- a/readme.txt +++ b/readme.txt @@ -20,15 +20,15 @@ This plugin registers secure API endpoints that allow you to request translation Each object that is translated will store its cached translation in the `wp_postmeta` table within the database. This caching mechanism ensures that translations are efficiently reused, reducing unnecessary API requests to DeepL and saving costs. -* Serving Cached Translations: If a cached translation is newer than the `post_modified` date of the object, the cached version is served. +* Serving Cached Translations: Translations are cached per text. If a cached translation is newer than the `post_modified` date of the object, every requested text found in the cache is served from it. Texts that are not cached yet (for example a changed menu or footer) are fetched from DeepL and merged into the cache. The response contains exactly the requested texts. * Fetching New Translations: When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. -* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. +* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability never write to the cache: texts missing from the cache are translated by DeepL and returned together with the cached texts, without storing the result. When the rate limit is reached, only the cached texts are returned. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. = Admin: Cache Metabox = -A metabox labeled Yard DeepL is displayed on the edit screen of supported post types (default: page). It provides two options: +A metabox labeled Yard DeepL is displayed on the edit screen of supported post types (default: all public post types except attachment). It provides two options: * Disable translation cache: When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. * Clear translation cache: When checked and the post is saved, all cached translations for this object are deleted. @@ -38,10 +38,11 @@ A metabox labeled Yard DeepL is displayed on the edit screen of supported post t A Translation cache column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. * Green badge per language (e.g. NL, EN-US): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +* Yellow badge with a count per language (e.g. EN-US 3): a cached translation exists, but that many visitor requests received only part of their texts because the rest is missing from the cache. Hovering the badge shows the full count as a tooltip. An editor visiting the page translates the missing texts, stores them and resets the count. * Grey badge with a count per language (e.g. EN-US 42): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. * —: the post has never been requested for translation, or caching is disabled for this post. -The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the Disable translation cache option enabled are excluded from the column entirely. +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. The same applies to the incomplete response count, which is also removed when the cache is cleared. Posts with the Disable translation cache option enabled are excluded from the column entirely. == External Services == @@ -74,7 +75,7 @@ The API endpoints registered by this plugin are secured using a WordPress nonce. When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). -Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. +Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 9 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. Requests that are fully served from cache do not count towards the limit. When the cache contains any of the requested texts, users without the cache capability are served the cached texts only; DeepL is not called for the missing texts. Cache capability: Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter. @@ -118,11 +119,21 @@ Response: == Filters == * `yard::deepl/cache_capability` (default: `edit_posts`) — The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. -* `yard::deepl/cache_metabox_post_types` (default: `['page']`) — Post types on which the Yard DeepL cache metabox is displayed. +* `yard::deepl/cache_metabox_post_types` (default: all public post types except `attachment`) — Post types on which the Yard DeepL cache metabox is displayed. * `yard::deepl/disable_cache_metabox_post_types` — Deprecated. Use `yard::deepl/cache_metabox_post_types` instead. == Changelog == += NEXT = + +* Fix: cache translations per text and fetch texts missing from the cache from DeepL instead of serving a stale page snapshot +* Change: users without the cache capability receive texts missing from the cache from DeepL without storing them; when rate limited they receive the cached texts only +* Change: raise the rate limit from 3 to 9 requests per 60 seconds +* Fix: requests from users with the cache capability no longer count towards the visitor rate limit +* Add: yellow badge in the translation cache column for cached languages where visitors received only part of their texts +* Fix: clearing the translation cache also resets the uncached call count +* Change: the cache metabox and translation cache column are shown on all public post types except `attachment` by default + = 2.1.0: Aug 03, 2026 = * Add: configurable DeepL API base URL setting, defaulting to `https://api.deepl.com/v2/translate` diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index d4ed365..a615663 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -22,7 +22,7 @@ */ class RestAPIController { - protected const RATE_LIMIT = 3; + protected const RATE_LIMIT = 9; protected const RATE_LIMIT_TIME_WINDOW_IN_SECONDS = 60; use ErrorLog; @@ -58,14 +58,24 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re } catch ( ObjectNotFoundException $e ) { return $this->set_failure_response( 404, 'Object not found.' ); } + + $missing_texts = $this->service->missing_texts( $text, $cached_translation ); } else { $cached_translation = null; + $missing_texts = $text; } - // Apply rate limit check if object ID is absent or translation is not cached when an object ID is present. - if ( ! $cached_translation ) { - if ( $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { - return $this->set_failure_response( 429, 'Rate limit exceeded.' ); + if ( ! $user_has_cache_capability && array() !== $missing_texts ) { + $cached_texts = $this->service->translations_for( $text, $cached_translation ?? array() ); + + if ( array() !== $cached_texts ) { + $this->service->increment_incomplete_request_count( $object_id, $target_lang ); + } + + if ( $this->is_rate_limit_exceeded() ) { + return array() !== $cached_texts + ? new WP_REST_Response( $cached_texts ) + : $this->set_failure_response( 429, 'Rate limit exceeded.' ); } } diff --git a/src/Providers/AdminColumnsServiceProvider.php b/src/Providers/AdminColumnsServiceProvider.php index 701ef8f..cf917a1 100644 --- a/src/Providers/AdminColumnsServiceProvider.php +++ b/src/Providers/AdminColumnsServiceProvider.php @@ -36,13 +36,22 @@ public function __construct() * @since 2.0.0 */ public function register(): void + { + add_action( 'admin_init', array( $this, 'register_columns' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + } + + /** + * Must run after every post type is registered on init. + * + * @since NEXT + */ + public function register_columns(): void { foreach ( $this->get_cache_metabox_post_types() as $post_type ) { add_filter( "manage_{$post_type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$post_type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); } - - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); } /** @@ -72,14 +81,29 @@ public function render_column( string $column, int $post_id ): void return; } - $column_data = $this->repository->get_column_data( $post_id, $configured_languages ); - $cached_langs = $column_data['cached_languages']; - $uncached_counts = $column_data['uncached_request_counts']; + $column_data = $this->repository->get_column_data( $post_id, $configured_languages ); + $cached_langs = $column_data['cached_languages']; + $uncached_counts = $column_data['uncached_request_counts']; + $incomplete_counts = $column_data['incomplete_request_counts']; $badges = ''; foreach ( $configured_languages as $lang ) { - if ( in_array( $lang, $cached_langs, true ) ) { + if ( in_array( $lang, $cached_langs, true ) && isset( $incomplete_counts[ $lang ] ) ) { + $count = $incomplete_counts[ $lang ]; + $badges .= sprintf( + '%s %s', + esc_attr( + sprintf( + /* translators: %d: number of visitor requests that received only part of their texts from the cache */ + _n( '%d incomplete response', '%d incomplete responses', $count, 'yard-deepl' ), + $count + ) + ), + esc_html( $lang ), + esc_html( number_format_i18n( $count ) ) + ); + } elseif ( in_array( $lang, $cached_langs, true ) ) { $badges .= sprintf( '%s', esc_html( $lang ) diff --git a/src/Repositories/TranslationRepository.php b/src/Repositories/TranslationRepository.php index f895c78..f86d6a6 100644 --- a/src/Repositories/TranslationRepository.php +++ b/src/Repositories/TranslationRepository.php @@ -46,19 +46,22 @@ public function get_cached_translation( int $object_id, string $target_lang ): ? /** * Returns data needed to render the admin list-table column in a single meta fetch. * - * 'cached_languages' — ISO codes from $language_codes whose cache is valid and non-stale. - * 'uncached_request_counts' — map of ISO code => cumulative uncached visitor API calls, only for - * languages that have at least one recorded call. + * 'cached_languages' — ISO codes from $language_codes whose cache is valid and non-stale. + * 'uncached_request_counts' — map of ISO code => cumulative uncached visitor API calls, only for + * languages that have at least one recorded call. + * 'incomplete_request_counts' — map of ISO code => visitor requests that received only part of + * their texts from the cache, only for languages with at least one. * * @since 2.0.0 * - * @return array{ cached_languages: string[], uncached_request_counts: array } + * @return array{ cached_languages: string[], uncached_request_counts: array, incomplete_request_counts: array } */ public function get_column_data( int $object_id, array $language_codes ): array { $empty = array( - 'cached_languages' => array(), - 'uncached_request_counts' => array(), + 'cached_languages' => array(), + 'uncached_request_counts' => array(), + 'incomplete_request_counts' => array(), ); if ( ! $this->translated_object_exists( $object_id ) || $this->is_cache_disabled( $object_id ) ) { @@ -70,6 +73,7 @@ public function get_column_data( int $object_id, array $language_codes ): array $all_meta = get_post_meta( $object_id ); $cached = array(); $counts = array(); + $incomplete_counts = array(); foreach ( $language_codes as $lang ) { $translation_value = $all_meta[ "_translation_$lang" ][0] ?? null; @@ -84,11 +88,18 @@ public function get_column_data( int $object_id, array $language_codes ): array if ( 0 < $count ) { $counts[ $lang ] = $count; } + + $incomplete_count = (int) ( $all_meta[ "_ydpl_incomplete_request_count_$lang" ][0] ?? 0 ); + + if ( 0 < $incomplete_count ) { + $incomplete_counts[ $lang ] = $incomplete_count; + } } return array( - 'cached_languages' => $cached, - 'uncached_request_counts' => $counts, + 'cached_languages' => $cached, + 'uncached_request_counts' => $counts, + 'incomplete_request_counts' => $incomplete_counts, ); } @@ -102,12 +113,24 @@ public function get_column_data( int $object_id, array $language_codes ): array * @since 2.0.0 */ public function increment_uncached_request_count( int $object_id, string $target_lang ): void + { + $this->increment_request_count( $object_id, "_ydpl_uncached_request_count_$target_lang" ); + } + + /** + * @since NEXT + */ + public function increment_incomplete_request_count( int $object_id, string $target_lang ): void + { + $this->increment_request_count( $object_id, "_ydpl_incomplete_request_count_$target_lang" ); + } + + protected function increment_request_count( int $object_id, string $key ): void { if ( ! $this->translated_object_exists( $object_id ) || $this->is_cache_disabled( $object_id ) ) { return; } - $key = "_ydpl_uncached_request_count_$target_lang"; $current = (int) get_post_meta( $object_id, $key, true ); update_post_meta( $object_id, $key, $current + 1 ); @@ -124,12 +147,6 @@ protected function translated_object_exists( int $object_id ): bool } /** - * Deletes all cached translations and their modified timestamps for every language. - * - * Removes both the cached translation data (_translation_) and the - * staleness timestamp (_translation_modified_) written by store_translation(). - * Returns silently when the object does not exist. - * * @since 2.0.0 */ public function delete_cached_translations( int $object_id ): void @@ -141,7 +158,7 @@ public function delete_cached_translations( int $object_id ): void $meta_keys = array_keys( get_post_meta( $object_id ) ); foreach ( $meta_keys as $key ) { - if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_translation_modified_' ) ) { + if ( 0 === strpos( $key, '_translation_' ) || 0 === strpos( $key, '_ydpl_incomplete_request_count_' ) || 0 === strpos( $key, '_ydpl_uncached_request_count_' ) ) { delete_post_meta( $object_id, $key ); } } @@ -165,6 +182,7 @@ public function store_translation( int $object_id, string $target_lang, array $t update_post_meta( $object_id, "_translation_$target_lang", $translation ); update_post_meta( $object_id, "_translation_modified_$target_lang", current_time( 'mysql' ) ); delete_post_meta( $object_id, "_ydpl_uncached_request_count_$target_lang" ); + delete_post_meta( $object_id, "_ydpl_incomplete_request_count_$target_lang" ); } protected function is_cache_disabled( int $object_id ): bool diff --git a/src/Services/TranslationService.php b/src/Services/TranslationService.php index 14c754e..2f341f6 100644 --- a/src/Services/TranslationService.php +++ b/src/Services/TranslationService.php @@ -42,24 +42,70 @@ public function handle_translation( int $object_id, array $text, string $target_ public function handle_translation_with_object_id( int $object_id, array $text, string $target_lang, bool $cache = false, ?array $cached_translation = null ): array { if ( null === $cached_translation ) { - $cached_translation = $this->get_cached_translation( $object_id, $target_lang ); + $cached_translation = $this->get_cached_translation( $object_id, $target_lang ) ?? array(); } - if ( $cached_translation ) { - return $cached_translation; + $missing_texts = $this->missing_texts( $text, $cached_translation ); + + if ( array() === $missing_texts ) { + return $this->translations_for( $text, $cached_translation ); } - $translation = $this->handle_translation_without_object_id( $text, $target_lang ); + $translations = $this->translations_by_text( $this->handle_translation_without_object_id( $missing_texts, $target_lang ) ) + + $this->translations_by_text( $cached_translation ); + $merged = array_map( + fn ( $source, $translation ) => array( + 'text' => (string) $source, + 'translation' => $translation, + ), + array_keys( $translations ), + $translations + ); - if ( ! $cache ) { + if ( $cache ) { + $this->repository->store_translation( $object_id, $target_lang, $merged ); + } else { $this->repository->increment_uncached_request_count( $object_id, $target_lang ); } - if ( $cache ) { - $this->repository->store_translation( $object_id, $target_lang, $translation ); - } + return $this->translations_for( $text, $merged ); + } - return $translation; + /** + * @since NEXT + */ + public function missing_texts( array $text, array $cached_translation ): array + { + $translations_by_text = $this->translations_by_text( $cached_translation ); + $cached_texts = array_keys( $translations_by_text ); + $missing_texts = array_diff( $text, $cached_texts ); + $unique_missing_texts = array_unique( $missing_texts ); + + return array_values( $unique_missing_texts ); + } + + /** + * @since NEXT + */ + public function translations_for( array $text, array $translation ): array + { + $translations = $this->translations_by_text( $translation ); + $known_texts = array_filter( array_values( $text ), fn ( $source ) => isset( $translations[ $source ] ) ); + + return array_values( + array_map( + fn ( $source ) => array( + 'text' => $source, + 'translation' => $translations[ $source ], + ), + $known_texts + ) + ); + } + + protected function translations_by_text( array $translation ): array + { + return array_column( $translation, 'translation', 'text' ); } /** @@ -72,6 +118,14 @@ public function handle_translation_without_object_id( array $text, string $targe return $translation; } + /** + * @since NEXT + */ + public function increment_incomplete_request_count( int $object_id, string $target_lang ): void + { + $this->repository->increment_incomplete_request_count( $object_id, $target_lang ); + } + /** * @since 2.0.0 * diff --git a/src/Traits/CachePostTypesTrait.php b/src/Traits/CachePostTypesTrait.php index cdb43f9..1b48f1b 100644 --- a/src/Traits/CachePostTypesTrait.php +++ b/src/Traits/CachePostTypesTrait.php @@ -26,7 +26,7 @@ protected function get_cache_metabox_post_types(): array { $post_types = apply_filters_deprecated( 'yard::deepl/disable_cache_metabox_post_types', - array( array( 'page' ) ), + array( array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ) ), '2.0.0', 'yard::deepl/cache_metabox_post_types' ); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..82f4bc1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,193 @@ +params[ $key ] ?? null; + } + + public function get_header( string $key ) + { + return null; + } +} + +class WP_REST_Response +{ + public function __construct( public $data = null, public int $status = 200 ) + { + } +} + +function get_post( int $id ): ?WP_Post +{ + return $GLOBALS['ydpl_test']['posts'][ $id ] ?? null; +} + +function get_post_field( string $field, int $id ): string +{ + return get_post( $id )->$field; +} + +function get_post_meta( int $id, string $key = '', bool $single = false ) +{ + $meta = $GLOBALS['ydpl_test']['meta'][ $id ] ?? array(); + + if ( '' === $key ) { + return array_map( fn ( $value ) => array( $value ), $meta ); + } + + return $meta[ $key ] ?? ''; +} + +function update_post_meta( int $id, string $key, $value ): void +{ + $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] = $value; +} + +function delete_post_meta( int $id, string $key ): void +{ + unset( $GLOBALS['ydpl_test']['meta'][ $id ][ $key ] ); +} + +function current_time( string $type ): string +{ + return '2026-10-06 12:00:00'; +} + +function current_user_can( string $capability ): bool +{ + return $GLOBALS['ydpl_test']['can_cache']; +} + +function apply_filters( string $hook, $value ) +{ + return $value; +} + +function apply_filters_deprecated( string $hook, array $args ) +{ + return $args[0]; +} + +function get_post_types( array $args ): array +{ + return array( + 'post' => 'post', + 'page' => 'page', + 'attachment' => 'attachment', + 'pdc-item' => 'pdc-item', + ); +} + +function get_transient( string $key ) +{ + return $GLOBALS['ydpl_test']['transients'][ $key ] ?? false; +} + +function set_transient( string $key, $value, int $expiration ): void +{ + $GLOBALS['ydpl_test']['transients'][ $key ] = $value; +} + +function ydpl_resolve_from_container( string $key ) +{ + if ( isset( $GLOBALS['ydpl_test']['languages'] ) ) { + return new class( $GLOBALS['ydpl_test']['languages'] ) { + public function __construct( private array $languages ) + { + } + + public function configured_supported_languages(): array + { + return $this->languages; + } + }; + } + + return YDPL\Singletons\SiteOptionsSingleton::get_instance( array() ); +} + +function esc_attr( string $text ): string +{ + return $text; +} + +function esc_html( string $text ): string +{ + return $text; +} + +function esc_attr__( string $text, string $domain ): string +{ + return $text; +} + +function _n( string $single, string $plural, int $number, string $domain ): string +{ + return 1 === $number ? $single : $plural; +} + +function number_format_i18n( int $number ): string +{ + return (string) $number; +} + +function fake_deepl_service(): YDPL\Services\TranslationService +{ + return new class() extends YDPL\Services\TranslationService { + public array $deepl_requests = array(); + + public function handle_translation_without_object_id( array $text, string $target_lang ): array + { + $this->deepl_requests[] = $text; + + return array_map( fn ( $source ) => pair( $source, "$target_lang:$source" ), $text ); + } + }; +} + +function given_post( int $id, string $post_modified ): void +{ + $post = new WP_Post(); + $post->post_modified = $post_modified; + + $GLOBALS['ydpl_test']['posts'][ $id ] = $post; +} + +function given_cached_translation( int $id, string $lang, array $translation, string $modified ): void +{ + update_post_meta( $id, "_translation_$lang", $translation ); + update_post_meta( $id, "_translation_modified_$lang", $modified ); +} + +function pair( string $text, string $translation ): array +{ + return array( + 'text' => $text, + 'translation' => $translation, + ); +} + +uses()->beforeEach( function () { + $GLOBALS['ydpl_test'] = array( + 'posts' => array(), + 'meta' => array(), + 'transients' => array(), + 'can_cache' => false, + ); + $_SERVER['REMOTE_ADDR'] = '10.0.0.1'; +} )->in( 'Unit' ); diff --git a/tests/Unit/AdminColumnsServiceProviderTest.php b/tests/Unit/AdminColumnsServiceProviderTest.php new file mode 100644 index 0000000..678d5b7 --- /dev/null +++ b/tests/Unit/AdminColumnsServiceProviderTest.php @@ -0,0 +1,29 @@ +render_column( 'ydpl_translation_cache', 1 ); + + return ob_get_clean(); +} + +it( 'shows a cached language without incomplete responses as cached', function () { + expect( rendered_column() )->toContain( 'ydpl-cache-badge--cached' ); +} ); + +it( 'shows a cached language with incomplete responses as incomplete', function () { + update_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', 2 ); + + expect( rendered_column() ) + ->toContain( 'ydpl-cache-badge--incomplete" title="2 incomplete responses">EN-GB' ) + ->not->toContain( 'ydpl-cache-badge--cached' ); +} ); diff --git a/tests/Unit/CachePostTypesTraitTest.php b/tests/Unit/CachePostTypesTraitTest.php new file mode 100644 index 0000000..b20c79d --- /dev/null +++ b/tests/Unit/CachePostTypesTraitTest.php @@ -0,0 +1,16 @@ +get_cache_metabox_post_types(); + } + }; + + expect( $subject->post_types() )->toBe( array( 'post', 'page', 'pdc-item' ) ); +} ); diff --git a/tests/Unit/RestAPIControllerTest.php b/tests/Unit/RestAPIControllerTest.php new file mode 100644 index 0000000..f3ed767 --- /dev/null +++ b/tests/Unit/RestAPIControllerTest.php @@ -0,0 +1,102 @@ +service = $service; + } + }; +} + +function translate_request( array $text ): WP_REST_Request +{ + return new WP_REST_Request( + array( + 'text' => $text, + 'target_lang' => 'EN-GB', + 'object_id' => 1, + ) + ); +} + +function exhaust_rate_limit( RestAPIController $controller ): void +{ + foreach ( range( 1, 9 ) as $i ) { + $controller->handle_translate_request( translate_request( array( "Tekst $i" ) ) ); + } +} + +beforeEach( function () { + given_post( 1, '2026-10-01 10:00:00' ); + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ), '2026-10-02 10:00:00' ); + $this->service = fake_deepl_service(); + $this->controller = controller( $this->service ); +} ); + +it( 'does not count fully cached requests towards the rate limit', function () { + foreach ( range( 1, 5 ) as $i ) { + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu' ) ) ); + } + + expect( $response->status )->toBe( 200 ) + ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ); +} ); + +it( 'translates texts missing from the cache for visitors without storing them', function () { + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Goedemiddag', 'EN-GB:Goedemiddag' ) ) ) + ->and( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ) + ->and( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ) ); +} ); + +it( 'serves rate limited visitors the cached texts without calling DeepL', function () { + exhaust_rate_limit( $this->controller ); + $this->service->deepl_requests = array(); + + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 200 ) + ->and( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ) + ->and( $this->service->deepl_requests )->toBe( array() ); +} ); + +it( 'translates texts missing from the cache for users with the cache capability', function () { + $GLOBALS['ydpl_test']['can_cache'] = true; + + $response = $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + + expect( $response->data )->toBe( array( pair( 'Menu', 'Menu EN' ), pair( 'Goedemiddag', 'EN-GB:Goedemiddag' ) ) ) + ->and( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ); +} ); + +it( 'rejects a rate limited request when nothing is cached', function () { + exhaust_rate_limit( $this->controller ); + + $response = $this->controller->handle_translate_request( translate_request( array( 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 429 ); +} ); + +it( 'does not count requests from users with the cache capability towards the rate limit', function () { + $GLOBALS['ydpl_test']['can_cache'] = true; + exhaust_rate_limit( $this->controller ); + $GLOBALS['ydpl_test']['can_cache'] = false; + + $response = $this->controller->handle_translate_request( translate_request( array( 'Goedemiddag' ) ) ); + + expect( $response->status )->toBe( 200 ); +} ); + +it( 'counts visitor requests that received only part of their texts from the cache', function () { + $this->controller->handle_translate_request( translate_request( array( 'Menu', 'Goedemiddag' ) ) ); + $this->controller->handle_translate_request( translate_request( array( 'Menu' ) ) ); + + expect( get_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', true ) )->toBe( 1 ); +} ); diff --git a/tests/Unit/TranslationRepositoryTest.php b/tests/Unit/TranslationRepositoryTest.php new file mode 100644 index 0000000..fc15880 --- /dev/null +++ b/tests/Unit/TranslationRepositoryTest.php @@ -0,0 +1,30 @@ +repository = new TranslationRepository(); +} ); + +it( 'reports incomplete request counts per language', function () { + $column_data = $this->repository->get_column_data( 1, array( 'EN-GB', 'DE' ) ); + + expect( $column_data['incomplete_request_counts'] )->toBe( array( 'EN-GB' => 3 ) ); +} ); + +it( 'resets the incomplete request count when a translation is stored', function () { + $this->repository->store_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ) ); + + expect( get_post_meta( 1, '_ydpl_incomplete_request_count_EN-GB', true ) )->toBe( '' ); +} ); + +it( 'deletes the request counts together with the cached translations', function () { + update_post_meta( 1, '_ydpl_uncached_request_count_DE', 5 ); + + $this->repository->delete_cached_translations( 1 ); + + expect( get_post_meta( 1 ) )->toBe( array() ); +} ); diff --git a/tests/Unit/TranslationServiceTest.php b/tests/Unit/TranslationServiceTest.php new file mode 100644 index 0000000..c3f3770 --- /dev/null +++ b/tests/Unit/TranslationServiceTest.php @@ -0,0 +1,73 @@ +service = fake_deepl_service(); +} ); + +it( 'sends only texts missing from the cache to DeepL', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu', 'Goedemiddag', 'Goedemiddag' ), 'EN-GB' ); + + expect( $this->service->deepl_requests )->toBe( array( array( 'Goedemiddag' ) ) ); +} ); + +it( 'does not call DeepL when every text is cached', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB' ); + + expect( $this->service->deepl_requests )->toBe( array() ); +} ); + +it( 'returns exactly the requested texts in request order', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ), pair( 'Admin bar', 'Admin bar EN' ) ), '2026-10-02 10:00:00' ); + + $translation = $this->service->handle_translation_with_object_id( 1, array( 'Nieuws', 'Menu', '2026' ), 'EN-GB' ); + + expect( $translation )->toBe( + array( + pair( 'Nieuws', 'EN-GB:Nieuws' ), + pair( 'Menu', 'Menu EN' ), + pair( '2026', 'EN-GB:2026' ), + ) + ); +} ); + +it( 'merges new translations into the cache when the user may cache', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + update_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', 4 ); + + $this->service->handle_translation_with_object_id( 1, array( 'Nieuws' ), 'EN-GB', true ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) ) + ->toEqualCanonicalizing( array( pair( 'Nieuws', 'EN-GB:Nieuws' ), pair( 'Menu', 'Menu EN' ) ) ) + ->and( get_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', true ) )->toBe( '' ); +} ); + +it( 'keeps the cache untouched and counts the request when the user may not cache', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Menu EN' ) ), '2026-10-02 10:00:00' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Nieuws' ), 'EN-GB' ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'Menu EN' ) ) ) + ->and( get_post_meta( 1, '_ydpl_uncached_request_count_EN-GB', true ) )->toBe( 1 ); +} ); + +it( 'ignores a cache older than the post and replaces it', function () { + given_cached_translation( 1, 'EN-GB', array( pair( 'Menu', 'Old menu EN' ), pair( 'Oud', 'Old EN' ) ), '2026-09-30 10:00:00' ); + + $translation = $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB', true ); + + expect( $translation )->toBe( array( pair( 'Menu', 'EN-GB:Menu' ) ) ) + ->and( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( array( pair( 'Menu', 'EN-GB:Menu' ) ) ); +} ); + +it( 'does not cache when caching is disabled for the post', function () { + update_post_meta( 1, 'ydpl_disable_deepl_translation_cache', '1' ); + + $this->service->handle_translation_with_object_id( 1, array( 'Menu' ), 'EN-GB', true ); + + expect( get_post_meta( 1, '_translation_EN-GB', true ) )->toBe( '' ); +} );