Plugin Directory

Changeset 3401089


Ignore:
Timestamp:
11/22/2025 09:58:36 PM (4 months ago)
Author:
sethta
Message:

Update to version 1.4.3

Location:
easy-critical-css
Files:
295 added
17 edited

Legend:

Unmodified
Added
Removed
  • easy-critical-css/trunk/assets/admin.css

    r3388125 r3401089  
    66
    77.wp-core-ui button.button-danger:hover {
    8     background: #a32b2d;
    9     border-color: #a32b2d;
     8    background: #b12331;
     9    border-color: #b12331;
    1010    color: #fff;
     11}
     12
     13/* Auto Mode Status Indicator Styles */
     14.ecc-auto-mode-status {
     15    padding: 12px 16px;
     16    border-radius: 4px;
     17    margin: 12px 0;
     18    background-color: #f9f9f9;
     19    border: 1px solid #e0e0e0;
     20}
     21
     22.ecc-auto-mode-status.ecc-status-ready {
     23    background-color: #f0f6f3;
     24    border-color: #1e7e34;
     25}
     26
     27.ecc-auto-mode-status.ecc-status-blocked {
     28    background-color: #fef5f5;
     29    border-color: #b12331;
     30}
     31
     32.ecc-status-main {
     33    display: flex;
     34    align-items: center;
     35    gap: 10px;
     36    margin-bottom: 8px;
     37}
     38
     39.ecc-status-icon {
     40    font-size: 20px;
     41    font-weight: bold;
     42    min-width: 24px;
     43    text-align: center;
     44}
     45
     46.ecc-status-text {
     47    font-weight: 500;
     48    font-size: 14px;
     49}
     50
     51.ecc-status-details {
     52    margin: 10px 0;
     53    padding-left: 34px;
     54    font-size: 12px;
     55    color: #666;
     56}
     57
     58.ecc-status-details ul {
     59    list-style: none;
     60    margin: 0;
     61    padding: 0;
     62}
     63
     64.ecc-status-details li {
     65    margin-bottom: 6px;
     66    display: flex;
     67    align-items: center;
     68    gap: 8px;
     69}
     70
     71.ecc-status-details strong {
     72    min-width: 140px;
     73}
     74
     75.ecc-check {
     76    display: inline-block;
     77    font-weight: bold;
     78    font-size: 14px;
     79    min-width: 16px;
     80    text-align: center;
     81}
     82
     83.ecc-check-ok {
     84    color: #1e7e34;
     85}
     86
     87.ecc-check-fail {
     88    color: #b12331;
     89}
     90
     91.ecc-status-toggle {
     92    cursor: pointer;
     93    margin-top: 4px;
     94    font-size: 12px;
     95}
     96
     97.ecc-status-refresh {
     98    cursor: pointer;
    1199}
    12100
  • easy-critical-css/trunk/assets/admin.js

    r3394020 r3401089  
    2929  }
    3030
     31  function toggleAutoModeStatus() {
     32    const mode = document.querySelector('input[name="easy_cc_critical_css_mode"]:checked')?.value
     33    const statusContainer = document.getElementById('auto-mode-status-container')
     34    if (!statusContainer) return
     35
     36    // Only show when mode is auto
     37    statusContainer.style.display = (mode === 'auto') ? '' : 'none'
     38  }
     39
    3140  function toggleSettingVisibility(settingId, behavior = false) {
    3241    const mode = document.querySelector('input[name="easy_cc_critical_css_mode"]:checked')?.value
     
    4150        row.style.display = (mode === "manual") ? "none" : "table-row"
    4251      }
     52    }
     53  }
     54
     55  function updateExcludeCssLabel() {
     56    var mode = document.querySelector('input[name="easy_cc_critical_css_mode"]:checked')?.value
     57    var container = document.getElementById('exclude-css-files-container')
     58    console.log(mode, container)
     59    if (!container) return
     60    var row = container.closest('tr')
     61    var labelEl = null
     62    console.log(row)
     63    if (row) {
     64      labelEl = row.querySelector('th')
     65    }
     66    var descEl = container.querySelector('.description')
     67    console.log(labelEl, descEl)
     68    if (!labelEl || !descEl) return
     69
     70    if (mode === 'manual') {
     71      if (easyCriticalCss.excludeLabelManual) labelEl.textContent = easyCriticalCss.excludeLabelManual
     72      if (easyCriticalCss.excludeDescManual) descEl.innerHTML = easyCriticalCss.excludeDescManual
     73    } else {
     74      if (easyCriticalCss.excludeLabelAuto) labelEl.textContent = easyCriticalCss.excludeLabelAuto
     75      if (easyCriticalCss.excludeDescAuto) descEl.innerHTML = easyCriticalCss.excludeDescAuto
    4376    }
    4477  }
     
    5285        toggleSettingVisibility('forced-secondary-selectors-container', true)
    5386        toggleSettingVisibility('add-common-secondary-container', true)
    54         toggleSettingVisibility('exclude-css-files-container')
    5587        toggleSettingVisibility('ignore-cross-domain-css-container')
    5688        toggleSettingVisibility('serve-css-from-files-container')
     
    6193        toggleSettingVisibility('cloudflare-zone-id-container')
    6294        toggleDeleteSection()
     95        toggleAutoModeStatus()
     96        updateExcludeCssLabel()
    6397      })
    6498    })
     
    140174  }
    141175
     176  // Auto mode status details toggle
     177  document.querySelectorAll('.ecc-status-toggle').forEach(function(btn) {
     178    btn.addEventListener('click', function(e) {
     179      e.preventDefault()
     180      const statusContainer = this.closest('.ecc-auto-mode-status')
     181      if (!statusContainer) return
     182     
     183      const details = statusContainer.querySelector('.ecc-status-details')
     184      if (!details) return
     185     
     186      const isVisible = details.style.display !== 'none'
     187      details.style.display = isVisible ? 'none' : 'block'
     188      this.textContent = isVisible ? 'Details' : 'Hide'
     189    })
     190  })
     191
     192  // Auto mode status refresh button
     193  document.querySelectorAll('.ecc-status-refresh').forEach(function(btn) {
     194    btn.addEventListener('click', function(e) {
     195      e.preventDefault()
     196     
     197      btn.disabled = true
     198      btn.textContent = 'Refreshing...'
     199     
     200      fetch(`${easyCriticalCss.restUrl}refresh-auto-mode-status`, {
     201        method: 'POST',
     202        headers: {
     203          'Content-Type': 'application/json',
     204          'X-WP-Nonce': easyCriticalCss.nonce
     205        }
     206      })
     207      .then(response => {
     208        if (!response.ok) {
     209          throw new Error('HTTP ' + response.status + ': ' + response.statusText)
     210        }
     211        return response.json()
     212      })
     213      .then(data => {
     214        // Update status display
     215        const statusContainer = btn.closest('.ecc-auto-mode-status')
     216        if (!statusContainer) return
     217       
     218        const allOk = data.all_ok
     219        const localOk = data.local_ok
     220        const restApiOk = data.rest_api_ok
     221       
     222        // Update main status
     223        const icon = statusContainer.querySelector('.ecc-status-icon')
     224        const text = statusContainer.querySelector('.ecc-status-text')
     225        const statusClass = allOk ? 'ecc-status-ready' : 'ecc-status-blocked'
     226       
     227        icon.style.color = allOk ? '#1e7e34' : '#b12331'
     228        icon.textContent = allOk ? '✓' : '✕'
     229        text.textContent = allOk ? 'Auto mode ready' : 'Auto mode cannot run'
     230       
     231        statusContainer.className = 'ecc-auto-mode-status ' + statusClass
     232       
     233        // Update details
     234        const checks = statusContainer.querySelectorAll('.ecc-check')
     235        const detailItems = statusContainer.querySelectorAll('.ecc-status-details li')
     236       
     237        if (checks[0]) {
     238          checks[0].className = 'ecc-check ' + (localOk ? 'ecc-check-ok' : 'ecc-check-fail')
     239          checks[0].textContent = localOk ? '✓' : '✕'
     240        }
     241       
     242        if (checks[1]) {
     243          checks[1].className = 'ecc-check ' + (restApiOk ? 'ecc-check-ok' : 'ecc-check-fail')
     244          checks[1].textContent = restApiOk ? '✓' : '✕'
     245        }
     246       
     247        // Update error messages in detail items
     248        if (detailItems[0]) {
     249          const localErrorEl = detailItems[0].querySelector('em') || detailItems[0].lastChild
     250          if (localErrorEl && !localOk) {
     251            if (!detailItems[0].querySelector('em')) {
     252              const em = document.createElement('em')
     253              em.textContent = 'Site is local'
     254              detailItems[0].appendChild(em)
     255            }
     256          } else if (localErrorEl && localOk && localErrorEl.tagName === 'EM') {
     257            localErrorEl.remove()
     258          }
     259        }
     260       
     261        if (detailItems[1]) {
     262          const apiErrorEl = detailItems[1].querySelector('em') || detailItems[1].lastChild
     263          if (apiErrorEl && !restApiOk) {
     264            if (!detailItems[1].querySelector('em')) {
     265              const em = document.createElement('em')
     266              em.textContent = 'Generator cannot reach the site'
     267              detailItems[1].appendChild(em)
     268            }
     269          } else if (apiErrorEl && restApiOk && apiErrorEl.tagName === 'EM') {
     270            apiErrorEl.remove()
     271          }
     272        }
     273       
     274        btn.disabled = false
     275        btn.textContent = 'Refresh'
     276      })
     277      .catch(error => {
     278        console.error('Error refreshing auto mode status:', error)
     279        btn.disabled = false
     280        btn.textContent = 'Refresh'
     281      })
     282    })
     283  })
     284
    142285  // Run on page load
    143286  toggleSettingVisibility('secondary-css-behavior-container')
     
    145288  toggleSettingVisibility('forced-secondary-selectors-container', true)
    146289  toggleSettingVisibility('add-common-secondary-container', true)
    147   toggleSettingVisibility('exclude-css-files-container')
    148290  toggleSettingVisibility('ignore-cross-domain-css-container')
    149291  toggleSettingVisibility('serve-css-from-files-container')
     
    154296  toggleSettingVisibility('cloudflare-zone-id-container')
    155297  toggleDeleteSection()
     298  toggleAutoModeStatus()
     299  updateExcludeCssLabel()
    156300})
  • easy-critical-css/trunk/easy-critical-css.php

    r3395875 r3401089  
    22/**
    33 * Plugin Name:       Easy Critical CSS
    4  * Description:       Easily inject Critical CSS and optimized Secondary CSS to improve page speed and performance.
    5  * Version:           1.4.2
     4 * Description:       Easily inject Critical CSS and Secondary CSS (with unused styles removed) to improve site speed and performance.
     5 * Version:           1.4.3
    66 * Requires at least: 6.2
    77 * Tested up to:      6.8.3
  • easy-critical-css/trunk/inc/class-admin-settings.php

    r3395875 r3401089  
    219219                <?php } ?>
    220220                <?php
     221            } elseif ( $type === 'status-indicator' ) {
     222                $status = Helpers::get_auto_mode_status();
     223                $all_ok = $status['all_ok'];
     224               
     225                $status_color = $all_ok ? '#1e7e34' : '#b12331';
     226                $status_icon  = $all_ok ? '&#x2713;' : '&#x2717;';
     227                $status_text  = $all_ok ? __( 'Auto mode ready', 'easy-critical-css' ) : __( 'Auto mode cannot run', 'easy-critical-css' );
     228                $status_class = $all_ok ? 'ecc-status-ready' : 'ecc-status-blocked';
     229                ?>
     230                <div class="ecc-auto-mode-status <?php echo esc_attr( $status_class ); ?>">
     231                    <div class="ecc-status-main">
     232                        <span class="ecc-status-icon" style="color: <?php echo esc_attr( $status_color ); ?>;">
     233                            <?php echo esc_html( $status_icon ); ?>
     234                        </span>
     235                        <span class="ecc-status-text">
     236                            <?php echo esc_html( $status_text ); ?>
     237                        </span>
     238                    </div>
     239                    <div class="ecc-status-details" style="display: none;">
     240                        <ul>
     241                            <li>
     242                                <strong><?php esc_html_e( 'Active API Key:', 'easy-critical-css' ); ?></strong>
     243                                <span class="ecc-check <?php echo $status['active_key'] ? 'ecc-check-ok' : 'ecc-check-fail'; ?>">
     244                                    <?php echo $status['active_key'] ? '✓' : '✕'; ?>
     245                                </span>
     246                                <?php
     247                                if ( ! $status['active_key'] ) {
     248                                    esc_html_e( 'No active API key', 'easy-critical-css' );
     249                                }
     250                                ?>
     251                            </li>
     252
     253                            <li>
     254                                <strong><?php esc_html_e( 'Local Install:', 'easy-critical-css' ); ?></strong>
     255                                <span class="ecc-check <?php echo $status['local_ok'] ? 'ecc-check-ok' : 'ecc-check-fail'; ?>">
     256                                    <?php echo $status['local_ok'] ? '✓' : '✕'; ?>
     257                                </span>
     258                                <?php
     259                                if ( ! $status['local_ok'] ) {
     260                                    esc_html_e( 'Site is local', 'easy-critical-css' );
     261                                }
     262                                ?>
     263                            </li>
     264                            <li>
     265                                <strong><?php esc_html_e( 'REST API Reachable:', 'easy-critical-css' ); ?></strong>
     266                                <span class="ecc-check <?php echo $status['rest_api_ok'] ? 'ecc-check-ok' : 'ecc-check-fail'; ?>">
     267                                    <?php echo $status['rest_api_ok'] ? '✓' : '✕'; ?>
     268                                </span>
     269                                <?php
     270                                if ( ! $status['rest_api_ok'] ) {
     271                                    esc_html_e( 'Generator cannot reach the site', 'easy-critical-css' );
     272                                }
     273                                ?>
     274                            </li>
     275                        </ul>
     276                    </div>
     277                    <div style="margin-top: 10px;">
     278                        <button type="button" class="ecc-status-toggle" style="background: none; border: none; padding: 0; color: #007cba; cursor: pointer; text-decoration: underline; font-size: 12px; margin-right: 15px;">
     279                            <?php esc_html_e( 'Details', 'easy-critical-css' ); ?>
     280                        </button>
     281                        <button type="button" class="ecc-status-refresh" style="background: none; border: none; padding: 0; color: #007cba; cursor: pointer; text-decoration: underline; font-size: 12px;">
     282                            <?php esc_html_e( 'Refresh', 'easy-critical-css' ); ?>
     283                        </button>
     284                    </div>
     285                </div>
     286                <?php
    221287            }
    222288
     
    247313            if ( ! empty( $warning ) ) {
    248314                ?>
    249                 <p style="color: #a32b2d; font-weight: bold;">
     315                <p style="color: #b12331; font-weight: bold;">
    250316                    <?php echo wp_kses_post( $warning ); ?>
    251317                </p>
     
    258324    }
    259325
    260     public static function get_settings_schema() {
     326    public static function get_critical_css_mode_text() {
     327        $getting_started_link = sprintf(
     328            '<p style="margin:1.5em 0;"><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></p>',
     329            'https://criticalcss.net/docs/',
     330            esc_html__( 'Need help getting started? View the Easy Critical CSS guides.', 'easy-critical-css' )
     331        );
     332
    261333        $critical_css_mode_text = sprintf(
    262             // translators: %s is a link to the API.
    263             __( 'The Auto mode sends page data to an API at %s that requires a key.', 'easy-critical-css' ),
     334            // translators: %s is a link to the API site.
     335            __( 'Auto mode sends page data to an external API at %s and requires an API key.', 'easy-critical-css' ),
    264336            '<a href="https://criticalcss.net/" target="_blank" rel="noopener noreferrer">CriticalCSS.net</a>'
    265337        );
    266         $critical_css_mode_warn = "<p style=\"color: #a32b2d; font-weight: bold;\">$critical_css_mode_text</p>";
    267         // Add a simple get API link if they don't have one added.
     338
     339        // Base warning (shown in red).
     340        $critical_css_mode_warn  = $getting_started_link;
     341        $critical_css_mode_warn .= sprintf(
     342            '<p style="color:#b12331;font-weight:bold;">%s</p>',
     343            $critical_css_mode_text
     344        );
     345
    268346        if ( empty( Helpers::get_api_key() ) ) {
    269             $critical_css_mode_warn = sprintf(
    270                 '<p style="color: #a32b2d; font-weight: bold;">%s <a href="%s">%s</a>. <span class="activate-license easy-critical-css"><a href="#">Add API Key</a>.</span></p><p style="font-weight: bold;">Want to get started without an API key? Use <a href="https://criticalcss.net/#pricing" target="_blank" rel="noopener noreferrer">CriticalCSS.net</a> to generate Critical CSS manually and paste it into your settings.</p>',
    271                 $critical_css_mode_text,
     347            // Auto mode path.
     348            $critical_css_mode_warn .= sprintf(
     349                '<p style="font-weight:bold;">%1$s <a href="%2$s">%3$s</a> · <span class="activate-license easy-critical-css"><a href="#">%4$s</a></span></p>',
     350                esc_html__( 'To use Auto mode, you need an API key.', 'easy-critical-css' ),
    272351                esc_url( admin_url( 'admin.php?page=easy-critical-css-settings-pricing' ) ),
    273                 __( 'Get an API key', 'easy-critical-css' )
     352                esc_html__( 'Get an API key', 'easy-critical-css' ),
     353                esc_html__( 'Add your API key', 'easy-critical-css' )
    274354            );
    275         }
     355           
     356
     357            // Free/manual path.
     358            $critical_css_mode_warn .= sprintf(
     359                '<p>%1$s <a href="https://criticalcss.net/#free-generator" target="_blank" rel="noopener noreferrer">%2$s</a> %3$s</p>',
     360                esc_html__( 'Staying in free Manual mode? You can generate CSS manually at', 'easy-critical-css' ),
     361                'CriticalCSS.net',
     362                esc_html__( 'and paste it into your settings.', 'easy-critical-css' )
     363            );
     364        }
     365
     366        return $critical_css_mode_warn;
     367    }
     368
     369    public static function get_exclude_css_strings() {
     370        $label_auto = __( 'Exclude CSS Files from Critical CSS', 'easy-critical-css' );
     371        $label_manual = __( 'Always Loaded CSS Files', 'easy-critical-css' );
     372
     373        $desc_auto = __( 'Enter the CSS file URLs or partial matches that should be excluded from Critical CSS generation.', 'easy-critical-css' );
     374        $desc_manual = __( 'Enter the CSS file URLs or partial matches that should always be loaded (not inlined) when using Manual mode.', 'easy-critical-css' );
     375
     376        $common = '<br><em>' . sprintf(
     377            // translators: %1$s and %2$s are opening and closing <strong> tags for emphasis.
     378            __( 'One entry per line. Example: %1$shttps://example.com/style.css%2$s or %1$smy-style.css%2$s', 'easy-critical-css' ),
     379            '<strong>',
     380            '</strong>'
     381        ) . '</em>';
     382
     383        return [
     384            'label_auto' => $label_auto,
     385            'label_manual' => $label_manual,
     386            'desc_auto' => $desc_auto . $common,
     387            'desc_manual' => $desc_manual . $common,
     388        ];
     389    }
     390
     391    public static function get_settings_schema() {
     392        $critical_css_mode_warn = self::get_critical_css_mode_text();
    276393
    277394        $forced_selectors_desc = '<br><em>' . sprintf(
     
    287404        ) . '</em>';
    288405
    289         $exclude_css_files_desc = __( 'Enter the CSS file URLs or partial matches that should be excluded from Critical CSS generation.', 'easy-critical-css' )
    290         . '<br><em>' . sprintf(
    291             // translators: %1$s and %2$s are opening and closing <strong> tags for emphasis.
    292             __( 'One entry per line. Example: %1$shttps://example.com/style.css%2$s or %1$smy-style.css%2$s', 'easy-critical-css' ),
    293             '<strong>',
    294             '</strong>'
    295         ) . '</em>';
     406        $exclude_strings = self::get_exclude_css_strings();
    296407
    297408        $settings = [
     
    310421                'basic'         => true,
    311422            ],
     423            'auto_mode_status'           => [
     424                'label'         => '',
     425                'type'          => 'status-indicator',
     426                'value_type'    => 'string',
     427                'desc'          => '',
     428                'basic'         => true,
     429                'hidden'        => true, // Don't register as a real setting
     430            ],
    312431            'secondary_css_behavior'     => [
    313432                'label'         => __( 'Secondary CSS Behavior', 'easy-critical-css' ),
     
    355474            ],
    356475            'exclude_css_files'          => [
    357                 'label'      => __( 'Exclude CSS Files from Critical CSS', 'easy-critical-css' ),
     476                'label'      => $exclude_strings['label_auto'],
    358477                'type'       => 'textarea',
    359478                'value_type' => 'array',
    360                 'desc'       => $exclude_css_files_desc,
     479                'desc'       => $exclude_strings['desc_auto'],
    361480            ],
    362481            'ignore_cross_domain_css'    => [
     
    574693        foreach ( $settings as $key => $setting ) {
    575694            $is_basic = isset( $setting['basic'] ) && $setting['basic'];
     695            $is_hidden = isset( $setting['hidden'] ) && $setting['hidden'];
    576696            $section  = $is_basic ? 'easy-critical-css-basic-section' : 'easy-critical-css-advanced-section';
    577697
     
    579699                "easy_cc_$key",
    580700                $setting['label'],
    581                 function () use ( $key, $setting, $is_basic ) {
     701                function () use ( $key, $setting, $is_basic, $is_hidden ) {
    582702                    static $last_advanced_key = null;
    583703                    static $all_keys          = null;
     
    617737            );
    618738
     739            // Skip registering the setting if it's hidden (like auto_mode_status).
     740            if ( $is_hidden ) {
     741                continue;
     742            }
     743
    619744            // Plugin Checker warns against dynamic arguments in the third param. Sanitization has been double-checked.
    620745            register_setting( // phpcs:ignore PluginCheck.CodeAnalysis.SettingSanitization.register_settingDynamic
     
    641766            true
    642767        );
     768
     769        $exclude_strings = self::get_exclude_css_strings();
     770        wp_localize_script(
     771            'easy-critical-css-admin',
     772            'easyCriticalCss',
     773            [
     774                'restUrl' => esc_url_raw( rest_url( 'easy-critical-css/v1/' ) ),
     775                'nonce'   => wp_create_nonce( 'wp_rest' ),
     776                'excludeLabelAuto' => $exclude_strings['label_auto'],
     777                'excludeLabelManual' => $exclude_strings['label_manual'],
     778                'excludeDescAuto' => $exclude_strings['desc_auto'],
     779                'excludeDescManual' => $exclude_strings['desc_manual'],
     780            ]
     781            );
    643782
    644783        wp_enqueue_style(
  • easy-critical-css/trunk/inc/class-api-request-handler.php

    r3388125 r3401089  
    3939        $url_hash = md5( $url );
    4040
    41         // Get API-related data.
     41        // Short cooldown to prevent immediate re-claim if a generation just completed.
     42        $cooldown_seconds = 120; // 2 minutes
     43        $existing_row     = Database::get_row_by_url_hash( $url_hash );
     44        if ( $existing_row && ! empty( $existing_row['processing_status'] ) && $existing_row['processing_status'] === 'completed' ) {
     45            if ( ! empty( $existing_row['generated_time'] ) ) {
     46                $generated_ts = strtotime( $existing_row['generated_time'] );
     47                if ( $generated_ts !== false && ( time() - $generated_ts ) < $cooldown_seconds ) {
     48                    $remaining = $cooldown_seconds - ( time() - $generated_ts );
     49                    $minutes   = max( 1, ceil( $remaining / 60 ) );
     50                    return new WP_Error(
     51                        'cooldown_active',
     52                        sprintf( __( 'Critical CSS was recently generated. Please try again in %d minute(s).', 'easy-critical-css' ), $minutes )
     53                    );
     54                }
     55            }
     56        }
     57
     58        // Generate unique handshake and timestamp early to avoid race conditions with atomic DB claim.
     59        $handshake       = wp_generate_password( 20, false );
     60        $mysql_timestamp = current_time( 'mysql' );
     61
     62        // Prepare table and stale threshold.
     63        $table_name      = esc_sql( Database::get_table_name() );
     64        $stale_threshold = gmdate( 'Y-m-d H:i:s', time() - ( 30 * MINUTE_IN_SECONDS ) );
     65
     66        // Atomically claim non-pending existing row by updating it.
     67        $update_sql = $wpdb->prepare(
     68            "UPDATE {$table_name}
     69             SET handshake = %s, page_url = %s, processing_status = 'pending', requested_time = %s
     70             WHERE url_hash = %s AND (processing_status != 'pending' OR requested_time < %s)",
     71            $handshake,
     72            esc_url_raw( $url ),
     73            $mysql_timestamp,
     74            $url_hash,
     75            $stale_threshold
     76        );
     77
     78        $wpdb->query( $update_sql );
     79        if ( $wpdb->rows_affected <= 0 ) {
     80            // No existing row could be claimed. Check if non stale, pending row exists and return already_pending.
     81            $existing_row = Database::get_row_by_url_hash( $url_hash );
     82            if ( $existing_row && isset( $existing_row['processing_status'] ) && $existing_row['processing_status'] === 'pending' ) {
     83                $remaining = 0;
     84                if ( ! empty( $existing_row['requested_time'] ) ) {
     85                    $remaining = Helpers::get_remaining_time( $existing_row['requested_time'] );
     86                }
     87
     88                if ( $remaining > 0 ) {
     89                    return new WP_Error(
     90                        'already_pending',
     91                        sprintf( __( 'Critical CSS is already being generated. Please try again in %d minutes.', 'easy-critical-css' ), $remaining )
     92                    );
     93                }
     94            }
     95
     96            // No non-stale pending row to block us, so insert a new row.
     97            $insert_result = $wpdb->insert(
     98                $table_name,
     99                [
     100                    'page_url'          => esc_url_raw( $url ),
     101                    'url_hash'          => $url_hash,
     102                    'post_id'           => isset( $data['post_id'] ) ? (int) $data['post_id'] : null,
     103                    'handshake'         => $handshake,
     104                    'processing_status' => 'pending',
     105                    'requested_time'    => $mysql_timestamp,
     106                ]
     107            );
     108
     109            if ( $insert_result === false ) {
     110                // If insert failed, play safe and indicate another process likely claimed it.
     111                return new WP_Error( 'already_pending', __( 'Critical CSS is already being generated.', 'easy-critical-css' ) );
     112            }
     113        }
     114
     115        // Get API-related data for the outgoing request.
    42116        $uid        = Helpers::get_uid();
    43117        $api_key    = Helpers::get_api_key();
    44118        $install_id = Helpers::get_install_id();
    45119
    46         // Generate unique handshake.
    47         $handshake = wp_generate_password( 20, false );
    48 
    49         // Construct the response URL dynamically.
     120        // Construct the response URL dynamically and ensure SSL.
    50121        $response_url = site_url( '/wp-json/easy-critical-css/v1/receive' );
    51 
    52         // Response URL must be https.
    53         $scheme = wp_parse_url( $response_url, PHP_URL_SCHEME );
     122        $scheme       = wp_parse_url( $response_url, PHP_URL_SCHEME );
    54123        if ( $scheme !== 'https' ) {
    55124            return new WP_Error( 'no_ssl', __( 'The response URL must have SSL.', 'easy-critical-css' ) );
    56125        }
    57126
    58         $mysql_timestamp = current_time( 'mysql' );
    59 
    60         // Convert timestamp to the URL formate we need. This prevents anjy sort of mismatch.
     127        // Convert timestamp to the URL format we need for the skip param.
    61128        $url_timestamp = '';
    62129        $datetime      = DateTime::createFromFormat( 'Y-m-d H:i:s', $mysql_timestamp );
     
    64131            $url_timestamp = $datetime->format( 'YmdHis' );
    65132        }
    66 
    67         $data = array_merge(
    68             $data,
    69             [
    70                 'handshake'         => $handshake,
    71                 'page_url'          => esc_url_raw( $url ),
    72                 'processing_status' => 'pending',
    73                 'requested_time'    => $mysql_timestamp,
    74                 'url_hash'          => $url_hash,
    75             ]
    76         );
    77 
    78         Database::upsert_row( $data );
    79133
    80134        $prepared_url = add_query_arg(
     
    85139                'nowprocket'          => '1',
    86140                'no_optimize'         => '1',
     141                'perfmattersoff'      => '1',
    87142                'two_nooptimize'      => '1',
    88143                'mvt_flags'           => 'disable_critical_css',
  • easy-critical-css/trunk/inc/class-api-service.php

    r3319025 r3401089  
    1313        $api_url = 'https://api.criticalcss.net';
    1414        return apply_filters( 'easy_cc_critical_css_api_url', $api_url );
     15    }
     16
     17    public static function test_receive_endpoint() {
     18        // Check transient for cached result.
     19        $cached = get_transient( 'easy_cc_is_rest_api_reachable' );
     20        if ( $cached !== false ) {
     21            return (bool) $cached;
     22        }
     23
     24        // Generate a unique handshake "nonce" for this test.
     25        $nonce = wp_generate_password( 20, false );
     26
     27        // Store the nonce in a transient for verification (5 minute expiry for the test).
     28        set_transient( 'easy_cc_test_nonce_' . substr( $nonce, 0, 8 ), $nonce, 5 * MINUTE_IN_SECONDS );
     29
     30        $api_url = self::get_api_url();
     31        $body = [
     32            'siteUrl' => esc_url_raw( home_url() ),
     33            'nonce'   => $nonce,
     34        ];
     35
     36        $response = wp_remote_post(
     37            trailingslashit( $api_url ) . 'v1/test-receive-endpoint',
     38            [
     39                'body'    => wp_json_encode( $body ),
     40                'headers' => [ 'Content-Type' => 'application/json' ],
     41                'timeout' => 30,
     42            ]
     43        );
     44
     45        if ( is_wp_error( $response ) ) {
     46            set_transient( 'easy_cc_is_rest_api_reachable', '0', 6 * HOUR_IN_SECONDS );
     47            return false;
     48        }
     49
     50        $code         = wp_remote_retrieve_response_code( $response );
     51        $body_content = json_decode( wp_remote_retrieve_body( $response ), true );
     52
     53        $success = ( 200 === $code && ! empty( $body_content['success'] ) );
     54
     55        // Cache the result for 6 hours.
     56        set_transient( 'easy_cc_is_rest_api_reachable', $success ? '1' : '0', 6 * HOUR_IN_SECONDS );
     57
     58        return $success;
    1559    }
    1660
  • easy-critical-css/trunk/inc/class-compatibility-perfmatters.php

    r3395875 r3401089  
    1010    public static function init() {
    1111        add_filter( 'perfmatters_rest_api_exceptions', [ __CLASS__, 'add_rest_api_exception' ] );
     12        add_action( 'admin_notices', [ __CLASS__, 'display_admin_notice' ] );
    1213    }
    1314
     
    1718        return $exceptions;
    1819    }
     20
     21    public static function is_perfmatters_installed() {
     22        return defined( 'PERFMATTERS_VERSION' );
     23    }
     24
     25    public static function has_conflicting_css_settings() {
     26        $override = apply_filters( 'easy_cc_mock_perfmatters_conflict', null );
     27        if ( null !== $override ) {
     28            return (bool) $override;
     29        }
     30
     31        if ( ! self::is_perfmatters_installed() ) {
     32            return false;
     33        }
     34
     35        $options = get_option( 'perfmatters_options' );
     36        if ( empty( $options ) || ! is_array( $options ) ) {
     37            return false;
     38        }
     39
     40        $assets            = isset( $options['assets'] ) && is_array( $options['assets'] ) ? $options['assets'] : [];
     41        $remove_unused_css = ! empty( $assets['remove_unused_css'] );
     42
     43        // Perfmatters exposes the Clear Used CSS control when Remove Unused CSS is active, so treat either as conflicting.
     44        if ( ! $remove_unused_css && ! empty( $assets['clear_used_css'] ) ) {
     45            $remove_unused_css = true;
     46        }
     47
     48        $minify_css = ! empty( $assets['minify_css'] );
     49
     50        return (bool) ( $remove_unused_css || $minify_css );
     51    }
     52
     53    public static function display_admin_notice() {
     54        if ( ! self::has_conflicting_css_settings() ) {
     55            return;
     56        }
     57
     58        $settings_url = is_network_admin()
     59            ? network_admin_url( 'settings.php?page=perfmatters#css' )
     60            : admin_url( 'options-general.php?page=perfmatters#css' );
     61        ?>
     62
     63        <div class="notice notice-warning">
     64            <p>
     65                <?php esc_html_e( 'Perfmatters Clear Used CSS or Minify CSS is currently active. Easy Critical CSS has been paused to avoid conflicts.', 'easy-critical-css' ); ?>
     66            </p>
     67            <?php if ( current_user_can( 'manage_options' ) ) { ?>
     68                <p>
     69                    <a href="<?php echo esc_url( $settings_url ); ?>" class="button button-primary">
     70                        <?php esc_html_e( 'Open Perfmatters CSS Settings', 'easy-critical-css' ); ?>
     71                    </a>
     72                </p>
     73            <?php } ?>
     74        </div>
     75        <?php
     76    }
    1977}
  • easy-critical-css/trunk/inc/class-critical-css-status.php

    r3327873 r3401089  
    4848        $generated = Critical_CSS::get_generated_css( $identifier );
    4949
    50         // No requested time or hash exists (unlikely).
     50        // No requested time or hash exists (unlikely). No requested time means we cannot determine actual expires time.
    5151        if ( empty( $generated['requested_time'] ) || empty( $generated['url_hash'] ) ) {
    52             return true;
     52            return false;
    5353        }
    5454
     
    223223
    224224        wp_enqueue_script( 'wp-api-fetch' );
     225
     226        $api_settings = [
     227            'root'  => esc_url_raw( rest_url() ),
     228            'nonce' => wp_create_nonce( 'wp_rest' ),
     229        ];
     230
     231        // Ensure apiFetch requests include the REST root and nonce on the front end.
     232        wp_add_inline_script(
     233            'wp-api-fetch',
     234            sprintf(
     235                'window.wpApiSettings = Object.assign({}, window.wpApiSettings || {}, %s);',
     236                wp_json_encode( $api_settings )
     237            ),
     238            'before'
     239        );
    225240        ?>
    226241        <script>
    227242            document.addEventListener('DOMContentLoaded', function () {
     243                if (window.wp && window.wp.apiFetch && !window.easyCriticalCssApiFetchConfigured) {
     244                    const { apiFetch } = window.wp
     245                    const root = window.wpApiSettings && window.wpApiSettings.root
     246                    const nonce = window.wpApiSettings && window.wpApiSettings.nonce
     247
     248                    if (typeof apiFetch.createRootURLMiddleware === 'function' && root) {
     249                        apiFetch.use(apiFetch.createRootURLMiddleware(root))
     250                    }
     251
     252                    if (typeof apiFetch.createNonceMiddleware === 'function' && nonce) {
     253                        apiFetch.use(apiFetch.createNonceMiddleware(nonce))
     254                    }
     255
     256                    window.easyCriticalCssApiFetchConfigured = true
     257                }
     258
    228259                const eccIdentifier = '<?php echo esc_js( $identifier ); ?>'
     260                const apiRoot = (window.wpApiSettings && window.wpApiSettings.root) || '/wp-json/'
     261                const buildUrl = (route) => {
     262                    try {
     263                        return new URL(route.replace(/^\//, ''), apiRoot).toString()
     264                    } catch (err) {
     265                        return apiRoot.replace(/\/?$/, '/') + route.replace(/^\//, '')
     266                    }
     267                }
     268                const buildHeaders = () => {
     269                    const headers = { 'Content-Type': 'application/json' }
     270                    if (window.wpApiSettings && window.wpApiSettings.nonce) {
     271                        headers['X-WP-Nonce'] = window.wpApiSettings.nonce
     272                    }
     273                    return headers
     274                }
    229275                document.querySelectorAll('.critical-css-regenerate-link a').forEach(link => {
    230276                    link.addEventListener('click', function (event) {
     
    237283       
    238284                        wp.apiFetch({
    239                             path: '/easy-critical-css/v1/generate',
     285                            url: buildUrl('/easy-critical-css/v1/generate'),
    240286                            method: 'POST',
    241                             headers: { 'Content-Type': 'application/json' },
     287                            headers: buildHeaders(),
    242288                            body: JSON.stringify({ identifier: eccIdentifier })
    243289                        })
     
    256302       
    257303                        wp.apiFetch({
    258                             path: '/easy-critical-css/v1/delete',
     304                            url: buildUrl('/easy-critical-css/v1/delete'),
    259305                            method: 'POST',
    260                             headers: { 'Content-Type': 'application/json' },
     306                            headers: buildHeaders(),
    261307                            body: JSON.stringify({ identifier: eccIdentifier })
    262308                        })
  • easy-critical-css/trunk/inc/class-critical-css.php

    r3336970 r3401089  
    111111        $data = Database::get_row( $identifier );
    112112
     113        // Prefer canonical permalink hash for this post.
     114        if ( is_numeric( $identifier ) ) {
     115            $expected_hash = Helpers::get_url_hash( $identifier );
     116            if ( empty( $data['url_hash'] ) || $data['url_hash'] !== $expected_hash ) {
     117                $by_hash = Database::get_row_by_url_hash( $expected_hash );
     118                if ( ! empty( $by_hash ) ) {
     119                    $data = $by_hash;
     120                }
     121            }
     122        }
     123
    113124        // If we've already retrieved generated Critical based off hash, then return that.
    114125        if ( ! empty( $data['url_hash'] ) && isset( self::$generated_css[ $data['url_hash'] ] ) ) {
  • easy-critical-css/trunk/inc/class-database.php

    r3336970 r3401089  
    140140        $table_name = esc_sql( self::get_table_name() );
    141141
    142         return $wpdb->get_row(
    143             $wpdb->prepare( "SELECT * FROM $table_name WHERE post_id = %d", $post_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    144             ARRAY_A
     142        // Prefer canonical permalink hash for this post.
     143        $expected_hash = Helpers::get_url_hash( (int) $post_id );
     144        if ( ! empty( $expected_hash ) ) {
     145            $by_hash = self::get_row_by_url_hash( $expected_hash );
     146            if ( ! empty( $by_hash ) ) {
     147                Debug::ecc_log(
     148                    [
     149                        'step'    => 'db_pick_by_hash',
     150                        'post_id' => $post_id,
     151                        'pick_id' => $by_hash['id'],
     152                        'url_hash'=> $expected_hash,
     153                        'reason'  => 'matched_permalink_hash'
     154                    ]
     155                );
     156
     157                return $by_hash;
     158            }
     159        }
     160
     161        // If no row matches canonical hash, choose the "best" row by preferring completed or pending rows,
     162        // then most recent generated_time, then newest id. This avoids returning an older/expired duplicate.
     163        $sql = $wpdb->prepare(
     164            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     165            "SELECT * FROM {$table_name} WHERE post_id = %d
     166                ORDER BY FIELD(processing_status, 'completed','pending','unprocessed','failed','expired') DESC,
     167                         COALESCE(generated_time, '1970-01-01') DESC,
     168                         id DESC
     169                LIMIT 1",
     170            $post_id
    145171        );
     172
     173        $row = $wpdb->get_row( $sql, ARRAY_A );
     174
     175        if ( ! empty( $row ) ) {
     176            Debug::ecc_log(
     177                [
     178                    'step'    => 'db_pick_best_row',
     179                    'post_id' => $post_id,
     180                    'pick_id' => $row['id'],
     181                    'url_hash'=> isset( $row['url_hash'] ) ? $row['url_hash'] : null,
     182                    'status'  => isset( $row['processing_status'] ) ? $row['processing_status'] : null,
     183                    'generated_time' => isset( $row['generated_time'] ) ? $row['generated_time'] : null,
     184                ]
     185            );
     186        }
     187
     188        return $row;
    146189    }
    147190
     
    154197        // If we have a number, it's a post ID.
    155198        if ( is_numeric( $identifier ) ) {
     199            // Get expected URL hash first, avoiding older duplicates for this post.
     200            $expected_hash = Helpers::get_url_hash( $identifier );
     201            if ( ! empty( $expected_hash ) ) {
     202                $by_hash = self::get_row_by_url_hash( $expected_hash );
     203                if ( ! empty( $by_hash ) ) {
     204                    return $by_hash;
     205                }
     206            }
     207
    156208            return self::get_row_by_post_id( $identifier );
    157209        }
     
    173225        } elseif ( ! empty( $sanitized_data['post_id'] ) ) {
    174226            $where['post_id'] = $sanitized_data['post_id'];
    175             $existing_row     = self::get_row_by_url_hash( $sanitized_data['post_id'] );
     227            $existing_row     = self::get_row_by_post_id( $sanitized_data['post_id'] );
    176228        } elseif ( ! empty( $sanitized_data['page_url'] ) ) {
    177229            $where['page_url'] = $sanitized_data['page_url'];
    178             $existing_row      = self::get_row_by_url_hash( $sanitized_data['page_url'] );
    179         } else {
    180             // We need at least one unique identifier (page_url, post_id, url_hash)
    181             return new WP_Error( 'missing_identifier', __( 'No valid identifier provided for saving settings.', 'easy-critical-css' ) );
    182         }
    183 
    184         // Do we update or insert?
    185         if ( $existing_row ) {
    186             $wpdb->update( self::get_table_name(), $sanitized_data, $where );
     230            $existing_row = self::get_row_by_url( $sanitized_data['page_url'] );
     231        }
     232
     233        // If existing row was found, update it.
     234        if ( ! empty( $existing_row ) ) {
     235            $updated = $wpdb->update( self::get_table_name(), $sanitized_data, $where );
     236
     237            // If update returned false, it failed.
     238            if ( $updated === false ) {
     239                return new WP_Error( 'db_update_failed', __( 'Failed to update existing row.', 'easy-critical-css' ) );
     240            }
     241
    187242            return [
    188243                'row_id' => $existing_row['id'],
    189244                'status' => 'updated',
    190245            ];
    191         } else {
    192             $wpdb->insert( self::get_table_name(), $sanitized_data );
    193             return [
    194                 'row_id' => $wpdb->insert_id,
    195                 'status' => 'inserted',
    196             ];
    197         }
     246        }
     247
     248        // No existing row found, insert a new one.
     249        $insert_result = $wpdb->insert( self::get_table_name(), $sanitized_data );
     250        if ( $insert_result === false ) {
     251            return new WP_Error( 'db_insert_failed', __( 'Failed to insert row.', 'easy-critical-css' ) );
     252        }
     253
     254        return [
     255            'row_id' => $wpdb->insert_id,
     256            'status' => 'inserted',
     257        ];
    198258    }
    199259
     
    210270        ];
    211271
    212         // Add a status if we have one.
    213272        if ( ! empty( $status ) ) {
    214             $valid_statuses = [ 'completed', 'expired', 'failed', 'pending', 'unprocessed' ];
    215             if ( in_array( $status, $valid_statuses, true ) ) {
    216                 $data['processing_status'] = $status;
    217             }
     273            $data['processing_status'] = $status;
    218274        }
    219275
     
    309365        );
    310366    }
     367
     368    public static function dedupe_post_rows( $post_id = null, $handshake = null, $dry_run = true ) {
     369        global $wpdb;
     370
     371        $table_name = esc_sql( self::get_table_name() );
     372
     373        if ( empty( $post_id ) ) {
     374            return [ 'action' => 'none', 'reason' => 'missing_post_id' ];
     375        }
     376
     377        $rows = $wpdb->get_results(
     378            $wpdb->prepare( "SELECT * FROM {$table_name} WHERE post_id = %d", (int) $post_id ),
     379            ARRAY_A
     380        );
     381
     382        if ( ! is_array( $rows ) || count( $rows ) <= 1 ) {
     383            return [ 'action' => 'none', 'reason' => 'no_duplicates', 'rows' => $rows ];
     384        }
     385
     386        // Try to pick canonical row.
     387        $canonical = null;
     388
     389        // 1) Prefer handshake match if provided.
     390        if ( ! empty( $handshake ) ) {
     391            foreach ( $rows as $r ) {
     392                if ( ! empty( $r['handshake'] ) && hash_equals( $r['handshake'], $handshake ) ) {
     393                    $canonical = $r;
     394                    break;
     395                }
     396            }
     397        }
     398
     399        // 2) Prefer canonical permalink hash.
     400        if ( empty( $canonical ) ) {
     401            $expected_hash = Helpers::get_url_hash( (int) $post_id );
     402            if ( ! empty( $expected_hash ) ) {
     403                $by_hash = self::get_row_by_url_hash( $expected_hash );
     404                if ( ! empty( $by_hash ) && isset( $by_hash['post_id'] ) && (int) $by_hash['post_id'] === (int) $post_id ) {
     405                    $canonical = $by_hash;
     406                }
     407            }
     408        }
     409
     410        // 3) Fallback: choose best by processing_status, generated_time, id.
     411        if ( empty( $canonical ) ) {
     412            $best = null;
     413            $priority = [ 'completed', 'pending', 'unprocessed', 'failed', 'expired' ];
     414            foreach ( $rows as $r ) {
     415                if ( empty( $best ) ) {
     416                    $best = $r;
     417                    continue;
     418                }
     419
     420                $best_status_idx = array_search( isset( $best['processing_status'] ) ? $best['processing_status'] : 'unprocessed', $priority, true );
     421                $r_status_idx    = array_search( isset( $r['processing_status'] ) ? $r['processing_status'] : 'unprocessed', $priority, true );
     422
     423                if ( $r_status_idx === false ) {
     424                    $r_status_idx = count( $priority );
     425                }
     426                if ( $best_status_idx === false ) {
     427                    $best_status_idx = count( $priority );
     428                }
     429
     430                if ( $r_status_idx < $best_status_idx ) {
     431                    $best = $r;
     432                    continue;
     433                }
     434
     435                // If same status priority, prefer later generated_time.
     436                $best_gen = ! empty( $best['generated_time'] ) ? strtotime( $best['generated_time'] ) : 0;
     437                $r_gen    = ! empty( $r['generated_time'] ) ? strtotime( $r['generated_time'] ) : 0;
     438                if ( $r_gen > $best_gen ) {
     439                    $best = $r;
     440                    continue;
     441                }
     442
     443                // Finally prefer higher id.
     444                if ( (int) $r['id'] > (int) $best['id'] ) {
     445                    $best = $r;
     446                }
     447            }
     448
     449            $canonical = $best;
     450        }
     451
     452        if ( empty( $canonical ) ) {
     453            return [ 'action' => 'error', 'reason' => 'could_not_pick_canonical' ];
     454        }
     455
     456        $canonical_id   = (int) $canonical['id'];
     457        $canonical_hash = isset( $canonical['url_hash'] ) ? $canonical['url_hash'] : null;
     458
     459        // Build merged data starting from canonical.
     460        $merged = $canonical;
     461
     462        foreach ( $rows as $r ) {
     463            if ( (int) $r['id'] === $canonical_id ) {
     464                continue;
     465            }
     466
     467            // Merge settings (JSON) without losing keys.
     468            $canon_settings = ! empty( $merged['settings'] ) ? json_decode( $merged['settings'], true ) : [];
     469            $other_settings = ! empty( $r['settings'] ) ? json_decode( $r['settings'], true ) : [];
     470            if ( is_array( $other_settings ) ) {
     471                foreach ( $other_settings as $k => $v ) {
     472                    if ( ! isset( $canon_settings[ $k ] ) || $canon_settings[ $k ] === '' || $canon_settings[ $k ] === null ) {
     473                        $canon_settings[ $k ] = $v;
     474                    }
     475                }
     476                $merged['settings'] = wp_json_encode( $canon_settings );
     477            }
     478
     479            // For css fields, prefer non-empty and the most recent generated_time.
     480            $fields = [ 'critical_css', 'remaining_css', 'secondary_css', 'size_savings', 'last_error' ];
     481            foreach ( $fields as $f ) {
     482                $cur = ! empty( $merged[ $f ] ) ? $merged[ $f ] : '';
     483                $oth = ! empty( $r[ $f ] ) ? $r[ $f ] : '';
     484                if ( $oth === '' ) {
     485                    continue;
     486                }
     487                $cur_gen = ! empty( $merged['generated_time'] ) ? strtotime( $merged['generated_time'] ) : 0;
     488                $oth_gen = ! empty( $r['generated_time'] ) ? strtotime( $r['generated_time'] ) : 0;
     489                if ( $cur === '' || $oth_gen >= $cur_gen ) {
     490                    $merged[ $f ] = $oth;
     491                }
     492            }
     493
     494            // requested_time: prefer most recent non-empty requested_time to avoid losing it.
     495            $cur_req = ! empty( $merged['requested_time'] ) ? strtotime( $merged['requested_time'] ) : 0;
     496            $oth_req = ! empty( $r['requested_time'] ) ? strtotime( $r['requested_time'] ) : 0;
     497            if ( $oth_req > $cur_req ) {
     498                $merged['requested_time'] = $r['requested_time'];
     499            }
     500
     501            // generated_time: prefer most recent.
     502            $cur_gen = ! empty( $merged['generated_time'] ) ? strtotime( $merged['generated_time'] ) : 0;
     503            $oth_gen = ! empty( $r['generated_time'] ) ? strtotime( $r['generated_time'] ) : 0;
     504            if ( $oth_gen > $cur_gen ) {
     505                $merged['generated_time'] = $r['generated_time'];
     506            }
     507
     508            // processing_status: prefer higher priority status.
     509            $priority = [ 'completed' => 5, 'pending' => 4, 'unprocessed' => 3, 'failed' => 2, 'expired' => 1 ];
     510            $cur_pr = isset( $merged['processing_status'] ) ? $merged['processing_status'] : 'unprocessed';
     511            $oth_pr = isset( $r['processing_status'] ) ? $r['processing_status'] : 'unprocessed';
     512            if ( isset( $priority[ $oth_pr ] ) && isset( $priority[ $cur_pr ] ) && $priority[ $oth_pr ] > $priority[ $cur_pr ] ) {
     513                $merged['processing_status'] = $oth_pr;
     514            }
     515        }
     516
     517        // Prepare sanitized data for DB write.
     518        $write_data = [];
     519        $allowed_cols = [ 'page_url', 'url_hash', 'post_id', 'requested_time', 'generated_time', 'critical_css', 'remaining_css', 'secondary_css', 'size_savings', 'handshake', 'processing_status', 'settings', 'last_error' ];
     520        foreach ( $allowed_cols as $c ) {
     521            if ( isset( $merged[ $c ] ) ) {
     522                $write_data[ $c ] = $merged[ $c ];
     523            }
     524        }
     525
     526        // Dry-run: return plan with what we'd do.
     527        $duplicate_ids = array_map( function ( $r ) use ( $canonical_id ) {
     528            return (int) $r['id'];
     529        }, array_filter( $rows, function ( $r ) use ( $canonical_id ) {
     530            return (int) $r['id'] !== $canonical_id;
     531        } ) );
     532
     533        $plan = [
     534            'action'        => $dry_run ? 'dry_run' : 'apply',
     535            'post_id'       => (int) $post_id,
     536            'canonical_id'  => $canonical_id,
     537            'canonical_hash'=> $canonical_hash,
     538            'merged'        => $write_data,
     539            'delete_ids'    => $duplicate_ids,
     540            'rows'          => $rows,
     541        ];
     542
     543        Debug::ecc_log( array_merge( [ 'step' => 'dedupe_plan' ], $plan ) );
     544
     545        if ( $dry_run ) {
     546            return $plan;
     547        }
     548
     549        // Apply the merge in a transaction.
     550        $wpdb->query( 'START TRANSACTION' );
     551
     552        $sanitized = self::sanitize_data( $write_data );
     553        $updated   = $wpdb->update( self::get_table_name(), $sanitized, [ 'id' => $canonical_id ] );
     554        if ( $updated === false ) {
     555            $wpdb->query( 'ROLLBACK' );
     556            return [ 'action' => 'error', 'reason' => 'update_failed' ];
     557        }
     558
     559        // Delete duplicates
     560        foreach ( $duplicate_ids as $del_id ) {
     561            $del_ok = $wpdb->delete( self::get_table_name(), [ 'id' => $del_id ] );
     562            if ( $del_ok === false ) {
     563                $wpdb->query( 'ROLLBACK' );
     564                return [ 'action' => 'error', 'reason' => 'delete_failed', 'id' => $del_id ];
     565            }
     566        }
     567
     568        $wpdb->query( 'COMMIT' );
     569
     570        // Clear static cache for affected identifier(s).
     571        if ( ! empty( $canonical_hash ) ) {
     572            Critical_CSS::clear_cache( $canonical_hash );
     573        }
     574        Critical_CSS::clear_cache( $post_id );
     575
     576        return [ 'action' => 'applied', 'plan' => $plan ];
     577    }
    311578}
  • easy-critical-css/trunk/inc/class-helpers.php

    r3395875 r3401089  
    255255        }
    256256
    257         // Use rest route param to avoid potential redirect conflicts
    258         $rest_url = home_url( '/index.php?rest_route=/wp/v2/types' );
    259         $response = wp_remote_get(
    260             $rest_url,
    261             [
    262                 'timeout'  => 5,
    263                 'blocking' => true,
    264             ]
    265         );
    266 
    267         $code         = wp_remote_retrieve_response_code( $response );
    268         $is_reachable = ! is_wp_error( $response ) && ( 200 === $code );
     257        $is_reachable = API_Service::test_receive_endpoint();
    269258
    270259        // We need to store both true and false values as a transient, but only if no debug.
     
    275264            return [
    276265                'is_reachable' => $is_reachable,
    277                 'url'          => rest_url( 'wp/v2/types' ),
    278                 'is_error'     => is_wp_error( $response ),
    279                 'error'        => is_wp_error( $response ) ? $response->get_error_message() : null,
    280                 'code'         => $code,
    281                 'headers'      => wp_remote_retrieve_headers( $response ),
     266                'url'          => rest_url( 'easy-critical-css/v1/receive-test' ),
     267                'checked_via'  => 'test_receive_endpoint',
    282268            ];
    283269        }
     
    343329        return apply_filters( 'easy_cc_excluded_url_params', $excluded_url_params );
    344330    }
     331
     332    public static function get_auto_mode_status() {
     333        // Only show status if there's an active API key.
     334        if ( empty( easy_cc_fs()->has_active_valid_license() ) ) {
     335            return [
     336                'active_key'  => false,
     337                'local_ok'    => false,
     338                'rest_api_ok' => false,
     339                'all_ok'      => false,
     340            ];
     341        }
     342
     343        $status = [
     344            'active_key'  => true,
     345            'local_ok'    => ! self::is_local_site( wp_parse_url( site_url(), PHP_URL_HOST ) ),
     346            'rest_api_ok' => API_Service::test_receive_endpoint(),
     347        ];
     348
     349        // Overall ready state requires an active key, non-local install, and REST API reachability.
     350        $status['all_ok'] = $status['active_key'] && $status['local_ok'] && $status['rest_api_ok'];
     351
     352        return $status;
     353    }
    345354}
  • easy-critical-css/trunk/inc/class-individual-settings.php

    r3284313 r3401089  
    102102            ],
    103103            'excluded_css_files'         => [
    104                 'label'   => __( 'Excluded CSS Files', 'easy-critical-css' ),
     104                'label'   => [
     105                    'auto'   => __( 'Excluded CSS Files', 'easy-critical-css' ),
     106                    'manual' => __( 'Always Loaded CSS Files', 'easy-critical-css' ),
     107                ],
    105108                'type'    => 'textarea',
    106109                'default' => '',
    107110                'desc'    => [
    108111                    'auto'   => __( 'Enter one CSS file URL or partial match per line. Any matching file will be excluded from Critical CSS generation on this post. Excluded files will always load on the page. Keep blank to use global setting.', 'easy-critical-css' ),
    109                     'manual' => __( 'Enter one CSS file URL or partial match per line. Excluded files will always load on the page. Keep blank to use global setting.', 'easy-critical-css' ),
     112                    'manual' => __( 'Enter one CSS file URL or partial match per line. These files will always be loaded on the page. Keep blank to use global setting.', 'easy-critical-css' ),
    110113                ],
    111114                'visible' => [
  • easy-critical-css/trunk/inc/class-plugin.php

    r3395875 r3401089  
    1010    private static $instance = null;
    1111
    12     private static $plugin_version = '1.4.2';
     12    private static $plugin_version = '1.4.3';
    1313
    1414    private static $db_version = '2';
  • easy-critical-css/trunk/inc/class-rest-api.php

    r3394020 r3401089  
    196196            ]
    197197        );
     198
     199        register_rest_route(
     200            self::$route_namespace,
     201            '/receive-test',
     202            [
     203                'methods'             => 'POST',
     204                'callback'            => [ __CLASS__, 'handle_receive_test' ],
     205                // Open permission_callback because request validation is securely handled at the start of `handle_receive_test` callback through a handshake validation.
     206                'permission_callback' => '__return_true',
     207            ]
     208        );
     209
     210        register_rest_route(
     211            self::$route_namespace,
     212            '/refresh-auto-mode-status',
     213            [
     214                'methods'             => 'POST',
     215                'callback'            => [ __CLASS__, 'refresh_auto_mode_status' ],
     216                'permission_callback' => function () {
     217                    return current_user_can( 'manage_options' );
     218                }
     219            ]
     220        );
    198221    }
    199222
     
    488511        );
    489512
     513        // Set requested_time now so later reads don't think it's missing.
     514        $requested_time_to_save = $timestamp;
     515
    490516        // Store generated CSS.
    491517        $new_data = [
     
    496522            'size_savings'      => $savings,
    497523            'generated_time'    => $timestamp,
     524            'requested_time'    => $requested_time_to_save,
    498525            'handshake'         => null, // Clear to prevent brute force attempts
    499526            'processing_status' => 'completed',
    500527        ];
     528
     529        // Capture previous row for debugging and upsert.
     530        $previous = Database::get_row_by_url_hash( $params['hash'] );
     531
    501532        Database::upsert_row( $new_data );
     533
     534        // Clear in-memory/static cache to prevent stale cache from showing an outdated status such as 'expired'.
     535        if ( ! empty( $params['hash'] ) ) {
     536            Critical_CSS::clear_cache( $params['hash'] );
     537        }
     538
     539        // Dedupe any additional rows now that we have valid handshake and generated CSS.
     540        $resolved_post_id = null;
     541        if ( isset( $params['post_id'] ) && is_numeric( $params['post_id'] ) ) {
     542            $resolved_post_id = (int) $params['post_id'];
     543        } elseif ( ! empty( $previous ) && ! empty( $previous['post_id'] ) ) {
     544            $resolved_post_id = (int) $previous['post_id'];
     545        }
     546
     547        if ( empty( $resolved_post_id ) ) {
     548            Debug::ecc_log( [ 'step' => 'receive_dedupe_skipped', 'reason' => 'no_post_id', 'hash' => $params['hash'] ] );
     549        } else {
     550            global $wpdb;
     551            $table = esc_sql( Database::get_table_name() );
     552            $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE post_id = %d", $resolved_post_id ) );
     553
     554            if ( (int) $count > 1 ) {
     555                $dedupe_result = Database::dedupe_post_rows( $resolved_post_id, $handshake, false );
     556                Debug::ecc_log( [ 'step' => 'receive_dedupe_apply', 'result' => $dedupe_result ] );
     557            } else {
     558                Debug::ecc_log( [ 'step' => 'receive_dedupe_skipped', 'reason' => 'no_duplicates', 'post_id' => $resolved_post_id ] );
     559            }
     560        }
     561
     562        // Mark REST API reachability as successful so we don't trigger repeated
     563        // remote tests immediately after receiving CSS. Cache for 6 hours.
     564        set_transient( 'easy_cc_is_rest_api_reachable', '1', 6 * HOUR_IN_SECONDS );
    502565
    503566        /**
     
    736799        return rest_ensure_response( $response );
    737800    }
     801
     802    public static function handle_receive_test( WP_REST_Request $request ) {
     803        global $wpdb;
     804
     805        $nonce = sanitize_text_field( $request->get_param( 'nonce' ) );
     806
     807        if ( empty( $nonce ) ) {
     808            return new WP_Error(
     809                'missing_nonce',
     810                __( 'Nonce is required.', 'easy-critical-css' ),
     811                [ 'status' => 400 ]
     812            );
     813        }
     814
     815        $test_nonce = get_transient( 'easy_cc_test_nonce_' . substr( $nonce, 0, 8 ) );
     816        if ( ! $test_nonce || $test_nonce !== $nonce ) {
     817            return new WP_Error(
     818                'invalid_nonce',
     819                __( 'Invalid or expired nonce.', 'easy-critical-css' ),
     820                [ 'status' => 403 ]
     821            );
     822        }
     823
     824        return rest_ensure_response( [ 'success' => true ] );
     825    }
     826
     827    public static function refresh_auto_mode_status( WP_REST_Request $request ) {
     828        $nonce = $request->get_header( 'X-WP-Nonce' ) ?: $request->get_param( '_wpnonce' );
     829        if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
     830            return new WP_Error(
     831                'invalid_nonce',
     832                __( 'Invalid security token.', 'easy-critical-css' ),
     833                [ 'status' => 403 ]
     834            );
     835        }
     836
     837        if ( ! current_user_can( 'manage_options' ) ) {
     838            return new WP_Error(
     839                'insufficient_permissions',
     840                __( 'You do not have permission to perform this action.', 'easy-critical-css' ),
     841                [ 'status' => 403 ]
     842            );
     843        }
     844
     845        // Clear the REST API reachability cache and get fresh status.
     846        delete_transient( 'easy_cc_is_rest_api_reachable' );
     847        $status = Helpers::get_auto_mode_status();
     848
     849        return rest_ensure_response( $status );
     850    }
    738851}
  • easy-critical-css/trunk/inc/class-settings.php

    r3395875 r3401089  
    4343        // If Trellis is installed, Critical is paused
    4444        if ( Compatibility_Trellis::is_trellis_critical_activated() ) {
     45            return false;
     46        }
     47
     48        // If Perfmatters conflicting CSS settings are active, Critical is paused
     49        if ( Compatibility_Perfmatters::has_conflicting_css_settings() ) {
    4550            return false;
    4651        }
  • easy-critical-css/trunk/readme.txt

    r3395875 r3401089  
    22
    33Contributors:      criticalcss, sethta
    4 Tags: critical css, performance, optimization, speed, lighthouse
     4Tags: critical css, unused css, performance, optimization, lighthouse
    55Requires at least: 6.2
    66Tested up to:      6.8.3
     
    1010License URI:       https://www.gnu.org/licenses/gpl-2.0.html
    1111
    12 Easily inject Critical CSS and optimized Secondary CSS to improve page speed and performance.
     12Easily inject Critical CSS and Secondary CSS (with unused styles removed) to improve site speed and performance.
    1313
    1414== Description ==
     
    102102== Changelog ==
    103103
     104= 1.4.3 =
     105- OPTIMIZATION: Adds better REST API check with more details on Settings page
     106- OPTIMIZATION: Cleans up duplicate database rows that are formed when a post slug is changed
     107- COMPATIBILITY: Fixes compatibility issue with Perfmatters's Critical CSS feature
     108- FIX: Fixes issue where Expired statuses could not be cleared
     109- FIX: Fixes issue with status bar Regenerate button not working when conflicting plugins break REST API url
     110- FIX: Renames Excluded CSS Files to Always Loaded CSS Files when on Manual mode
     111
    104112= 1.4.2 =
    105113- COMPATIBILITY: Adds EWWW Image Optimizer compatibility for lazy-loaded images
  • easy-critical-css/trunk/vendor/composer/installed.php

    r3395875 r3401089  
    44        'pretty_version' => 'dev-main',
    55        'version' => 'dev-main',
    6         'reference' => '76edc056ab47ce75b9c22841df314400ca3034b6',
     6        'reference' => '7cba3efe0d456a72032002b0be1b75841af0dbdc',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
     
    2323            'pretty_version' => 'dev-main',
    2424            'version' => 'dev-main',
    25             'reference' => '76edc056ab47ce75b9c22841df314400ca3034b6',
     25            'reference' => '7cba3efe0d456a72032002b0be1b75841af0dbdc',
    2626            'type' => 'wordpress-plugin',
    2727            'install_path' => __DIR__ . '/../../',
Note: See TracChangeset for help on using the changeset viewer.