Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6af270d
Set title of modal to image title
tijmenbruggeman Jul 23, 2026
86e8030
move backup func to image
tijmenbruggeman Jul 23, 2026
88bc995
show backup info and actions on details modal
tijmenbruggeman Jul 23, 2026
7a90ef7
tiny ui fix
tijmenbruggeman Jul 23, 2026
8fc5564
add ajax action for restoring
tijmenbruggeman Jul 24, 2026
38e0029
restore backup js
tijmenbruggeman Jul 24, 2026
39fb07b
add template
tijmenbruggeman Jul 24, 2026
2521b11
style: apply WordPress PHP coding standards
tijmenbruggeman Jul 24, 2026
834e024
delete backup when deleting attachment
tijmenbruggeman Jul 24, 2026
8b380e3
remove unused variables
tijmenbruggeman Jul 24, 2026
c8b54f5
handle error cases
tijmenbruggeman Jul 24, 2026
a3fb73a
test(backup): add restore and clean attachment tests
tijmenbruggeman Jul 24, 2026
befe6d5
refactor(backup): use invoker API for restore dialog
tijmenbruggeman Jul 25, 2026
de2e855
refactor(admin): async restoreBackup with spinner feedback
tijmenbruggeman Jul 26, 2026
a252aab
feat(plugin): add data-tiny-media-id to image elements
tijmenbruggeman Jul 26, 2026
c77f3ea
refactor(backup): improve restore dialog UX and markup structure
tijmenbruggeman Jul 26, 2026
2feb2f3
refactor(compress-details): show backup section only when compressed
tijmenbruggeman Jul 26, 2026
5b1193c
style: fix WPCS spacing in plugin and views
tijmenbruggeman Jul 26, 2026
498c6f1
git commit -m "fix(backup): show error in dialog on restore failure
tijmenbruggeman Jul 26, 2026
70b68bb
fix(admin): prevent Thickbox content duplication on restore
tijmenbruggeman Jul 26, 2026
959d0a4
refactor(helpers): consolidate filesystem init into one method
tijmenbruggeman Jul 30, 2026
af5cdc6
simplify get original image and reset meta data
tijmenbruggeman Jul 30, 2026
7018b13
prevent double escaping
tijmenbruggeman Jul 30, 2026
94eda1b
fix template issues
tijmenbruggeman Jul 31, 2026
2d67c26
formatting
tijmenbruggeman Jul 31, 2026
d39dea5
fix(backup): restore original image from dialog
tijmenbruggeman Aug 2, 2026
dd43184
test(backup): add restore integration tests
tijmenbruggeman Aug 2, 2026
13f9626
resolve metadata tests
tijmenbruggeman Aug 3, 2026
f605450
design pass improvements
tijmenbruggeman Aug 3, 2026
f029496
format
tijmenbruggeman Aug 3, 2026
eb220d1
resolve e2e test
tijmenbruggeman Aug 3, 2026
c648f7a
reload page to view uncompressed image
tijmenbruggeman Aug 3, 2026
431106b
prevent automatic compression to recompress
tijmenbruggeman Aug 3, 2026
2d91d6f
feat(hooks): expose attachment metadata
tijmenbruggeman Aug 3, 2026
73d1a04
fix(backup): prevent duplicate compression
tijmenbruggeman Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/hooks/tiny_image_before_compression.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ Action that runs before compressing an single attachment.
## Arguments

1. `int $attachment_id` - The attachment ID.
2. `array|null $wp_metadata` - The attachment metadata.

The metadata is passed along because WordPress has not necessarily stored it
yet. On upload the compression runs from within
`wp_generate_attachment_metadata`, so `wp_get_attachment_metadata()` can still
be empty at this point. Use the passed metadata instead of looking it up.

## Example

```php
add_action(
'tiny_image_before_compression',
function ( $id ) {
function ( $id, $wp_metadata ) {
// notify system of compression
}
},
10,
2
);
```
16 changes: 15 additions & 1 deletion src/class-tiny-diagnostics.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,13 @@ public function create_diagnostic_zip() {
}

