Skip to content

Restore backup - #119

Draft
tijmenbruggeman wants to merge 23 commits into
tinify:masterfrom
wcreateweb:restore-backup
Draft

Restore backup#119
tijmenbruggeman wants to merge 23 commits into
tinify:masterfrom
wcreateweb:restore-backup

Conversation

@tijmenbruggeman

@tijmenbruggeman tijmenbruggeman commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

#98 added backups of the original image. This will add the restore functionality.

Screenshots

Summary by CodeRabbit

  • New Features

    • Added automatic backups of original compressed images.
    • Added options to view, restore, and delete image backups.
    • Added confirmation dialogs and progress feedback for restoring backups.
    • Restored images now refresh their media details and metadata automatically.
  • Bug Fixes

    • Backups are cleaned up when attachments are deleted.
    • Improved handling of restored image sizes and compression information.
  • Style

    • Updated compression details spacing and dialog styling.

@Sreini
Sreini requested review from Sreini and rkoopmans July 27, 2026 09:30
@rkoopmans

rkoopmans commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@rkoopmans Hi! How can I help with this PR?

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5414eeaf-6cee-49bf-b7f0-deda7a0b42a7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds original-image backup lifecycle methods, authenticated admin restoration, backup controls in compression details, cleanup on attachment deletion, and unit coverage for restoration and metadata behavior.

Changes

Image backup restoration

Layer / File(s) Summary
Backup lifecycle
src/class-tiny-image.php
Adds methods to create, retrieve, restore, and delete original-image backups, including metadata regeneration after restoration.
Plugin restoration endpoint
src/class-tiny-plugin.php
Delegates backup creation, registers the restore AJAX action, cleans backups on attachment deletion, and returns refreshed compression details.
Admin restore interface
src/views/compress-details.php, src/views/compress-details-backup.php, src/js/admin.js, src/css/admin.css, src/class-tiny-plugin.php
Adds backup status and restore dialogs, attachment identifiers, AJAX response handling, and dialog styling.
Backup restoration validation
test/unit/TinyPluginBackupTest.php, test/helpers/wordpress.php, test/wp-includes-for-tests/file.php
Tests missing and successful restoration, metadata clearing, cleanup, and AJAX registration while extending filesystem and WordPress test stubs.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminUI
  participant admin.js
  participant restore_backup_image
  participant Tiny_Image
  participant WordPressFilesystem
  AdminUI->>admin.js: Confirm backup restoration
  admin.js->>restore_backup_image: POST tiny_restore_backup with attachment ID
  restore_backup_image->>Tiny_Image: restore_backup()
  Tiny_Image->>WordPressFilesystem: Copy backup over original
  restore_backup_image-->>admin.js: Return compression details
  admin.js-->>AdminUI: Replace media container
Loading

Suggested reviewers: rkoopmans, sreini

Poem

