Changeset 3778274
- Timestamp:
- 11/01/2026 04:33:57 PM (14 hours ago)
- Location:
- lightweight-modern-media
- Files:
-
- 2 deleted
- 8 edited
- 1 copied
-
tags/1.2.0 (copied) (copied from lightweight-modern-media/trunk )
-
tags/1.2.0/assets/admin.css (modified) (3 diffs)
-
tags/1.2.0/assets/admin.js (modified) (5 diffs)
-
tags/1.2.0/lightweight-modern-media.php (modified) (25 diffs)
-
tags/1.2.0/readme.txt (modified) (3 diffs)
-
tags/1.2.0/tests (deleted)
-
trunk/assets/admin.css (modified) (3 diffs)
-
trunk/assets/admin.js (modified) (5 diffs)
-
trunk/lightweight-modern-media.php (modified) (25 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/tests (deleted)
Legend:
- Unmodified
- Added
- Removed
-
lightweight-modern-media/tags/1.2.0/assets/admin.css
r3679655 r3778274 45 45 .rupanta-grid { 46 46 display: grid; 47 grid-template-columns: repeat( 4, minmax(0, 1fr));47 grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); 48 48 gap: 14px; 49 49 margin: 18px 0; … … 227 227 } 228 228 229 .rupanta-failed-section[hidden], 230 #rupanta-cron-warning[hidden] { 231 display: none; 232 } 233 234 .rupanta-failed-list { 235 overflow-x: auto; 236 margin-top: 14px; 237 } 238 239 .rupanta-failed-list th:first-child, 240 .rupanta-failed-list td:first-child { 241 width: 70px; 242 } 243 229 244 .rupanta-muted { 230 245 color: #646970; … … 242 257 } 243 258 259 .rupanta-review-notice { 260 padding: 14px 16px; 261 } 262 263 .rupanta-review-notice p { 264 margin: 0 0 10px; 265 line-height: 1.6; 266 } 267 268 .rupanta-review-notice p:last-child { 269 margin-bottom: 0; 270 } 271 272 .rupanta-review-actions { 273 display: flex; 274 flex-wrap: wrap; 275 align-items: center; 276 gap: 8px 10px; 277 } 278 279 .rupanta-review-notice .rupanta-review-actions { 280 margin-top: 16px; 281 } 282 283 .rupanta-review-notice [aria-hidden="true"] { 284 display: inline-block; 285 margin-left: 3px; 286 font-size: 16px; 287 line-height: 1; 288 vertical-align: -1px; 289 } 290 244 291 .rupanta-media-status strong { 245 292 display: block; -
lightweight-modern-media/tags/1.2.0/assets/admin.js
r3730748 r3778274 15 15 const elapsedText = document.getElementById('rupanta-elapsed'); 16 16 const etaText = document.getElementById('rupanta-eta'); 17 18 if (!start || !stop || !refresh || !log || !statusText || !progressBar || !elapsedText || !etaText) { 17 const failedSection = document.getElementById('rupanta-failed-section'); 18 const failedList = document.getElementById('rupanta-failed-list'); 19 const cronWarning = document.getElementById('rupanta-cron-warning'); 20 21 if (!start || !stop || !refresh || !log || !statusText || !progressBar || !elapsedText || !etaText || !failedSection || !failedList || !cronWarning) { 19 22 return; 20 23 } 21 24 22 let running = false; 23 let startedAt = 0; 24 let timerId = null; 25 let processedThisRun = 0; 26 let totalThisRun = 0; 27 let timingState = 'idle'; 28 let failedIdsThisRun = []; 25 let pollTimer = null; 29 26 30 27 function formatDuration(seconds) { … … 44 41 } 45 42 46 function updateTiming() { 47 const elapsedSeconds = startedAt > 0 ? (Date.now() - startedAt) / 1000 : 0; 48 elapsedText.textContent = formatDuration(elapsedSeconds); 49 50 if (timingState === 'complete') { 51 etaText.textContent = config.i18n.complete; 52 return; 53 } 54 55 if (timingState !== 'running') { 56 etaText.textContent = timingState === 'paused' ? config.i18n.paused : config.i18n.notStarted; 57 return; 58 } 59 60 if (processedThisRun < 1 || elapsedSeconds < 1) { 61 etaText.textContent = config.i18n.calculating; 62 return; 63 } 64 65 const remaining = Math.max(0, totalThisRun - processedThisRun); 66 etaText.textContent = formatDuration((elapsedSeconds / processedThisRun) * remaining); 67 } 68 69 function startTiming() { 70 const remainingNode = document.querySelector('[data-rupanta-stat="remaining"]'); 71 totalThisRun = remainingNode ? parseInt(remainingNode.textContent || '0', 10) : 0; 72 processedThisRun = 0; 73 startedAt = Date.now(); 74 timingState = 'running'; 75 window.clearInterval(timerId); 76 timerId = window.setInterval(updateTiming, 1000); 77 updateTiming(); 78 } 79 80 function stopTiming(state) { 81 timingState = state; 82 window.clearInterval(timerId); 83 timerId = null; 84 updateTiming(); 85 } 86 87 function addLog(message) { 88 const row = document.createElement('div'); 89 row.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message; 90 log.appendChild(row); 91 log.scrollTop = log.scrollHeight; 92 } 93 94 function setStat(key, value) { 95 const node = document.querySelector('[data-rupanta-stat="' + key + '"]'); 96 97 if (node) { 98 node.textContent = key === 'progress' ? value + '%' : value; 99 } 100 } 101 102 function renderStatus(data) { 103 const eligible = parseInt(data.eligible || 0, 10); 104 const converted = parseInt(data.converted || 0, 10); 105 const remaining = Math.max(0, eligible - converted); 106 const progress = eligible > 0 107 ? Math.min(100, Math.round((converted / eligible) * 100)) 108 : 100; 109 110 setStat('eligible', eligible); 111 setStat('converted', converted); 112 setStat('remaining', remaining); 113 setStat('progress', progress); 114 115 progressBar.style.width = progress + '%'; 116 statusText.textContent = remaining > 0 117 ? remaining + ' ' + config.i18n.waiting 118 : config.i18n.allProcessed; 119 } 120 121 function request(action, data) { 43 function request(action) { 122 44 const body = new URLSearchParams(); 123 45 body.set('action', action); 124 46 body.set('nonce', config.nonce); 125 126 Object.keys(data || {}).forEach(function (key) {127 body.set(key, data[key]);128 });129 47 130 48 return fetch(config.ajaxUrl, { … … 143 61 payload = JSON.parse(responseText); 144 62 } catch (error) { 145 throw new Error( 146 'Request returned an invalid response (HTTP ' + response.status + ').' 147 ); 63 throw new Error('Request returned an invalid response (HTTP ' + response.status + ').'); 148 64 } 149 65 … … 153 69 .then(function (payload) { 154 70 if (!payload || !payload.success) { 155 throw new Error( 156 payload && 157 payload.data && 158 payload.data.message 159 ? payload.data.message 160 : config.i18n.requestFailed 161 ); 71 throw new Error(payload && payload.data && payload.data.message ? payload.data.message : config.i18n.requestFailed); 162 72 } 163 73 … … 166 76 } 167 77 168 function setRunning(value) { 169 running = value; 170 start.disabled = value; 171 stop.disabled = !value; 172 refresh.disabled = value; 173 } 174 175 function processNext() { 176 if (!running) { 78 function setStat(key, value) { 79 const node = document.querySelector('[data-rupanta-stat="' + key + '"]'); 80 81 if (node) { 82 node.textContent = key === 'progress' ? value + '%' : value; 83 } 84 } 85 86 function renderLogs(logs) { 87 log.textContent = ''; 88 89 if (!logs.length) { 90 const emptyRow = document.createElement('div'); 91 emptyRow.textContent = config.i18n.logReady; 92 log.appendChild(emptyRow); 177 93 return; 178 94 } 179 95 180 request(config.convertAction, { 181 batch_size: 1, 182 exclude_ids: failedIdsThisRun.join(',') 183 }) 184 .then(function (data) { 185 processedThisRun += parseInt(data.result.processed || 0, 10); 186 renderStatus(data.counts); 187 updateTiming(); 188 189 if (data.result.items && data.result.items.length) { 190 data.result.items.forEach(function (item) { 191 let message = '#' + item.id + ' ' + item.file + ' - ' + item.message; 192 193 if (item.title && item.title !== item.file) { 194 message += ' (' + item.title + ')'; 195 } 196 197 addLog(message); 198 199 if (item.status !== 'converted' && item.status !== 'existing' && failedIdsThisRun.indexOf(item.id) === -1) { 200 failedIdsThisRun.push(item.id); 201 } 202 }); 203 } else { 204 addLog( 205 'Batch processed ' + 206 data.result.processed + 207 ' attachment(s), created/refreshed ' + 208 (data.result.created + data.result.existing) + 209 ' file(s), skipped ' + 210 data.result.skipped + 211 '.' 212 ); 213 214 if (data.result.diagnostics && data.result.diagnostics.length) { 215 addLog(data.result.diagnostics.join(' ')); 216 } 217 } 218 219 if (data.done || data.result.processed === 0) { 220 setRunning(false); 221 stopTiming('complete'); 222 const completionMessage = failedIdsThisRun.length > 0 223 ? config.i18n.conversionCompleteWithFailures.replace('%d', failedIdsThisRun.length) 224 : config.i18n.conversionComplete; 225 addLog(completionMessage); 226 statusText.textContent = completionMessage; 227 return; 228 } 229 230 window.setTimeout(processNext, 350); 231 }) 232 .catch(function (error) { 233 setRunning(false); 234 stopTiming('paused'); 235 statusText.textContent = config.i18n.pausedAfterError; 236 addLog('Error: ' + error.message); 237 }); 238 } 239 240 const initialProgress = parseInt(progressBar.getAttribute('data-progress') || '0', 10); 241 progressBar.style.width = Math.max(0, Math.min(100, initialProgress)) + '%'; 96 logs.forEach(function (entry) { 97 const row = document.createElement('div'); 98 const timestamp = entry.time ? new Date(parseInt(entry.time, 10) * 1000).toLocaleTimeString() : ''; 99 row.textContent = (timestamp ? '[' + timestamp + '] ' : '') + entry.message; 100 log.appendChild(row); 101 }); 102 103 log.scrollTop = log.scrollHeight; 104 } 105 106 function renderFailedItems(items) { 107 failedList.textContent = ''; 108 failedSection.hidden = items.length === 0; 109 110 items.forEach(function (item) { 111 const row = document.createElement('tr'); 112 const idCell = document.createElement('td'); 113 const imageCell = document.createElement('td'); 114 const reasonCell = document.createElement('td'); 115 116 idCell.textContent = '#' + item.id; 117 imageCell.textContent = item.title && item.title !== item.file ? item.title + ' (' + item.file + ')' : item.file; 118 reasonCell.textContent = item.reason; 119 row.appendChild(idCell); 120 row.appendChild(imageCell); 121 row.appendChild(reasonCell); 122 failedList.appendChild(row); 123 }); 124 } 125 126 function renderTiming(job, waiting) { 127 const now = Math.round(Date.now() / 1000); 128 const startedAt = parseInt(job.started_at || 0, 10); 129 const endedAt = parseInt(job.completed_at || 0, 10) || now; 130 const elapsed = startedAt > 0 ? Math.max(0, endedAt - startedAt) : 0; 131 const processed = parseInt(job.processed || 0, 10); 132 133 elapsedText.textContent = formatDuration(elapsed); 134 135 if (job.status === 'complete') { 136 etaText.textContent = config.i18n.complete; 137 } else if (job.status === 'paused') { 138 etaText.textContent = config.i18n.paused; 139 } else if (job.status !== 'running') { 140 etaText.textContent = config.i18n.notStarted; 141 } else if (processed < 1 || elapsed < 1) { 142 etaText.textContent = config.i18n.calculating; 143 } else { 144 etaText.textContent = formatDuration((elapsed / processed) * waiting); 145 } 146 } 147 148 function renderStatus(data) { 149 const eligible = parseInt(data.counts.eligible || 0, 10); 150 const converted = parseInt(data.counts.converted || 0, 10); 151 const waiting = parseInt(data.waiting || 0, 10); 152 const job = data.job || {}; 153 const failedItems = Array.isArray(job.failed_items) ? job.failed_items : []; 154 const logs = Array.isArray(job.logs) ? job.logs : []; 155 const progress = eligible > 0 ? Math.min(100, Math.round((converted / eligible) * 100)) : 100; 156 const running = job.status === 'running'; 157 158 setStat('eligible', eligible); 159 setStat('converted', converted); 160 setStat('remaining', waiting); 161 setStat('failed', failedItems.length); 162 setStat('progress', progress); 163 progressBar.style.width = progress + '%'; 164 start.disabled = running; 165 stop.disabled = !running; 166 refresh.disabled = false; 167 cronWarning.hidden = !data.cron_disabled; 168 169 if (running) { 170 statusText.textContent = waiting + ' ' + config.i18n.waiting; 171 } else if (job.status === 'paused') { 172 statusText.textContent = config.i18n.paused; 173 } else if (job.status === 'complete' && failedItems.length > 0) { 174 statusText.textContent = config.i18n.conversionCompleteWithFailures.replace('%d', failedItems.length); 175 } else if (job.status === 'complete') { 176 statusText.textContent = config.i18n.conversionComplete; 177 } else { 178 statusText.textContent = config.i18n.ready; 179 } 180 181 renderTiming(job, waiting); 182 renderLogs(logs); 183 renderFailedItems(failedItems); 184 scheduleStatusPoll(running); 185 } 186 187 function scheduleStatusPoll(running) { 188 window.clearTimeout(pollTimer); 189 pollTimer = null; 190 191 if (running && !document.hidden) { 192 pollTimer = window.setTimeout(loadStatus, 2000); 193 } 194 } 195 196 function showRequestError(error) { 197 statusText.textContent = config.i18n.pausedAfterError; 198 const row = document.createElement('div'); 199 row.textContent = 'Error: ' + error.message; 200 log.appendChild(row); 201 } 202 203 function loadStatus() { 204 return request(config.statusAction) 205 .then(renderStatus) 206 .catch(showRequestError); 207 } 242 208 243 209 start.addEventListener('click', function () { 244 failedIdsThisRun = []; 245 setRunning(true); 246 startTiming(); 247 addLog(config.i18n.conversionStarted); 248 processNext(); 210 start.disabled = true; 211 request(config.startAction).then(loadStatus).catch(showRequestError); 249 212 }); 250 213 251 214 stop.addEventListener('click', function () { 252 setRunning(false); 253 stopTiming('paused'); 254 addLog(config.i18n.conversionPaused); 255 statusText.textContent = config.i18n.paused; 215 stop.disabled = true; 216 request(config.pauseAction).then(loadStatus).catch(showRequestError); 256 217 }); 257 218 258 refresh.addEventListener('click', function () { 259 request(config.statusAction) 260 .then(function (data) { 261 renderStatus(data.counts); 262 addLog(config.i18n.statusRefreshed); 263 }) 264 .catch(function (error) { 265 addLog('Error: ' + error.message); 266 }); 219 refresh.addEventListener('click', loadStatus); 220 document.addEventListener('visibilitychange', function () { 221 if (!document.hidden) { 222 loadStatus(); 223 } 267 224 }); 225 226 loadStatus(); 268 227 })(); -
lightweight-modern-media/tags/1.2.0/lightweight-modern-media.php
r3765626 r3778274 5 5 * Plugin Name: Rupanta Media Converter 6 6 * Description: Generates WebP and AVIF copies for uploaded images and serves modern formats with picture markup. 7 * Version: 1. 1.177 * Version: 1.2.0 8 8 * Requires at least: 6.0 9 9 * Tested up to: 7.1 … … 20 20 21 21 define( 'RUPANTA_PLUGIN_FILE', __FILE__ ); 22 define( 'RUPANTA_PLUGIN_VERSION', '1. 1.17' );22 define( 'RUPANTA_PLUGIN_VERSION', '1.2.0' ); 23 23 define( 'RUPANTA_CONVERSION_VERSION', '1' ); 24 24 … … 28 28 private const META_VERSION = '_rupanta_image_converter_version'; 29 29 private const META_PRIORITY = '_rupanta_image_converter_priority'; 30 private const REVIEW_OPTION = 'rupanta_image_converter_review_prompt'; 31 private const JOB_OPTION = 'rupanta_image_converter_background_job'; 32 private const JOB_LOCK_OPTION = 'rupanta_image_converter_background_lock'; 33 private const JOB_HOOK = 'rupanta_image_converter_background_conversion'; 30 34 31 35 private static $instance = null; … … 56 60 add_action( 'wp_ajax_rupanta_image_converter_status', array( $this, 'ajax_status' ) ); 57 61 add_action( 'wp_ajax_rupanta_image_converter_convert', array( $this, 'ajax_convert' ) ); 62 add_action( 'wp_ajax_rupanta_image_converter_start', array( $this, 'ajax_start_background_job' ) ); 63 add_action( 'wp_ajax_rupanta_image_converter_pause', array( $this, 'ajax_pause_background_job' ) ); 64 add_action( self::JOB_HOOK, array( $this, 'run_background_conversion' ) ); 58 65 add_action( 'admin_post_rupanta_fix_image_mime_types', array( $this, 'handle_fix_image_mime_types' ) ); 66 add_action( 'admin_post_rupanta_review_prompt_action', array( $this, 'handle_review_prompt_action' ) ); 59 67 add_action( 'template_redirect', array( $this, 'start_frontend_rewrite_buffer' ), 0 ); 60 68 add_filter( 'elementor/widget/render_content', array( $this, 'rewrite_frontend_image_html' ), 20, 1 ); … … 74 82 75 83 add_option( self::OPTION, self::default_settings() ); 84 } 85 86 public static function deactivate(): void { 87 wp_clear_scheduled_hook( self::JOB_HOOK ); 88 delete_option( self::JOB_LOCK_OPTION ); 76 89 } 77 90 … … 101 114 public function add_plugin_action_links( array $links ): array { 102 115 $settings_url = add_query_arg( 'tab', 'settings', admin_url( 'upload.php?page=rupanta-media-converter' ) ); 116 $review_url = 'https://wordpress.org/support/plugin/lightweight-modern-media/reviews/#new-post'; 103 117 array_unshift( 104 118 $links, … … 108 122 esc_html__( 'Settings', 'lightweight-modern-media' ) 109 123 ) 124 ); 125 $links[] = sprintf( 126 '<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', 127 esc_url( $review_url ), 128 esc_html__( 'Review plugin', 'lightweight-modern-media' ) 110 129 ); 111 130 … … 136 155 137 156 public function enqueue_admin_assets( string $hook_suffix ): void { 157 $asset_version = RUPANTA_PLUGIN_VERSION . '-background'; 158 138 159 if ( in_array( $hook_suffix, array( 'media_page_rupanta-media-converter', 'upload.php', 'media-new.php', 'post.php', 'post-new.php' ), true ) ) { 139 160 wp_enqueue_style( … … 141 162 plugins_url( 'assets/admin.css', RUPANTA_PLUGIN_FILE ), 142 163 array(), 143 RUPANTA_PLUGIN_VERSION164 $asset_version 144 165 ); 145 166 } … … 153 174 plugins_url( 'assets/admin.js', RUPANTA_PLUGIN_FILE ), 154 175 array(), 155 RUPANTA_PLUGIN_VERSION,176 $asset_version, 156 177 true 157 178 ); … … 163 184 'ajaxUrl' => wp_make_link_relative( admin_url( 'admin-ajax.php' ) ), 164 185 'nonce' => wp_create_nonce( 'rupanta_image_converter_ajax' ), 165 'convertAction' => 'rupanta_image_converter_convert', 186 'startAction' => 'rupanta_image_converter_start', 187 'pauseAction' => 'rupanta_image_converter_pause', 166 188 'statusAction' => 'rupanta_image_converter_status', 167 189 'i18n' => array( … … 179 201 'notStarted' => __( 'Not started', 'lightweight-modern-media' ), 180 202 'complete' => __( 'Complete', 'lightweight-modern-media' ), 203 'ready' => __( 'Ready to process your media library.', 'lightweight-modern-media' ), 204 'logReady' => __( 'Log ready. Click Start Conversion to begin.', 'lightweight-modern-media' ), 181 205 ), 182 206 ) … … 210 234 $support = $this->format_support(); 211 235 $counts = $this->count_convertible_media(); 236 $job = $this->job_state(); 237 $failed_count = count( $job['failed_items'] ); 238 $waiting_count = max( 0, $counts['eligible'] - $counts['converted'] - $failed_count ); 212 239 $progress = $counts['eligible'] > 0 ? round( ( $counts['converted'] / $counts['eligible'] ) * 100 ) : 100; 213 240 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'converter'; … … 219 246 $converter_url = admin_url( 'upload.php?page=rupanta-media-converter' ); 220 247 $settings_url = add_query_arg( 'tab', 'settings', $converter_url ); 248 249 if ( $counts['converted'] > 0 && false === get_option( self::REVIEW_OPTION, false ) ) { 250 $this->record_review_prompt_success( time() ); 251 } 221 252 ?> 222 253 <div class="wrap"> … … 228 259 229 260 <div class="rupanta-shell"> 261 <?php $this->render_review_prompt( $counts['converted'] ); ?> 230 262 <div class="rupanta-hero"> 231 263 <h2><?php esc_html_e( 'Fast background conversion for your media library', 'lightweight-modern-media' ); ?></h2> 232 <p><?php esc_html_e( 'Generate modern image formats in safe AJAX batches, with live progress and conversion logs while the page stays responsive.', 'lightweight-modern-media' ); ?></p>264 <p><?php esc_html_e( 'Generate modern image formats in safe background batches that continue when you leave this page, with persistent progress and conversion logs.', 'lightweight-modern-media' ); ?></p> 233 265 </div> 234 266 … … 258 290 259 291 <?php if ( 'converter' === $active_tab ) : ?> 292 <div id="rupanta-cron-warning" class="notice notice-warning" <?php echo defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ? '' : 'hidden'; ?>> 293 <p><?php esc_html_e( 'WP-Cron is disabled. Background conversion requires a hosting cron task that regularly requests wp-cron.php.', 'lightweight-modern-media' ); ?></p> 294 </div> 260 295 <details class="rupanta-accordion" open> 261 296 <summary><?php esc_html_e( 'Media Library Overview', 'lightweight-modern-media' ); ?></summary> … … 264 299 <div class="rupanta-stat"><strong data-rupanta-stat="eligible"><?php echo esc_html( $counts['eligible'] ); ?></strong><span><?php esc_html_e( 'Eligible images', 'lightweight-modern-media' ); ?></span></div> 265 300 <div class="rupanta-stat"><strong data-rupanta-stat="converted"><?php echo esc_html( $counts['converted'] ); ?></strong><span><?php esc_html_e( 'Converted', 'lightweight-modern-media' ); ?></span></div> 266 <div class="rupanta-stat"><strong data-rupanta-stat="remaining"><?php echo esc_html( max( 0, $counts['eligible'] - $counts['converted'] ) ); ?></strong><span><?php esc_html_e( 'Remaining', 'lightweight-modern-media' ); ?></span></div> 301 <div class="rupanta-stat"><strong data-rupanta-stat="remaining"><?php echo esc_html( $waiting_count ); ?></strong><span><?php esc_html_e( 'Waiting', 'lightweight-modern-media' ); ?></span></div> 302 <div class="rupanta-stat"><strong data-rupanta-stat="failed"><?php echo esc_html( $failed_count ); ?></strong><span><?php esc_html_e( 'Failed', 'lightweight-modern-media' ); ?></span></div> 267 303 <div class="rupanta-stat"><strong data-rupanta-stat="progress"><?php echo esc_html( $progress ); ?>%</strong><span><?php esc_html_e( 'Progress', 'lightweight-modern-media' ); ?></span></div> 268 304 </div> … … 294 330 </div> 295 331 </details> 332 <details class="rupanta-accordion rupanta-failed-section" id="rupanta-failed-section" <?php echo $failed_count > 0 ? 'open' : 'hidden'; ?>> 333 <summary><?php esc_html_e( 'Failed Images', 'lightweight-modern-media' ); ?></summary> 334 <div class="rupanta-accordion-content"> 335 <p class="description"><?php esc_html_e( 'These images were skipped for the current run so conversion could continue. Starting a new run retries them.', 'lightweight-modern-media' ); ?></p> 336 <div class="rupanta-failed-list"> 337 <table class="widefat striped"> 338 <thead><tr><th><?php esc_html_e( 'ID', 'lightweight-modern-media' ); ?></th><th><?php esc_html_e( 'Image', 'lightweight-modern-media' ); ?></th><th><?php esc_html_e( 'Reason', 'lightweight-modern-media' ); ?></th></tr></thead> 339 <tbody id="rupanta-failed-list"></tbody> 340 </table> 341 </div> 342 </div> 343 </details> 296 344 <?php else : ?> 297 345 … … 345 393 <label for="rupanta-batch-size"><?php esc_html_e( 'Attachments per request', 'lightweight-modern-media' ); ?></label> 346 394 <input id="rupanta-batch-size" type="number" min="1" max="100" name="<?php echo esc_attr( self::OPTION ); ?>[batch_size]" value="<?php echo esc_attr( $settings['batch_size'] ); ?>" class="small-text"> 347 <p class="description"><?php esc_html_e( ' Number of attachments processed per AJAX request.', 'lightweight-modern-media' ); ?></p>395 <p class="description"><?php esc_html_e( 'Maximum number of attachments processed per background worker batch.', 'lightweight-modern-media' ); ?></p> 348 396 </div> 349 397 </details> … … 380 428 } 381 429 430 private function record_review_prompt_success( int $timestamp ): void { 431 add_option( 432 self::REVIEW_OPTION, 433 array( 434 'first_success' => $timestamp, 435 'remind_after' => 0, 436 'dismissed' => 0, 437 ), 438 '', 439 false 440 ); 441 } 442 443 private function should_show_review_prompt( int $converted, int $now ): bool { 444 if ( $converted < 1 ) { 445 return false; 446 } 447 448 $state = get_option( self::REVIEW_OPTION, array() ); 449 450 if ( ! is_array( $state ) || ! empty( $state['dismissed'] ) ) { 451 return false; 452 } 453 454 $first_success = isset( $state['first_success'] ) ? absint( $state['first_success'] ) : 0; 455 $remind_after = isset( $state['remind_after'] ) ? absint( $state['remind_after'] ) : 0; 456 457 return $first_success > 0 458 && $now >= $first_success + ( 7 * DAY_IN_SECONDS ) 459 && $now >= $remind_after; 460 } 461 462 private function render_review_prompt( int $converted ): void { 463 if ( ! $this->should_show_review_prompt( $converted, time() ) ) { 464 return; 465 } 466 467 $action_url = admin_url( 'admin-post.php' ); 468 $review_url = wp_nonce_url( 469 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'review' ), $action_url ), 470 'rupanta_review_prompt_action' 471 ); 472 $remind_url = wp_nonce_url( 473 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'remind' ), $action_url ), 474 'rupanta_review_prompt_action' 475 ); 476 $dismiss_url = wp_nonce_url( 477 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'dismiss' ), $action_url ), 478 'rupanta_review_prompt_action' 479 ); 480 ?> 481 <div class="notice notice-info rupanta-review-notice"> 482 <p><strong><?php esc_html_e( 'Is Rupanta Media Converter helping your website?', 'lightweight-modern-media' ); ?> <span aria-hidden="true">🙂</span></strong></p> 483 <p><?php esc_html_e( 'If you have a moment, please share an honest review on WordPress.org. Your feedback helps other WordPress users discover the plugin.', 'lightweight-modern-media' ); ?></p> 484 <p class="rupanta-review-actions"> 485 <a class="button button-primary" href="<?php echo esc_url( $review_url ); ?>"><?php esc_html_e( 'Leave an honest review', 'lightweight-modern-media' ); ?></a> 486 <a class="button" href="<?php echo esc_url( $remind_url ); ?>"><?php esc_html_e( 'Remind me later', 'lightweight-modern-media' ); ?></a> 487 <a class="button-link" href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( "Don't ask again", 'lightweight-modern-media' ); ?></a> 488 </p> 489 </div> 490 <?php 491 } 492 493 public function handle_review_prompt_action(): void { 494 if ( ! current_user_can( 'manage_options' ) ) { 495 wp_die( esc_html__( 'You do not have permission to manage this review request.', 'lightweight-modern-media' ) ); 496 } 497 498 check_admin_referer( 'rupanta_review_prompt_action' ); 499 500 $response = isset( $_GET['response'] ) ? sanitize_key( wp_unslash( $_GET['response'] ) ) : ''; 501 $state = get_option( self::REVIEW_OPTION, array() ); 502 $state = is_array( $state ) ? $state : array(); 503 504 if ( 'remind' === $response ) { 505 $state['remind_after'] = time() + ( 30 * DAY_IN_SECONDS ); 506 $state['dismissed'] = 0; 507 } elseif ( in_array( $response, array( 'review', 'dismiss' ), true ) ) { 508 $state['dismissed'] = 1; 509 } else { 510 wp_die( esc_html__( 'Invalid review request action.', 'lightweight-modern-media' ) ); 511 } 512 513 update_option( self::REVIEW_OPTION, $state, false ); 514 515 if ( 'review' === $response ) { 516 wp_redirect( 'https://wordpress.org/support/plugin/lightweight-modern-media/reviews/#new-post' ); 517 exit; 518 } 519 520 wp_safe_redirect( admin_url( 'upload.php?page=rupanta-media-converter' ) ); 521 exit; 522 } 523 382 524 public function handle_fix_image_mime_types(): void { 383 525 if ( ! current_user_can( 'manage_options' ) ) { … … 538 680 } 539 681 682 private function default_job_state(): array { 683 return array( 684 'status' => 'idle', 685 'started_at' => 0, 686 'updated_at' => 0, 687 'completed_at' => 0, 688 'processed' => 0, 689 'failed_items' => array(), 690 'logs' => array(), 691 ); 692 } 693 694 private function job_state(): array { 695 $state = wp_parse_args( 696 (array) get_option( self::JOB_OPTION, array() ), 697 $this->default_job_state() 698 ); 699 700 $state['failed_items'] = is_array( $state['failed_items'] ) ? array_values( $state['failed_items'] ) : array(); 701 $state['logs'] = is_array( $state['logs'] ) ? array_values( $state['logs'] ) : array(); 702 703 return $state; 704 } 705 706 private function save_job_state( array $state ): void { 707 update_option( 708 self::JOB_OPTION, 709 wp_parse_args( $state, $this->default_job_state() ), 710 false 711 ); 712 } 713 714 private function append_job_log( array $state, string $message ): array { 715 $state['logs'][] = array( 716 'time' => time(), 717 'message' => $message, 718 ); 719 $state['logs'] = array_slice( $state['logs'], -100 ); 720 721 return $state; 722 } 723 724 private function acquire_job_lock(): bool { 725 $now = time(); 726 727 if ( add_option( self::JOB_LOCK_OPTION, $now, '', false ) ) { 728 return true; 729 } 730 731 $locked_at = absint( get_option( self::JOB_LOCK_OPTION, 0 ) ); 732 733 if ( $locked_at > 0 && $locked_at < $now - ( 5 * MINUTE_IN_SECONDS ) ) { 734 delete_option( self::JOB_LOCK_OPTION ); 735 return add_option( self::JOB_LOCK_OPTION, $now, '', false ); 736 } 737 738 return false; 739 } 740 741 private function release_job_lock(): void { 742 delete_option( self::JOB_LOCK_OPTION ); 743 } 744 745 private function failed_job_ids( array $state ): array { 746 $ids = array(); 747 748 foreach ( $state['failed_items'] as $item ) { 749 if ( isset( $item['id'] ) ) { 750 $ids[] = absint( $item['id'] ); 751 } 752 } 753 754 return array_values( array_unique( array_filter( $ids ) ) ); 755 } 756 757 private function has_pending_job_media( array $excluded_ids ): bool { 758 $query = new WP_Query( 759 array( 760 'post_type' => 'attachment', 761 'post_status' => 'inherit', 762 'post_mime_type' => $this->source_mime_types(), 763 'posts_per_page' => 1, 764 'fields' => 'ids', 765 'post__not_in' => array_values( array_unique( array_map( 'absint', $excluded_ids ) ) ), 766 'meta_query' => array( 767 array( 768 'key' => self::META_VERSION, 769 'compare' => 'NOT EXISTS', 770 ), 771 ), 772 'no_found_rows' => true, 773 ) 774 ); 775 776 return ! empty( $query->posts ); 777 } 778 779 private function schedule_background_conversion(): void { 780 if ( ! wp_next_scheduled( self::JOB_HOOK ) ) { 781 wp_schedule_single_event( time(), self::JOB_HOOK ); 782 } 783 } 784 785 private function start_background_job( int $timestamp ): array { 786 $current_state = $this->job_state(); 787 788 if ( 'running' === $current_state['status'] ) { 789 return $current_state; 790 } 791 792 $state = $this->default_job_state(); 793 $state['status'] = 'running'; 794 $state['started_at'] = $timestamp; 795 $state['updated_at'] = $timestamp; 796 $state = $this->append_job_log( $state, __( 'Conversion started.', 'lightweight-modern-media' ) ); 797 $this->save_job_state( $state ); 798 $this->schedule_background_conversion(); 799 800 return $state; 801 } 802 803 private function pause_background_job( int $timestamp ): array { 804 $state = $this->job_state(); 805 $state['status'] = 'paused'; 806 $state['updated_at'] = $timestamp; 807 $state = $this->append_job_log( $state, __( 'Conversion paused.', 'lightweight-modern-media' ) ); 808 $this->save_job_state( $state ); 809 wp_clear_scheduled_hook( self::JOB_HOOK ); 810 811 return $state; 812 } 813 814 private function recover_background_job( int $timestamp ): void { 815 $state = $this->job_state(); 816 817 if ( 818 'running' === $state['status'] 819 && (int) $state['updated_at'] < $timestamp - 30 820 && false === get_option( self::JOB_LOCK_OPTION, false ) 821 && ! wp_next_scheduled( self::JOB_HOOK ) 822 ) { 823 $this->schedule_background_conversion(); 824 } 825 } 826 827 private function respect_latest_job_control( array $worker_state ): array { 828 $latest_state = $this->job_state(); 829 830 if ( 'paused' === $latest_state['status'] ) { 831 $worker_state['status'] = 'paused'; 832 $worker_state['completed_at'] = 0; 833 $worker_state['updated_at'] = max( (int) $worker_state['updated_at'], (int) $latest_state['updated_at'] ); 834 $last_log = end( $worker_state['logs'] ); 835 836 if ( ! is_array( $last_log ) || __( 'Conversion paused.', 'lightweight-modern-media' ) !== $last_log['message'] ) { 837 $worker_state = $this->append_job_log( $worker_state, __( 'Conversion paused.', 'lightweight-modern-media' ) ); 838 } 839 } 840 841 return $worker_state; 842 } 843 844 private function trigger_background_cron(): void { 845 if ( ( ! defined( 'DISABLE_WP_CRON' ) || ! DISABLE_WP_CRON ) && function_exists( 'spawn_cron' ) ) { 846 spawn_cron( time() ); 847 } 848 } 849 850 public function run_background_conversion(): void { 851 if ( ! $this->acquire_job_lock() ) { 852 return; 853 } 854 855 try { 856 $state = $this->job_state(); 857 858 if ( 'running' !== $state['status'] ) { 859 return; 860 } 861 862 $failed_ids = $this->failed_job_ids( $state ); 863 $result = $this->convert_existing_media( (int) $this->settings()['batch_size'], $failed_ids ); 864 $state['processed'] += (int) $result['processed']; 865 $state['updated_at'] = time(); 866 867 foreach ( $result['items'] as $item ) { 868 $state = $this->append_job_log( 869 $state, 870 sprintf( '#%1$d %2$s - %3$s', (int) $item['id'], (string) $item['file'], (string) $item['message'] ) 871 ); 872 873 if ( ! in_array( $item['status'], array( 'converted', 'existing' ), true ) ) { 874 $state['failed_items'][] = array( 875 'id' => (int) $item['id'], 876 'file' => (string) $item['file'], 877 'title' => (string) $item['title'], 878 'reason' => (string) $item['message'], 879 ); 880 } 881 } 882 883 $failed_ids = $this->failed_job_ids( $state ); 884 $state = $this->respect_latest_job_control( $state ); 885 886 if ( 'paused' === $state['status'] ) { 887 $this->save_job_state( $state ); 888 return; 889 } 890 891 if ( 0 === (int) $result['processed'] || ! $this->has_pending_job_media( $failed_ids ) ) { 892 $state['status'] = 'complete'; 893 $state['completed_at'] = time(); 894 $state = $this->append_job_log( 895 $state, 896 empty( $state['failed_items'] ) 897 ? __( 'Conversion complete.', 'lightweight-modern-media' ) 898 : sprintf( 899 /* translators: %d: Number of failed attachments. */ 900 _n( 'Conversion complete with %d failed attachment.', 'Conversion complete with %d failed attachments.', count( $state['failed_items'] ), 'lightweight-modern-media' ), 901 count( $state['failed_items'] ) 902 ) 903 ); 904 } else { 905 $this->schedule_background_conversion(); 906 } 907 908 $this->save_job_state( $state ); 909 } finally { 910 $this->release_job_lock(); 911 } 912 } 913 540 914 public function ajax_status(): void { 541 915 if ( ! current_user_can( 'manage_options' ) ) { … … 545 919 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 546 920 921 $this->recover_background_job( time() ); 922 $counts = $this->count_convertible_media(); 923 $job = $this->job_state(); 924 $failed_count = count( $job['failed_items'] ); 925 547 926 wp_send_json_success( 548 927 array( 549 'counts' => $this->count_convertible_media(), 928 'counts' => $counts, 929 'job' => $job, 930 'waiting' => max( 0, $counts['eligible'] - $counts['converted'] - $failed_count ), 931 'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 550 932 'support' => $this->format_support(), 551 933 'encoders' => $this->available_encoder_labels(), 552 934 ) 553 935 ); 936 } 937 938 public function ajax_start_background_job(): void { 939 if ( ! current_user_can( 'manage_options' ) ) { 940 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'lightweight-modern-media' ) ), 403 ); 941 } 942 943 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 944 $state = $this->start_background_job( time() ); 945 $this->trigger_background_cron(); 946 947 wp_send_json_success( array( 'job' => $state ) ); 948 } 949 950 public function ajax_pause_background_job(): void { 951 if ( ! current_user_can( 'manage_options' ) ) { 952 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'lightweight-modern-media' ) ), 403 ); 953 } 954 955 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 956 957 wp_send_json_success( array( 'job' => $this->pause_background_job( time() ) ) ); 554 958 } 555 959 … … 900 1304 if ( ! empty( $manifest ) && 0 === $this->last_generation_result['failed'] ) { 901 1305 update_post_meta( $attachment_id, self::META_VERSION, RUPANTA_CONVERSION_VERSION ); 1306 $this->record_review_prompt_success( time() ); 902 1307 } else { 903 1308 delete_post_meta( $attachment_id, self::META_VERSION ); … … 1057 1462 } 1058 1463 1059 $image = imagecreatefromstring( $image_data );1464 $image = @imagecreatefromstring( $image_data ); 1060 1465 1061 1466 if ( ! $image ) { … … 1521 1926 1522 1927 register_activation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'activate' ) ); 1928 register_deactivation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'deactivate' ) ); 1523 1929 1524 1930 add_action( -
lightweight-modern-media/tags/1.2.0/readme.txt
r3765626 r3778274 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 1.177 Stable tag: 1.2.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 24 24 * No subscription, license key, conversion quota, or external image-processing service. 25 25 * Automatic conversion for new JPEG and PNG uploads. 26 * AJAX batch conversion for existing media library images. 27 * Live progress, status counts, and per-file conversion logs. 26 * Persistent background conversion for existing media library images using WP-Cron. 27 * Conversion continues across admin navigation and browser closure when WordPress cron is running. 28 * Persistent progress, status counts, per-file logs, and failed-image reporting. 28 29 * GD and Imagick encoder detection for WebP and AVIF. 29 30 * Optional front-end `<picture>` output for WordPress attachment image markup. 30 * Elementor-compatible rewriting of rendered image markup containing `wp-image-*` classes.31 * Divi and Elementor-compatible rewriting of rendered image markup containing `wp-image-*` classes. 31 32 * Generated file cleanup when attachments are deleted. 32 33 * Local image conversion on your own WordPress server. … … 92 93 93 94 == Changelog == 95 96 = 1.2.0 = 97 98 * Added a contextual request for an honest WordPress.org review after successful use. 99 * The review request is administrator-only, delayed for seven days, and includes reminder and permanent-dismissal controls. 100 * Added a Review plugin link to the Plugins screen without collecting usage data. 101 * Moved existing-media conversion into a persistent, locked WP-Cron background job that continues after leaving the converter page. 102 * Restores progress, timing, recent logs, and job state when an administrator returns. 103 * Continues to later images when one conversion fails and displays a persistent failed-image list with the recorded reason. 104 * Added persistent Start and Pause controls, failed-item retry on a new run, stale-job recovery, and WP-Cron configuration guidance. 94 105 95 106 = 1.1.17 = -
lightweight-modern-media/trunk/assets/admin.css
r3679655 r3778274 45 45 .rupanta-grid { 46 46 display: grid; 47 grid-template-columns: repeat( 4, minmax(0, 1fr));47 grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); 48 48 gap: 14px; 49 49 margin: 18px 0; … … 227 227 } 228 228 229 .rupanta-failed-section[hidden], 230 #rupanta-cron-warning[hidden] { 231 display: none; 232 } 233 234 .rupanta-failed-list { 235 overflow-x: auto; 236 margin-top: 14px; 237 } 238 239 .rupanta-failed-list th:first-child, 240 .rupanta-failed-list td:first-child { 241 width: 70px; 242 } 243 229 244 .rupanta-muted { 230 245 color: #646970; … … 242 257 } 243 258 259 .rupanta-review-notice { 260 padding: 14px 16px; 261 } 262 263 .rupanta-review-notice p { 264 margin: 0 0 10px; 265 line-height: 1.6; 266 } 267 268 .rupanta-review-notice p:last-child { 269 margin-bottom: 0; 270 } 271 272 .rupanta-review-actions { 273 display: flex; 274 flex-wrap: wrap; 275 align-items: center; 276 gap: 8px 10px; 277 } 278 279 .rupanta-review-notice .rupanta-review-actions { 280 margin-top: 16px; 281 } 282 283 .rupanta-review-notice [aria-hidden="true"] { 284 display: inline-block; 285 margin-left: 3px; 286 font-size: 16px; 287 line-height: 1; 288 vertical-align: -1px; 289 } 290 244 291 .rupanta-media-status strong { 245 292 display: block; -
lightweight-modern-media/trunk/assets/admin.js
r3730748 r3778274 15 15 const elapsedText = document.getElementById('rupanta-elapsed'); 16 16 const etaText = document.getElementById('rupanta-eta'); 17 18 if (!start || !stop || !refresh || !log || !statusText || !progressBar || !elapsedText || !etaText) { 17 const failedSection = document.getElementById('rupanta-failed-section'); 18 const failedList = document.getElementById('rupanta-failed-list'); 19 const cronWarning = document.getElementById('rupanta-cron-warning'); 20 21 if (!start || !stop || !refresh || !log || !statusText || !progressBar || !elapsedText || !etaText || !failedSection || !failedList || !cronWarning) { 19 22 return; 20 23 } 21 24 22 let running = false; 23 let startedAt = 0; 24 let timerId = null; 25 let processedThisRun = 0; 26 let totalThisRun = 0; 27 let timingState = 'idle'; 28 let failedIdsThisRun = []; 25 let pollTimer = null; 29 26 30 27 function formatDuration(seconds) { … … 44 41 } 45 42 46 function updateTiming() { 47 const elapsedSeconds = startedAt > 0 ? (Date.now() - startedAt) / 1000 : 0; 48 elapsedText.textContent = formatDuration(elapsedSeconds); 49 50 if (timingState === 'complete') { 51 etaText.textContent = config.i18n.complete; 52 return; 53 } 54 55 if (timingState !== 'running') { 56 etaText.textContent = timingState === 'paused' ? config.i18n.paused : config.i18n.notStarted; 57 return; 58 } 59 60 if (processedThisRun < 1 || elapsedSeconds < 1) { 61 etaText.textContent = config.i18n.calculating; 62 return; 63 } 64 65 const remaining = Math.max(0, totalThisRun - processedThisRun); 66 etaText.textContent = formatDuration((elapsedSeconds / processedThisRun) * remaining); 67 } 68 69 function startTiming() { 70 const remainingNode = document.querySelector('[data-rupanta-stat="remaining"]'); 71 totalThisRun = remainingNode ? parseInt(remainingNode.textContent || '0', 10) : 0; 72 processedThisRun = 0; 73 startedAt = Date.now(); 74 timingState = 'running'; 75 window.clearInterval(timerId); 76 timerId = window.setInterval(updateTiming, 1000); 77 updateTiming(); 78 } 79 80 function stopTiming(state) { 81 timingState = state; 82 window.clearInterval(timerId); 83 timerId = null; 84 updateTiming(); 85 } 86 87 function addLog(message) { 88 const row = document.createElement('div'); 89 row.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message; 90 log.appendChild(row); 91 log.scrollTop = log.scrollHeight; 92 } 93 94 function setStat(key, value) { 95 const node = document.querySelector('[data-rupanta-stat="' + key + '"]'); 96 97 if (node) { 98 node.textContent = key === 'progress' ? value + '%' : value; 99 } 100 } 101 102 function renderStatus(data) { 103 const eligible = parseInt(data.eligible || 0, 10); 104 const converted = parseInt(data.converted || 0, 10); 105 const remaining = Math.max(0, eligible - converted); 106 const progress = eligible > 0 107 ? Math.min(100, Math.round((converted / eligible) * 100)) 108 : 100; 109 110 setStat('eligible', eligible); 111 setStat('converted', converted); 112 setStat('remaining', remaining); 113 setStat('progress', progress); 114 115 progressBar.style.width = progress + '%'; 116 statusText.textContent = remaining > 0 117 ? remaining + ' ' + config.i18n.waiting 118 : config.i18n.allProcessed; 119 } 120 121 function request(action, data) { 43 function request(action) { 122 44 const body = new URLSearchParams(); 123 45 body.set('action', action); 124 46 body.set('nonce', config.nonce); 125 126 Object.keys(data || {}).forEach(function (key) {127 body.set(key, data[key]);128 });129 47 130 48 return fetch(config.ajaxUrl, { … … 143 61 payload = JSON.parse(responseText); 144 62 } catch (error) { 145 throw new Error( 146 'Request returned an invalid response (HTTP ' + response.status + ').' 147 ); 63 throw new Error('Request returned an invalid response (HTTP ' + response.status + ').'); 148 64 } 149 65 … … 153 69 .then(function (payload) { 154 70 if (!payload || !payload.success) { 155 throw new Error( 156 payload && 157 payload.data && 158 payload.data.message 159 ? payload.data.message 160 : config.i18n.requestFailed 161 ); 71 throw new Error(payload && payload.data && payload.data.message ? payload.data.message : config.i18n.requestFailed); 162 72 } 163 73 … … 166 76 } 167 77 168 function setRunning(value) { 169 running = value; 170 start.disabled = value; 171 stop.disabled = !value; 172 refresh.disabled = value; 173 } 174 175 function processNext() { 176 if (!running) { 78 function setStat(key, value) { 79 const node = document.querySelector('[data-rupanta-stat="' + key + '"]'); 80 81 if (node) { 82 node.textContent = key === 'progress' ? value + '%' : value; 83 } 84 } 85 86 function renderLogs(logs) { 87 log.textContent = ''; 88 89 if (!logs.length) { 90 const emptyRow = document.createElement('div'); 91 emptyRow.textContent = config.i18n.logReady; 92 log.appendChild(emptyRow); 177 93 return; 178 94 } 179 95 180 request(config.convertAction, { 181 batch_size: 1, 182 exclude_ids: failedIdsThisRun.join(',') 183 }) 184 .then(function (data) { 185 processedThisRun += parseInt(data.result.processed || 0, 10); 186 renderStatus(data.counts); 187 updateTiming(); 188 189 if (data.result.items && data.result.items.length) { 190 data.result.items.forEach(function (item) { 191 let message = '#' + item.id + ' ' + item.file + ' - ' + item.message; 192 193 if (item.title && item.title !== item.file) { 194 message += ' (' + item.title + ')'; 195 } 196 197 addLog(message); 198 199 if (item.status !== 'converted' && item.status !== 'existing' && failedIdsThisRun.indexOf(item.id) === -1) { 200 failedIdsThisRun.push(item.id); 201 } 202 }); 203 } else { 204 addLog( 205 'Batch processed ' + 206 data.result.processed + 207 ' attachment(s), created/refreshed ' + 208 (data.result.created + data.result.existing) + 209 ' file(s), skipped ' + 210 data.result.skipped + 211 '.' 212 ); 213 214 if (data.result.diagnostics && data.result.diagnostics.length) { 215 addLog(data.result.diagnostics.join(' ')); 216 } 217 } 218 219 if (data.done || data.result.processed === 0) { 220 setRunning(false); 221 stopTiming('complete'); 222 const completionMessage = failedIdsThisRun.length > 0 223 ? config.i18n.conversionCompleteWithFailures.replace('%d', failedIdsThisRun.length) 224 : config.i18n.conversionComplete; 225 addLog(completionMessage); 226 statusText.textContent = completionMessage; 227 return; 228 } 229 230 window.setTimeout(processNext, 350); 231 }) 232 .catch(function (error) { 233 setRunning(false); 234 stopTiming('paused'); 235 statusText.textContent = config.i18n.pausedAfterError; 236 addLog('Error: ' + error.message); 237 }); 238 } 239 240 const initialProgress = parseInt(progressBar.getAttribute('data-progress') || '0', 10); 241 progressBar.style.width = Math.max(0, Math.min(100, initialProgress)) + '%'; 96 logs.forEach(function (entry) { 97 const row = document.createElement('div'); 98 const timestamp = entry.time ? new Date(parseInt(entry.time, 10) * 1000).toLocaleTimeString() : ''; 99 row.textContent = (timestamp ? '[' + timestamp + '] ' : '') + entry.message; 100 log.appendChild(row); 101 }); 102 103 log.scrollTop = log.scrollHeight; 104 } 105 106 function renderFailedItems(items) { 107 failedList.textContent = ''; 108 failedSection.hidden = items.length === 0; 109 110 items.forEach(function (item) { 111 const row = document.createElement('tr'); 112 const idCell = document.createElement('td'); 113 const imageCell = document.createElement('td'); 114 const reasonCell = document.createElement('td'); 115 116 idCell.textContent = '#' + item.id; 117 imageCell.textContent = item.title && item.title !== item.file ? item.title + ' (' + item.file + ')' : item.file; 118 reasonCell.textContent = item.reason; 119 row.appendChild(idCell); 120 row.appendChild(imageCell); 121 row.appendChild(reasonCell); 122 failedList.appendChild(row); 123 }); 124 } 125 126 function renderTiming(job, waiting) { 127 const now = Math.round(Date.now() / 1000); 128 const startedAt = parseInt(job.started_at || 0, 10); 129 const endedAt = parseInt(job.completed_at || 0, 10) || now; 130 const elapsed = startedAt > 0 ? Math.max(0, endedAt - startedAt) : 0; 131 const processed = parseInt(job.processed || 0, 10); 132 133 elapsedText.textContent = formatDuration(elapsed); 134 135 if (job.status === 'complete') { 136 etaText.textContent = config.i18n.complete; 137 } else if (job.status === 'paused') { 138 etaText.textContent = config.i18n.paused; 139 } else if (job.status !== 'running') { 140 etaText.textContent = config.i18n.notStarted; 141 } else if (processed < 1 || elapsed < 1) { 142 etaText.textContent = config.i18n.calculating; 143 } else { 144 etaText.textContent = formatDuration((elapsed / processed) * waiting); 145 } 146 } 147 148 function renderStatus(data) { 149 const eligible = parseInt(data.counts.eligible || 0, 10); 150 const converted = parseInt(data.counts.converted || 0, 10); 151 const waiting = parseInt(data.waiting || 0, 10); 152 const job = data.job || {}; 153 const failedItems = Array.isArray(job.failed_items) ? job.failed_items : []; 154 const logs = Array.isArray(job.logs) ? job.logs : []; 155 const progress = eligible > 0 ? Math.min(100, Math.round((converted / eligible) * 100)) : 100; 156 const running = job.status === 'running'; 157 158 setStat('eligible', eligible); 159 setStat('converted', converted); 160 setStat('remaining', waiting); 161 setStat('failed', failedItems.length); 162 setStat('progress', progress); 163 progressBar.style.width = progress + '%'; 164 start.disabled = running; 165 stop.disabled = !running; 166 refresh.disabled = false; 167 cronWarning.hidden = !data.cron_disabled; 168 169 if (running) { 170 statusText.textContent = waiting + ' ' + config.i18n.waiting; 171 } else if (job.status === 'paused') { 172 statusText.textContent = config.i18n.paused; 173 } else if (job.status === 'complete' && failedItems.length > 0) { 174 statusText.textContent = config.i18n.conversionCompleteWithFailures.replace('%d', failedItems.length); 175 } else if (job.status === 'complete') { 176 statusText.textContent = config.i18n.conversionComplete; 177 } else { 178 statusText.textContent = config.i18n.ready; 179 } 180 181 renderTiming(job, waiting); 182 renderLogs(logs); 183 renderFailedItems(failedItems); 184 scheduleStatusPoll(running); 185 } 186 187 function scheduleStatusPoll(running) { 188 window.clearTimeout(pollTimer); 189 pollTimer = null; 190 191 if (running && !document.hidden) { 192 pollTimer = window.setTimeout(loadStatus, 2000); 193 } 194 } 195 196 function showRequestError(error) { 197 statusText.textContent = config.i18n.pausedAfterError; 198 const row = document.createElement('div'); 199 row.textContent = 'Error: ' + error.message; 200 log.appendChild(row); 201 } 202 203 function loadStatus() { 204 return request(config.statusAction) 205 .then(renderStatus) 206 .catch(showRequestError); 207 } 242 208 243 209 start.addEventListener('click', function () { 244 failedIdsThisRun = []; 245 setRunning(true); 246 startTiming(); 247 addLog(config.i18n.conversionStarted); 248 processNext(); 210 start.disabled = true; 211 request(config.startAction).then(loadStatus).catch(showRequestError); 249 212 }); 250 213 251 214 stop.addEventListener('click', function () { 252 setRunning(false); 253 stopTiming('paused'); 254 addLog(config.i18n.conversionPaused); 255 statusText.textContent = config.i18n.paused; 215 stop.disabled = true; 216 request(config.pauseAction).then(loadStatus).catch(showRequestError); 256 217 }); 257 218 258 refresh.addEventListener('click', function () { 259 request(config.statusAction) 260 .then(function (data) { 261 renderStatus(data.counts); 262 addLog(config.i18n.statusRefreshed); 263 }) 264 .catch(function (error) { 265 addLog('Error: ' + error.message); 266 }); 219 refresh.addEventListener('click', loadStatus); 220 document.addEventListener('visibilitychange', function () { 221 if (!document.hidden) { 222 loadStatus(); 223 } 267 224 }); 225 226 loadStatus(); 268 227 })(); -
lightweight-modern-media/trunk/lightweight-modern-media.php
r3765626 r3778274 5 5 * Plugin Name: Rupanta Media Converter 6 6 * Description: Generates WebP and AVIF copies for uploaded images and serves modern formats with picture markup. 7 * Version: 1. 1.177 * Version: 1.2.0 8 8 * Requires at least: 6.0 9 9 * Tested up to: 7.1 … … 20 20 21 21 define( 'RUPANTA_PLUGIN_FILE', __FILE__ ); 22 define( 'RUPANTA_PLUGIN_VERSION', '1. 1.17' );22 define( 'RUPANTA_PLUGIN_VERSION', '1.2.0' ); 23 23 define( 'RUPANTA_CONVERSION_VERSION', '1' ); 24 24 … … 28 28 private const META_VERSION = '_rupanta_image_converter_version'; 29 29 private const META_PRIORITY = '_rupanta_image_converter_priority'; 30 private const REVIEW_OPTION = 'rupanta_image_converter_review_prompt'; 31 private const JOB_OPTION = 'rupanta_image_converter_background_job'; 32 private const JOB_LOCK_OPTION = 'rupanta_image_converter_background_lock'; 33 private const JOB_HOOK = 'rupanta_image_converter_background_conversion'; 30 34 31 35 private static $instance = null; … … 56 60 add_action( 'wp_ajax_rupanta_image_converter_status', array( $this, 'ajax_status' ) ); 57 61 add_action( 'wp_ajax_rupanta_image_converter_convert', array( $this, 'ajax_convert' ) ); 62 add_action( 'wp_ajax_rupanta_image_converter_start', array( $this, 'ajax_start_background_job' ) ); 63 add_action( 'wp_ajax_rupanta_image_converter_pause', array( $this, 'ajax_pause_background_job' ) ); 64 add_action( self::JOB_HOOK, array( $this, 'run_background_conversion' ) ); 58 65 add_action( 'admin_post_rupanta_fix_image_mime_types', array( $this, 'handle_fix_image_mime_types' ) ); 66 add_action( 'admin_post_rupanta_review_prompt_action', array( $this, 'handle_review_prompt_action' ) ); 59 67 add_action( 'template_redirect', array( $this, 'start_frontend_rewrite_buffer' ), 0 ); 60 68 add_filter( 'elementor/widget/render_content', array( $this, 'rewrite_frontend_image_html' ), 20, 1 ); … … 74 82 75 83 add_option( self::OPTION, self::default_settings() ); 84 } 85 86 public static function deactivate(): void { 87 wp_clear_scheduled_hook( self::JOB_HOOK ); 88 delete_option( self::JOB_LOCK_OPTION ); 76 89 } 77 90 … … 101 114 public function add_plugin_action_links( array $links ): array { 102 115 $settings_url = add_query_arg( 'tab', 'settings', admin_url( 'upload.php?page=rupanta-media-converter' ) ); 116 $review_url = 'https://wordpress.org/support/plugin/lightweight-modern-media/reviews/#new-post'; 103 117 array_unshift( 104 118 $links, … … 108 122 esc_html__( 'Settings', 'lightweight-modern-media' ) 109 123 ) 124 ); 125 $links[] = sprintf( 126 '<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', 127 esc_url( $review_url ), 128 esc_html__( 'Review plugin', 'lightweight-modern-media' ) 110 129 ); 111 130 … … 136 155 137 156 public function enqueue_admin_assets( string $hook_suffix ): void { 157 $asset_version = RUPANTA_PLUGIN_VERSION . '-background'; 158 138 159 if ( in_array( $hook_suffix, array( 'media_page_rupanta-media-converter', 'upload.php', 'media-new.php', 'post.php', 'post-new.php' ), true ) ) { 139 160 wp_enqueue_style( … … 141 162 plugins_url( 'assets/admin.css', RUPANTA_PLUGIN_FILE ), 142 163 array(), 143 RUPANTA_PLUGIN_VERSION164 $asset_version 144 165 ); 145 166 } … … 153 174 plugins_url( 'assets/admin.js', RUPANTA_PLUGIN_FILE ), 154 175 array(), 155 RUPANTA_PLUGIN_VERSION,176 $asset_version, 156 177 true 157 178 ); … … 163 184 'ajaxUrl' => wp_make_link_relative( admin_url( 'admin-ajax.php' ) ), 164 185 'nonce' => wp_create_nonce( 'rupanta_image_converter_ajax' ), 165 'convertAction' => 'rupanta_image_converter_convert', 186 'startAction' => 'rupanta_image_converter_start', 187 'pauseAction' => 'rupanta_image_converter_pause', 166 188 'statusAction' => 'rupanta_image_converter_status', 167 189 'i18n' => array( … … 179 201 'notStarted' => __( 'Not started', 'lightweight-modern-media' ), 180 202 'complete' => __( 'Complete', 'lightweight-modern-media' ), 203 'ready' => __( 'Ready to process your media library.', 'lightweight-modern-media' ), 204 'logReady' => __( 'Log ready. Click Start Conversion to begin.', 'lightweight-modern-media' ), 181 205 ), 182 206 ) … … 210 234 $support = $this->format_support(); 211 235 $counts = $this->count_convertible_media(); 236 $job = $this->job_state(); 237 $failed_count = count( $job['failed_items'] ); 238 $waiting_count = max( 0, $counts['eligible'] - $counts['converted'] - $failed_count ); 212 239 $progress = $counts['eligible'] > 0 ? round( ( $counts['converted'] / $counts['eligible'] ) * 100 ) : 100; 213 240 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'converter'; … … 219 246 $converter_url = admin_url( 'upload.php?page=rupanta-media-converter' ); 220 247 $settings_url = add_query_arg( 'tab', 'settings', $converter_url ); 248 249 if ( $counts['converted'] > 0 && false === get_option( self::REVIEW_OPTION, false ) ) { 250 $this->record_review_prompt_success( time() ); 251 } 221 252 ?> 222 253 <div class="wrap"> … … 228 259 229 260 <div class="rupanta-shell"> 261 <?php $this->render_review_prompt( $counts['converted'] ); ?> 230 262 <div class="rupanta-hero"> 231 263 <h2><?php esc_html_e( 'Fast background conversion for your media library', 'lightweight-modern-media' ); ?></h2> 232 <p><?php esc_html_e( 'Generate modern image formats in safe AJAX batches, with live progress and conversion logs while the page stays responsive.', 'lightweight-modern-media' ); ?></p>264 <p><?php esc_html_e( 'Generate modern image formats in safe background batches that continue when you leave this page, with persistent progress and conversion logs.', 'lightweight-modern-media' ); ?></p> 233 265 </div> 234 266 … … 258 290 259 291 <?php if ( 'converter' === $active_tab ) : ?> 292 <div id="rupanta-cron-warning" class="notice notice-warning" <?php echo defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ? '' : 'hidden'; ?>> 293 <p><?php esc_html_e( 'WP-Cron is disabled. Background conversion requires a hosting cron task that regularly requests wp-cron.php.', 'lightweight-modern-media' ); ?></p> 294 </div> 260 295 <details class="rupanta-accordion" open> 261 296 <summary><?php esc_html_e( 'Media Library Overview', 'lightweight-modern-media' ); ?></summary> … … 264 299 <div class="rupanta-stat"><strong data-rupanta-stat="eligible"><?php echo esc_html( $counts['eligible'] ); ?></strong><span><?php esc_html_e( 'Eligible images', 'lightweight-modern-media' ); ?></span></div> 265 300 <div class="rupanta-stat"><strong data-rupanta-stat="converted"><?php echo esc_html( $counts['converted'] ); ?></strong><span><?php esc_html_e( 'Converted', 'lightweight-modern-media' ); ?></span></div> 266 <div class="rupanta-stat"><strong data-rupanta-stat="remaining"><?php echo esc_html( max( 0, $counts['eligible'] - $counts['converted'] ) ); ?></strong><span><?php esc_html_e( 'Remaining', 'lightweight-modern-media' ); ?></span></div> 301 <div class="rupanta-stat"><strong data-rupanta-stat="remaining"><?php echo esc_html( $waiting_count ); ?></strong><span><?php esc_html_e( 'Waiting', 'lightweight-modern-media' ); ?></span></div> 302 <div class="rupanta-stat"><strong data-rupanta-stat="failed"><?php echo esc_html( $failed_count ); ?></strong><span><?php esc_html_e( 'Failed', 'lightweight-modern-media' ); ?></span></div> 267 303 <div class="rupanta-stat"><strong data-rupanta-stat="progress"><?php echo esc_html( $progress ); ?>%</strong><span><?php esc_html_e( 'Progress', 'lightweight-modern-media' ); ?></span></div> 268 304 </div> … … 294 330 </div> 295 331 </details> 332 <details class="rupanta-accordion rupanta-failed-section" id="rupanta-failed-section" <?php echo $failed_count > 0 ? 'open' : 'hidden'; ?>> 333 <summary><?php esc_html_e( 'Failed Images', 'lightweight-modern-media' ); ?></summary> 334 <div class="rupanta-accordion-content"> 335 <p class="description"><?php esc_html_e( 'These images were skipped for the current run so conversion could continue. Starting a new run retries them.', 'lightweight-modern-media' ); ?></p> 336 <div class="rupanta-failed-list"> 337 <table class="widefat striped"> 338 <thead><tr><th><?php esc_html_e( 'ID', 'lightweight-modern-media' ); ?></th><th><?php esc_html_e( 'Image', 'lightweight-modern-media' ); ?></th><th><?php esc_html_e( 'Reason', 'lightweight-modern-media' ); ?></th></tr></thead> 339 <tbody id="rupanta-failed-list"></tbody> 340 </table> 341 </div> 342 </div> 343 </details> 296 344 <?php else : ?> 297 345 … … 345 393 <label for="rupanta-batch-size"><?php esc_html_e( 'Attachments per request', 'lightweight-modern-media' ); ?></label> 346 394 <input id="rupanta-batch-size" type="number" min="1" max="100" name="<?php echo esc_attr( self::OPTION ); ?>[batch_size]" value="<?php echo esc_attr( $settings['batch_size'] ); ?>" class="small-text"> 347 <p class="description"><?php esc_html_e( ' Number of attachments processed per AJAX request.', 'lightweight-modern-media' ); ?></p>395 <p class="description"><?php esc_html_e( 'Maximum number of attachments processed per background worker batch.', 'lightweight-modern-media' ); ?></p> 348 396 </div> 349 397 </details> … … 380 428 } 381 429 430 private function record_review_prompt_success( int $timestamp ): void { 431 add_option( 432 self::REVIEW_OPTION, 433 array( 434 'first_success' => $timestamp, 435 'remind_after' => 0, 436 'dismissed' => 0, 437 ), 438 '', 439 false 440 ); 441 } 442 443 private function should_show_review_prompt( int $converted, int $now ): bool { 444 if ( $converted < 1 ) { 445 return false; 446 } 447 448 $state = get_option( self::REVIEW_OPTION, array() ); 449 450 if ( ! is_array( $state ) || ! empty( $state['dismissed'] ) ) { 451 return false; 452 } 453 454 $first_success = isset( $state['first_success'] ) ? absint( $state['first_success'] ) : 0; 455 $remind_after = isset( $state['remind_after'] ) ? absint( $state['remind_after'] ) : 0; 456 457 return $first_success > 0 458 && $now >= $first_success + ( 7 * DAY_IN_SECONDS ) 459 && $now >= $remind_after; 460 } 461 462 private function render_review_prompt( int $converted ): void { 463 if ( ! $this->should_show_review_prompt( $converted, time() ) ) { 464 return; 465 } 466 467 $action_url = admin_url( 'admin-post.php' ); 468 $review_url = wp_nonce_url( 469 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'review' ), $action_url ), 470 'rupanta_review_prompt_action' 471 ); 472 $remind_url = wp_nonce_url( 473 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'remind' ), $action_url ), 474 'rupanta_review_prompt_action' 475 ); 476 $dismiss_url = wp_nonce_url( 477 add_query_arg( array( 'action' => 'rupanta_review_prompt_action', 'response' => 'dismiss' ), $action_url ), 478 'rupanta_review_prompt_action' 479 ); 480 ?> 481 <div class="notice notice-info rupanta-review-notice"> 482 <p><strong><?php esc_html_e( 'Is Rupanta Media Converter helping your website?', 'lightweight-modern-media' ); ?> <span aria-hidden="true">🙂</span></strong></p> 483 <p><?php esc_html_e( 'If you have a moment, please share an honest review on WordPress.org. Your feedback helps other WordPress users discover the plugin.', 'lightweight-modern-media' ); ?></p> 484 <p class="rupanta-review-actions"> 485 <a class="button button-primary" href="<?php echo esc_url( $review_url ); ?>"><?php esc_html_e( 'Leave an honest review', 'lightweight-modern-media' ); ?></a> 486 <a class="button" href="<?php echo esc_url( $remind_url ); ?>"><?php esc_html_e( 'Remind me later', 'lightweight-modern-media' ); ?></a> 487 <a class="button-link" href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( "Don't ask again", 'lightweight-modern-media' ); ?></a> 488 </p> 489 </div> 490 <?php 491 } 492 493 public function handle_review_prompt_action(): void { 494 if ( ! current_user_can( 'manage_options' ) ) { 495 wp_die( esc_html__( 'You do not have permission to manage this review request.', 'lightweight-modern-media' ) ); 496 } 497 498 check_admin_referer( 'rupanta_review_prompt_action' ); 499 500 $response = isset( $_GET['response'] ) ? sanitize_key( wp_unslash( $_GET['response'] ) ) : ''; 501 $state = get_option( self::REVIEW_OPTION, array() ); 502 $state = is_array( $state ) ? $state : array(); 503 504 if ( 'remind' === $response ) { 505 $state['remind_after'] = time() + ( 30 * DAY_IN_SECONDS ); 506 $state['dismissed'] = 0; 507 } elseif ( in_array( $response, array( 'review', 'dismiss' ), true ) ) { 508 $state['dismissed'] = 1; 509 } else { 510 wp_die( esc_html__( 'Invalid review request action.', 'lightweight-modern-media' ) ); 511 } 512 513 update_option( self::REVIEW_OPTION, $state, false ); 514 515 if ( 'review' === $response ) { 516 wp_redirect( 'https://wordpress.org/support/plugin/lightweight-modern-media/reviews/#new-post' ); 517 exit; 518 } 519 520 wp_safe_redirect( admin_url( 'upload.php?page=rupanta-media-converter' ) ); 521 exit; 522 } 523 382 524 public function handle_fix_image_mime_types(): void { 383 525 if ( ! current_user_can( 'manage_options' ) ) { … … 538 680 } 539 681 682 private function default_job_state(): array { 683 return array( 684 'status' => 'idle', 685 'started_at' => 0, 686 'updated_at' => 0, 687 'completed_at' => 0, 688 'processed' => 0, 689 'failed_items' => array(), 690 'logs' => array(), 691 ); 692 } 693 694 private function job_state(): array { 695 $state = wp_parse_args( 696 (array) get_option( self::JOB_OPTION, array() ), 697 $this->default_job_state() 698 ); 699 700 $state['failed_items'] = is_array( $state['failed_items'] ) ? array_values( $state['failed_items'] ) : array(); 701 $state['logs'] = is_array( $state['logs'] ) ? array_values( $state['logs'] ) : array(); 702 703 return $state; 704 } 705 706 private function save_job_state( array $state ): void { 707 update_option( 708 self::JOB_OPTION, 709 wp_parse_args( $state, $this->default_job_state() ), 710 false 711 ); 712 } 713 714 private function append_job_log( array $state, string $message ): array { 715 $state['logs'][] = array( 716 'time' => time(), 717 'message' => $message, 718 ); 719 $state['logs'] = array_slice( $state['logs'], -100 ); 720 721 return $state; 722 } 723 724 private function acquire_job_lock(): bool { 725 $now = time(); 726 727 if ( add_option( self::JOB_LOCK_OPTION, $now, '', false ) ) { 728 return true; 729 } 730 731 $locked_at = absint( get_option( self::JOB_LOCK_OPTION, 0 ) ); 732 733 if ( $locked_at > 0 && $locked_at < $now - ( 5 * MINUTE_IN_SECONDS ) ) { 734 delete_option( self::JOB_LOCK_OPTION ); 735 return add_option( self::JOB_LOCK_OPTION, $now, '', false ); 736 } 737 738 return false; 739 } 740 741 private function release_job_lock(): void { 742 delete_option( self::JOB_LOCK_OPTION ); 743 } 744 745 private function failed_job_ids( array $state ): array { 746 $ids = array(); 747 748 foreach ( $state['failed_items'] as $item ) { 749 if ( isset( $item['id'] ) ) { 750 $ids[] = absint( $item['id'] ); 751 } 752 } 753 754 return array_values( array_unique( array_filter( $ids ) ) ); 755 } 756 757 private function has_pending_job_media( array $excluded_ids ): bool { 758 $query = new WP_Query( 759 array( 760 'post_type' => 'attachment', 761 'post_status' => 'inherit', 762 'post_mime_type' => $this->source_mime_types(), 763 'posts_per_page' => 1, 764 'fields' => 'ids', 765 'post__not_in' => array_values( array_unique( array_map( 'absint', $excluded_ids ) ) ), 766 'meta_query' => array( 767 array( 768 'key' => self::META_VERSION, 769 'compare' => 'NOT EXISTS', 770 ), 771 ), 772 'no_found_rows' => true, 773 ) 774 ); 775 776 return ! empty( $query->posts ); 777 } 778 779 private function schedule_background_conversion(): void { 780 if ( ! wp_next_scheduled( self::JOB_HOOK ) ) { 781 wp_schedule_single_event( time(), self::JOB_HOOK ); 782 } 783 } 784 785 private function start_background_job( int $timestamp ): array { 786 $current_state = $this->job_state(); 787 788 if ( 'running' === $current_state['status'] ) { 789 return $current_state; 790 } 791 792 $state = $this->default_job_state(); 793 $state['status'] = 'running'; 794 $state['started_at'] = $timestamp; 795 $state['updated_at'] = $timestamp; 796 $state = $this->append_job_log( $state, __( 'Conversion started.', 'lightweight-modern-media' ) ); 797 $this->save_job_state( $state ); 798 $this->schedule_background_conversion(); 799 800 return $state; 801 } 802 803 private function pause_background_job( int $timestamp ): array { 804 $state = $this->job_state(); 805 $state['status'] = 'paused'; 806 $state['updated_at'] = $timestamp; 807 $state = $this->append_job_log( $state, __( 'Conversion paused.', 'lightweight-modern-media' ) ); 808 $this->save_job_state( $state ); 809 wp_clear_scheduled_hook( self::JOB_HOOK ); 810 811 return $state; 812 } 813 814 private function recover_background_job( int $timestamp ): void { 815 $state = $this->job_state(); 816 817 if ( 818 'running' === $state['status'] 819 && (int) $state['updated_at'] < $timestamp - 30 820 && false === get_option( self::JOB_LOCK_OPTION, false ) 821 && ! wp_next_scheduled( self::JOB_HOOK ) 822 ) { 823 $this->schedule_background_conversion(); 824 } 825 } 826 827 private function respect_latest_job_control( array $worker_state ): array { 828 $latest_state = $this->job_state(); 829 830 if ( 'paused' === $latest_state['status'] ) { 831 $worker_state['status'] = 'paused'; 832 $worker_state['completed_at'] = 0; 833 $worker_state['updated_at'] = max( (int) $worker_state['updated_at'], (int) $latest_state['updated_at'] ); 834 $last_log = end( $worker_state['logs'] ); 835 836 if ( ! is_array( $last_log ) || __( 'Conversion paused.', 'lightweight-modern-media' ) !== $last_log['message'] ) { 837 $worker_state = $this->append_job_log( $worker_state, __( 'Conversion paused.', 'lightweight-modern-media' ) ); 838 } 839 } 840 841 return $worker_state; 842 } 843 844 private function trigger_background_cron(): void { 845 if ( ( ! defined( 'DISABLE_WP_CRON' ) || ! DISABLE_WP_CRON ) && function_exists( 'spawn_cron' ) ) { 846 spawn_cron( time() ); 847 } 848 } 849 850 public function run_background_conversion(): void { 851 if ( ! $this->acquire_job_lock() ) { 852 return; 853 } 854 855 try { 856 $state = $this->job_state(); 857 858 if ( 'running' !== $state['status'] ) { 859 return; 860 } 861 862 $failed_ids = $this->failed_job_ids( $state ); 863 $result = $this->convert_existing_media( (int) $this->settings()['batch_size'], $failed_ids ); 864 $state['processed'] += (int) $result['processed']; 865 $state['updated_at'] = time(); 866 867 foreach ( $result['items'] as $item ) { 868 $state = $this->append_job_log( 869 $state, 870 sprintf( '#%1$d %2$s - %3$s', (int) $item['id'], (string) $item['file'], (string) $item['message'] ) 871 ); 872 873 if ( ! in_array( $item['status'], array( 'converted', 'existing' ), true ) ) { 874 $state['failed_items'][] = array( 875 'id' => (int) $item['id'], 876 'file' => (string) $item['file'], 877 'title' => (string) $item['title'], 878 'reason' => (string) $item['message'], 879 ); 880 } 881 } 882 883 $failed_ids = $this->failed_job_ids( $state ); 884 $state = $this->respect_latest_job_control( $state ); 885 886 if ( 'paused' === $state['status'] ) { 887 $this->save_job_state( $state ); 888 return; 889 } 890 891 if ( 0 === (int) $result['processed'] || ! $this->has_pending_job_media( $failed_ids ) ) { 892 $state['status'] = 'complete'; 893 $state['completed_at'] = time(); 894 $state = $this->append_job_log( 895 $state, 896 empty( $state['failed_items'] ) 897 ? __( 'Conversion complete.', 'lightweight-modern-media' ) 898 : sprintf( 899 /* translators: %d: Number of failed attachments. */ 900 _n( 'Conversion complete with %d failed attachment.', 'Conversion complete with %d failed attachments.', count( $state['failed_items'] ), 'lightweight-modern-media' ), 901 count( $state['failed_items'] ) 902 ) 903 ); 904 } else { 905 $this->schedule_background_conversion(); 906 } 907 908 $this->save_job_state( $state ); 909 } finally { 910 $this->release_job_lock(); 911 } 912 } 913 540 914 public function ajax_status(): void { 541 915 if ( ! current_user_can( 'manage_options' ) ) { … … 545 919 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 546 920 921 $this->recover_background_job( time() ); 922 $counts = $this->count_convertible_media(); 923 $job = $this->job_state(); 924 $failed_count = count( $job['failed_items'] ); 925 547 926 wp_send_json_success( 548 927 array( 549 'counts' => $this->count_convertible_media(), 928 'counts' => $counts, 929 'job' => $job, 930 'waiting' => max( 0, $counts['eligible'] - $counts['converted'] - $failed_count ), 931 'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 550 932 'support' => $this->format_support(), 551 933 'encoders' => $this->available_encoder_labels(), 552 934 ) 553 935 ); 936 } 937 938 public function ajax_start_background_job(): void { 939 if ( ! current_user_can( 'manage_options' ) ) { 940 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'lightweight-modern-media' ) ), 403 ); 941 } 942 943 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 944 $state = $this->start_background_job( time() ); 945 $this->trigger_background_cron(); 946 947 wp_send_json_success( array( 'job' => $state ) ); 948 } 949 950 public function ajax_pause_background_job(): void { 951 if ( ! current_user_can( 'manage_options' ) ) { 952 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'lightweight-modern-media' ) ), 403 ); 953 } 954 955 check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' ); 956 957 wp_send_json_success( array( 'job' => $this->pause_background_job( time() ) ) ); 554 958 } 555 959 … … 900 1304 if ( ! empty( $manifest ) && 0 === $this->last_generation_result['failed'] ) { 901 1305 update_post_meta( $attachment_id, self::META_VERSION, RUPANTA_CONVERSION_VERSION ); 1306 $this->record_review_prompt_success( time() ); 902 1307 } else { 903 1308 delete_post_meta( $attachment_id, self::META_VERSION ); … … 1057 1462 } 1058 1463 1059 $image = imagecreatefromstring( $image_data );1464 $image = @imagecreatefromstring( $image_data ); 1060 1465 1061 1466 if ( ! $image ) { … … 1521 1926 1522 1927 register_activation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'activate' ) ); 1928 register_deactivation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'deactivate' ) ); 1523 1929 1524 1930 add_action( -
lightweight-modern-media/trunk/readme.txt
r3765626 r3778274 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 1.177 Stable tag: 1.2.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 24 24 * No subscription, license key, conversion quota, or external image-processing service. 25 25 * Automatic conversion for new JPEG and PNG uploads. 26 * AJAX batch conversion for existing media library images. 27 * Live progress, status counts, and per-file conversion logs. 26 * Persistent background conversion for existing media library images using WP-Cron. 27 * Conversion continues across admin navigation and browser closure when WordPress cron is running. 28 * Persistent progress, status counts, per-file logs, and failed-image reporting. 28 29 * GD and Imagick encoder detection for WebP and AVIF. 29 30 * Optional front-end `<picture>` output for WordPress attachment image markup. 30 * Elementor-compatible rewriting of rendered image markup containing `wp-image-*` classes.31 * Divi and Elementor-compatible rewriting of rendered image markup containing `wp-image-*` classes. 31 32 * Generated file cleanup when attachments are deleted. 32 33 * Local image conversion on your own WordPress server. … … 92 93 93 94 == Changelog == 95 96 = 1.2.0 = 97 98 * Added a contextual request for an honest WordPress.org review after successful use. 99 * The review request is administrator-only, delayed for seven days, and includes reminder and permanent-dismissal controls. 100 * Added a Review plugin link to the Plugins screen without collecting usage data. 101 * Moved existing-media conversion into a persistent, locked WP-Cron background job that continues after leaving the converter page. 102 * Restores progress, timing, recent logs, and job state when an administrator returns. 103 * Continues to later images when one conversion fails and displays a persistent failed-image list with the recorded reason. 104 * Added persistent Start and Pause controls, failed-item retry on a new run, stale-job recovery, and WP-Cron configuration guidance. 94 105 95 106 = 1.1.17 =
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)