$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
$temp_dir = trailingslashit( get_temp_dir() ) . 'tiny-compress-temp';
if ( false === $wp_filesystem ) {
return new WP_Error(
'filesystem_unavailable',
__( 'WordPress filesystem could not be initialized.', 'tiny-compress-images' )
);
}
Comment on lines +209 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

echo "== candidate files =="
git ls-files | rg '(^|/)(src/class-tiny-diagnostics\.php|.*diagnostic.*|.*tiny.*diagnostic.*)$' || true

echo "== relevant symbols and call sites =="
rg -n -C 8 'create_diagnostic_zip|download_diagnostics|download_zip|filesystem_unavailable|is_wp_error' src/class-tiny-diagnostics.php

echo "== file outline =="
ast-grep outline src/class-tiny-diagnostics.php --lang php

Repository: tinify/wordpress-plugin

Length of output: 3042


🏁 Script executed:

#!/bin/bash
set -eu

echo "== download and archive methods =="
sed -n '176,295p' src/class-tiny-diagnostics.php

echo "== all create_diagnostic_zip callers =="
rg -n -C 6 'create_diagnostic_zip\s*\(' .

echo "== diagnostics tests and related error handling =="
git ls-files | rg '(^|/)(test|tests|spec|phpunit|.*diagnostic.*)' || true
rg -n -C 5 'wp_die\(|wp_send_json_error\(|is_wp_error\(' src | head -n 240

Repository: tinify/wordpress-plugin

Length of output: 10307


🏁 Script executed:

#!/bin/bash
set -eu

echo "== filesystem helper implementation =="
rg -n -C 12 'function\s+get_wp_filesystem|get_wp_filesystem\s*\(' src test | head -n 220

echo "== WP_Error definition and filesystem exists implementations in repository =="
rg -n -C 8 'class\s+WP_Error|function\s+exists\s*\(' . | head -n 260

echo "== diagnostics unit test context =="
sed -n '1,145p' test/unit/Tiny_Diagnostics_Test.php

echo "== exact diagnostic error-return paths =="
python3 - <<'PY'
from pathlib import Path
p = Path("src/class-tiny-diagnostics.php")
text = p.read_text()
start = text.index("public function create_diagnostic_zip")
end = text.index("\n\t/**", start)
for i, line in enumerate(text[start:end].splitlines(), start=text[:start].count("\n")+1):
    if "return new WP_Error" in line or "return $temp_path" in line:
        print(f"{i}: {line.strip()}")
PY

Repository: tinify/wordpress-plugin

Length of output: 18033


🏁 Script executed:

#!/bin/bash
set -eu

echo "== helper return contract =="
sed -n '114,145p' src/class-tiny-helpers.php

echo "== test filesystem path contract =="
sed -n '1,35p' test/wp-includes-for-tests/file.php

echo "== standalone PHP probe: WP_Error passed to file_exists =="
if command -v php >/dev/null 2>&1; then
	php -d display_errors=1 -r '
		class WP_Error {}
		$path = new WP_Error();
		var_dump(file_exists($path));
	'
else
	echo "php unavailable"
fi

echo "== read-only structural verifier for create_diagnostic_zip =="
python3 - <<'PY'
from pathlib import Path
import re

text = Path("src/class-tiny-diagnostics.php").read_text()
start = text.index("public function create_diagnostic_zip")
end = text.index("\n\t/**", start)
body = text[start:end]

errors = re.findall(r"return\s+new\s+WP_Error\s*\(\s*'([^']+)'", body)
print("WP_Error return codes:", errors)
print("success return present:", "return $temp_path;" in body)
print("caller guards result before download_zip:",
      bool(re.search(r"\$zippath\s*=\s*\$this->create_diagnostic_zip\(\);\s*if\s*\(\s*is_wp_error\s*\(\s*\$zippath\s*\)", text)))
PY

Repository: tinify/wordpress-plugin

Length of output: 2592


Handle every WP_Error from create_diagnostic_zip() before calling download_zip().