A rabbit guards the backup byte,
Then hops to restore files just right.
Dialogs bloom and spinners spin,
Metadata starts fresh again.
“Thump-thump!” says Bun, “the tests all cheer!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and directly describes the main change: adding backup restore functionality.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/class-tiny-image.php`:
- Around line 720-772: Handle exceptions from Tiny_Helpers::get_wp_filesystem()
in all backup methods: create_backup(), get_backup(), restore_backup(), and
delete_backup(). Wrap each filesystem initialization in try/catch and return
false when initialization fails, preserving normal behavior when it succeeds so
media rendering and compression degrade gracefully.
- Around line 774-825: Update restore_backup() to reuse get_original_image() for
the restored original image instead of duplicating the
ORIGINAL_UNSCALED/ORIGINAL lookup. After wp_generate_attachment_metadata()
succeeds, rebuild $this->sizes from the new attachment metadata so
get_image_sizes() and get_image_size() immediately reflect regenerated, added,
removed, or renamed thumbnails; retain the existing metadata update and success
behavior.

In `@src/js/admin.js`:
- Around line 22-64: Move the restore workflow from the dialog’s cancel listener
to the confirm button’s click handler, so Escape only dismisses the dialog and
never calls restoreBackup. In the relevant admin.js handler, explicitly remove
any previously registered confirm handler before adding or assigning the new
one, rather than relying on { once: true }, so cancelling and reopening cannot
accumulate listeners. Update the confirm button markup to use type="button" and
remove command="request-close" and commandfor.

In `@src/views/compress-details-backup.php`:
- Around line 18-49: Fix the conditional markup around the backup view in the
template: remove the paragraph wrapper that contains the flow-content dialog,
ensure the no-backup branch emits no orphaned closing tag, and keep the dialog
as valid sibling markup. Also update the “original upload” translation call to
use esc_html_e() without echo, add the tiny-compress-images text domain to “View
uncompressed file,” and use esc_url() for the $backup href.

In `@src/views/compress-details.php`:
- Around line 116-121: Update the modal_title construction and Thickbox name
attribute around $modal_title to escape the translated title exactly once for
its HTML attribute context. Keep the filename unescaped while formatting the
translated string, then rely on esc_attr() at output without changing the
displayed title or surrounding link behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a9aa22b-2fe7-40ef-b27c-4d843c3c1e32

📥 Commits

Reviewing files that changed from the base of the PR and between f19bae2 and 70b68bb.

📒 Files selected for processing (9)
  • src/class-tiny-image.php
  • src/class-tiny-plugin.php
  • src/css/admin.css
  • src/js/admin.js
  • src/views/compress-details-backup.php
  • src/views/compress-details.php
  • test/helpers/wordpress.php
  • test/unit/TinyPluginBackupTest.php
  • test/wp-includes-for-tests/file.php

Comment thread src/class-tiny-image.php
Comment thread src/class-tiny-image.php
Comment thread src/js/admin.js
Comment on lines +22 to +64
const dialog = document.getElementById(dialogID);
if (!dialog) {
return;
}

const attachmentId = trigger.data('id');
const container = document.querySelector(`[data-tiny-media-id="${attachmentId}"]`);

dialog.showModal();

dialog.addEventListener('cancel', async (e) => {
e.preventDefault();
const spinner = dialog.querySelector('.spinner');
try {
if (spinner) {
spinner.style.visibility = 'visible';
}
const result = await restoreBackup(attachmentId);
dialog.close();

// refresh thickbox
const modal = container.querySelector('.modal');
const ajaxContent = document.getElementById('TB_ajaxContent');
if (modal && ajaxContent) {
modal.append(...ajaxContent.children);
}

container.innerHTML = result;
if (typeof tb_remove === 'function') {
tb_remove();
}
} catch (err) {
const errorEl = dialog.querySelector('.tiny-dialog-error');
if (errorEl) {
errorEl.textContent = err.responseText || 'Failed to restore backup.';
errorEl.hidden = false;
}
} finally {
if (spinner) {
spinner.style.visibility = 'hidden';
}
}
}, { once: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pressing Escape (or any dialog-close-request) triggers the destructive restore action.

The confirm ("Yes, Restore") button uses command="request-close". Per spec, that command fires a cancel event on the dialog — the same event that fires when the user presses the Escape key to dismiss the dialog. Since the only listener attached is on dialog's cancel event, a user pressing Escape to back out of the confirmation will inadvertently execute the restore (overwriting all compressed sizes), not cancel it.

Separately, a new one-time cancel listener is added on every click of "Restore Backup" (line 32), but it's never removed if the dialog is dismissed via the "Cancel" button (command="close", which does not fire cancel). Re-opening the same dialog (same DOM node, since only a successful restore replaces container.innerHTML) after a prior cancel therefore stacks multiple listeners — a subsequent confirm click would fire restoreBackup() once per accumulated listener.

Bind the confirm action to the button's own click instead of overloading the shared cancel event, and don't rely on { once: true } alone to prevent duplicate handlers.

🐛 Proposed fix
     dialog.showModal();
 
-    dialog.addEventListener('cancel', async (e) => {
-      e.preventDefault();
-      const spinner = dialog.querySelector('.spinner');
+    const confirmButton = dialog.querySelector('button[value="submit"]');
+    if (!confirmButton) {
+      return;
+    }
+
+    const onConfirm = async () => {
+      const spinner = dialog.querySelector('.spinner');
       try {
         if (spinner) {
           spinner.style.visibility = 'visible';
         }
         const result = await restoreBackup(attachmentId);
         dialog.close();
 
         // refresh thickbox
         const modal = container.querySelector('.modal');
         const ajaxContent = document.getElementById('TB_ajaxContent');
         if (modal && ajaxContent) {
           modal.append(...ajaxContent.children);
         }
 
         container.innerHTML = result;
         if (typeof tb_remove === 'function') {
           tb_remove();
         }
       } catch (err) {
         const errorEl = dialog.querySelector('.tiny-dialog-error');
         if (errorEl) {
           errorEl.textContent = err.responseText || 'Failed to restore backup.';
           errorEl.hidden = false;
         }
       } finally {
         if (spinner) {
           spinner.style.visibility = 'hidden';
         }
       }
-    }, { once: true });
+    };
+
+    confirmButton.addEventListener('click', onConfirm, { once: true });
   });

This also requires dropping command="request-close"/commandfor from the confirm button in src/views/compress-details-backup.php (lines 39-41) in favor of a plain type="button", so it no longer triggers the dialog's cancel event at all.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 48-48: Avoid assigning untrusted data to innerHTML/outerHTML or document.write
Context: container.innerHTML = result
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(inner-outer-html)

🤖 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/js/admin.js` around lines 22 - 64, Move the restore workflow from the
dialog’s cancel listener to the confirm button’s click handler, so Escape only
dismisses the dialog and never calls restoreBackup. In the relevant admin.js
handler, explicitly remove any previously registered confirm handler before
adding or assigning the new one, rather than relying on { once: true }, so
cancelling and reopening cannot accumulate listeners. Update the confirm button
markup to use type="button" and remove command="request-close" and commandfor.

