Plugin Directory

Changeset 3778274


Ignore:
Timestamp:
11/01/2026 04:33:57 PM (14 hours ago)
Author:
thesid66
Message:

Release 1.2.0: persistent background conversion and failed-image reporting

Location:
lightweight-modern-media
Files:
2 deleted
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • lightweight-modern-media/tags/1.2.0/assets/admin.css

    r3679655 r3778274  
    4545.rupanta-grid {
    4646        display: grid;
    47         grid-template-columns: repeat(4, minmax(0, 1fr));
     47        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
    4848        gap: 14px;
    4949        margin: 18px 0;
    … …  
    227227}
    228228
     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
    229244.rupanta-muted {
    230245        color: #646970;
    … …  
    242257}
    243258
     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
    244291.rupanta-media-status strong {
    245292        display: block;
  • lightweight-modern-media/tags/1.2.0/assets/admin.js

    r3730748 r3778274  
    1515        const elapsedText = document.getElementById('rupanta-elapsed');
    1616        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) {
    1922                return;
    2023        }
    2124
    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;
    2926
    3027        function formatDuration(seconds) {
    … …  
    4441        }
    4542
    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) {
    12244                const body = new URLSearchParams();
    12345                body.set('action', action);
    12446                body.set('nonce', config.nonce);
    125 
    126                 Object.keys(data || {}).forEach(function (key) {
    127                         body.set(key, data[key]);
    128                 });
    12947
    13048                return fetch(config.ajaxUrl, {
    … …  
    14361                                                payload = JSON.parse(responseText);
    14462                                        } 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 + ').');
    14864                                        }
    14965
    … …  
    15369                        .then(function (payload) {
    15470                                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);
    16272                                }
    16373
    … …  
    16676        }
    16777
    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);
    17793                        return;
    17894                }
    17995
    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        }
    242208
    243209        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);
    249212        });
    250213
    251214        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);
    256217        });
    257218
    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                }
    267224        });
     225
     226        loadStatus();
    268227})();
  • lightweight-modern-media/tags/1.2.0/lightweight-modern-media.php

    r3765626 r3778274  
    55 * Plugin Name: Rupanta Media Converter
    66 * Description: Generates WebP and AVIF copies for uploaded images and serves modern formats with picture markup.
    7  * Version: 1.1.17
     7 * Version: 1.2.0
    88 * Requires at least: 6.0
    99 * Tested up to: 7.1
    … …  
    2020
    2121define( 'RUPANTA_PLUGIN_FILE', __FILE__ );
    22 define( 'RUPANTA_PLUGIN_VERSION', '1.1.17' );
     22define( 'RUPANTA_PLUGIN_VERSION', '1.2.0' );
    2323define( 'RUPANTA_CONVERSION_VERSION', '1' );
    2424
    … …  
    2828        private const META_VERSION = '_rupanta_image_converter_version';
    2929        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';
    3034
    3135        private static $instance = null;
    … …  
    5660                add_action( 'wp_ajax_rupanta_image_converter_status', array( $this, 'ajax_status' ) );
    5761                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' ) );
    5865                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' ) );
    5967                add_action( 'template_redirect', array( $this, 'start_frontend_rewrite_buffer' ), 0 );
    6068                add_filter( 'elementor/widget/render_content', array( $this, 'rewrite_frontend_image_html' ), 20, 1 );
    … …  
    7482
    7583                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 );
    7689        }
    7790
    … …  
    101114        public function add_plugin_action_links( array $links ): array {
    102115                $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';
    103117                array_unshift(
    104118                        $links,
    … …  
    108122                                esc_html__( 'Settings', 'lightweight-modern-media' )
    109123                        )
     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' )
    110129                );
    111130
    … …  
    136155
    137156        public function enqueue_admin_assets( string $hook_suffix ): void {
     157                $asset_version = RUPANTA_PLUGIN_VERSION . '-background';
     158
    138159                if ( in_array( $hook_suffix, array( 'media_page_rupanta-media-converter', 'upload.php', 'media-new.php', 'post.php', 'post-new.php' ), true ) ) {
    139160                        wp_enqueue_style(
    … …  
    141162                                plugins_url( 'assets/admin.css', RUPANTA_PLUGIN_FILE ),
    142163                                array(),
    143                                 RUPANTA_PLUGIN_VERSION
     164                                $asset_version
    144165                        );
    145166                }
    … …  
    153174                        plugins_url( 'assets/admin.js', RUPANTA_PLUGIN_FILE ),
    154175                        array(),
    155                         RUPANTA_PLUGIN_VERSION,
     176                        $asset_version,
    156177                        true
    157178                );
    … …  
    163184                                'ajaxUrl'       => wp_make_link_relative( admin_url( 'admin-ajax.php' ) ),
    164185                                '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',
    166188                                'statusAction'  => 'rupanta_image_converter_status',
    167189                                'i18n'          => array(
    … …  
    179201                                        'notStarted'         => __( 'Not started', 'lightweight-modern-media' ),
    180202                                        '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' ),
    181205                                ),
    182206                        )
    … …  
    210234                $support  = $this->format_support();
    211235                $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 );
    212239                $progress = $counts['eligible'] > 0 ? round( ( $counts['converted'] / $counts['eligible'] ) * 100 ) : 100;
    213240                $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'converter';
    … …  
    219246                $converter_url = admin_url( 'upload.php?page=rupanta-media-converter' );
    220247                $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                }
    221252                ?>
    222253                <div class="wrap">
    … …  
    228259
    229260                        <div class="rupanta-shell">
     261                        <?php $this->render_review_prompt( $counts['converted'] ); ?>
    230262                        <div class="rupanta-hero">
    231263                                <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>
    233265                        </div>
    234266
    … …  
    258290
    259291                        <?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>
    260295                        <details class="rupanta-accordion" open>
    261296                                <summary><?php esc_html_e( 'Media Library Overview', 'lightweight-modern-media' ); ?></summary>
    … …  
    264299                                                <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>
    265300                                                <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>
    267303                                                <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>
    268304                                        </div>
    … …  
    294330                                </div>
    295331                        </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>
    296344                        <?php else : ?>
    297345
    … …  
    345393                                                <label for="rupanta-batch-size"><?php esc_html_e( 'Attachments per request', 'lightweight-modern-media' ); ?></label>
    346394                                                        <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>
    348396                                        </div>
    349397                                </details>
    … …  
    380428        }
    381429
     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
    382524        public function handle_fix_image_mime_types(): void {
    383525                if ( ! current_user_can( 'manage_options' ) ) {
    … …  
    538680        }
    539681
     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
    540914        public function ajax_status(): void {
    541915                if ( ! current_user_can( 'manage_options' ) ) {
    … …  
    545919                check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' );
    546920
     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
    547926                wp_send_json_success(
    548927                        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,
    550932                                'support'  => $this->format_support(),
    551933                                'encoders' => $this->available_encoder_labels(),
    552934                        )
    553935                );
     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() ) ) );
    554958        }
    555959
    … …  
    9001304                if ( ! empty( $manifest ) && 0 === $this->last_generation_result['failed'] ) {
    9011305                        update_post_meta( $attachment_id, self::META_VERSION, RUPANTA_CONVERSION_VERSION );
     1306                        $this->record_review_prompt_success( time() );
    9021307                } else {
    9031308                        delete_post_meta( $attachment_id, self::META_VERSION );
    … …  
    10571462                }
    10581463
    1059                 $image = imagecreatefromstring( $image_data );
     1464                $image = @imagecreatefromstring( $image_data );
    10601465
    10611466                if ( ! $image ) {
    … …  
    15211926
    15221927register_activation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'activate' ) );
     1928register_deactivation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'deactivate' ) );
    15231929
    15241930add_action(
  • lightweight-modern-media/tags/1.2.0/readme.txt

    r3765626 r3778274  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.1.17
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2424* No subscription, license key, conversion quota, or external image-processing service.
    2525* 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.
    2829* GD and Imagick encoder detection for WebP and AVIF.
    2930* 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.
    3132* Generated file cleanup when attachments are deleted.
    3233* Local image conversion on your own WordPress server.
    … …  
    9293
    9394== 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.
    94105
    95106= 1.1.17 =
  • lightweight-modern-media/trunk/assets/admin.css

    r3679655 r3778274  
    4545.rupanta-grid {
    4646        display: grid;
    47         grid-template-columns: repeat(4, minmax(0, 1fr));
     47        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
    4848        gap: 14px;
    4949        margin: 18px 0;
    … …  
    227227}
    228228
     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
    229244.rupanta-muted {
    230245        color: #646970;
    … …  
    242257}
    243258
     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
    244291.rupanta-media-status strong {
    245292        display: block;
  • lightweight-modern-media/trunk/assets/admin.js

    r3730748 r3778274  
    1515        const elapsedText = document.getElementById('rupanta-elapsed');
    1616        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) {
    1922                return;
    2023        }
    2124
    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;
    2926
    3027        function formatDuration(seconds) {
    … …  
    4441        }
    4542
    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) {
    12244                const body = new URLSearchParams();
    12345                body.set('action', action);
    12446                body.set('nonce', config.nonce);
    125 
    126                 Object.keys(data || {}).forEach(function (key) {
    127                         body.set(key, data[key]);
    128                 });
    12947
    13048                return fetch(config.ajaxUrl, {
    … …  
    14361                                                payload = JSON.parse(responseText);
    14462                                        } 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 + ').');
    14864                                        }
    14965
    … …  
    15369                        .then(function (payload) {
    15470                                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);
    16272                                }
    16373
    … …  
    16676        }
    16777
    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);
    17793                        return;
    17894                }
    17995
    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        }
    242208
    243209        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);
    249212        });
    250213
    251214        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);
    256217        });
    257218
    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                }
    267224        });
     225
     226        loadStatus();
    268227})();
  • lightweight-modern-media/trunk/lightweight-modern-media.php

    r3765626 r3778274  
    55 * Plugin Name: Rupanta Media Converter
    66 * Description: Generates WebP and AVIF copies for uploaded images and serves modern formats with picture markup.
    7  * Version: 1.1.17
     7 * Version: 1.2.0
    88 * Requires at least: 6.0
    99 * Tested up to: 7.1
    … …  
    2020
    2121define( 'RUPANTA_PLUGIN_FILE', __FILE__ );
    22 define( 'RUPANTA_PLUGIN_VERSION', '1.1.17' );
     22define( 'RUPANTA_PLUGIN_VERSION', '1.2.0' );
    2323define( 'RUPANTA_CONVERSION_VERSION', '1' );
    2424
    … …  
    2828        private const META_VERSION = '_rupanta_image_converter_version';
    2929        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';
    3034
    3135        private static $instance = null;
    … …  
    5660                add_action( 'wp_ajax_rupanta_image_converter_status', array( $this, 'ajax_status' ) );
    5761                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' ) );
    5865                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' ) );
    5967                add_action( 'template_redirect', array( $this, 'start_frontend_rewrite_buffer' ), 0 );
    6068                add_filter( 'elementor/widget/render_content', array( $this, 'rewrite_frontend_image_html' ), 20, 1 );
    … …  
    7482
    7583                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 );
    7689        }
    7790
    … …  
    101114        public function add_plugin_action_links( array $links ): array {
    102115                $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';
    103117                array_unshift(
    104118                        $links,
    … …  
    108122                                esc_html__( 'Settings', 'lightweight-modern-media' )
    109123                        )
     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' )
    110129                );
    111130
    … …  
    136155
    137156        public function enqueue_admin_assets( string $hook_suffix ): void {
     157                $asset_version = RUPANTA_PLUGIN_VERSION . '-background';
     158
    138159                if ( in_array( $hook_suffix, array( 'media_page_rupanta-media-converter', 'upload.php', 'media-new.php', 'post.php', 'post-new.php' ), true ) ) {
    139160                        wp_enqueue_style(
    … …  
    141162                                plugins_url( 'assets/admin.css', RUPANTA_PLUGIN_FILE ),
    142163                                array(),
    143                                 RUPANTA_PLUGIN_VERSION
     164                                $asset_version
    144165                        );
    145166                }
    … …  
    153174                        plugins_url( 'assets/admin.js', RUPANTA_PLUGIN_FILE ),
    154175                        array(),
    155                         RUPANTA_PLUGIN_VERSION,
     176                        $asset_version,
    156177                        true
    157178                );
    … …  
    163184                                'ajaxUrl'       => wp_make_link_relative( admin_url( 'admin-ajax.php' ) ),
    164185                                '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',
    166188                                'statusAction'  => 'rupanta_image_converter_status',
    167189                                'i18n'          => array(
    … …  
    179201                                        'notStarted'         => __( 'Not started', 'lightweight-modern-media' ),
    180202                                        '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' ),
    181205                                ),
    182206                        )
    … …  
    210234                $support  = $this->format_support();
    211235                $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 );
    212239                $progress = $counts['eligible'] > 0 ? round( ( $counts['converted'] / $counts['eligible'] ) * 100 ) : 100;
    213240                $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'converter';
    … …  
    219246                $converter_url = admin_url( 'upload.php?page=rupanta-media-converter' );
    220247                $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                }
    221252                ?>
    222253                <div class="wrap">
    … …  
    228259
    229260                        <div class="rupanta-shell">
     261                        <?php $this->render_review_prompt( $counts['converted'] ); ?>
    230262                        <div class="rupanta-hero">
    231263                                <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>
    233265                        </div>
    234266
    … …  
    258290
    259291                        <?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>
    260295                        <details class="rupanta-accordion" open>
    261296                                <summary><?php esc_html_e( 'Media Library Overview', 'lightweight-modern-media' ); ?></summary>
    … …  
    264299                                                <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>
    265300                                                <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>
    267303                                                <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>
    268304                                        </div>
    … …  
    294330                                </div>
    295331                        </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>
    296344                        <?php else : ?>
    297345
    … …  
    345393                                                <label for="rupanta-batch-size"><?php esc_html_e( 'Attachments per request', 'lightweight-modern-media' ); ?></label>
    346394                                                        <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>
    348396                                        </div>
    349397                                </details>
    … …  
    380428        }
    381429
     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
    382524        public function handle_fix_image_mime_types(): void {
    383525                if ( ! current_user_can( 'manage_options' ) ) {
    … …  
    538680        }
    539681
     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
    540914        public function ajax_status(): void {
    541915                if ( ! current_user_can( 'manage_options' ) ) {
    … …  
    545919                check_ajax_referer( 'rupanta_image_converter_ajax', 'nonce' );
    546920
     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
    547926                wp_send_json_success(
    548927                        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,
    550932                                'support'  => $this->format_support(),
    551933                                'encoders' => $this->available_encoder_labels(),
    552934                        )
    553935                );
     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() ) ) );
    554958        }
    555959
    … …  
    9001304                if ( ! empty( $manifest ) && 0 === $this->last_generation_result['failed'] ) {
    9011305                        update_post_meta( $attachment_id, self::META_VERSION, RUPANTA_CONVERSION_VERSION );
     1306                        $this->record_review_prompt_success( time() );
    9021307                } else {
    9031308                        delete_post_meta( $attachment_id, self::META_VERSION );
    … …  
    10571462                }
    10581463
    1059                 $image = imagecreatefromstring( $image_data );
     1464                $image = @imagecreatefromstring( $image_data );
    10601465
    10611466                if ( ! $image ) {
    … …  
    15211926
    15221927register_activation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'activate' ) );
     1928register_deactivation_hook( __FILE__, array( 'RUPANTA_Image_Converter', 'deactivate' ) );
    15231929
    15241930add_action(
  • lightweight-modern-media/trunk/readme.txt

    r3765626 r3778274  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.1.17
     7Stable tag: 1.2.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2424* No subscription, license key, conversion quota, or external image-processing service.
    2525* 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.
    2829* GD and Imagick encoder detection for WebP and AVIF.
    2930* 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.
    3132* Generated file cleanup when attachments are deleted.
    3233* Local image conversion on your own WordPress server.
    … …  
    9293
    9394== 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.
    94105
    95106= 1.1.17 =
Note: See TracChangeset for help on using the changeset viewer.