The error paths zip_not_available, filesystem_unavailable, and zip_create_failed pass a WP_Error object to a method that requires a string path. Add an is_wp_error( $zippath ) guard and report the error message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/class-tiny-diagnostics.php` around lines 209 - 214, Update the caller of
create_diagnostic_zip() before download_zip() to guard with is_wp_error(
$zippath ). When it returns a WP_Error, report its error message and stop before
passing the value to download_zip(); preserve the existing download flow for
valid string paths.

$temp_dir = trailingslashit( get_temp_dir() ) . 'tiny-compress-temp';
if ( ! $wp_filesystem->exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
}
Expand Down Expand Up @@ -249,6 +255,14 @@ public function create_diagnostic_zip() {
*/
public static function download_zip( $zip_path ) {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
wp_die(
esc_html__(
'WordPress filesystem could not be initialized.',
'tiny-compress-images'
)
);
}
if ( ! $wp_filesystem->exists( $zip_path ) ) {
wp_die( esc_html__( 'Diagnostic file not found.', 'tiny-compress-images' ) );
}
Expand Down
9 changes: 4 additions & 5 deletions src/class-tiny-helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,10 @@ public static function get_mimetype( $input ) {
* Gets or initializes the WordPress filesystem instance.
*
* Returns the global WP_Filesystem instance, initializing it if necessary.
* This helper prevents repeated initialization code throughout the plugin.
*
* @since 3.7.0
*
* @return WP_Filesystem_Base The WP_Filesystem instance.
* @throws Exception If the filesystem cannot be initialized.
* @return WP_Filesystem_Base|false The WP_Filesystem instance, or false on failure.
*/
public static function get_wp_filesystem() {
global $wp_filesystem;
Expand All @@ -129,14 +127,15 @@ public static function get_wp_filesystem() {
return $wp_filesystem;
}

// Initialize the filesystem only if the function isn't available yet.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();

if ( ! ( $wp_filesystem instanceof WP_Filesystem_Base ) ) {
throw new Exception( 'Unable to initialize WordPress filesystem.' );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Tiny Compress: Unable to initialize WordPress filesystem.' );
return false;
Comment on lines +136 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file context ---'
sed -n '100,155p' src/class-tiny-helpers.php

printf '%s\n' '--- related filesystem calls and declarations ---'
rg -n -C 3 'WP_Filesystem|wp_filesystem|WP_Filesystem_Base' src tests .github 2>/dev/null || true

printf '%s\n' '--- repository metadata and dependency references ---'
rg -n -C 2 'wordpress|WP_Filesystem' composer.json composer.lock package.json phpunit.xml* README.md 2>/dev/null || true

Repository: tinify/wordpress-plugin

Length of output: 16525


🌐 Web query:

WordPress core WP_Filesystem function source assigns global wp_filesystem before connect returns false

💡 Result:

In the WordPress core function WP_Filesystem, the global variable $wp_filesystem is indeed assigned an instance of the chosen filesystem class before the $wp_filesystem->connect method is called [1][2][3]. The implementation follows this sequence: 1. The filesystem method is determined via get_filesystem_method [1][4]. 2. The corresponding filesystem class is instantiated and assigned to the global variable: $wp_filesystem = new $method($args); [1][5]. 3. The function then proceeds to perform checks (such as verifying potential errors) and defines connection timeouts [1][5][3]. 4. Finally, it attempts to connect using $wp_filesystem->connect [1][5][3]. If $wp_filesystem->connect returns false, the function returns false [1][5][3]. Because the assignment to the global $wp_filesystem has already occurred, the global variable remains populated with the instantiated object even if the connection attempt fails [1][2]. Consequently, developers should verify both that the function returned true and that the filesystem object successfully connected or is ready for use, rather than relying solely on the existence of the global variable [6][7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import urllib.request

url = "https://raw.githubusercontent.com/WordPress/wordpress-develop/trunk/src/wp-admin/includes/file.php"
text = urllib.request.urlopen(url, timeout=10).read().decode()

start = text.index("function WP_Filesystem(")
end = text.find("\n}", start) + 2
print(text[start:end])
PY

Repository: tinify/wordpress-plugin

Length of output: 2447


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import ssl
import urllib.request

url = "https://raw.githubusercontent.com/WordPress/wordpress-develop/trunk/src/wp-admin/includes/file.php"
context = ssl._create_unverified_context()
text = urllib.request.urlopen(url, context=context, timeout=10).read().decode()

start = text.index("function WP_Filesystem(")
# Print only the function body region, bounded by the next documented top-level function.
next_function = text.find("\nfunction ", start + 1)
print(text[start:next_function if next_function != -1 else start + 5000])
PY

Repository: tinify/wordpress-plugin

Length of output: 3341


Check the return value from WP_Filesystem().

A failed connection can leave a WP_Filesystem_Base object in $wp_filesystem while WP_Filesystem() returns false. Capture the return value and require true before returning the global object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/class-tiny-helpers.php` around lines 136 - 138, Update the filesystem
initialization flow around WP_Filesystem() to capture its return value and
require a true result before returning the global $wp_filesystem object.
Preserve the existing error logging and false return path when initialization
fails, even if $wp_filesystem contains an object.

}