Comment on lines +18 to +49
<?php if ( $backup ) { ?>
<p><?php echo esc_html_e( 'The original upload has been backed up.', 'tiny-compress-images' ); ?></p>

<p>
<a href="<?php echo esc_attr( $backup ); ?>" target="_blank">
<?php esc_html_e( 'View uncompressed file' ); ?>
</a>
&nbsp;
<a class="button button-small" href="#" data-dialog-id="<?php echo esc_attr( $modal_id ); ?>" data-id="<?php echo absint( $tiny_image->get_id() ); ?>">
<?php esc_html_e( 'Restore Backup', 'tiny-compress-images' ); ?>
</a>
<dialog id="<?php echo esc_attr( $modal_id ); ?>" class="tiny-dialog">
<strong class="tiny-dialog-title"><?php esc_html_e( 'Are you sure you want to restore the original uncompressed image?', 'tiny-compress-images' ); ?></strong>
<p><?php esc_html_e( 'This action will replace all the compressed images with uncompressed images', 'tiny-compress-images' ); ?></p>
<p class="tiny-dialog-error" hidden></p>

<div class="tiny-dialog-actions">
<span class="spinner"></span>
<button value="cancel" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="close" class="button">
<?php esc_html_e( 'Cancel', 'tiny-compress-images' ); ?>
</button>
<button value="submit" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="request-close" class="button button-primary" autofocus>
<?php esc_html_e( 'Yes, Restore', 'tiny-compress-images' ); ?>
</button>
</div>
</dialog>
<?php } else { ?>
<span>
<?php esc_html_e( 'No backup available', 'tiny-compress-images' ); ?>
</span>
<?php } ?>
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unbalanced <p>/<dialog> markup in the "no backup" branch, plus a couple of WP-conventions nits.

  • The <p> opened at line 21 only exists inside the if ( $backup ) branch, but its closing </p> at line 49 is emitted unconditionally after the if/else. When $backup is falsy, this leaves an orphaned </p> with no matching opener.
  • Separately, a <dialog> element (flow content) is nested inside that <p> (which only permits phrasing content) — browsers will auto-close the <p> before the <dialog>, so the actual DOM won't match the source nesting.
  • Line 19: echo esc_html_e(...)esc_html_e() already echoes and returns nothing; wrapping it in echo is redundant/incorrect usage.
  • Line 23: esc_html_e( 'View uncompressed file' ) is missing the 'tiny-compress-images' text domain, so this string won't be picked up for translation.
  • Line 22: esc_attr( $backup ) for a href — WordPress convention is esc_url() for URL attributes.