return $wp_filesystem;
Expand Down
230 changes: 228 additions & 2 deletions src/class-tiny-image.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,19 @@ public function compress() {
/**
* Fires before an image is sent for compression.
*
* The metadata is passed along because it has not necessarily been
* stored yet. On upload the compression runs from within
* `wp_generate_attachment_metadata`, before WordPress saves it.
*
* @since 3.7.0
*
* @param int $attachment_id The attachment ID
* @param int $attachment_id The attachment ID
* @param array $wp_metadata The attachment metadata
*/
do_action(
'tiny_image_before_compression',
$this->id
$this->id,
$this->wp_metadata
);

$success = 0;
Expand Down Expand Up @@ -675,4 +681,224 @@ public function mark_as_compressed() {

$this->update_tiny_post_meta();
}

/**
* Retrieves the original image of the Tiny_Image
*
*
* @return Tiny_Image_Size|false the image or false if does not exist
*/
private function get_original_image() {
$original_image = $this->get_image_size( self::ORIGINAL_UNSCALED );
if ( null === $original_image ) {
$original_image = $this->get_image_size();
}

if ( null === $original_image ) {
return false;
}

return $original_image;
}

/**
* Builds the filesystem path where the backup of the original image is
* (or would be) stored.
*
* @return string|false the backup file path, or false if there is no original image
*/
private function get_backup_path() {
$original_image = $this->get_original_image();
if ( false === $original_image ) {
return false;
}

$file_path = $original_image->filename;
$upload_dir = wp_upload_dir();
$basedir = trailingslashit( $upload_dir['basedir'] );
if ( Tiny_Helpers::str_starts_with( $file_path, $basedir ) ) {
$file_path = substr( $file_path, strlen( $basedir ) );
}

return $basedir . 'tinify_backup/' . $file_path;
}

/**
* Creates a backup copy of the original image, if one does not already exist.
*
* @return bool true on success, false on failure or if a backup already exists
*/
public function create_backup() {

$backup_file_path = $this->get_backup_path();
if ( false === $backup_file_path ) {
return false;
}

$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return false;
}

if ( $wp_filesystem->exists( $backup_file_path ) ) {
return false;
}

$backup_dir = dirname( $backup_file_path );

if ( ! wp_mkdir_p( $backup_dir ) ) {
return false;
}

$original_image = $this->get_original_image();

return $wp_filesystem->copy( $original_image->filename, $backup_file_path );
}


/**
* Retrieves the public URL of the backup of the original image.
*
* @return string|false the backup URL, or false if no backup exists
*/
public function get_backup() {
$backup_file_path = $this->get_backup_path();
if ( false === $backup_file_path ) {
return false;
}

$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return false;
}

if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
return false;
}

$upload_dir = wp_upload_dir();
$basedir = trailingslashit( $upload_dir['basedir'] );
$baseurl = trailingslashit( $upload_dir['baseurl'] );

return str_replace( $basedir, $baseurl, $backup_file_path );
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Regenerates all thumbnail sizes of an attachment from a file.
*
* Uses wp_create_image_subsizes() when available, which does not apply
* the `wp_generate_attachment_metadata` filter. That filter is what starts
* a compression and the image that has just been restored should be left alone.
*
* @since 3.7.0
*
* @param string $filename Absolute path of the image to regenerate from.
* @return array The regenerated attachment metadata.
*/
private function regenerate_sizes( $filename ) {
// https://developer.wordpress.org/reference/functions/wp_create_image_subsizes/
if ( function_exists( 'wp_create_image_subsizes' ) ) {
return wp_create_image_subsizes( $filename, $this->id );
}

/*
WordPress < 5.3 has no way of regenerating the sizes without applying
the filter, so it is disabled for the duration of the call. */
global $tiny_plugin;
$compress_on_upload = ( $tiny_plugin instanceof Tiny_Plugin )
? array( $tiny_plugin, 'process_attachment' )
: null;

if ( $compress_on_upload ) {
remove_filter( 'wp_generate_attachment_metadata', $compress_on_upload, 10 );
}

// https://developer.wordpress.org/reference/functions/wp_generate_attachment_metadata/
$metadata = wp_generate_attachment_metadata( $this->id, $filename );

if ( $compress_on_upload ) {
add_filter( 'wp_generate_attachment_metadata', $compress_on_upload, 10, 2 );
}

return $metadata;
}

/**
* Restores the original image from its backup.
*
* - Copies the backup file over the current original.
* - Clears compression metadata for all image sizes.
* - Regenerates all thumbnail sizes from the restored image.
* - Updates the WordPress attachment metadata.
*
* @since 3.7.0
*
* @return bool True on success, false if no backup exists or the copy fails.
*/
public function restore_backup() {
$backup_file_path = $this->get_backup_path();
if ( false === $backup_file_path ) {
return false;
}

$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return false;
}

if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
return false;
}

$original_image = $this->get_original_image();
if ( false === $original_image ) {
return false;
}

if ( ! $wp_filesystem->copy( $backup_file_path, $original_image->filename, true ) ) {
return false;
}

// Clear compression metadata for all image sizes.
foreach ( $this->sizes as $size ) {
$size->meta = array();
}
$this->update_tiny_post_meta();

// Regenerate all thumbnail sizes from the restored image.
$new_metadata = $this->regenerate_sizes( $original_image->filename );
if ( $new_metadata ) {
$this->wp_metadata = $new_metadata;
wp_update_attachment_metadata( $this->id, $this->wp_metadata );
$this->sizes = array();
$this->parse_wp_metadata();
}

return true;
}
Comment thread
tijmenbruggeman marked this conversation as resolved.

/**
* Deletes the backup file of the original image, if it exists.
*
* @since 3.7.0
*
* @return bool True on success or if no backup exists, false on deletion failure.
*/
public function delete_backup() {
$backup_file_path = $this->get_backup_path();
if ( false === $backup_file_path ) {
return true;
}

$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return false;
}

if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
return true;
}

return $wp_filesystem->delete( $backup_file_path );
}
}
14 changes: 13 additions & 1 deletion src/class-tiny-logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ private function log( $level, $message, $context = array() ) {
// Ensure log directory exists.
$log_dir = dirname( $this->log_file_path );
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return;
}
if ( ! $wp_filesystem->exists( $log_dir ) ) {
wp_mkdir_p( $log_dir );
self::create_blocking_files( $log_dir );
Expand All @@ -201,6 +204,9 @@ private function log( $level, $message, $context = array() ) {
*/
private function rotate_logs() {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return;
}
if ( ! $wp_filesystem->exists( $this->log_file_path ) ) {
return;
}
Expand All @@ -222,7 +228,10 @@ public static function clear_logs() {
$instance = self::get_instance();
$log_path = $instance->get_log_file_path();
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
$file_exits = $wp_filesystem->exists( $log_path );
if ( false === $wp_filesystem ) {
return false;
}
$file_exits = $wp_filesystem->exists( $log_path );
if ( $file_exits ) {
return $wp_filesystem->delete( $log_path );
}
Expand All @@ -239,6 +248,9 @@ public static function clear_logs() {
*/
private static function create_blocking_files( $log_dir ) {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
if ( false === $wp_filesystem ) {
return;
}

$index_file = trailingslashit( $log_dir ) . 'index.html';
if ( ! $wp_filesystem->exists( $index_file ) ) {
Expand Down
Loading
Loading