🛠️ Proposed fix
 	<?php if ( $backup ) { ?>
-		<p><?php echo esc_html_e( 'The original upload has been backed up.', 'tiny-compress-images' ); ?></p>
-			
+		<p><?php esc_html_e( 'The original upload has been backed up.', 'tiny-compress-images' ); ?></p>
+
 		<p>
-			<a href="<?php echo esc_attr( $backup ); ?>" target="_blank">
-				<?php esc_html_e( 'View uncompressed file' ); ?>
+			<a href="<?php echo esc_url( $backup ); ?>" target="_blank">
+				<?php esc_html_e( 'View uncompressed file', 'tiny-compress-images' ); ?>
 			</a>
 			&nbsp;
 			<a class="button button-small" href="#" data-dialog-id="<?php echo esc_attr( $modal_id ); ?>" data-id="<?php echo absint( $tiny_image->get_id() ); ?>">
 				<?php esc_html_e( 'Restore Backup', 'tiny-compress-images' ); ?>
 			</a>
+		</p>
+		<dialog id="<?php echo esc_attr( $modal_id ); ?>" class="tiny-dialog">
+			...
+		</dialog>
+	<?php } else { ?>
+		<p>
+			<span>
+				<?php esc_html_e( 'No backup available', 'tiny-compress-images' ); ?>
+			</span>
+		</p>
+	<?php } ?>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<?php if ( $backup ) { ?>
<p><?php echo esc_html_e( 'The original upload has been backed up.', 'tiny-compress-images' ); ?></p>
<p>
<a href="<?php echo esc_attr( $backup ); ?>" target="_blank">
<?php esc_html_e( 'View uncompressed file' ); ?>
</a>
&nbsp;
<a class="button button-small" href="#" data-dialog-id="<?php echo esc_attr( $modal_id ); ?>" data-id="<?php echo absint( $tiny_image->get_id() ); ?>">
<?php esc_html_e( 'Restore Backup', 'tiny-compress-images' ); ?>
</a>
<dialog id="<?php echo esc_attr( $modal_id ); ?>" class="tiny-dialog">
<strong class="tiny-dialog-title"><?php esc_html_e( 'Are you sure you want to restore the original uncompressed image?', 'tiny-compress-images' ); ?></strong>
<p><?php esc_html_e( 'This action will replace all the compressed images with uncompressed images', 'tiny-compress-images' ); ?></p>
<p class="tiny-dialog-error" hidden></p>
<div class="tiny-dialog-actions">
<span class="spinner"></span>
<button value="cancel" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="close" class="button">
<?php esc_html_e( 'Cancel', 'tiny-compress-images' ); ?>
</button>
<button value="submit" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="request-close" class="button button-primary" autofocus>
<?php esc_html_e( 'Yes, Restore', 'tiny-compress-images' ); ?>
</button>
</div>
</dialog>
<?php } else { ?>
<span>
<?php esc_html_e( 'No backup available', 'tiny-compress-images' ); ?>
</span>
<?php } ?>
</p>
<?php if ( $backup ) { ?>
<p><?php esc_html_e( 'The original upload has been backed up.', 'tiny-compress-images' ); ?></p>
<p>
<a href="<?php echo esc_url( $backup ); ?>" target="_blank">
<?php esc_html_e( 'View uncompressed file', 'tiny-compress-images' ); ?>
</a>
&nbsp;
<a class="button button-small" href="#" data-dialog-id="<?php echo esc_attr( $modal_id ); ?>" data-id="<?php echo absint( $tiny_image->get_id() ); ?>">
<?php esc_html_e( 'Restore Backup', 'tiny-compress-images' ); ?>
</a>
</p>
<dialog id="<?php echo esc_attr( $modal_id ); ?>" class="tiny-dialog">
<strong class="tiny-dialog-title"><?php esc_html_e( 'Are you sure you want to restore the original uncompressed image?', 'tiny-compress-images' ); ?></strong>
<p><?php esc_html_e( 'This action will replace all the compressed images with uncompressed images', 'tiny-compress-images' ); ?></p>
<p class="tiny-dialog-error" hidden></p>
<div class="tiny-dialog-actions">
<span class="spinner"></span>
<button value="cancel" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="close" class="button">
<?php esc_html_e( 'Cancel', 'tiny-compress-images' ); ?>
</button>
<button value="submit" commandfor="<?php echo esc_attr( $modal_id ); ?>" command="request-close" class="button button-primary" autofocus>
<?php esc_html_e( 'Yes, Restore', 'tiny-compress-images' ); ?>
</button>
</div>
</dialog>
<?php } else { ?>
<p>
<span>
<?php esc_html_e( 'No backup available', 'tiny-compress-images' ); ?>
</span>
</p>
<?php } ?>
🤖 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/views/compress-details-backup.php` around lines 18 - 49, Fix the
conditional markup around the backup view in the template: remove the paragraph
wrapper that contains the flow-content dialog, ensure the no-backup branch emits
no orphaned closing tag, and keep the dialog as valid sibling markup. Also
update the “original upload” translation call to use esc_html_e() without echo,
add the tiny-compress-images text domain to “View uncompressed file,” and use
esc_url() for the $backup href.

Comment thread src/views/compress-details.php
get_wp_filesystem() now returns WP_Filesystem_Base|false and logs via error_log() on failure instead of throwing. Removes the separate get_filesystem() wrapper. All callers guard against false.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants