Plugin Directory

Changeset 3777263


Ignore:
Timestamp:
10/31/2026 06:18:34 PM (less than one hour ago)
Author:
shaounchandrashill
Message:

Release CacheNova v1.1.0 with database optimization and improved cache clearing

Location:
cachenova
Files:
26 added
13 edited

Legend:

Unmodified
Added
Removed
  • cachenova/trunk/assets/css/cn-admin.css

    r3737033 r3777263  
    613613        }
    614614}
     615
     616/* CacheNova 1.1.0 dashboard polish: scoped to the plugin settings screen */
     617#rlcc-settings-root {--cn-accent:#6254e8;--cn-border:#e6e9f2;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
     618#rlcc-settings-root .rlcc-card {border:1px solid var(--cn-border);border-radius:18px;box-shadow:0 8px 28px rgba(29,34,71,.055);transition:box-shadow .2s ease;}
     619#rlcc-settings-root .rlcc-card:hover {box-shadow:0 12px 32px rgba(29,34,71,.09);}
     620#rlcc-settings-root button,#rlcc-settings-root select {transition:background-color .16s ease,border-color .16s ease,box-shadow .16s ease;}
     621#rlcc-settings-root button:focus-visible,#rlcc-settings-root select:focus-visible {outline:3px solid rgba(98,84,232,.38);outline-offset:2px;}
     622#rlcc-settings-root .rlcc-toast {border-radius:12px;box-shadow:0 12px 28px rgba(20,25,60,.14);}
     623@media (prefers-reduced-motion:reduce){#rlcc-settings-root *{transition:none!important;animation:none!important;}}
     624
     625/* CacheNova 1.1.0 — dashboard design system (scoped to the plugin admin page) */
     626body.settings_page_rlcc-settings {background:#f3f5fa;}
     627body.settings_page_rlcc-settings #wpcontent {background:#f3f5fa;}
     628.rlcc-wrap {margin:22px 24px 40px 4px;}
     629.rlcc-dashboard {--rlcc-accent:#635bdf;--rlcc-accent-light:#8679fa;--rlcc-accent-bg:#f0efff;--rlcc-border:#e7eaf2;--rlcc-text:#18223b;--rlcc-text-muted:#66728b;--rlcc-surface-soft:#f8f9fc;--rlcc-shadow:0 5px 25px rgba(22,35,73,.045);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:1540px;}
     630.rlcc-page-intro {display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 22px;}
     631.rlcc-page-intro h1 {font-size:30px;line-height:1.2;font-weight:760;letter-spacing:-.8px;color:#15203b;margin:5px 0 8px;}
     632.rlcc-page-intro p {margin:0;color:#6c7790;font-size:13px;}
     633.rlcc-eyebrow {color:#7466d8;font-weight:750;letter-spacing:1.5px;font-size:10px;}
     634.rlcc-intro-chip {background:#fff;border:1px solid #e7eaf2;border-radius:50px;color:#485674;padding:10px 16px;white-space:nowrap;font-size:12px;font-weight:650;box-shadow:0 2px 10px rgba(15,23,42,.025);}
     635.rlcc-hero {position:relative;overflow:hidden;background:linear-gradient(116deg,#242f72 0%,#5144bb 54%,#7865eb 100%);border:1px solid rgba(255,255,255,.15);border-radius:20px;padding:29px 34px;box-shadow:0 16px 38px rgba(67,56,163,.16);margin-bottom:20px;}
     636.rlcc-hero::after {content:"";position:absolute;width:340px;height:340px;right:-90px;top:-230px;border:60px solid rgba(255,255,255,.055);border-radius:50%;pointer-events:none;}
     637.rlcc-hero-title {display:flex;align-items:center;gap:10px;font-size:26px;letter-spacing:-.6px;color:#fff;}
     638.rlcc-brand-mark {display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:11px;background:rgba(255,255,255,.19);font-size:24px;color:#fff;}
     639.rlcc-version {font-size:10px;font-weight:650;letter-spacing:.2px;padding:5px 8px;border:1px solid rgba(255,255,255,.24);border-radius:6px;color:#e8e7ff;}
     640.rlcc-status-pill {background:rgba(35,207,148,.15);border:1px solid rgba(132,255,205,.28);color:#d9fff0;font-size:11px;}
     641.rlcc-status-dot {background:#62f3b1;}
     642.rlcc-hero-meta {border-top:1px solid rgba(255,255,255,.17);padding-top:20px;margin-top:24px;}
     643.rlcc-countdown {font-variant-numeric:tabular-nums;background:rgba(255,255,255,.18);border-radius:8px;padding:7px 12px;}
     644.rlcc-card-grid {gap:18px;}
     645.rlcc-card {background:#fff;border:1px solid #e8ebf3;border-radius:17px;box-shadow:var(--rlcc-shadow);padding:24px 26px;transition:border-color .18s ease,box-shadow .18s ease;}
     646.rlcc-card:hover {border-color:#d7d9ed;box-shadow:0 9px 28px rgba(27,34,76,.075);}
     647.rlcc-card-title {display:flex;align-items:center;gap:10px;font-size:14px;letter-spacing:-.15px;color:#1d2942;margin-bottom:22px;}
     648.rlcc-card-title::before {display:none;}
     649.rlcc-card-symbol {display:inline-flex;align-items:center;justify-content:center;background:#f0efff;color:#6558d7;width:32px;height:32px;border-radius:10px;font-size:18px;font-weight:600;flex:none;}
     650.rlcc-field label {font-weight:650;color:#56617b;letter-spacing:.15px;}
     651.rlcc-select,.rlcc-dashboard input[type=text],.rlcc-dashboard input[type=number] {border:1px solid #dfe3ed;border-radius:9px;min-height:43px;box-shadow:none;}
     652.rlcc-dashboard select:focus,.rlcc-dashboard input:focus,.rlcc-dashboard button:focus-visible {outline:3px solid rgba(99,91,223,.22);outline-offset:2px;border-color:#7768eb;}
     653.rlcc-switch {transition:background .15s ease,border-color .15s ease,transform .15s ease;}
     654.rlcc-switch:active {transform:scale(.97);}
     655.rlcc-switch--on {background:#e9f8f0;border-color:#a6e6c4;color:#147a46;}
     656.rlcc-toast-container {z-index:999999;max-width:min(400px,calc(100vw - 32px));}
     657.rlcc-toast {border-radius:12px;box-shadow:0 12px 35px rgba(20,27,55,.16);border:1px solid rgba(0,0,0,.04);}
     658@media(max-width:782px){.rlcc-wrap{margin:12px 12px 28px 0}.rlcc-page-intro{align-items:flex-start}.rlcc-page-intro h1{font-size:24px}.rlcc-intro-chip{display:none}.rlcc-hero{padding:22px}.rlcc-card{padding:19px}.rlcc-hero-title{font-size:23px}}
     659@media(prefers-reduced-motion:reduce){.rlcc-card,.rlcc-switch{transition:none}}
     660
     661.rlcc-divi-compat{margin:18px 0;padding:22px;border-left:3px solid #7563f5}.rlcc-divi-compat p{color:#6b7280;line-height:1.55}
     662
     663/* CacheNova Divi compatibility control */
     664.rlcc-divi-compat{margin:20px 0;padding:24px!important;border:1px solid #dce2f2!important;border-left:4px solid #6955ee!important}.rlcc-divi-compat p{color:#526079;margin:12px 0 18px;line-height:1.6}
     665
     666/* CacheNova tabs, database schedule row, footer */
     667.rlcc-tabs{display:flex;gap:8px;margin:0 0 20px;padding:6px;background:#fff;border:1px solid #e8ebf3;border-radius:14px;box-shadow:var(--rlcc-shadow);width:fit-content;max-width:100%;flex-wrap:wrap}
     668.rlcc-tab{appearance:none;border:0;background:transparent;color:#56617b;font:inherit;font-size:13.5px;font-weight:650;padding:10px 18px;border-radius:10px;cursor:pointer;transition:background .15s ease,color .15s ease}
     669.rlcc-tab:hover{background:#f4f3ff;color:#4a3fc7}
     670.rlcc-tab--active,.rlcc-tab--active:hover{background:#635bdf;color:#fff}
     671.rlcc-db-status{display:flex;align-items:center;min-height:43px;padding:9px 14px;border-radius:9px;font-size:13.5px;font-weight:600;border:1px solid #dfe3ed;background:#f8f9fc;color:#56617b}
     672.rlcc-db-status--on{background:#e9f8f0;border-color:#a6e6c4;color:#147a46}
     673.rlcc-footer-grid{margin-top:20px}
     674@media(max-width:782px){.rlcc-tabs{width:100%}.rlcc-tab{flex:1;text-align:center;padding:10px 12px}}
     675
     676/* CacheNova confirmation modal */
     677.rlcc-modal-overlay{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(18,22,44,.55);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);animation:rlccFade .15s ease}
     678.rlcc-modal{width:100%;max-width:440px;background:#fff;border-radius:20px;padding:30px 30px 24px;text-align:center;box-shadow:0 24px 70px rgba(18,22,44,.35);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;animation:rlccPop .18s cubic-bezier(.2,.9,.3,1.2)}
     679.rlcc-modal-icon{width:56px;height:56px;margin:0 auto 16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:800;line-height:1}
     680.rlcc-modal--info .rlcc-modal-icon{background:#f0efff;color:#635bdf}
     681.rlcc-modal--warning .rlcc-modal-icon{background:#fff4e0;color:#d98200}
     682.rlcc-modal--danger .rlcc-modal-icon{background:#fdeceb;color:#c0392b}
     683.rlcc-modal-title{margin:0 0 10px;font-size:19px;font-weight:700;color:#18223b;letter-spacing:-.2px}
     684.rlcc-modal-text{margin:0;font-size:14px;line-height:1.6;color:#56617b}
     685.rlcc-modal-points{list-style:none;margin:16px 0 0;padding:12px 16px;background:#f8f9fc;border:1px solid #e7eaf2;border-radius:12px;text-align:left}
     686.rlcc-modal-points li{position:relative;padding:4px 0 4px 20px;font-size:13px;color:#3b4560}
     687.rlcc-modal-points li::before{content:"";position:absolute;left:3px;top:11px;width:6px;height:6px;border-radius:50%;background:#9aa3bb}
     688.rlcc-modal-actions{display:flex;gap:10px;margin-top:24px}
     689.rlcc-modal-btn{flex:1;appearance:none;border:1px solid transparent;border-radius:11px;min-height:44px;padding:0 18px;font:inherit;font-size:14px;font-weight:650;cursor:pointer;transition:background .15s ease,transform .1s ease,box-shadow .15s ease}
     690.rlcc-modal-btn:active{transform:scale(.98)}
     691.rlcc-modal-btn:focus-visible{outline:3px solid rgba(99,91,223,.3);outline-offset:2px}
     692.rlcc-modal-btn--ghost{background:#fff;border-color:#dfe3ed;color:#3b4560}
     693.rlcc-modal-btn--ghost:hover{background:#f4f5fa}
     694.rlcc-modal-btn--info{background:#635bdf;color:#fff}
     695.rlcc-modal-btn--info:hover{background:#5249cc}
     696.rlcc-modal-btn--warning{background:#d98200;color:#fff}
     697.rlcc-modal-btn--warning:hover{background:#bf7200}
     698.rlcc-modal-btn--danger{background:#c0392b;color:#fff}
     699.rlcc-modal-btn--danger:hover{background:#a63023}
     700@keyframes rlccFade{from{opacity:0}to{opacity:1}}
     701@keyframes rlccPop{from{opacity:0;transform:translateY(8px) scale(.96)}to{opacity:1;transform:none}}
     702@media(max-width:480px){.rlcc-modal{padding:24px 20px 20px}.rlcc-modal-actions{flex-direction:column-reverse}}
     703@media(prefers-reduced-motion:reduce){.rlcc-modal-overlay,.rlcc-modal{animation:none}}
     704
     705/* Database Optimization tab: one row, three columns */
     706.rlcc-card-grid--three{grid-template-columns:repeat(3,minmax(0,1fr));align-items:stretch}
     707.rlcc-card-grid--three .rlcc-card{display:flex;flex-direction:column}
     708.rlcc-card-grid--three .rlcc-grid{grid-template-columns:1fr}
     709.rlcc-card-grid--three .rlcc-db-grid{grid-template-columns:1fr;margin:0 0 18px}
     710.rlcc-card-grid--three .rlcc-clear-btn{align-self:flex-start}
     711.rlcc-card-grid--three .rlcc-hint{margin:14px 0 0}
     712.rlcc-card-grid--three .rlcc-danger-row{flex-direction:column;align-items:flex-start;gap:14px}
     713@media(max-width:1100px){.rlcc-card-grid--three{grid-template-columns:1fr}}
  • cachenova/trunk/assets/css/cn-admin.min.css

    r3737033 r3777263  
    1 :root{--rlcc-accent:#6b5ce7;--rlcc-accent-light:#8b7cf0;--rlcc-accent-bg:#f0eeff;--rlcc-ok:#1a7f37;--rlcc-ok-bg:#eaf7ec;--rlcc-danger:#c0392b;--rlcc-danger-bg:#fdf3f2;--rlcc-warn:#b5820a;--rlcc-warn-bg:#fbf3e0;--rlcc-text:#1e1e1e;--rlcc-text-muted:#6b7280;--rlcc-border:#e4e6eb;--rlcc-surface:#ffffff;--rlcc-surface-soft:#f7f7fb;--rlcc-radius-lg:16px;--rlcc-radius-md:10px;--rlcc-radius-sm:7px;--rlcc-shadow:0 1px 2px rgba(20,20,43,0.04),0 8px 24px rgba(20,20,43,0.06);--rlcc-shadow-hover:0 4px 10px rgba(20,20,43,0.06),0 14px 32px rgba(20,20,43,0.10)}.rlcc-wrap{max-width:none}.rlcc-dashboard{box-sizing:border-box;width:100%;max-width:1600px;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--rlcc-text)}.rlcc-dashboard *,.rlcc-dashboard *::before,.rlcc-dashboard *::after{box-sizing:border-box}.rlcc-dashboard-loading{color:var(--rlcc-text-muted);text-align:center;padding:80px 0;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}.rlcc-hero{background:linear-gradient(120deg,#5b4bd6 0%,#6b5ce7 45%,#8b7cf0 100%);border-radius:var(--rlcc-radius-lg);padding:28px 32px;color:#fff;box-shadow:0 10px 30px rgba(107,92,231,0.28);margin-bottom:22px}.rlcc-hero-top{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap}.rlcc-hero-title-block{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.rlcc-hero-title{margin:0;font-size:22px;font-weight:700;letter-spacing:-0.01em}.rlcc-status-pill{display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;background:rgba(255,255,255,0.16);padding:6px 14px;border-radius:999px}.rlcc-status-dot{width:8px;height:8px;border-radius:50%;background:#6dffb0;box-shadow:0 0 0 3px rgba(109,255,176,0.30)}.rlcc-hero-meta{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin-top:20px;padding-top:18px;border-top:1px solid rgba(255,255,255,0.18);font-size:13px}.rlcc-hero-meta-item{color:rgba(255,255,255,0.85)}.rlcc-hero-meta-item strong{color:#fff}.rlcc-hero-meta-item--divi{font-weight:600}.rlcc-countdown-row{display:flex;align-items:center;gap:10px}.rlcc-countdown{font-variant-numeric:tabular-nums;font-size:16px;font-weight:700;color:#fff;background:rgba(255,255,255,0.18);padding:3px 12px;border-radius:6px;letter-spacing:0.02em}.rlcc-clear-btn{padding:12px 22px;border-radius:999px;border:none;background:#fff;color:var(--rlcc-accent);font-size:14px;font-weight:700;cursor:pointer;transition:transform 0.1s ease,box-shadow 0.15s ease;box-shadow:0 2px 10px rgba(0,0,0,0.15);white-space:nowrap}.rlcc-clear-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 16px rgba(0,0,0,0.22)}.rlcc-clear-btn:disabled{opacity:0.65;cursor:not-allowed}.rlcc-card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%;align-items:start}.rlcc-card{background:var(--rlcc-surface);border:1px solid var(--rlcc-border);border-radius:var(--rlcc-radius-lg);padding:24px 26px;box-shadow:var(--rlcc-shadow);transition:box-shadow 0.15s ease}.rlcc-card:hover{box-shadow:var(--rlcc-shadow-hover)}.rlcc-card--wide{grid-column:1 / -1}.rlcc-card--danger{border-color:#f3d4d0}.rlcc-card-title{margin:0 0 18px;font-size:14px;font-weight:700;color:var(--rlcc-text);text-transform:uppercase;letter-spacing:0.04em;padding-left:12px;border-left:3px solid var(--rlcc-accent)}.rlcc-card-title--danger{border-left-color:var(--rlcc-danger);color:var(--rlcc-danger)}.rlcc-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.rlcc-grid--4{grid-template-columns:repeat(4,1fr)}@media (max-width:560px){.rlcc-grid--4{grid-template-columns:repeat(2,1fr)}}.rlcc-field label{display:block;font-size:11.5px;font-weight:600;color:var(--rlcc-text-muted);margin-bottom:7px;text-transform:uppercase;letter-spacing:0.03em}.rlcc-select,.rlcc-input{width:100%;padding:9px 10px;border-radius:var(--rlcc-radius-sm);border:1px solid var(--rlcc-border);background:var(--rlcc-surface-soft);font-size:14px;color:var(--rlcc-text)}.rlcc-select:focus,.rlcc-input:focus{outline:none;border-color:var(--rlcc-accent);box-shadow:0 0 0 3px rgba(107,92,231,0.15)}.rlcc-switch{display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;border:1px solid var(--rlcc-border);background:var(--rlcc-surface-soft);cursor:pointer;font-size:13px;font-weight:600;color:var(--rlcc-text)}.rlcc-switch:disabled{opacity:0.55;cursor:not-allowed}.rlcc-switch--on{background:var(--rlcc-ok-bg);border-color:var(--rlcc-ok);color:var(--rlcc-ok)}.rlcc-switch-knob{width:14px;height:14px;border-radius:50%;background:#bbb;transition:background 0.15s ease;flex-shrink:0}.rlcc-switch--on .rlcc-switch-knob{background:var(--rlcc-ok)}.rlcc-hint{margin:14px 0 0;font-size:12px;line-height:1.5;color:var(--rlcc-text-muted)}.rlcc-infotip{position:relative;display:inline-flex;align-items:center;justify-content:center;margin-left:6px;cursor:help;vertical-align:middle}.rlcc-infotip-icon{width:16px;height:16px;border-radius:50%;background:#e9e6fc;color:var(--rlcc-brand,#6b5ce7);font-size:11px;font-weight:700;font-style:italic;font-family:Georgia,'Times New Roman',serif;display:flex;align-items:center;justify-content:center;line-height:1}.rlcc-infotip-text{visibility:hidden;opacity:0;position:absolute;bottom:130%;left:50%;transform:translateX(-50%);background:#1e1e1e;color:#fff;padding:8px 12px;border-radius:6px;font-size:12px;font-weight:400;line-height:1.45;width:260px;text-align:left;transition:opacity 0.12s ease;z-index:30;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,0.25)}.rlcc-infotip-text::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:#1e1e1e}.rlcc-infotip:hover .rlcc-infotip-text,.rlcc-infotip:focus .rlcc-infotip-text,.rlcc-infotip:focus-within .rlcc-infotip-text{visibility:visible;opacity:1}.rlcc-db-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px;margin:16px 0 20px}.rlcc-db-checkbox{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;background:var(--rlcc-bg-subtle,#f7f7f8);font-size:13px;cursor:pointer}.rlcc-db-checkbox input[type="checkbox"]{width:16px;height:16px;flex-shrink:0}.rlcc-db-checkbox span:nth-child(2){flex:1}.rlcc-db-count{font-size:11px;font-weight:700;color:var(--rlcc-brand,#6b5ce7);background:#e9e6fc;padding:2px 8px;border-radius:999px;min-width:20px;text-align:center}.rlcc-danger-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;background:var(--rlcc-danger-bg);border:1px solid #f3d4d0;border-radius:var(--rlcc-radius-md);flex-wrap:wrap}.rlcc-danger-text label{display:block;font-size:13px;font-weight:700;color:#922b21;margin-bottom:4px}.rlcc-danger-text .rlcc-hint{margin:0;color:#a04b42;max-width:480px}.rlcc-switch--danger.rlcc-switch--off{background:var(--rlcc-ok-bg);border-color:var(--rlcc-ok);color:var(--rlcc-ok)}.rlcc-switch--danger.rlcc-switch--off .rlcc-switch-knob{background:var(--rlcc-ok)}.rlcc-switch--danger.rlcc-switch--on{background:#fceceb;border-color:var(--rlcc-danger);color:var(--rlcc-danger)}.rlcc-switch--danger.rlcc-switch--on .rlcc-switch-knob{background:var(--rlcc-danger)}.rlcc-oc-backend-row{display:flex;gap:12px;margin-top:14px;flex-wrap:wrap}.rlcc-oc-backend-row .rlcc-field{flex:1;min-width:140px}.rlcc-oc-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:4px 10px;border-radius:999px;margin-top:12px}.rlcc-oc-status--ok{background:var(--rlcc-ok-bg);color:var(--rlcc-ok)}.rlcc-oc-status--error{background:var(--rlcc-danger-bg);color:var(--rlcc-danger)}.rlcc-oc-status--idle{background:var(--rlcc-accent-bg);color:var(--rlcc-accent)}.rlcc-oc-test-btn{margin-top:12px;padding:8px 16px;border-radius:var(--rlcc-radius-sm);border:1px solid var(--rlcc-border);background:#fff;font-size:13px;font-weight:600;cursor:pointer}.rlcc-oc-test-btn:disabled{opacity:0.6;cursor:not-allowed}.rlcc-oc-unavailable{font-size:11.5px;color:#b23b3b;margin:0 0 12px;line-height:1.5}.rlcc-report-list{list-style:none;margin:12px 0 0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px}.rlcc-report-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:var(--rlcc-radius-sm);font-size:13px;font-weight:500;background:var(--rlcc-surface-soft)}.rlcc-report-icon{font-weight:700;width:18px;text-align:center;flex-shrink:0}.rlcc-report-label{flex:1;color:var(--rlcc-text)}.rlcc-report-badge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.03em;padding:2px 8px;border-radius:999px;white-space:nowrap}.rlcc-report-ok{background:var(--rlcc-ok-bg)}.rlcc-report-ok .rlcc-report-icon{color:var(--rlcc-ok)}.rlcc-report-ok .rlcc-report-badge{background:var(--rlcc-ok);color:#fff}.rlcc-report-triggered{background:var(--rlcc-accent-bg)}.rlcc-report-triggered .rlcc-report-icon{color:var(--rlcc-accent)}.rlcc-report-triggered .rlcc-report-badge{background:var(--rlcc-accent);color:#fff}.rlcc-report-failed{background:var(--rlcc-danger-bg)}.rlcc-report-failed .rlcc-report-icon{color:var(--rlcc-danger)}.rlcc-report-failed .rlcc-report-badge{background:var(--rlcc-danger);color:#fff}.rlcc-toast-container{position:fixed;bottom:24px;right:24px;display:flex;flex-direction:column;gap:10px;z-index:100000}.rlcc-toast{display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:10px;background:#1e1e1e;color:#fff;font-size:13px;font-weight:500;box-shadow:0 6px 20px rgba(0,0,0,0.25);cursor:pointer;animation:rlcc-toast-in 0.25s ease;min-width:220px}.rlcc-toast--success{background:var(--rlcc-ok)}.rlcc-toast--error{background:#c53030}.rlcc-toast--info{background:#1e1e1e}.rlcc-toast-icon{font-weight:700}@keyframes rlcc-toast-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media (max-width:720px){.rlcc-hero{padding:22px 20px}.rlcc-hero-top{flex-direction:column;align-items:flex-start}.rlcc-clear-btn{width:100%}.rlcc-hero-meta{flex-direction:column;align-items:flex-start;gap:10px}.rlcc-card{padding:20px}.rlcc-grid{grid-template-columns:1fr}.rlcc-card-grid{grid-template-columns:1fr}}
     1:root {
     2        --rlcc-accent: #6b5ce7;
     3        --rlcc-accent-light: #8b7cf0;
     4        --rlcc-accent-bg: #f0eeff;
     5        --rlcc-ok: #1a7f37;
     6        --rlcc-ok-bg: #eaf7ec;
     7        --rlcc-danger: #c0392b;
     8        --rlcc-danger-bg: #fdf3f2;
     9        --rlcc-warn: #b5820a;
     10        --rlcc-warn-bg: #fbf3e0;
     11        --rlcc-text: #1e1e1e;
     12        --rlcc-text-muted: #6b7280;
     13        --rlcc-border: #e4e6eb;
     14        --rlcc-surface: #ffffff;
     15        --rlcc-surface-soft: #f7f7fb;
     16        --rlcc-radius-lg: 16px;
     17        --rlcc-radius-md: 10px;
     18        --rlcc-radius-sm: 7px;
     19        --rlcc-shadow: 0 1px 2px rgba(20,20,43,0.04), 0 8px 24px rgba(20,20,43,0.06);
     20        --rlcc-shadow-hover: 0 4px 10px rgba(20,20,43,0.06), 0 14px 32px rgba(20,20,43,0.10);
     21}
     22
     23.rlcc-wrap {
     24        max-width: none;
     25}
     26
     27.rlcc-dashboard {
     28        box-sizing: border-box;
     29        width: 100%;
     30        max-width: 1600px;
     31        margin: 0;
     32        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
     33        color: var(--rlcc-text);
     34}
     35
     36.rlcc-dashboard *,
     37.rlcc-dashboard *::before,
     38.rlcc-dashboard *::after {
     39        box-sizing: border-box;
     40}
     41
     42.rlcc-dashboard-loading {
     43        color: var(--rlcc-text-muted);
     44        text-align: center;
     45        padding: 80px 0;
     46        font-size: 14px;
     47        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
     48}
     49
     50.rlcc-hero {
     51        background: linear-gradient(120deg, #5b4bd6 0%, #6b5ce7 45%, #8b7cf0 100%);
     52        border-radius: var(--rlcc-radius-lg);
     53        padding: 28px 32px;
     54        color: #fff;
     55        box-shadow: 0 10px 30px rgba(107,92,231,0.28);
     56        margin-bottom: 22px;
     57}
     58
     59.rlcc-hero-top {
     60        display: flex;
     61        align-items: center;
     62        justify-content: space-between;
     63        gap: 20px;
     64        flex-wrap: wrap;
     65}
     66
     67.rlcc-hero-title-block {
     68        display: flex;
     69        align-items: center;
     70        gap: 16px;
     71        flex-wrap: wrap;
     72}
     73
     74.rlcc-hero-title {
     75        margin: 0;
     76        font-size: 22px;
     77        font-weight: 700;
     78        letter-spacing: -0.01em;
     79}
     80
     81.rlcc-status-pill {
     82        display: inline-flex;
     83        align-items: center;
     84        gap: 8px;
     85        font-size: 12px;
     86        font-weight: 600;
     87        text-transform: uppercase;
     88        letter-spacing: 0.04em;
     89        background: rgba(255,255,255,0.16);
     90        padding: 6px 14px;
     91        border-radius: 999px;
     92}
     93
     94.rlcc-status-dot {
     95        width: 8px;
     96        height: 8px;
     97        border-radius: 50%;
     98        background: #6dffb0;
     99        box-shadow: 0 0 0 3px rgba(109,255,176,0.30);
     100}
     101
     102.rlcc-hero-meta {
     103        display: flex;
     104        align-items: center;
     105        gap: 24px;
     106        flex-wrap: wrap;
     107        margin-top: 20px;
     108        padding-top: 18px;
     109        border-top: 1px solid rgba(255,255,255,0.18);
     110        font-size: 13px;
     111}
     112
     113.rlcc-hero-meta-item {
     114        color: rgba(255,255,255,0.85);
     115}
     116
     117.rlcc-hero-meta-item strong {
     118        color: #fff;
     119}
     120
     121.rlcc-hero-meta-item--divi {
     122        font-weight: 600;
     123}
     124
     125.rlcc-countdown-row {
     126        display: flex;
     127        align-items: center;
     128        gap: 10px;
     129}
     130
     131.rlcc-countdown {
     132        font-variant-numeric: tabular-nums;
     133        font-size: 16px;
     134        font-weight: 700;
     135        color: #fff;
     136        background: rgba(255,255,255,0.18);
     137        padding: 3px 12px;
     138        border-radius: 6px;
     139        letter-spacing: 0.02em;
     140}
     141
     142.rlcc-clear-btn {
     143        padding: 12px 22px;
     144        border-radius: 999px;
     145        border: none;
     146        background: #fff;
     147        color: var(--rlcc-accent);
     148        font-size: 14px;
     149        font-weight: 700;
     150        cursor: pointer;
     151        transition: transform 0.1s ease, box-shadow 0.15s ease;
     152        box-shadow: 0 2px 10px rgba(0,0,0,0.15);
     153        white-space: nowrap;
     154}
     155
     156.rlcc-clear-btn:hover:not(:disabled) {
     157        transform: translateY(-1px);
     158        box-shadow: 0 6px 16px rgba(0,0,0,0.22);
     159}
     160
     161.rlcc-clear-btn:disabled {
     162        opacity: 0.65;
     163        cursor: not-allowed;
     164}
     165
     166.rlcc-card-grid {
     167        display: grid;
     168        grid-template-columns: repeat(2, minmax(0, 1fr));
     169        gap: 20px;
     170        width: 100%;
     171        align-items: start;
     172}
     173
     174.rlcc-card {
     175        background: var(--rlcc-surface);
     176        border: 1px solid var(--rlcc-border);
     177        border-radius: var(--rlcc-radius-lg);
     178        padding: 24px 26px;
     179        box-shadow: var(--rlcc-shadow);
     180        transition: box-shadow 0.15s ease;
     181}
     182
     183.rlcc-card:hover {
     184        box-shadow: var(--rlcc-shadow-hover);
     185}
     186
     187.rlcc-card--wide {
     188        grid-column: 1 / -1;
     189}
     190
     191.rlcc-card--danger {
     192        border-color: #f3d4d0;
     193}
     194
     195.rlcc-card-title {
     196        margin: 0 0 18px;
     197        font-size: 14px;
     198        font-weight: 700;
     199        color: var(--rlcc-text);
     200        text-transform: uppercase;
     201        letter-spacing: 0.04em;
     202        padding-left: 12px;
     203        border-left: 3px solid var(--rlcc-accent);
     204}
     205
     206.rlcc-card-title--danger {
     207        border-left-color: var(--rlcc-danger);
     208        color: var(--rlcc-danger);
     209}
     210
     211.rlcc-grid {
     212        display: grid;
     213        grid-template-columns: 1fr 1fr;
     214        gap: 18px;
     215}
     216
     217.rlcc-grid--4 {
     218        grid-template-columns: repeat(4, 1fr);
     219}
     220
     221@media (max-width: 560px) {
     222        .rlcc-grid--4 {
     223                grid-template-columns: repeat(2, 1fr);
     224        }
     225}
     226
     227.rlcc-field label {
     228        display: block;
     229        font-size: 11.5px;
     230        font-weight: 600;
     231        color: var(--rlcc-text-muted);
     232        margin-bottom: 7px;
     233        text-transform: uppercase;
     234        letter-spacing: 0.03em;
     235}
     236
     237.rlcc-select,
     238.rlcc-input {
     239        width: 100%;
     240        padding: 9px 10px;
     241        border-radius: var(--rlcc-radius-sm);
     242        border: 1px solid var(--rlcc-border);
     243        background: var(--rlcc-surface-soft);
     244        font-size: 14px;
     245        color: var(--rlcc-text);
     246}
     247
     248.rlcc-select:focus,
     249.rlcc-input:focus {
     250        outline: none;
     251        border-color: var(--rlcc-accent);
     252        box-shadow: 0 0 0 3px rgba(107,92,231,0.15);
     253}
     254
     255.rlcc-switch {
     256        display: flex;
     257        align-items: center;
     258        gap: 8px;
     259        padding: 8px 14px;
     260        border-radius: 999px;
     261        border: 1px solid var(--rlcc-border);
     262        background: var(--rlcc-surface-soft);
     263        cursor: pointer;
     264        font-size: 13px;
     265        font-weight: 600;
     266        color: var(--rlcc-text);
     267}
     268
     269.rlcc-switch:disabled {
     270        opacity: 0.55;
     271        cursor: not-allowed;
     272}
     273
     274.rlcc-switch--on {
     275        background: var(--rlcc-ok-bg);
     276        border-color: var(--rlcc-ok);
     277        color: var(--rlcc-ok);
     278}
     279
     280.rlcc-switch-knob {
     281        width: 14px;
     282        height: 14px;
     283        border-radius: 50%;
     284        background: #bbb;
     285        transition: background 0.15s ease;
     286        flex-shrink: 0;
     287}
     288
     289.rlcc-switch--on .rlcc-switch-knob {
     290        background: var(--rlcc-ok);
     291}
     292
     293.rlcc-hint {
     294        margin: 14px 0 0;
     295        font-size: 12px;
     296        line-height: 1.5;
     297        color: var(--rlcc-text-muted);
     298}
     299
     300.rlcc-infotip {
     301        position: relative;
     302        display: inline-flex;
     303        align-items: center;
     304        justify-content: center;
     305        margin-left: 6px;
     306        cursor: help;
     307        vertical-align: middle;
     308}
     309.rlcc-infotip-icon {
     310        width: 16px;
     311        height: 16px;
     312        border-radius: 50%;
     313        background: #e9e6fc;
     314        color: var(--rlcc-brand, #6b5ce7);
     315        font-size: 11px;
     316        font-weight: 700;
     317        font-style: italic;
     318        font-family: Georgia, 'Times New Roman', serif;
     319        display: flex;
     320        align-items: center;
     321        justify-content: center;
     322        line-height: 1;
     323}
     324.rlcc-infotip-text {
     325        visibility: hidden;
     326        opacity: 0;
     327        position: absolute;
     328        bottom: 130%;
     329        left: 50%;
     330        transform: translateX(-50%);
     331        background: #1e1e1e;
     332        color: #fff;
     333        padding: 8px 12px;
     334        border-radius: 6px;
     335        font-size: 12px;
     336        font-weight: 400;
     337        line-height: 1.45;
     338        width: 260px;
     339        text-align: left;
     340        transition: opacity 0.12s ease;
     341        z-index: 30;
     342        pointer-events: none;
     343        box-shadow: 0 4px 14px rgba(0,0,0,0.25);
     344}
     345.rlcc-infotip-text::after {
     346        content: '';
     347        position: absolute;
     348        top: 100%;
     349        left: 50%;
     350        transform: translateX(-50%);
     351        border: 5px solid transparent;
     352        border-top-color: #1e1e1e;
     353}
     354.rlcc-infotip:hover .rlcc-infotip-text,
     355.rlcc-infotip:focus .rlcc-infotip-text,
     356.rlcc-infotip:focus-within .rlcc-infotip-text {
     357        visibility: visible;
     358        opacity: 1;
     359}
     360
     361.rlcc-db-grid {
     362        display: grid;
     363        grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
     364        gap: 10px;
     365        margin: 16px 0 20px;
     366}
     367
     368.rlcc-db-checkbox {
     369        display: flex;
     370        align-items: center;
     371        gap: 8px;
     372        padding: 10px 12px;
     373        border-radius: 8px;
     374        background: var(--rlcc-bg-subtle, #f7f7f8);
     375        font-size: 13px;
     376        cursor: pointer;
     377}
     378
     379.rlcc-db-checkbox input[type="checkbox"] {
     380        width: 16px;
     381        height: 16px;
     382        flex-shrink: 0;
     383}
     384
     385.rlcc-db-checkbox span:nth-child(2) {
     386        flex: 1;
     387}
     388
     389.rlcc-db-count {
     390        font-size: 11px;
     391        font-weight: 700;
     392        color: var(--rlcc-brand, #6b5ce7);
     393        background: #e9e6fc;
     394        padding: 2px 8px;
     395        border-radius: 999px;
     396        min-width: 20px;
     397        text-align: center;
     398}
     399
     400.rlcc-danger-row {
     401        display: flex;
     402        align-items: center;
     403        justify-content: space-between;
     404        gap: 16px;
     405        padding: 14px 16px;
     406        background: var(--rlcc-danger-bg);
     407        border: 1px solid #f3d4d0;
     408        border-radius: var(--rlcc-radius-md);
     409        flex-wrap: wrap;
     410}
     411
     412.rlcc-danger-text label {
     413        display: block;
     414        font-size: 13px;
     415        font-weight: 700;
     416        color: #922b21;
     417        margin-bottom: 4px;
     418}
     419
     420.rlcc-danger-text .rlcc-hint {
     421        margin: 0;
     422        color: #a04b42;
     423        max-width: 480px;
     424}
     425
     426.rlcc-switch--danger.rlcc-switch--off {
     427        background: var(--rlcc-ok-bg);
     428        border-color: var(--rlcc-ok);
     429        color: var(--rlcc-ok);
     430}
     431
     432.rlcc-switch--danger.rlcc-switch--off .rlcc-switch-knob {
     433        background: var(--rlcc-ok);
     434}
     435
     436.rlcc-switch--danger.rlcc-switch--on {
     437        background: #fceceb;
     438        border-color: var(--rlcc-danger);
     439        color: var(--rlcc-danger);
     440}
     441
     442.rlcc-switch--danger.rlcc-switch--on .rlcc-switch-knob {
     443        background: var(--rlcc-danger);
     444}
     445
     446.rlcc-oc-backend-row {
     447        display: flex;
     448        gap: 12px;
     449        margin-top: 14px;
     450        flex-wrap: wrap;
     451}
     452
     453.rlcc-oc-backend-row .rlcc-field {
     454        flex: 1;
     455        min-width: 140px;
     456}
     457
     458.rlcc-oc-status {
     459        display: inline-flex;
     460        align-items: center;
     461        gap: 6px;
     462        font-size: 12px;
     463        font-weight: 600;
     464        padding: 4px 10px;
     465        border-radius: 999px;
     466        margin-top: 12px;
     467}
     468
     469.rlcc-oc-status--ok { background: var(--rlcc-ok-bg); color: var(--rlcc-ok); }
     470.rlcc-oc-status--error { background: var(--rlcc-danger-bg); color: var(--rlcc-danger); }
     471.rlcc-oc-status--idle { background: var(--rlcc-accent-bg); color: var(--rlcc-accent); }
     472
     473.rlcc-oc-test-btn {
     474        margin-top: 12px;
     475        padding: 8px 16px;
     476        border-radius: var(--rlcc-radius-sm);
     477        border: 1px solid var(--rlcc-border);
     478        background: #fff;
     479        font-size: 13px;
     480        font-weight: 600;
     481        cursor: pointer;
     482}
     483
     484.rlcc-oc-test-btn:disabled {
     485        opacity: 0.6;
     486        cursor: not-allowed;
     487}
     488
     489.rlcc-oc-unavailable {
     490        font-size: 11.5px;
     491        color: #b23b3b;
     492        margin: 0 0 12px;
     493        line-height: 1.5;
     494}
     495
     496.rlcc-report-list {
     497        list-style: none;
     498        margin: 12px 0 0;
     499        padding: 0;
     500        display: grid;
     501        grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
     502        gap: 8px;
     503}
     504
     505.rlcc-report-row {
     506        display: flex;
     507        align-items: center;
     508        gap: 10px;
     509        padding: 9px 12px;
     510        border-radius: var(--rlcc-radius-sm);
     511        font-size: 13px;
     512        font-weight: 500;
     513        background: var(--rlcc-surface-soft);
     514}
     515
     516.rlcc-report-icon {
     517        font-weight: 700;
     518        width: 18px;
     519        text-align: center;
     520        flex-shrink: 0;
     521}
     522
     523.rlcc-report-label {
     524        flex: 1;
     525        color: var(--rlcc-text);
     526}
     527
     528.rlcc-report-badge {
     529        font-size: 10.5px;
     530        font-weight: 700;
     531        text-transform: uppercase;
     532        letter-spacing: 0.03em;
     533        padding: 2px 8px;
     534        border-radius: 999px;
     535        white-space: nowrap;
     536}
     537
     538.rlcc-report-ok { background: var(--rlcc-ok-bg); }
     539.rlcc-report-ok .rlcc-report-icon { color: var(--rlcc-ok); }
     540.rlcc-report-ok .rlcc-report-badge { background: var(--rlcc-ok); color: #fff; }
     541
     542.rlcc-report-triggered { background: var(--rlcc-accent-bg); }
     543.rlcc-report-triggered .rlcc-report-icon { color: var(--rlcc-accent); }
     544.rlcc-report-triggered .rlcc-report-badge { background: var(--rlcc-accent); color: #fff; }
     545
     546.rlcc-report-failed { background: var(--rlcc-danger-bg); }
     547.rlcc-report-failed .rlcc-report-icon { color: var(--rlcc-danger); }
     548.rlcc-report-failed .rlcc-report-badge { background: var(--rlcc-danger); color: #fff; }
     549
     550.rlcc-toast-container {
     551        position: fixed;
     552        bottom: 24px;
     553        right: 24px;
     554        display: flex;
     555        flex-direction: column;
     556        gap: 10px;
     557        z-index: 100000;
     558}
     559
     560.rlcc-toast {
     561        display: flex;
     562        align-items: center;
     563        gap: 10px;
     564        padding: 12px 18px;
     565        border-radius: 10px;
     566        background: #1e1e1e;
     567        color: #fff;
     568        font-size: 13px;
     569        font-weight: 500;
     570        box-shadow: 0 6px 20px rgba(0,0,0,0.25);
     571        cursor: pointer;
     572        animation: rlcc-toast-in 0.25s ease;
     573        min-width: 220px;
     574}
     575
     576.rlcc-toast--success { background: var(--rlcc-ok); }
     577.rlcc-toast--error { background: #c53030; }
     578.rlcc-toast--info { background: #1e1e1e; }
     579
     580.rlcc-toast-icon {
     581        font-weight: 700;
     582}
     583
     584@keyframes rlcc-toast-in {
     585        from { opacity: 0; transform: translateY(10px); }
     586        to { opacity: 1; transform: translateY(0); }
     587}
     588
     589@media (max-width: 720px) {
     590        .rlcc-hero {
     591                padding: 22px 20px;
     592        }
     593        .rlcc-hero-top {
     594                flex-direction: column;
     595                align-items: flex-start;
     596        }
     597        .rlcc-clear-btn {
     598                width: 100%;
     599        }
     600        .rlcc-hero-meta {
     601                flex-direction: column;
     602                align-items: flex-start;
     603                gap: 10px;
     604        }
     605        .rlcc-card {
     606                padding: 20px;
     607        }
     608        .rlcc-grid {
     609                grid-template-columns: 1fr;
     610        }
     611        .rlcc-card-grid {
     612                grid-template-columns: 1fr;
     613        }
     614}
     615
     616/* CacheNova 1.1.0 dashboard polish: scoped to the plugin settings screen */
     617#rlcc-settings-root {--cn-accent:#6254e8;--cn-border:#e6e9f2;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
     618#rlcc-settings-root .rlcc-card {border:1px solid var(--cn-border);border-radius:18px;box-shadow:0 8px 28px rgba(29,34,71,.055);transition:box-shadow .2s ease;}
     619#rlcc-settings-root .rlcc-card:hover {box-shadow:0 12px 32px rgba(29,34,71,.09);}
     620#rlcc-settings-root button,#rlcc-settings-root select {transition:background-color .16s ease,border-color .16s ease,box-shadow .16s ease;}
     621#rlcc-settings-root button:focus-visible,#rlcc-settings-root select:focus-visible {outline:3px solid rgba(98,84,232,.38);outline-offset:2px;}
     622#rlcc-settings-root .rlcc-toast {border-radius:12px;box-shadow:0 12px 28px rgba(20,25,60,.14);}
     623@media (prefers-reduced-motion:reduce){#rlcc-settings-root *{transition:none!important;animation:none!important;}}
     624
     625/* CacheNova 1.1.0 — dashboard design system (scoped to the plugin admin page) */
     626body.settings_page_rlcc-settings {background:#f3f5fa;}
     627body.settings_page_rlcc-settings #wpcontent {background:#f3f5fa;}
     628.rlcc-wrap {margin:22px 24px 40px 4px;}
     629.rlcc-dashboard {--rlcc-accent:#635bdf;--rlcc-accent-light:#8679fa;--rlcc-accent-bg:#f0efff;--rlcc-border:#e7eaf2;--rlcc-text:#18223b;--rlcc-text-muted:#66728b;--rlcc-surface-soft:#f8f9fc;--rlcc-shadow:0 5px 25px rgba(22,35,73,.045);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;max-width:1540px;}
     630.rlcc-page-intro {display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 22px;}
     631.rlcc-page-intro h1 {font-size:30px;line-height:1.2;font-weight:760;letter-spacing:-.8px;color:#15203b;margin:5px 0 8px;}
     632.rlcc-page-intro p {margin:0;color:#6c7790;font-size:13px;}
     633.rlcc-eyebrow {color:#7466d8;font-weight:750;letter-spacing:1.5px;font-size:10px;}
     634.rlcc-intro-chip {background:#fff;border:1px solid #e7eaf2;border-radius:50px;color:#485674;padding:10px 16px;white-space:nowrap;font-size:12px;font-weight:650;box-shadow:0 2px 10px rgba(15,23,42,.025);}
     635.rlcc-hero {position:relative;overflow:hidden;background:linear-gradient(116deg,#242f72 0%,#5144bb 54%,#7865eb 100%);border:1px solid rgba(255,255,255,.15);border-radius:20px;padding:29px 34px;box-shadow:0 16px 38px rgba(67,56,163,.16);margin-bottom:20px;}
     636.rlcc-hero::after {content:"";position:absolute;width:340px;height:340px;right:-90px;top:-230px;border:60px solid rgba(255,255,255,.055);border-radius:50%;pointer-events:none;}
     637.rlcc-hero-title {display:flex;align-items:center;gap:10px;font-size:26px;letter-spacing:-.6px;color:#fff;}
     638.rlcc-brand-mark {display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:11px;background:rgba(255,255,255,.19);font-size:24px;color:#fff;}
     639.rlcc-version {font-size:10px;font-weight:650;letter-spacing:.2px;padding:5px 8px;border:1px solid rgba(255,255,255,.24);border-radius:6px;color:#e8e7ff;}
     640.rlcc-status-pill {background:rgba(35,207,148,.15);border:1px solid rgba(132,255,205,.28);color:#d9fff0;font-size:11px;}
     641.rlcc-status-dot {background:#62f3b1;}
     642.rlcc-hero-meta {border-top:1px solid rgba(255,255,255,.17);padding-top:20px;margin-top:24px;}
     643.rlcc-countdown {font-variant-numeric:tabular-nums;background:rgba(255,255,255,.18);border-radius:8px;padding:7px 12px;}
     644.rlcc-card-grid {gap:18px;}
     645.rlcc-card {background:#fff;border:1px solid #e8ebf3;border-radius:17px;box-shadow:var(--rlcc-shadow);padding:24px 26px;transition:border-color .18s ease,box-shadow .18s ease;}
     646.rlcc-card:hover {border-color:#d7d9ed;box-shadow:0 9px 28px rgba(27,34,76,.075);}
     647.rlcc-card-title {display:flex;align-items:center;gap:10px;font-size:14px;letter-spacing:-.15px;color:#1d2942;margin-bottom:22px;}
     648.rlcc-card-title::before {display:none;}
     649.rlcc-card-symbol {display:inline-flex;align-items:center;justify-content:center;background:#f0efff;color:#6558d7;width:32px;height:32px;border-radius:10px;font-size:18px;font-weight:600;flex:none;}
     650.rlcc-field label {font-weight:650;color:#56617b;letter-spacing:.15px;}
     651.rlcc-select,.rlcc-dashboard input[type=text],.rlcc-dashboard input[type=number] {border:1px solid #dfe3ed;border-radius:9px;min-height:43px;box-shadow:none;}
     652.rlcc-dashboard select:focus,.rlcc-dashboard input:focus,.rlcc-dashboard button:focus-visible {outline:3px solid rgba(99,91,223,.22);outline-offset:2px;border-color:#7768eb;}
     653.rlcc-switch {transition:background .15s ease,border-color .15s ease,transform .15s ease;}
     654.rlcc-switch:active {transform:scale(.97);}
     655.rlcc-switch--on {background:#e9f8f0;border-color:#a6e6c4;color:#147a46;}
     656.rlcc-toast-container {z-index:999999;max-width:min(400px,calc(100vw - 32px));}
     657.rlcc-toast {border-radius:12px;box-shadow:0 12px 35px rgba(20,27,55,.16);border:1px solid rgba(0,0,0,.04);}
     658@media(max-width:782px){.rlcc-wrap{margin:12px 12px 28px 0}.rlcc-page-intro{align-items:flex-start}.rlcc-page-intro h1{font-size:24px}.rlcc-intro-chip{display:none}.rlcc-hero{padding:22px}.rlcc-card{padding:19px}.rlcc-hero-title{font-size:23px}}
     659@media(prefers-reduced-motion:reduce){.rlcc-card,.rlcc-switch{transition:none}}
     660
     661.rlcc-divi-compat{margin:18px 0;padding:22px;border-left:3px solid #7563f5}.rlcc-divi-compat p{color:#6b7280;line-height:1.55}
     662
     663/* CacheNova Divi compatibility control */
     664.rlcc-divi-compat{margin:20px 0;padding:24px!important;border:1px solid #dce2f2!important;border-left:4px solid #6955ee!important}.rlcc-divi-compat p{color:#526079;margin:12px 0 18px;line-height:1.6}
     665
     666/* CacheNova tabs, database schedule row, footer */
     667.rlcc-tabs{display:flex;gap:8px;margin:0 0 20px;padding:6px;background:#fff;border:1px solid #e8ebf3;border-radius:14px;box-shadow:var(--rlcc-shadow);width:fit-content;max-width:100%;flex-wrap:wrap}
     668.rlcc-tab{appearance:none;border:0;background:transparent;color:#56617b;font:inherit;font-size:13.5px;font-weight:650;padding:10px 18px;border-radius:10px;cursor:pointer;transition:background .15s ease,color .15s ease}
     669.rlcc-tab:hover{background:#f4f3ff;color:#4a3fc7}
     670.rlcc-tab--active,.rlcc-tab--active:hover{background:#635bdf;color:#fff}
     671.rlcc-db-status{display:flex;align-items:center;min-height:43px;padding:9px 14px;border-radius:9px;font-size:13.5px;font-weight:600;border:1px solid #dfe3ed;background:#f8f9fc;color:#56617b}
     672.rlcc-db-status--on{background:#e9f8f0;border-color:#a6e6c4;color:#147a46}
     673.rlcc-footer-grid{margin-top:20px}
     674@media(max-width:782px){.rlcc-tabs{width:100%}.rlcc-tab{flex:1;text-align:center;padding:10px 12px}}
     675
     676/* CacheNova confirmation modal */
     677.rlcc-modal-overlay{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(18,22,44,.55);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);animation:rlccFade .15s ease}
     678.rlcc-modal{width:100%;max-width:440px;background:#fff;border-radius:20px;padding:30px 30px 24px;text-align:center;box-shadow:0 24px 70px rgba(18,22,44,.35);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;animation:rlccPop .18s cubic-bezier(.2,.9,.3,1.2)}
     679.rlcc-modal-icon{width:56px;height:56px;margin:0 auto 16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:800;line-height:1}
     680.rlcc-modal--info .rlcc-modal-icon{background:#f0efff;color:#635bdf}
     681.rlcc-modal--warning .rlcc-modal-icon{background:#fff4e0;color:#d98200}
     682.rlcc-modal--danger .rlcc-modal-icon{background:#fdeceb;color:#c0392b}
     683.rlcc-modal-title{margin:0 0 10px;font-size:19px;font-weight:700;color:#18223b;letter-spacing:-.2px}
     684.rlcc-modal-text{margin:0;font-size:14px;line-height:1.6;color:#56617b}
     685.rlcc-modal-points{list-style:none;margin:16px 0 0;padding:12px 16px;background:#f8f9fc;border:1px solid #e7eaf2;border-radius:12px;text-align:left}
     686.rlcc-modal-points li{position:relative;padding:4px 0 4px 20px;font-size:13px;color:#3b4560}
     687.rlcc-modal-points li::before{content:"";position:absolute;left:3px;top:11px;width:6px;height:6px;border-radius:50%;background:#9aa3bb}
     688.rlcc-modal-actions{display:flex;gap:10px;margin-top:24px}
     689.rlcc-modal-btn{flex:1;appearance:none;border:1px solid transparent;border-radius:11px;min-height:44px;padding:0 18px;font:inherit;font-size:14px;font-weight:650;cursor:pointer;transition:background .15s ease,transform .1s ease,box-shadow .15s ease}
     690.rlcc-modal-btn:active{transform:scale(.98)}
     691.rlcc-modal-btn:focus-visible{outline:3px solid rgba(99,91,223,.3);outline-offset:2px}
     692.rlcc-modal-btn--ghost{background:#fff;border-color:#dfe3ed;color:#3b4560}
     693.rlcc-modal-btn--ghost:hover{background:#f4f5fa}
     694.rlcc-modal-btn--info{background:#635bdf;color:#fff}
     695.rlcc-modal-btn--info:hover{background:#5249cc}
     696.rlcc-modal-btn--warning{background:#d98200;color:#fff}
     697.rlcc-modal-btn--warning:hover{background:#bf7200}
     698.rlcc-modal-btn--danger{background:#c0392b;color:#fff}
     699.rlcc-modal-btn--danger:hover{background:#a63023}
     700@keyframes rlccFade{from{opacity:0}to{opacity:1}}
     701@keyframes rlccPop{from{opacity:0;transform:translateY(8px) scale(.96)}to{opacity:1;transform:none}}
     702@media(max-width:480px){.rlcc-modal{padding:24px 20px 20px}.rlcc-modal-actions{flex-direction:column-reverse}}
     703@media(prefers-reduced-motion:reduce){.rlcc-modal-overlay,.rlcc-modal{animation:none}}
     704
     705/* Database Optimization tab: one row, three columns */
     706.rlcc-card-grid--three{grid-template-columns:repeat(3,minmax(0,1fr));align-items:stretch}
     707.rlcc-card-grid--three .rlcc-card{display:flex;flex-direction:column}
     708.rlcc-card-grid--three .rlcc-grid{grid-template-columns:1fr}
     709.rlcc-card-grid--three .rlcc-db-grid{grid-template-columns:1fr;margin:0 0 18px}
     710.rlcc-card-grid--three .rlcc-clear-btn{align-self:flex-start}
     711.rlcc-card-grid--three .rlcc-hint{margin:14px 0 0}
     712.rlcc-card-grid--three .rlcc-danger-row{flex-direction:column;align-items:flex-start;gap:14px}
     713@media(max-width:1100px){.rlcc-card-grid--three{grid-template-columns:1fr}}
  • cachenova/trunk/assets/js/cn-admin.js

    r3737033 r3777263  
    3636    const NS = "/" + RLCC_DATA.namespace;
    3737    const INTERVAL_LABELS = {
    38         1: "1 minute",
    39         3: "3 minutes",
    40         5: "5 minutes",
    4138        10: "10 minutes",
    4239        20: "20 minutes",
    4340        30: "30 minutes",
    44         60: "1 hour"
     41        60: "1 hour",
     42        1440: "1 day",
     43        10080: "7 days",
     44        43200: "30 days (monthly)"
     45    };
     46    const DB_SCHEDULE_LABELS = {
     47        off: "Off (manual only)",
     48        daily: "Every 1 day",
     49        weekly: "Every 7 days",
     50        monthly: "Every month"
    4551    };
    4652    const OC_BACKEND_LABELS = {
    … …  
    6470        if (mins < 60) return mins + " min ago";
    6571        const hrs = Math.round(mins / 60);
    66         return hrs + " hr ago";
     72        if (hrs < 24) return hrs + " hr ago";
     73        const days = Math.round(hrs / 24);
     74        return days + (days === 1 ? " day ago" : " days ago");
    6775    }
    6876    function InfoTip({text: text}) {
    … …  
    7987            className: "screen-reader-text"
    8088        }, text));
     89    }
     90    function formatDateTime(timestamp) {
     91        if (!timestamp) return "—";
     92        try {
     93            return new Date(timestamp * 1e3).toLocaleString(undefined, {
     94                dateStyle: "medium",
     95                timeStyle: "short"
     96            });
     97        } catch (e) {
     98            return new Date(timestamp * 1e3).toString();
     99        }
     100    }
     101    const MODAL_ICONS = {
     102        danger: "!",
     103        warning: "!",
     104        info: "i"
     105    };
     106    function ConfirmModal({modal: modal, onCancel: onCancel, onConfirm: onConfirm}) {
     107        const confirmRef = useRef(null);
     108        const cancelRef = useRef(null);
     109        useEffect(() => {
     110            if (!modal) return;
     111            const previous = document.activeElement;
     112            const target = "danger" === modal.tone ? cancelRef.current : confirmRef.current;
     113            if (target) target.focus();
     114            function onKey(e) {
     115                if (e.key === "Escape") {
     116                    e.preventDefault();
     117                    onCancel();
     118                } else if (e.key === "Tab") {
     119                    const nodes = [ cancelRef.current, confirmRef.current ].filter(Boolean);
     120                    if (!nodes.length) return;
     121                    const first = nodes[0], last = nodes[nodes.length - 1];
     122                    if (e.shiftKey && document.activeElement === first) {
     123                        e.preventDefault();
     124                        last.focus();
     125                    } else if (!e.shiftKey && document.activeElement === last) {
     126                        e.preventDefault();
     127                        first.focus();
     128                    }
     129                }
     130            }
     131            document.addEventListener("keydown", onKey);
     132            return () => {
     133                document.removeEventListener("keydown", onKey);
     134                if (previous && previous.focus) previous.focus();
     135            };
     136        }, [ modal ]);
     137        if (!modal) return null;
     138        const tone = modal.tone || "info";
     139        return h("div", {
     140            className: "rlcc-modal-overlay",
     141            onMouseDown: e => {
     142                if (e.target === e.currentTarget) onCancel();
     143            }
     144        }, h("div", {
     145            className: "rlcc-modal rlcc-modal--" + tone,
     146            role: "alertdialog",
     147            "aria-modal": "true",
     148            "aria-labelledby": "rlcc-modal-title",
     149            "aria-describedby": "rlcc-modal-desc"
     150        }, h("div", {
     151            className: "rlcc-modal-icon",
     152            "aria-hidden": "true"
     153        }, MODAL_ICONS[tone] || "i"), h("h3", {
     154            className: "rlcc-modal-title",
     155            id: "rlcc-modal-title"
     156        }, modal.title), h("p", {
     157            className: "rlcc-modal-text",
     158            id: "rlcc-modal-desc"
     159        }, modal.message), modal.points && modal.points.length ? h("ul", {
     160            className: "rlcc-modal-points"
     161        }, modal.points.map((pt, i) => h("li", {
     162            key: i
     163        }, pt))) : null, h("div", {
     164            className: "rlcc-modal-actions"
     165        }, h("button", {
     166            type: "button",
     167            ref: cancelRef,
     168            className: "rlcc-modal-btn rlcc-modal-btn--ghost",
     169            onClick: onCancel
     170        }, modal.cancelLabel || "Cancel"), h("button", {
     171            type: "button",
     172            ref: confirmRef,
     173            className: "rlcc-modal-btn rlcc-modal-btn--" + tone,
     174            onClick: onConfirm
     175        }, modal.confirmLabel || "Continue"))));
    81176    }
    82177    class RLCCErrorBoundary extends Component {
    … …  
    158253            optimize_tables: false
    159254        });
     255        const [modal, setModal] = useState(null);
     256        function askConfirm(opts) {
     257            setModal(opts);
     258        }
     259        function closeModal() {
     260            setModal(null);
     261        }
     262        function acceptModal() {
     263            const fn = modal && modal.onConfirm;
     264            setModal(null);
     265            if (fn) fn();
     266        }
     267        const [activeTab, setActiveTab] = useState(window.location.hash === "#database" ? "database" : "cache");
     268        const [dbSchedule, setDbSchedule] = useState("off");
     269        const [dbNextRun, setDbNextRun] = useState(0);
     270        const [dbLastType, setDbLastType] = useState("manual");
     271        const [dbScheduleSaving, setDbScheduleSaving] = useState(false);
    160272        const [dbRunning, setDbRunning] = useState(false);
    161273        const [dbLastReport, setDbLastReport] = useState(null);
    … …  
    183295        const [localSslFixActive, setLocalSslFixActive] = useState(false);
    184296        const [diviActive, setDiviActive] = useState(false);
     297        const [diviPerRequest, setDiviPerRequest] = useState(false);
     298        const [diviPurgeSaving, setDiviPurgeSaving] = useState(false);
    185299        const [ecWpConfigWritable, setEcWpConfigWritable] = useState(true);
    186300        const [ecSaving, setEcSaving] = useState(false);
    … …  
    224338            if (!toastEnabledRef.current) return;
    225339            const id = ++toastIdRef.current;
    226             setToasts(prev => [ ...prev, {
     340            setToasts([{
    227341                id: id,
    228342                message: message,
    … …  
    268382                setLocalSslFixActive(!!res.local_ssl_fix_active);
    269383                setDiviActive(!!res.divi_active);
     384                setDiviPerRequest(!!res.divi_per_request_purge);
    270385                setDbCounts(res.db_counts || {
    271386                    revisions: 0,
    … …  
    275390                    expired_transients: 0
    276391                });
     392                setDbSchedule(res.db_schedule || "off");
     393                setDbNextRun(res.db_next_run_timestamp || 0);
    277394                if (res.last_db_optimize) {
    278395                    setDbLastReport(res.last_db_optimize.report || null);
    279396                    setDbLastTimestamp(res.last_db_optimize.timestamp || 0);
     397                    setDbLastType(res.last_db_optimize.type || "manual");
    280398                }
    281399                setNextRun(res.next_run_timestamp);
    … …  
    387505        function handleDeleteOnUninstallToggle() {
    388506            const newVal = !deleteOnUninstall;
    389             if (newVal) {
    390                 const confirmed = window.confirm("When this is ON, deleting CacheNova from Plugins → Installed Plugins will permanently erase all of this plugin's settings and cached files, and trigger one final full-site cache clear. This cannot be undone. Turn it on?");
    391                 if (!confirmed) {
    392                     return;
    393                 }
    394             }
    395             setDeleteOnUninstall(newVal);
    396             deleteOnUninstallRef.current = newVal;
    397             saveSettings({
    398                 delete_data_on_uninstall: newVal
    399             }, "Delete all data on uninstall turned " + (newVal ? "ON" : "OFF") + ".");
     507            const apply = () => {
     508                setDeleteOnUninstall(newVal);
     509                deleteOnUninstallRef.current = newVal;
     510                saveSettings({
     511                    delete_data_on_uninstall: newVal
     512                }, "Delete all data on uninstall turned " + (newVal ? "ON" : "OFF") + ".");
     513            };
     514            if (!newVal) {
     515                apply();
     516                return;
     517            }
     518            askConfirm({
     519                tone: "danger",
     520                title: "Delete all data on uninstall?",
     521                message: "When you delete CacheNova from Plugins → Installed Plugins, everything it created will be removed. This cannot be undone.",
     522                points: [ "All CacheNova settings and reports", "All cached files", "One final full-site cache clear" ],
     523                confirmLabel: "Yes, turn it on",
     524                onConfirm: apply
     525            });
    400526        }
    401527        function saveObjectCacheSettings(overrides, onDone) {
    … …  
    448574            const newVal = !ocEnabled;
    449575            if (newVal) {
    450                 const confirmed = window.confirm("This installs a wp-content/object-cache.php file that changes how WordPress caches data site-wide. This plugin automatically detects and uses the fastest backend this server actually has working — no manual setup needed. Continue?");
    451                 if (!confirmed) {
    452                     return;
    453                 }
    454                 setOcTestStatus("testing");
    455                 saveObjectCacheSettings({
    456                     object_cache_enabled: newVal,
    457                     object_cache_backend: "auto"
     576                askConfirm({
     577                    tone: "info",
     578                    title: "Enable Persistent Object Cache?",
     579                    message: "CacheNova will install wp-content/object-cache.php, which changes how WordPress caches data across the whole site. It automatically picks the fastest backend your server has working.",
     580                    points: [ "Backend order: Redis, Memcached, APCu, then file-based", "No manual setup needed" ],
     581                    confirmLabel: "Enable",
     582                    onConfirm: () => {
     583                        setOcTestStatus("testing");
     584                        saveObjectCacheSettings({
     585                            object_cache_enabled: newVal,
     586                            object_cache_backend: "auto"
     587                        });
     588                    }
    458589                });
    459590                return;
    … …  
    483614                    return;
    484615                }
    485                 const confirmed = window.confirm("This installs a wp-content/advanced-cache.php file AND adds define('WP_CACHE', true) to wp-config.php, so cached pages can be served before WordPress even loads. Continue?");
    486                 if (!confirmed) {
    487                     return;
    488                 }
    489             }
     616                askConfirm({
     617                    tone: "info",
     618                    title: "Enable Early-Bypass Page Cache?",
     619                    message: "CacheNova will install wp-content/advanced-cache.php and add define('WP_CACHE', true) to wp-config.php, so cached pages are served before WordPress even loads.",
     620                    points: [ "Anonymous visitors only; logged-in users get Private Cache", "Removed automatically when you turn it off" ],
     621                    confirmLabel: "Enable",
     622                    onConfirm: () => runEarlyCache(newVal)
     623                });
     624                return;
     625            }
     626            runEarlyCache(newVal);
     627        }
     628        function runEarlyCache(newVal) {
    490629            setEcSaving(true);
    491630            apiFetch({
    … …  
    509648                setLocalSslFixActive(!!res.local_ssl_fix_active);
    510649                setDiviActive(!!res.divi_active);
     650                setDiviPerRequest(!!res.divi_per_request_purge);
    511651                const result = res.early_cache_result;
    512652                if (result) {
    … …  
    523663            });
    524664        }
     665        function toggleDiviPerRequest() {
     666            if (diviPurgeSaving) return;
     667            const enabled = !diviPerRequest;
     668            if (enabled) {
     669                askConfirm({
     670                    tone: "warning",
     671                    title: "Enable Divi cache compatibility?",
     672                    message: "CacheNova will delete the Divi et-cache folder after every frontend request. This can noticeably slow your website, so use it only for troubleshooting.",
     673                    confirmLabel: "Enable anyway",
     674                    onConfirm: () => runDiviPerRequest(enabled)
     675                });
     676                return;
     677            }
     678            runDiviPerRequest(enabled);
     679        }
     680        function runDiviPerRequest(enabled) {
     681            setDiviPurgeSaving(true);
     682            apiFetch({path: NS + "/divi-per-request", method: "POST", data: {enabled}}).then(res => {
     683                setDiviPerRequest(!!res.enabled);
     684                pushToast(res.enabled ? (res.folder_deleted ? "Divi et-cache folder deleted. Automatic deletion enabled." : "Divi setting enabled, but et-cache folder could not be deleted. Check filesystem permissions.") : "Divi Cache Compatibility disabled. No per-request folder deletion.", res.enabled && !res.folder_deleted ? "error" : "success");
     685            }).catch(err => pushToast("Could not save Divi setting: " + (err.message || "Unknown error"), "error"))
     686              .finally(() => setDiviPurgeSaving(false));
     687        }
     688        function dbReportTotal(report) {
     689            if (!report) return 0;
     690            return Object.keys(report).filter(k => k !== "optimize_tables").reduce((sum, k) => sum + (typeof report[k] === "number" ? report[k] : 0), 0);
     691        }
     692        function switchTab(tab) {
     693            setActiveTab(tab);
     694            try {
     695                window.history.replaceState(null, "", tab === "database" ? "#database" : window.location.pathname + window.location.search);
     696            } catch (e) {}
     697        }
     698        function handleDbScheduleChange(e) {
     699            const val = e.target.value;
     700            if (val !== "off") {
     701                askConfirm({
     702                    tone: "danger",
     703                    title: "Turn on automatic optimization?",
     704                    message: "CacheNova will run " + (DB_SCHEDULE_LABELS[val] || val).toLowerCase() + " in the background. Every run permanently deletes the items below. This cannot be undone.",
     705                    points: [ "All post revisions and trashed posts", "Spam and trashed comments", "Expired transients, then optimizes tables" ],
     706                    confirmLabel: "Turn on",
     707                    onConfirm: () => runDbSchedule(val)
     708                });
     709                return;
     710            }
     711            runDbSchedule(val);
     712        }
     713        function runDbSchedule(val) {
     714            const previous = dbSchedule;
     715            setDbSchedule(val);
     716            setDbScheduleSaving(true);
     717            apiFetch({
     718                path: NS + "/settings",
     719                method: "POST",
     720                data: {
     721                    interval_minutes: intervalRef.current,
     722                    toast_enabled: toastEnabledRef.current,
     723                    db_schedule: val
     724                }
     725            }).then(res => {
     726                setDbScheduleSaving(false);
     727                setDbSchedule(res.db_schedule || val);
     728                setDbNextRun(res.db_next_run_timestamp || 0);
     729                pushToast(val === "off" ? "Automatic database optimization turned OFF." : "Automatic database optimization set: " + (DB_SCHEDULE_LABELS[val] || val) + ".", "success");
     730            }).catch(err => {
     731                setDbScheduleSaving(false);
     732                setDbSchedule(previous);
     733                const detail = err && (err.message || err.code) || "unknown error";
     734                pushToast("Failed to save database schedule: " + detail, "error");
     735            });
     736        }
    525737        function toggleDbOption(key) {
    526738            setDbOptions(prev => ({
    … …  
    535747                return;
    536748            }
    537             const confirmed = window.confirm("This permanently deletes the selected items (revisions, trash, spam are not recoverable afterward). Continue?");
    538             if (!confirmed) {
    539                 return;
    540             }
     749            askConfirm({
     750                tone: "danger",
     751                title: "Optimize database now?",
     752                message: "The selected items will be permanently deleted. Revisions, trash and spam cannot be recovered afterward.",
     753                confirmLabel: "Optimize now",
     754                onConfirm: runDbOptimize
     755            });
     756        }
     757        function runDbOptimize() {
    541758            setDbRunning(true);
    542759            apiFetch({
    … …  
    548765                setDbLastReport(res.report);
    549766                setDbLastTimestamp(res.timestamp);
    550                 const total = Object.values(res.report).reduce((sum, n) => sum + (typeof n === "number" ? n : 0), 0);
     767                setDbLastType("manual");
     768                const total = dbReportTotal(res.report);
    551769                pushToast("Database optimized — " + total + " item(s) cleaned.", "success");
    552770                fetchSettings(false, false);
    … …  
    569787                reminderFiredForRef.current = null;
    570788                setClearing(false);
    571                 pushToast("Cache cleared successfully. Timer restarted.", "success");
     789                pushToast(res.divi_folder_deleted === false ? (res.divi_folder_message || "Cache cleared, but Divi et-cache still exists.") : "All theme & plugin caches cleared. Timer restarted.", res.divi_folder_deleted === false ? "error" : "success");
    572790                if (res.last_clear_report) {
    573791                    setLastClearReport(res.last_clear_report);
    … …  
    626844            })));
    627845        }
     846        function renderDbTab() {
     847            const scheduleOn = dbSchedule !== "off";
     848            return h("div", {
     849                className: "rlcc-card-grid rlcc-card-grid--three"
     850            }, h("div", {
     851                className: "rlcc-card"
     852            }, h("h3", {
     853                className: "rlcc-card-title"
     854            }, h("span", {
     855                className: "rlcc-card-symbol",
     856                "aria-hidden": "true"
     857            }, "◷"), "Automatic Optimization", h(InfoTip, {
     858                text: "Runs in the background on WordPress cron. Each run clears ALL post revisions, trashed posts, spam comments, trashed comments and expired transients, then optimizes the database tables. Off by default."
     859            })), h("div", {
     860                className: "rlcc-grid"
     861            }, h("div", {
     862                className: "rlcc-field"
     863            }, h("label", null, "Auto-optimize every"), h("select", {
     864                value: dbSchedule,
     865                onChange: handleDbScheduleChange,
     866                disabled: dbScheduleSaving,
     867                className: "rlcc-select"
     868            }, Object.keys(DB_SCHEDULE_LABELS).map(k => h("option", {
     869                key: k,
     870                value: k
     871            }, DB_SCHEDULE_LABELS[k])))), h("div", {
     872                className: "rlcc-field"
     873            }, h("label", null, "Status"), h("div", {
     874                className: "rlcc-db-status " + (scheduleOn ? "rlcc-db-status--on" : "rlcc-db-status--off")
     875            }, scheduleOn ? "Active — next run " + formatDateTime(dbNextRun) : "Off — nothing runs automatically")))), h("div", {
     876                className: "rlcc-card"
     877            }, h("h3", {
     878                className: "rlcc-card-title"
     879            }, h("span", {
     880                className: "rlcc-card-symbol",
     881                "aria-hidden": "true"
     882            }, "▤"), "Manual Optimization", h(InfoTip, {
     883                text: "Cleans up post revisions, trashed items, spam comments, and expired transients, and defragments your database tables. Nothing runs until you click the button below, and only for the items you've checked."
     884            })), h("div", {
     885                className: "rlcc-db-grid"
     886            }, [ [ "revisions", "Post Revisions" ], [ "trashed_posts", "Trashed Posts" ], [ "spam_comments", "Spam Comments" ], [ "trashed_comments", "Trashed Comments" ], [ "expired_transients", "Expired Transients" ] ].map(([key, label]) => h("label", {
     887                key: key,
     888                className: "rlcc-db-checkbox"
     889            }, h("input", {
     890                type: "checkbox",
     891                checked: !!dbOptions[key],
     892                onChange: () => toggleDbOption(key)
     893            }), h("span", null, label), h("span", {
     894                className: "rlcc-db-count"
     895            }, dbCounts[key] || 0))), h("label", {
     896                className: "rlcc-db-checkbox"
     897            }, h("input", {
     898                type: "checkbox",
     899                checked: !!dbOptions.optimize_tables,
     900                onChange: () => toggleDbOption("optimize_tables")
     901            }), h("span", null, "Optimize Database Tables"))), h("button", {
     902                className: "rlcc-clear-btn",
     903                onClick: handleDbOptimize,
     904                disabled: dbRunning,
     905                type: "button"
     906            }, dbRunning ? "Optimizing…" : "Optimize Database Now"), dbLastTimestamp > 0 && h("p", {
     907                className: "rlcc-hint"
     908            }, "Last run: ", timeAgo(dbLastTimestamp), " (" + (dbLastType === "auto" ? "automatic" : "manual") + ")", dbLastReport ? " — " + dbReportTotal(dbLastReport) + " item(s) cleaned" + (dbLastReport.optimize_tables ? ", " + dbLastReport.optimize_tables + " table(s) optimized" : "") : "")), h("div", {
     909            className: "rlcc-card rlcc-card--danger"
     910        }, h("h3", {
     911            className: "rlcc-card-title rlcc-card-title--danger"
     912        }, "Danger Zone"), h("div", {
     913            className: "rlcc-danger-row"
     914        }, h("div", {
     915            className: "rlcc-danger-text"
     916        }, h("label", null, "Delete all data on uninstall", h(InfoTip, {
     917            text: "Off by default. When ON, removing this plugin from Plugins → Installed Plugins deletes all of its settings and cached files, and runs one last full-site cache clear in the background."
     918        }))), h("button", {
     919            className: "rlcc-switch rlcc-switch--danger " + (deleteOnUninstall ? "rlcc-switch--on" : "rlcc-switch--off"),
     920            onClick: handleDeleteOnUninstallToggle,
     921            type: "button"
     922        }, h("span", {
     923            className: "rlcc-switch-knob"
     924        }), h("span", {
     925            className: "rlcc-switch-label"
     926        }, deleteOnUninstall ? "On" : "Off")))));
     927        }
    628928        if (loading) {
    629929            return h("div", {
    … …  
    641941        }, h("h2", {
    642942            className: "rlcc-hero-title"
    643         }, "CacheNova"), h("div", {
     943        }, h("span", {className:"rlcc-brand-mark", "aria-hidden":"true"}, "✦"), "CacheNova", h("span", {className:"rlcc-version"}, "v1.1.0")), h("div", {
    644944            className: "rlcc-status-pill"
    645945        }, h("span", {
    … …  
    659959        }, "Last cleared: ", h("strong", null, timeAgo(lastCleared))), diviActive ? h("div", {
    660960            className: "rlcc-hero-meta-item rlcc-hero-meta-item--divi"
    661         }, "🛡 Divi detected — et-cache folder purged continuously") : h("div", {
     961        }, "Divi detected — cache cleared on updates and scheduled runs") : h("div", {
    662962            className: "rlcc-hero-meta-item"
    663         }, "✓ Works with any theme and plugin"))), h("div", {
     963        }, "Works with WordPress themes — automatic cache clearing enabled"))), h("div", {
     964            className: "rlcc-tabs",
     965            role: "tablist"
     966        }, [ [ "cache", "Cache Settings" ], [ "database", "Database Optimization" ] ].map(([key, label]) => h("button", {
     967            key: key,
     968            type: "button",
     969            role: "tab",
     970            "aria-selected": activeTab === key,
     971            className: "rlcc-tab" + (activeTab === key ? " rlcc-tab--active" : ""),
     972            onClick: () => switchTab(key)
     973        }, label))), diviActive && activeTab === "cache" && h("div", {className:"rlcc-card rlcc-divi-compat"},
     974            h("h3", {className:"rlcc-card-title"}, "Divi Cache Compatibility"),
     975            h("p", null, "Delete the entire wp-content/et-cache folder immediately when enabled and after frontend requests. Divi may recreate it. Can significantly slow your site."),
     976            h("button", {type:"button", className:"rlcc-switch " + (diviPerRequest ? "rlcc-switch--on" : "rlcc-switch--off"), onClick:toggleDiviPerRequest, disabled:diviPurgeSaving, "aria-pressed":diviPerRequest},
     977                h("span", {className:"rlcc-switch-knob"}), h("span", {className:"rlcc-switch-label"}, diviPerRequest ? "On" : "Off"))
     978        ), activeTab === "database" ? renderDbTab() : h("div", {
    664979            className: "rlcc-card-grid"
    665980        }, h("div", {
    … …  
    667982        }, h("h3", {
    668983            className: "rlcc-card-title"
    669         }, "Schedule & Notifications", h(InfoTip, {
     984        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "◷"), "Schedule & Notifications", h(InfoTip, {
    670985            text: "Cache also clears instantly every time you save a page or post — Divi Builder, Gutenberg, or any other editor — no manual reload needed."
    671986        })), h("div", {
    … …  
    6951010        }, h("h3", {
    6961011            className: "rlcc-card-title"
    697         }, "Cache Types", h(InfoTip, {
     1012        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "▦"), "Cache Types", h(InfoTip, {
    6981013            text: "Whatever you switch ON here gets stored; the timer above is what clears it back out again."
    6991014        })), h("div", {
    … …  
    7401055            className: "rlcc-switch-label"
    7411056        }, cacheBrowser ? "On" : "Off"))))), h("div", {
    742             className: "rlcc-card rlcc-card--wide"
    743         }, h("h3", {
    744             className: "rlcc-card-title"
    745         }, "Last Clear Report"), h(ClearReportCard)), h("div", {
    7461057            className: "rlcc-card"
    7471058        }, h("h3", {
    7481059            className: "rlcc-card-title"
    749         }, "Persistent Object Cache", h(InfoTip, {
     1060        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "◇"), "Persistent Object Cache", h(InfoTip, {
    7501061            text: "A persistent object cache lets WordPress reuse database query results across requests. Off by default. Turning it on automatically detects and uses the fastest backend this server actually has working — Redis, then Memcached, then APCu, then a universal file-based fallback — with no manual picking or testing required."
    7511062        })), ocForeignDropin && h("p", {
    7521063            className: "rlcc-oc-unavailable"
    753         }, "A different object-cache.php already exists in wp-content/ — remove it first if you want this plugin to manage the persistent object cache."), h("div", {
     1064        }, "An existing object-cache.php is already installed (possibly by the host or an inactive plugin). CacheNova will not overwrite it. Identify its owner and back it up before switching cache providers."), h("div", {
    7541065            className: "rlcc-field"
    7551066        }, h("label", null, "Enable (" + (ocDropinActive ? "drop-in active" : "not active") + ")"), h("button", {
    … …  
    8231134        }, h("h3", {
    8241135            className: "rlcc-card-title"
    825         }, "Early-Bypass Page Cache", h(InfoTip, {
     1136        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "⚡"), "Early-Bypass Page Cache", h(InfoTip, {
    8261137            text: "Serves cached pages before WordPress even loads. Only anonymous, cookie-free visitors; everyone else gets Private Cache instead. This is what actually improves server response time (TTFB) on speed tests."
    8271138        })), ecForeignDropin && h("p", {
    8281139            className: "rlcc-oc-unavailable"
    829         }, "A different advanced-cache.php already exists in wp-content/ — remove it first if you want this plugin to manage early-bypass caching."), !cachePublic && !ecEnabled && h("p", {
     1140        }, "An existing advanced-cache.php is already installed (possibly by the host or an inactive plugin). CacheNova will not overwrite it. Identify its owner and back it up before switching cache providers."), !cachePublic && !ecEnabled && h("p", {
    8301141            className: "rlcc-oc-unavailable"
    8311142        }, "Turn on Public Cache first — early-bypass caching serves the same cached files, just before WordPress loads."), h("div", {
    … …  
    8481159        }, h("h3", {
    8491160            className: "rlcc-card-title"
    850         }, "Database Optimization", h(InfoTip, {
    851             text: "Cleans up post revisions, trashed items, spam comments, and expired transients, and defragments your database tables. Off by default — nothing runs until you click the button below, and only for the items you've checked."
    852         })), h("div", {
    853             className: "rlcc-db-grid"
    854         }, [ [ "revisions", "Post Revisions" ], [ "trashed_posts", "Trashed Posts" ], [ "spam_comments", "Spam Comments" ], [ "trashed_comments", "Trashed Comments" ], [ "expired_transients", "Expired Transients" ] ].map(([key, label]) => h("label", {
    855             key: key,
    856             className: "rlcc-db-checkbox"
    857         }, h("input", {
    858             type: "checkbox",
    859             checked: !!dbOptions[key],
    860             onChange: () => toggleDbOption(key)
    861         }), h("span", null, label), h("span", {
    862             className: "rlcc-db-count"
    863         }, dbCounts[key] || 0))), h("label", {
    864             className: "rlcc-db-checkbox"
    865         }, h("input", {
    866             type: "checkbox",
    867             checked: !!dbOptions.optimize_tables,
    868             onChange: () => toggleDbOption("optimize_tables")
    869         }), h("span", null, "Optimize Database Tables"))), h("button", {
    870             className: "rlcc-clear-btn",
    871             onClick: handleDbOptimize,
    872             disabled: dbRunning,
    873             type: "button"
    874         }, dbRunning ? "Optimizing…" : "Optimize Database Now"), dbLastTimestamp > 0 && h("p", {
    875             className: "rlcc-hint"
    876         }, "Last run: ", timeAgo(dbLastTimestamp), dbLastReport ? " — " + Object.values(dbLastReport).reduce((sum, n) => sum + (typeof n === "number" ? n : 0), 0) + " item(s) cleaned" : "")), h("div", {
    877             className: "rlcc-card rlcc-card--wide rlcc-card--danger"
    878         }, h("h3", {
    879             className: "rlcc-card-title rlcc-card-title--danger"
    880         }, "Danger Zone"), h("div", {
    881             className: "rlcc-danger-row"
    882         }, h("div", {
    883             className: "rlcc-danger-text"
    884         }, h("label", null, "Delete all data on uninstall", h(InfoTip, {
    885             text: "Off by default. When ON, removing this plugin from Plugins → Installed Plugins deletes all of its settings and cached files, and runs one last full-site cache clear in the background."
    886         }))), h("button", {
    887             className: "rlcc-switch rlcc-switch--danger " + (deleteOnUninstall ? "rlcc-switch--on" : "rlcc-switch--off"),
    888             onClick: handleDeleteOnUninstallToggle,
    889             type: "button"
    890         }, h("span", {
    891             className: "rlcc-switch-knob"
    892         }), h("span", {
    893             className: "rlcc-switch-label"
    894         }, deleteOnUninstall ? "On" : "Off"))))), h(ToastList, {
     1161        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "▤"), "Last Clear Report"), h(ClearReportCard))), h(ConfirmModal, {
     1162            modal: modal,
     1163            onCancel: closeModal,
     1164            onConfirm: acceptModal
     1165        }), h(ToastList, {
    8951166            toasts: toasts,
    8961167            remove: removeToast
  • cachenova/trunk/assets/js/cn-admin.min.js

    r3737033 r3777263  
    1 (function(){function onReady(fn){if("loading"===document.readyState){document.addEventListener("DOMContentLoaded",fn)}else{fn()}}function renderFatal(title,detail){const root=document.getElementById("rlcc-settings-root");if(!root)return;root.innerHTML="";const wrap=document.createElement("div");wrap.className="rlcc-fatal-error";wrap.style.cssText='max-width:820px;margin:20px 0;padding:16px 20px;border:1px solid #e4e6eb;border-left:4px solid #c0392b;border-radius:6px;background:#fdf3f2;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';const h4=document.createElement("p");h4.style.cssText="margin:0 0 6px;font-weight:600;color:#c0392b;";h4.textContent="CacheNova — "+title;const p=document.createElement("p");p.style.cssText="margin:0;color:#3c3c3c;white-space:pre-wrap;word-break:break-word;";p.textContent=detail;wrap.appendChild(h4);wrap.appendChild(p);root.appendChild(wrap)}if(typeof wp==="undefined"||!wp.element||!wp.apiFetch){onReady(function(){renderFatal("dashboard could not start","A required WordPress script (wp-element or wp-api-fetch) did not load on this page, most likely because another plugin or theme script on this screen errored first and stopped the rest of the queue. Open your browser console (F12) on this page for the first red error, or try disabling other plugins one at a time to find the conflict.")});return}const{createElement:h,useState:useState,useEffect:useEffect,useRef:useRef,Component:Component}=wp.element;const apiFetch=wp.apiFetch;apiFetch.use(apiFetch.createNonceMiddleware(RLCC_DATA.nonce));apiFetch.use(apiFetch.createRootURLMiddleware(RLCC_DATA.restUrl));const NS="/"+RLCC_DATA.namespace;const INTERVAL_LABELS={1:"1 minute",3:"3 minutes",5:"5 minutes",10:"10 minutes",20:"20 minutes",30:"30 minutes",60:"1 hour"};const OC_BACKEND_LABELS={redis:"Redis",memcached:"Memcache",apcu:"APCu",file:"File-based (universal fallback)"};function formatCountdown(seconds){if(seconds===null)return"--:--";if(seconds<=0)return"0:00";const m=Math.floor(seconds/60);const s=seconds%60;return m+":"+String(s).padStart(2,"0")}function timeAgo(timestamp){if(!timestamp)return"never";const seconds=Math.floor(Date.now()/1e3)-timestamp;if(seconds<60)return"just now";const mins=Math.round(seconds/60);if(mins<60)return mins+" min ago";const hrs=Math.round(mins/60);return hrs+" hr ago"}function InfoTip({text:text}){return h("span",{className:"rlcc-infotip",tabIndex:0},h("span",{className:"rlcc-infotip-icon","aria-hidden":"true"},"i"),h("span",{className:"rlcc-infotip-text",role:"tooltip"},text),h("span",{className:"screen-reader-text"},text))}class RLCCErrorBoundary extends Component{constructor(props){super(props);this.state={error:null}}static getDerivedStateFromError(error){return{error:error}}render(){if(this.state.error){return h("div",{className:"rlcc-fatal-error",style:{maxWidth:"820px",margin:"20px 0",padding:"16px 20px",border:"1px solid #e4e6eb",borderLeft:"4px solid #c0392b",borderRadius:"6px",background:"#fdf3f2"}},h("p",{style:{margin:"0 0 6px",fontWeight:600,color:"#c0392b"}},"CacheNova — the dashboard hit an error"),h("p",{style:{margin:0,color:"#3c3c3c",whiteSpace:"pre-wrap",wordBreak:"break-word"}},String(this.state.error&&this.state.error.message||this.state.error)))}return this.props.children}}function ToastList({toasts:toasts,remove:remove}){return h("div",{className:"rlcc-toast-container"},toasts.map(t=>h("div",{key:t.id,className:"rlcc-toast rlcc-toast--"+t.type,onClick:()=>remove(t.id)},h("span",{className:"rlcc-toast-icon"},t.type==="success"?"✓":t.type==="error"?"✕":"ℹ"),h("span",null,t.message))))}function App(){const[loading,setLoading]=useState(true);const[interval_,setInterval_]=useState(5);const[toastEnabled,setToastEnabled]=useState(true);const[cachePublic,setCachePublic]=useState(true);const[cachePrivate,setCachePrivate]=useState(true);const[cacheObject,setCacheObject]=useState(false);const[cacheBrowser,setCacheBrowser]=useState(false);const[deleteOnUninstall,setDeleteOnUninstall]=useState(false);const[dbCounts,setDbCounts]=useState({revisions:0,trashed_posts:0,spam_comments:0,trashed_comments:0,expired_transients:0});const[dbOptions,setDbOptions]=useState({revisions:true,trashed_posts:true,spam_comments:true,trashed_comments:true,expired_transients:true,optimize_tables:false});const[dbRunning,setDbRunning]=useState(false);const[dbLastReport,setDbLastReport]=useState(null);const[dbLastTimestamp,setDbLastTimestamp]=useState(0);const[ocEnabled,setOcEnabled]=useState(false);const[ocBackend,setOcBackend]=useState("apcu");const[ocRedisHost,setOcRedisHost]=useState("127.0.0.1");const[ocRedisPort,setOcRedisPort]=useState(6379);const[ocMemcachedHost,setOcMemcachedHost]=useState("127.0.0.1");const[ocMemcachedPort,setOcMemcachedPort]=useState(11211);const[ocAvailable,setOcAvailable]=useState({apcu:false,redis:false,memcached:false});const[ocDropinActive,setOcDropinActive]=useState(false);const[ocForeignDropin,setOcForeignDropin]=useState(false);const[ocTestStatus,setOcTestStatus]=useState("idle");const[ocTestMessage,setOcTestMessage]=useState("");const[ocSaving,setOcSaving]=useState(false);const[ecEnabled,setEcEnabled]=useState(false);const[ecDropinActive,setEcDropinActive]=useState(false);const[ecForeignDropin,setEcForeignDropin]=useState(false);const[ecWpCacheTrue,setEcWpCacheTrue]=useState(false);const[localSslFixActive,setLocalSslFixActive]=useState(false);const[diviActive,setDiviActive]=useState(false);const[ecWpConfigWritable,setEcWpConfigWritable]=useState(true);const[ecSaving,setEcSaving]=useState(false);const[ecResultStatus,setEcResultStatus]=useState("idle");const[ecResultMessage,setEcResultMessage]=useState("");const[nextRun,setNextRun]=useState(0);const[lastCleared,setLastCleared]=useState(0);const[lastClearReport,setLastClearReport]=useState(null);const[secondsLeft,setSecondsLeft]=useState(null);const[saving,setSaving]=useState(false);const[clearing,setClearing]=useState(false);const[toasts,setToasts]=useState([]);const toastIdRef=useRef(0);const toastEnabledRef=useRef(toastEnabled);toastEnabledRef.current=toastEnabled;const lastNotifiedClearRef=useRef(0);const reminderFiredForRef=useRef(null);const intervalRef=useRef(interval_);intervalRef.current=interval_;const cachePublicRef=useRef(cachePublic);cachePublicRef.current=cachePublic;const cachePrivateRef=useRef(cachePrivate);cachePrivateRef.current=cachePrivate;const cacheObjectRef=useRef(cacheObject);cacheObjectRef.current=cacheObject;const cacheBrowserRef=useRef(cacheBrowser);cacheBrowserRef.current=cacheBrowser;const deleteOnUninstallRef=useRef(deleteOnUninstall);deleteOnUninstallRef.current=deleteOnUninstall;const ocBackendRef=useRef(ocBackend);ocBackendRef.current=ocBackend;const ocRedisHostRef=useRef(ocRedisHost);ocRedisHostRef.current=ocRedisHost;const ocRedisPortRef=useRef(ocRedisPort);ocRedisPortRef.current=ocRedisPort;const ocMemcachedHostRef=useRef(ocMemcachedHost);ocMemcachedHostRef.current=ocMemcachedHost;const ocMemcachedPortRef=useRef(ocMemcachedPort);ocMemcachedPortRef.current=ocMemcachedPort;function pushToast(message,type){if(!toastEnabledRef.current)return;const id=++toastIdRef.current;setToasts(prev=>[...prev,{id:id,message:message,type:type}]);setTimeout(()=>{setToasts(prev=>prev.filter(t=>t.id!==id))},3500)}function removeToast(id){setToasts(prev=>prev.filter(t=>t.id!==id))}function fetchSettings(showErrorToast,isBackgroundPoll){return apiFetch({path:NS+"/settings",method:"GET"}).then(res=>{setInterval_(res.interval_minutes);setToastEnabled(!!res.toast_enabled);setCachePublic(!!res.cache_public);setCachePrivate(!!res.cache_private);setCacheObject(!!res.cache_object);setCacheBrowser(!!res.cache_browser);setDeleteOnUninstall(!!res.delete_data_on_uninstall);setOcEnabled(!!res.object_cache_enabled);setOcBackend(res.object_cache_backend||"apcu");setOcRedisHost(res.object_cache_redis_host||"127.0.0.1");setOcRedisPort(res.object_cache_redis_port||6379);setOcMemcachedHost(res.object_cache_memcached_host||"127.0.0.1");setOcMemcachedPort(res.object_cache_memcached_port||11211);setOcAvailable(res.object_cache_available_backends||{apcu:false,redis:false,memcached:false});setOcDropinActive(!!res.object_cache_dropin_active);setOcForeignDropin(!!res.object_cache_foreign_dropin);setEcEnabled(!!res.early_cache_enabled);setEcDropinActive(!!res.advanced_cache_dropin_active);setEcForeignDropin(!!res.advanced_cache_foreign_dropin);setEcWpCacheTrue(!!res.wp_cache_constant_true);setEcWpConfigWritable(res.wp_config_writable!==false);setLocalSslFixActive(!!res.local_ssl_fix_active);setDiviActive(!!res.divi_active);setDbCounts(res.db_counts||{revisions:0,trashed_posts:0,spam_comments:0,trashed_comments:0,expired_transients:0});if(res.last_db_optimize){setDbLastReport(res.last_db_optimize.report||null);setDbLastTimestamp(res.last_db_optimize.timestamp||0)}setNextRun(res.next_run_timestamp);if(isBackgroundPoll&&res.last_cleared_timestamp&&res.last_cleared_timestamp!==lastNotifiedClearRef.current){lastNotifiedClearRef.current=res.last_cleared_timestamp;pushToast("Cache auto-cleared in the background.","success")}else if(!isBackgroundPoll){lastNotifiedClearRef.current=res.last_cleared_timestamp}setLastCleared(res.last_cleared_timestamp);setLastClearReport(res.last_clear_report||null);setLoading(false)}).catch(err=>{setLoading(false);if(showErrorToast){const detail=err&&(err.message||err.code)||"unknown error";pushToast("Could not load settings: "+detail,"error")}})}useEffect(()=>{fetchSettings(true,false)},[]);useEffect(()=>{if(!nextRun){setSecondsLeft(null);return}if(reminderFiredForRef.current!==nextRun){reminderFiredForRef.current=null}function tick(){const remaining=nextRun-Math.floor(Date.now()/1e3);const clamped=Math.max(0,remaining);setSecondsLeft(clamped);const totalSeconds=intervalRef.current*60;const halfPoint=Math.floor(totalSeconds/2);if(clamped<=halfPoint&&clamped>0&&reminderFiredForRef.current!==nextRun&&totalSeconds>=120){reminderFiredForRef.current=nextRun;const mins=Math.round(clamped/60);const label=mins>=1?mins+" min":clamped+" sec";pushToast(label+" remaining until next cache clear.","info")}}tick();const id=window.setInterval(tick,1e3);return()=>window.clearInterval(id)},[nextRun]);useEffect(()=>{const id=window.setInterval(()=>fetchSettings(false,true),15e3);return()=>window.clearInterval(id)},[]);function saveSettings(overrides,successMessage){const newInterval="interval_minutes"in overrides?overrides.interval_minutes:intervalRef.current;setSaving(true);apiFetch({path:NS+"/settings",method:"POST",data:{interval_minutes:newInterval,toast_enabled:"toast_enabled"in overrides?overrides.toast_enabled:toastEnabledRef.current,cache_public:"cache_public"in overrides?overrides.cache_public:cachePublicRef.current,cache_private:"cache_private"in overrides?overrides.cache_private:cachePrivateRef.current,cache_object:"cache_object"in overrides?overrides.cache_object:cacheObjectRef.current,cache_browser:"cache_browser"in overrides?overrides.cache_browser:cacheBrowserRef.current,delete_data_on_uninstall:"delete_data_on_uninstall"in overrides?overrides.delete_data_on_uninstall:deleteOnUninstallRef.current}}).then(res=>{setNextRun(res.next_run_timestamp);setSaving(false);pushToast(successMessage||"Settings saved.","success")}).catch(err=>{setSaving(false);const detail=err&&(err.message||err.code)||"unknown error";pushToast("Failed to save settings: "+detail,"error");console.error("ACC save settings error:",err)})}function handleIntervalChange(e){const val=parseInt(e.target.value,10);setInterval_(val);saveSettings({interval_minutes:val},"Settings saved — auto-clear every "+(INTERVAL_LABELS[val]||val+" min")+".")}function handleToastToggle(){const newVal=!toastEnabled;setToastEnabled(newVal);toastEnabledRef.current=newVal;saveSettings({toast_enabled:newVal})}function handleCacheToggle(key){const setters={cache_public:[cachePublic,setCachePublic,cachePublicRef,"Public Cache"],cache_private:[cachePrivate,setCachePrivate,cachePrivateRef,"Private Cache"],cache_object:[cacheObject,setCacheObject,cacheObjectRef,"Object Cache"],cache_browser:[cacheBrowser,setCacheBrowser,cacheBrowserRef,"Browser Cache"]};const[current,setState,ref,label]=setters[key];const newVal=!current;setState(newVal);ref.current=newVal;saveSettings({[key]:newVal},label+" turned "+(newVal?"ON":"OFF")+".")}function handleDeleteOnUninstallToggle(){const newVal=!deleteOnUninstall;if(newVal){const confirmed=window.confirm("When this is ON, deleting CacheNova from Plugins → Installed Plugins will permanently erase all of this plugin's settings and cached files, and trigger one final full-site cache clear. This cannot be undone. Turn it on?");if(!confirmed){return}}setDeleteOnUninstall(newVal);deleteOnUninstallRef.current=newVal;saveSettings({delete_data_on_uninstall:newVal},"Delete all data on uninstall turned "+(newVal?"ON":"OFF")+".")}function saveObjectCacheSettings(overrides,onDone){setOcSaving(true);apiFetch({path:NS+"/settings",method:"POST",data:{interval_minutes:intervalRef.current,toast_enabled:toastEnabledRef.current,cache_public:cachePublicRef.current,cache_private:cachePrivateRef.current,cache_object:cacheObjectRef.current,cache_browser:cacheBrowserRef.current,delete_data_on_uninstall:deleteOnUninstallRef.current,object_cache_backend:ocBackendRef.current,object_cache_redis_host:ocRedisHostRef.current,object_cache_redis_port:ocRedisPortRef.current,object_cache_memcached_host:ocMemcachedHostRef.current,object_cache_memcached_port:ocMemcachedPortRef.current,...overrides}}).then(res=>{setOcSaving(false);setOcEnabled(!!res.object_cache_enabled);setOcBackend(res.object_cache_backend||ocBackendRef.current);setOcDropinActive(!!res.object_cache_dropin_active);const result=res.object_cache_result;if(result){if(result.success){setOcTestStatus("ok");setOcTestMessage(result.message);pushToast(result.message,"success")}else{setOcTestStatus("error");setOcTestMessage(result.message);pushToast(result.message,"error")}}else{pushToast("Object cache settings saved.","success")}if(onDone)onDone(result)}).catch(err=>{setOcSaving(false);const detail=err&&(err.message||err.code)||"unknown error";pushToast("Failed to save object cache settings: "+detail,"error")})}function handleOcToggle(){const newVal=!ocEnabled;if(newVal){const confirmed=window.confirm("This installs a wp-content/object-cache.php file that changes how WordPress caches data site-wide. This plugin automatically detects and uses the fastest backend this server actually has working — no manual setup needed. Continue?");if(!confirmed){return}setOcTestStatus("testing");saveObjectCacheSettings({object_cache_enabled:newVal,object_cache_backend:"auto"});return}saveObjectCacheSettings({object_cache_enabled:newVal})}function handleOcRedetect(){setOcTestStatus("testing");saveObjectCacheSettings({object_cache_enabled:true,object_cache_backend:"auto"})}function handleOcFieldCommit(){setOcTestStatus("idle");if(ocEnabled){saveObjectCacheSettings({})}}function handleEarlyCacheToggle(){const newVal=!ecEnabled;if(newVal){if(!cachePublic){pushToast("Turn on Public Cache first — early-bypass caching serves the same cached files, just before WordPress loads.","error");return}const confirmed=window.confirm("This installs a wp-content/advanced-cache.php file AND adds define('WP_CACHE', true) to wp-config.php, so cached pages can be served before WordPress even loads. Continue?");if(!confirmed){return}}setEcSaving(true);apiFetch({path:NS+"/settings",method:"POST",data:{interval_minutes:intervalRef.current,toast_enabled:toastEnabledRef.current,cache_public:cachePublicRef.current,cache_private:cachePrivateRef.current,cache_object:cacheObjectRef.current,cache_browser:cacheBrowserRef.current,delete_data_on_uninstall:deleteOnUninstallRef.current,early_cache_enabled:newVal}}).then(res=>{setEcSaving(false);setEcEnabled(!!res.early_cache_enabled);setEcDropinActive(!!res.advanced_cache_dropin_active);setEcWpCacheTrue(!!res.wp_cache_constant_true);setLocalSslFixActive(!!res.local_ssl_fix_active);setDiviActive(!!res.divi_active);const result=res.early_cache_result;if(result){setEcResultStatus(result.success?"ok":"error");setEcResultMessage(result.message);pushToast(result.message,result.success?"success":"error")}else{pushToast("Early-bypass cache settings saved.","success")}}).catch(err=>{setEcSaving(false);const detail=err&&(err.message||err.code)||"unknown error";pushToast("Failed to save early-bypass cache settings: "+detail,"error")})}function toggleDbOption(key){setDbOptions(prev=>({...prev,[key]:!prev[key]}))}function handleDbOptimize(){const selected=Object.keys(dbOptions).filter(k=>dbOptions[k]);if(!selected.length){pushToast("Select at least one item to optimize.","error");return}const confirmed=window.confirm("This permanently deletes the selected items (revisions, trash, spam are not recoverable afterward). Continue?");if(!confirmed){return}setDbRunning(true);apiFetch({path:NS+"/database/optimize",method:"POST",data:dbOptions}).then(res=>{setDbRunning(false);setDbLastReport(res.report);setDbLastTimestamp(res.timestamp);const total=Object.values(res.report).reduce((sum,n)=>sum+(typeof n==="number"?n:0),0);pushToast("Database optimized — "+total+" item(s) cleaned.","success");fetchSettings(false,false)}).catch(err=>{setDbRunning(false);const detail=err&&(err.message||err.code)||"unknown error";pushToast("Database optimization failed: "+detail,"error");console.error("ACC db optimize error:",err)})}function handleClearNow(){setClearing(true);apiFetch({path:NS+"/clear",method:"POST"}).then(res=>{setLastCleared(res.last_cleared_timestamp);lastNotifiedClearRef.current=res.last_cleared_timestamp;setNextRun(res.next_run_timestamp);reminderFiredForRef.current=null;setClearing(false);pushToast("Cache cleared successfully. Timer restarted.","success");if(res.last_clear_report){setLastClearReport(res.last_clear_report)}else{setTimeout(()=>fetchSettings(false,true),1500)}}).catch(err=>{setClearing(false);const detail=err&&(err.message||err.code)||"unknown error";pushToast("Failed to clear cache: "+detail,"error");console.error("ACC clear cache error:",err)})}const REPORT_STATUS_META={ok:{icon:"✓",text:"Verified",className:"rlcc-report-ok"},triggered:{icon:"↻",text:"Triggered",className:"rlcc-report-triggered"},failed:{icon:"✕",text:"Failed",className:"rlcc-report-failed"}};function ClearReportCard(){if(!lastClearReport||!lastClearReport.items||!Object.keys(lastClearReport.items).length){return h("p",{className:"rlcc-hint"},'No cache clear has run yet since this page loaded — trigger one with "Clear Cache Now" or wait for the scheduled timer.')}const items=lastClearReport.items;const failedCount=Object.values(items).filter(i=>i.status==="failed").length;return h("div",null,h("p",{className:"rlcc-hint"},("full"===lastClearReport.type?"Full clear":"Scheduled (light) clear")+" — "+(failedCount?failedCount+" layer(s) failed to verify.":"every checked layer verified successfully.")),h("ul",{className:"rlcc-report-list"},Object.keys(items).map(key=>{const item=items[key];const meta=REPORT_STATUS_META[item.status]||REPORT_STATUS_META.triggered;return h("li",{key:key,className:"rlcc-report-row "+meta.className},h("span",{className:"rlcc-report-icon"},meta.icon),h("span",{className:"rlcc-report-label"},item.label),h("span",{className:"rlcc-report-badge"},meta.text))})))}if(loading){return h("div",{className:"rlcc-dashboard-loading"},"Loading settings…")}return h("div",{className:"rlcc-dashboard"},h("div",{className:"rlcc-hero"},h("div",{className:"rlcc-hero-top"},h("div",{className:"rlcc-hero-title-block"},h("h2",{className:"rlcc-hero-title"},"CacheNova"),h("div",{className:"rlcc-status-pill"},h("span",{className:"rlcc-status-dot"}),h("span",null,"Running in background"))),h("button",{className:"rlcc-clear-btn",onClick:handleClearNow,disabled:clearing},clearing?"Clearing…":"Clear Cache Now")),h("div",{className:"rlcc-hero-meta"},h("div",{className:"rlcc-countdown-row"},h("span",null,"Next automatic clear in"),h("span",{className:"rlcc-countdown"},formatCountdown(secondsLeft))),h("div",{className:"rlcc-hero-meta-item"},"Last cleared: ",h("strong",null,timeAgo(lastCleared))),diviActive?h("div",{className:"rlcc-hero-meta-item rlcc-hero-meta-item--divi"},"🛡 Divi detected — et-cache folder purged continuously"):h("div",{className:"rlcc-hero-meta-item"},"✓ Works with any theme and plugin"))),h("div",{className:"rlcc-card-grid"},h("div",{className:"rlcc-card"},h("h3",{className:"rlcc-card-title"},"Schedule & Notifications",h(InfoTip,{text:"Cache also clears instantly every time you save a page or post — Divi Builder, Gutenberg, or any other editor — no manual reload needed."})),h("div",{className:"rlcc-grid"},h("div",{className:"rlcc-field"},h("label",null,"Auto-clear every"),h("select",{value:interval_,onChange:handleIntervalChange,disabled:saving,className:"rlcc-select"},RLCC_DATA.allowedIntervals.map(m=>h("option",{key:m,value:m},INTERVAL_LABELS[m]||m+" min")))),h("div",{className:"rlcc-field"},h("label",null,"Toast notifications"),h("button",{className:"rlcc-switch "+(toastEnabled?"rlcc-switch--on":"rlcc-switch--off"),onClick:handleToastToggle,type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},toastEnabled?"On":"Off"))))),h("div",{className:"rlcc-card"},h("h3",{className:"rlcc-card-title"},"Cache Types",h(InfoTip,{text:"Whatever you switch ON here gets stored; the timer above is what clears it back out again."})),h("div",{className:"rlcc-grid rlcc-grid--4"},h("div",{className:"rlcc-field"},h("label",null,"Public Cache"),h("button",{className:"rlcc-switch "+(cachePublic?"rlcc-switch--on":"rlcc-switch--off"),onClick:()=>handleCacheToggle("cache_public"),type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},cachePublic?"On":"Off"))),h("div",{className:"rlcc-field"},h("label",null,"Private Cache"),h("button",{className:"rlcc-switch "+(cachePrivate?"rlcc-switch--on":"rlcc-switch--off"),onClick:()=>handleCacheToggle("cache_private"),type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},cachePrivate?"On":"Off"))),h("div",{className:"rlcc-field"},h("label",null,"Object Cache"),h("button",{className:"rlcc-switch "+(cacheObject?"rlcc-switch--on":"rlcc-switch--off"),onClick:()=>handleCacheToggle("cache_object"),type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},cacheObject?"On":"Off"))),h("div",{className:"rlcc-field"},h("label",null,"Browser Cache"),h("button",{className:"rlcc-switch "+(cacheBrowser?"rlcc-switch--on":"rlcc-switch--off"),onClick:()=>handleCacheToggle("cache_browser"),type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},cacheBrowser?"On":"Off"))))),h("div",{className:"rlcc-card rlcc-card--wide"},h("h3",{className:"rlcc-card-title"},"Last Clear Report"),h(ClearReportCard)),h("div",{className:"rlcc-card"},h("h3",{className:"rlcc-card-title"},"Persistent Object Cache",h(InfoTip,{text:"A persistent object cache lets WordPress reuse database query results across requests. Off by default. Turning it on automatically detects and uses the fastest backend this server actually has working — Redis, then Memcached, then APCu, then a universal file-based fallback — with no manual picking or testing required."})),ocForeignDropin&&h("p",{className:"rlcc-oc-unavailable"},"A different object-cache.php already exists in wp-content/ — remove it first if you want this plugin to manage the persistent object cache."),h("div",{className:"rlcc-field"},h("label",null,"Enable ("+(ocDropinActive?"drop-in active":"not active")+")"),h("button",{className:"rlcc-switch "+(ocEnabled?"rlcc-switch--on":"rlcc-switch--off"),onClick:handleOcToggle,type:"button",disabled:ocSaving||ocForeignDropin},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},ocEnabled?"On":"Off"))),ocEnabled&&h("div",{className:"rlcc-oc-auto-status"},h("span",null,"Backend in use: ",h("strong",null,OC_BACKEND_LABELS[ocBackend]||ocBackend)),h("button",{className:"rlcc-oc-redetect-btn",onClick:handleOcRedetect,type:"button",disabled:ocSaving},ocSaving?"Checking…":"Re-detect best backend")),ocEnabled&&"redis"===ocBackend&&h("div",{className:"rlcc-oc-backend-row"},h("div",{className:"rlcc-field"},h("label",null,"Redis Host"),h("input",{type:"text",className:"rlcc-input",value:ocRedisHost,onChange:e=>{setOcRedisHost(e.target.value);ocRedisHostRef.current=e.target.value},onBlur:handleOcFieldCommit})),h("div",{className:"rlcc-field"},h("label",null,"Redis Port"),h("input",{type:"number",className:"rlcc-input",value:ocRedisPort,onChange:e=>{setOcRedisPort(e.target.value);ocRedisPortRef.current=e.target.value},onBlur:handleOcFieldCommit}))),ocEnabled&&"memcached"===ocBackend&&h("div",{className:"rlcc-oc-backend-row"},h("div",{className:"rlcc-field"},h("label",null,"Memcache Host"),h("input",{type:"text",className:"rlcc-input",value:ocMemcachedHost,onChange:e=>{setOcMemcachedHost(e.target.value);ocMemcachedHostRef.current=e.target.value},onBlur:handleOcFieldCommit})),h("div",{className:"rlcc-field"},h("label",null,"Memcache Port"),h("input",{type:"number",className:"rlcc-input",value:ocMemcachedPort,onChange:e=>{setOcMemcachedPort(e.target.value);ocMemcachedPortRef.current=e.target.value},onBlur:handleOcFieldCommit}))),"idle"!==ocTestStatus&&h("div",{className:"rlcc-oc-status rlcc-oc-status--"+("testing"===ocTestStatus?"idle":ocTestStatus)},"testing"===ocTestStatus?"Checking for the fastest available backend…":ocTestMessage)),h("div",{className:"rlcc-card"},h("h3",{className:"rlcc-card-title"},"Early-Bypass Page Cache",h(InfoTip,{text:"Serves cached pages before WordPress even loads. Only anonymous, cookie-free visitors; everyone else gets Private Cache instead. This is what actually improves server response time (TTFB) on speed tests."})),ecForeignDropin&&h("p",{className:"rlcc-oc-unavailable"},"A different advanced-cache.php already exists in wp-content/ — remove it first if you want this plugin to manage early-bypass caching."),!cachePublic&&!ecEnabled&&h("p",{className:"rlcc-oc-unavailable"},"Turn on Public Cache first — early-bypass caching serves the same cached files, just before WordPress loads."),h("div",{className:"rlcc-field"},h("label",null,"Enable ("+(ecDropinActive?"drop-in active":"not active")+(ecEnabled?", WP_CACHE "+(ecWpCacheTrue?"on":"NOT set"):"")+")"),h("button",{className:"rlcc-switch "+(ecEnabled?"rlcc-switch--on":"rlcc-switch--off"),onClick:handleEarlyCacheToggle,type:"button",disabled:ecSaving||ecForeignDropin||!cachePublic&&!ecEnabled},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},ecEnabled?"On":"Off"))),"idle"!==ecResultStatus&&h("div",{className:"rlcc-oc-status rlcc-oc-status--"+ecResultStatus},ecResultMessage),!ecWpConfigWritable&&h("p",{className:"rlcc-oc-unavailable"},"wp-config.php doesn't look writable — if enabling doesn't take effect, add define('WP_CACHE', true); yourself near the top, right after the opening <?php tag.")),h("div",{className:"rlcc-card rlcc-card--wide"},h("h3",{className:"rlcc-card-title"},"Database Optimization",h(InfoTip,{text:"Cleans up post revisions, trashed items, spam comments, and expired transients, and defragments your database tables. Off by default — nothing runs until you click the button below, and only for the items you've checked."})),h("div",{className:"rlcc-db-grid"},[["revisions","Post Revisions"],["trashed_posts","Trashed Posts"],["spam_comments","Spam Comments"],["trashed_comments","Trashed Comments"],["expired_transients","Expired Transients"]].map(([key,label])=>h("label",{key:key,className:"rlcc-db-checkbox"},h("input",{type:"checkbox",checked:!!dbOptions[key],onChange:()=>toggleDbOption(key)}),h("span",null,label),h("span",{className:"rlcc-db-count"},dbCounts[key]||0))),h("label",{className:"rlcc-db-checkbox"},h("input",{type:"checkbox",checked:!!dbOptions.optimize_tables,onChange:()=>toggleDbOption("optimize_tables")}),h("span",null,"Optimize Database Tables"))),h("button",{className:"rlcc-clear-btn",onClick:handleDbOptimize,disabled:dbRunning,type:"button"},dbRunning?"Optimizing…":"Optimize Database Now"),dbLastTimestamp>0&&h("p",{className:"rlcc-hint"},"Last run: ",timeAgo(dbLastTimestamp),dbLastReport?" — "+Object.values(dbLastReport).reduce((sum,n)=>sum+(typeof n==="number"?n:0),0)+" item(s) cleaned":"")),h("div",{className:"rlcc-card rlcc-card--wide rlcc-card--danger"},h("h3",{className:"rlcc-card-title rlcc-card-title--danger"},"Danger Zone"),h("div",{className:"rlcc-danger-row"},h("div",{className:"rlcc-danger-text"},h("label",null,"Delete all data on uninstall",h(InfoTip,{text:"Off by default. When ON, removing this plugin from Plugins → Installed Plugins deletes all of its settings and cached files, and runs one last full-site cache clear in the background."}))),h("button",{className:"rlcc-switch rlcc-switch--danger "+(deleteOnUninstall?"rlcc-switch--on":"rlcc-switch--off"),onClick:handleDeleteOnUninstallToggle,type:"button"},h("span",{className:"rlcc-switch-knob"}),h("span",{className:"rlcc-switch-label"},deleteOnUninstall?"On":"Off"))))),h(ToastList,{toasts:toasts,remove:removeToast}))}onReady(function(){const root=document.getElementById("rlcc-settings-root");if(!root)return;try{const tree=h(RLCCErrorBoundary,null,h(App));if(wp.element.createRoot){wp.element.createRoot(root).render(tree)}else{wp.element.render(tree,root)}}catch(err){renderFatal("dashboard failed to load",err&&(err.message||String(err))||"Unknown error.");console.error("CacheNova dashboard mount error:",err)}})})();
     1(function() {
     2    function onReady(fn) {
     3        if ("loading" === document.readyState) {
     4            document.addEventListener("DOMContentLoaded", fn);
     5        } else {
     6            fn();
     7        }
     8    }
     9    function renderFatal(title, detail) {
     10        const root = document.getElementById("rlcc-settings-root");
     11        if (!root) return;
     12        root.innerHTML = "";
     13        const wrap = document.createElement("div");
     14        wrap.className = "rlcc-fatal-error";
     15        wrap.style.cssText = 'max-width:820px;margin:20px 0;padding:16px 20px;border:1px solid #e4e6eb;border-left:4px solid #c0392b;border-radius:6px;background:#fdf3f2;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
     16        const h4 = document.createElement("p");
     17        h4.style.cssText = "margin:0 0 6px;font-weight:600;color:#c0392b;";
     18        h4.textContent = "CacheNova — " + title;
     19        const p = document.createElement("p");
     20        p.style.cssText = "margin:0;color:#3c3c3c;white-space:pre-wrap;word-break:break-word;";
     21        p.textContent = detail;
     22        wrap.appendChild(h4);
     23        wrap.appendChild(p);
     24        root.appendChild(wrap);
     25    }
     26    if (typeof wp === "undefined" || !wp.element || !wp.apiFetch) {
     27        onReady(function() {
     28            renderFatal("dashboard could not start", "A required WordPress script (wp-element or wp-api-fetch) did not load on this page, most likely because another plugin or theme script on this screen errored first and stopped the rest of the queue. Open your browser console (F12) on this page for the first red error, or try disabling other plugins one at a time to find the conflict.");
     29        });
     30        return;
     31    }
     32    const {createElement: h, useState: useState, useEffect: useEffect, useRef: useRef, Component: Component} = wp.element;
     33    const apiFetch = wp.apiFetch;
     34    apiFetch.use(apiFetch.createNonceMiddleware(RLCC_DATA.nonce));
     35    apiFetch.use(apiFetch.createRootURLMiddleware(RLCC_DATA.restUrl));
     36    const NS = "/" + RLCC_DATA.namespace;
     37    const INTERVAL_LABELS = {
     38        10: "10 minutes",
     39        20: "20 minutes",
     40        30: "30 minutes",
     41        60: "1 hour",
     42        1440: "1 day",
     43        10080: "7 days",
     44        43200: "30 days (monthly)"
     45    };
     46    const DB_SCHEDULE_LABELS = {
     47        off: "Off (manual only)",
     48        daily: "Every 1 day",
     49        weekly: "Every 7 days",
     50        monthly: "Every month"
     51    };
     52    const OC_BACKEND_LABELS = {
     53        redis: "Redis",
     54        memcached: "Memcache",
     55        apcu: "APCu",
     56        file: "File-based (universal fallback)"
     57    };
     58    function formatCountdown(seconds) {
     59        if (seconds === null) return "--:--";
     60        if (seconds <= 0) return "0:00";
     61        const m = Math.floor(seconds / 60);
     62        const s = seconds % 60;
     63        return m + ":" + String(s).padStart(2, "0");
     64    }
     65    function timeAgo(timestamp) {
     66        if (!timestamp) return "never";
     67        const seconds = Math.floor(Date.now() / 1e3) - timestamp;
     68        if (seconds < 60) return "just now";
     69        const mins = Math.round(seconds / 60);
     70        if (mins < 60) return mins + " min ago";
     71        const hrs = Math.round(mins / 60);
     72        if (hrs < 24) return hrs + " hr ago";
     73        const days = Math.round(hrs / 24);
     74        return days + (days === 1 ? " day ago" : " days ago");
     75    }
     76    function InfoTip({text: text}) {
     77        return h("span", {
     78            className: "rlcc-infotip",
     79            tabIndex: 0
     80        }, h("span", {
     81            className: "rlcc-infotip-icon",
     82            "aria-hidden": "true"
     83        }, "i"), h("span", {
     84            className: "rlcc-infotip-text",
     85            role: "tooltip"
     86        }, text), h("span", {
     87            className: "screen-reader-text"
     88        }, text));
     89    }
     90    function formatDateTime(timestamp) {
     91        if (!timestamp) return "—";
     92        try {
     93            return new Date(timestamp * 1e3).toLocaleString(undefined, {
     94                dateStyle: "medium",
     95                timeStyle: "short"
     96            });
     97        } catch (e) {
     98            return new Date(timestamp * 1e3).toString();
     99        }
     100    }
     101    const MODAL_ICONS = {
     102        danger: "!",
     103        warning: "!",
     104        info: "i"
     105    };
     106    function ConfirmModal({modal: modal, onCancel: onCancel, onConfirm: onConfirm}) {
     107        const confirmRef = useRef(null);
     108        const cancelRef = useRef(null);
     109        useEffect(() => {
     110            if (!modal) return;
     111            const previous = document.activeElement;
     112            const target = "danger" === modal.tone ? cancelRef.current : confirmRef.current;
     113            if (target) target.focus();
     114            function onKey(e) {
     115                if (e.key === "Escape") {
     116                    e.preventDefault();
     117                    onCancel();
     118                } else if (e.key === "Tab") {
     119                    const nodes = [ cancelRef.current, confirmRef.current ].filter(Boolean);
     120                    if (!nodes.length) return;
     121                    const first = nodes[0], last = nodes[nodes.length - 1];
     122                    if (e.shiftKey && document.activeElement === first) {
     123                        e.preventDefault();
     124                        last.focus();
     125                    } else if (!e.shiftKey && document.activeElement === last) {
     126                        e.preventDefault();
     127                        first.focus();
     128                    }
     129                }
     130            }
     131            document.addEventListener("keydown", onKey);
     132            return () => {
     133                document.removeEventListener("keydown", onKey);
     134                if (previous && previous.focus) previous.focus();
     135            };
     136        }, [ modal ]);
     137        if (!modal) return null;
     138        const tone = modal.tone || "info";
     139        return h("div", {
     140            className: "rlcc-modal-overlay",
     141            onMouseDown: e => {
     142                if (e.target === e.currentTarget) onCancel();
     143            }
     144        }, h("div", {
     145            className: "rlcc-modal rlcc-modal--" + tone,
     146            role: "alertdialog",
     147            "aria-modal": "true",
     148            "aria-labelledby": "rlcc-modal-title",
     149            "aria-describedby": "rlcc-modal-desc"
     150        }, h("div", {
     151            className: "rlcc-modal-icon",
     152            "aria-hidden": "true"
     153        }, MODAL_ICONS[tone] || "i"), h("h3", {
     154            className: "rlcc-modal-title",
     155            id: "rlcc-modal-title"
     156        }, modal.title), h("p", {
     157            className: "rlcc-modal-text",
     158            id: "rlcc-modal-desc"
     159        }, modal.message), modal.points && modal.points.length ? h("ul", {
     160            className: "rlcc-modal-points"
     161        }, modal.points.map((pt, i) => h("li", {
     162            key: i
     163        }, pt))) : null, h("div", {
     164            className: "rlcc-modal-actions"
     165        }, h("button", {
     166            type: "button",
     167            ref: cancelRef,
     168            className: "rlcc-modal-btn rlcc-modal-btn--ghost",
     169            onClick: onCancel
     170        }, modal.cancelLabel || "Cancel"), h("button", {
     171            type: "button",
     172            ref: confirmRef,
     173            className: "rlcc-modal-btn rlcc-modal-btn--" + tone,
     174            onClick: onConfirm
     175        }, modal.confirmLabel || "Continue"))));
     176    }
     177    class RLCCErrorBoundary extends Component {
     178        constructor(props) {
     179            super(props);
     180            this.state = {
     181                error: null
     182            };
     183        }
     184        static getDerivedStateFromError(error) {
     185            return {
     186                error: error
     187            };
     188        }
     189        render() {
     190            if (this.state.error) {
     191                return h("div", {
     192                    className: "rlcc-fatal-error",
     193                    style: {
     194                        maxWidth: "820px",
     195                        margin: "20px 0",
     196                        padding: "16px 20px",
     197                        border: "1px solid #e4e6eb",
     198                        borderLeft: "4px solid #c0392b",
     199                        borderRadius: "6px",
     200                        background: "#fdf3f2"
     201                    }
     202                }, h("p", {
     203                    style: {
     204                        margin: "0 0 6px",
     205                        fontWeight: 600,
     206                        color: "#c0392b"
     207                    }
     208                }, "CacheNova — the dashboard hit an error"), h("p", {
     209                    style: {
     210                        margin: 0,
     211                        color: "#3c3c3c",
     212                        whiteSpace: "pre-wrap",
     213                        wordBreak: "break-word"
     214                    }
     215                }, String(this.state.error && this.state.error.message || this.state.error)));
     216            }
     217            return this.props.children;
     218        }
     219    }
     220    function ToastList({toasts: toasts, remove: remove}) {
     221        return h("div", {
     222            className: "rlcc-toast-container"
     223        }, toasts.map(t => h("div", {
     224            key: t.id,
     225            className: "rlcc-toast rlcc-toast--" + t.type,
     226            onClick: () => remove(t.id)
     227        }, h("span", {
     228            className: "rlcc-toast-icon"
     229        }, t.type === "success" ? "✓" : t.type === "error" ? "✕" : "ℹ"), h("span", null, t.message))));
     230    }
     231    function App() {
     232        const [loading, setLoading] = useState(true);
     233        const [interval_, setInterval_] = useState(5);
     234        const [toastEnabled, setToastEnabled] = useState(true);
     235        const [cachePublic, setCachePublic] = useState(true);
     236        const [cachePrivate, setCachePrivate] = useState(true);
     237        const [cacheObject, setCacheObject] = useState(false);
     238        const [cacheBrowser, setCacheBrowser] = useState(false);
     239        const [deleteOnUninstall, setDeleteOnUninstall] = useState(false);
     240        const [dbCounts, setDbCounts] = useState({
     241            revisions: 0,
     242            trashed_posts: 0,
     243            spam_comments: 0,
     244            trashed_comments: 0,
     245            expired_transients: 0
     246        });
     247        const [dbOptions, setDbOptions] = useState({
     248            revisions: true,
     249            trashed_posts: true,
     250            spam_comments: true,
     251            trashed_comments: true,
     252            expired_transients: true,
     253            optimize_tables: false
     254        });
     255        const [modal, setModal] = useState(null);
     256        function askConfirm(opts) {
     257            setModal(opts);
     258        }
     259        function closeModal() {
     260            setModal(null);
     261        }
     262        function acceptModal() {
     263            const fn = modal && modal.onConfirm;
     264            setModal(null);
     265            if (fn) fn();
     266        }
     267        const [activeTab, setActiveTab] = useState(window.location.hash === "#database" ? "database" : "cache");
     268        const [dbSchedule, setDbSchedule] = useState("off");
     269        const [dbNextRun, setDbNextRun] = useState(0);
     270        const [dbLastType, setDbLastType] = useState("manual");
     271        const [dbScheduleSaving, setDbScheduleSaving] = useState(false);
     272        const [dbRunning, setDbRunning] = useState(false);
     273        const [dbLastReport, setDbLastReport] = useState(null);
     274        const [dbLastTimestamp, setDbLastTimestamp] = useState(0);
     275        const [ocEnabled, setOcEnabled] = useState(false);
     276        const [ocBackend, setOcBackend] = useState("apcu");
     277        const [ocRedisHost, setOcRedisHost] = useState("127.0.0.1");
     278        const [ocRedisPort, setOcRedisPort] = useState(6379);
     279        const [ocMemcachedHost, setOcMemcachedHost] = useState("127.0.0.1");
     280        const [ocMemcachedPort, setOcMemcachedPort] = useState(11211);
     281        const [ocAvailable, setOcAvailable] = useState({
     282            apcu: false,
     283            redis: false,
     284            memcached: false
     285        });
     286        const [ocDropinActive, setOcDropinActive] = useState(false);
     287        const [ocForeignDropin, setOcForeignDropin] = useState(false);
     288        const [ocTestStatus, setOcTestStatus] = useState("idle");
     289        const [ocTestMessage, setOcTestMessage] = useState("");
     290        const [ocSaving, setOcSaving] = useState(false);
     291        const [ecEnabled, setEcEnabled] = useState(false);
     292        const [ecDropinActive, setEcDropinActive] = useState(false);
     293        const [ecForeignDropin, setEcForeignDropin] = useState(false);
     294        const [ecWpCacheTrue, setEcWpCacheTrue] = useState(false);
     295        const [localSslFixActive, setLocalSslFixActive] = useState(false);
     296        const [diviActive, setDiviActive] = useState(false);
     297        const [diviPerRequest, setDiviPerRequest] = useState(false);
     298        const [diviPurgeSaving, setDiviPurgeSaving] = useState(false);
     299        const [ecWpConfigWritable, setEcWpConfigWritable] = useState(true);
     300        const [ecSaving, setEcSaving] = useState(false);
     301        const [ecResultStatus, setEcResultStatus] = useState("idle");
     302        const [ecResultMessage, setEcResultMessage] = useState("");
     303        const [nextRun, setNextRun] = useState(0);
     304        const [lastCleared, setLastCleared] = useState(0);
     305        const [lastClearReport, setLastClearReport] = useState(null);
     306        const [secondsLeft, setSecondsLeft] = useState(null);
     307        const [saving, setSaving] = useState(false);
     308        const [clearing, setClearing] = useState(false);
     309        const [toasts, setToasts] = useState([]);
     310        const toastIdRef = useRef(0);
     311        const toastEnabledRef = useRef(toastEnabled);
     312        toastEnabledRef.current = toastEnabled;
     313        const lastNotifiedClearRef = useRef(0);
     314        const reminderFiredForRef = useRef(null);
     315        const intervalRef = useRef(interval_);
     316        intervalRef.current = interval_;
     317        const cachePublicRef = useRef(cachePublic);
     318        cachePublicRef.current = cachePublic;
     319        const cachePrivateRef = useRef(cachePrivate);
     320        cachePrivateRef.current = cachePrivate;
     321        const cacheObjectRef = useRef(cacheObject);
     322        cacheObjectRef.current = cacheObject;
     323        const cacheBrowserRef = useRef(cacheBrowser);
     324        cacheBrowserRef.current = cacheBrowser;
     325        const deleteOnUninstallRef = useRef(deleteOnUninstall);
     326        deleteOnUninstallRef.current = deleteOnUninstall;
     327        const ocBackendRef = useRef(ocBackend);
     328        ocBackendRef.current = ocBackend;
     329        const ocRedisHostRef = useRef(ocRedisHost);
     330        ocRedisHostRef.current = ocRedisHost;
     331        const ocRedisPortRef = useRef(ocRedisPort);
     332        ocRedisPortRef.current = ocRedisPort;
     333        const ocMemcachedHostRef = useRef(ocMemcachedHost);
     334        ocMemcachedHostRef.current = ocMemcachedHost;
     335        const ocMemcachedPortRef = useRef(ocMemcachedPort);
     336        ocMemcachedPortRef.current = ocMemcachedPort;
     337        function pushToast(message, type) {
     338            if (!toastEnabledRef.current) return;
     339            const id = ++toastIdRef.current;
     340            setToasts([{
     341                id: id,
     342                message: message,
     343                type: type
     344            } ]);
     345            setTimeout(() => {
     346                setToasts(prev => prev.filter(t => t.id !== id));
     347            }, 3500);
     348        }
     349        function removeToast(id) {
     350            setToasts(prev => prev.filter(t => t.id !== id));
     351        }
     352        function fetchSettings(showErrorToast, isBackgroundPoll) {
     353            return apiFetch({
     354                path: NS + "/settings",
     355                method: "GET"
     356            }).then(res => {
     357                setInterval_(res.interval_minutes);
     358                setToastEnabled(!!res.toast_enabled);
     359                setCachePublic(!!res.cache_public);
     360                setCachePrivate(!!res.cache_private);
     361                setCacheObject(!!res.cache_object);
     362                setCacheBrowser(!!res.cache_browser);
     363                setDeleteOnUninstall(!!res.delete_data_on_uninstall);
     364                setOcEnabled(!!res.object_cache_enabled);
     365                setOcBackend(res.object_cache_backend || "apcu");
     366                setOcRedisHost(res.object_cache_redis_host || "127.0.0.1");
     367                setOcRedisPort(res.object_cache_redis_port || 6379);
     368                setOcMemcachedHost(res.object_cache_memcached_host || "127.0.0.1");
     369                setOcMemcachedPort(res.object_cache_memcached_port || 11211);
     370                setOcAvailable(res.object_cache_available_backends || {
     371                    apcu: false,
     372                    redis: false,
     373                    memcached: false
     374                });
     375                setOcDropinActive(!!res.object_cache_dropin_active);
     376                setOcForeignDropin(!!res.object_cache_foreign_dropin);
     377                setEcEnabled(!!res.early_cache_enabled);
     378                setEcDropinActive(!!res.advanced_cache_dropin_active);
     379                setEcForeignDropin(!!res.advanced_cache_foreign_dropin);
     380                setEcWpCacheTrue(!!res.wp_cache_constant_true);
     381                setEcWpConfigWritable(res.wp_config_writable !== false);
     382                setLocalSslFixActive(!!res.local_ssl_fix_active);
     383                setDiviActive(!!res.divi_active);
     384                setDiviPerRequest(!!res.divi_per_request_purge);
     385                setDbCounts(res.db_counts || {
     386                    revisions: 0,
     387                    trashed_posts: 0,
     388                    spam_comments: 0,
     389                    trashed_comments: 0,
     390                    expired_transients: 0
     391                });
     392                setDbSchedule(res.db_schedule || "off");
     393                setDbNextRun(res.db_next_run_timestamp || 0);
     394                if (res.last_db_optimize) {
     395                    setDbLastReport(res.last_db_optimize.report || null);
     396                    setDbLastTimestamp(res.last_db_optimize.timestamp || 0);
     397                    setDbLastType(res.last_db_optimize.type || "manual");
     398                }
     399                setNextRun(res.next_run_timestamp);
     400                if (isBackgroundPoll && res.last_cleared_timestamp && res.last_cleared_timestamp !== lastNotifiedClearRef.current) {
     401                    lastNotifiedClearRef.current = res.last_cleared_timestamp;
     402                    pushToast("Cache auto-cleared in the background.", "success");
     403                } else if (!isBackgroundPoll) {
     404                    lastNotifiedClearRef.current = res.last_cleared_timestamp;
     405                }
     406                setLastCleared(res.last_cleared_timestamp);
     407                setLastClearReport(res.last_clear_report || null);
     408                setLoading(false);
     409            }).catch(err => {
     410                setLoading(false);
     411                if (showErrorToast) {
     412                    const detail = err && (err.message || err.code) || "unknown error";
     413                    pushToast("Could not load settings: " + detail, "error");
     414                }
     415            });
     416        }
     417        useEffect(() => {
     418            fetchSettings(true, false);
     419        }, []);
     420        useEffect(() => {
     421            if (!nextRun) {
     422                setSecondsLeft(null);
     423                return;
     424            }
     425            if (reminderFiredForRef.current !== nextRun) {
     426                reminderFiredForRef.current = null;
     427            }
     428            function tick() {
     429                const remaining = nextRun - Math.floor(Date.now() / 1e3);
     430                const clamped = Math.max(0, remaining);
     431                setSecondsLeft(clamped);
     432                const totalSeconds = intervalRef.current * 60;
     433                const halfPoint = Math.floor(totalSeconds / 2);
     434                if (clamped <= halfPoint && clamped > 0 && reminderFiredForRef.current !== nextRun && totalSeconds >= 120) {
     435                    reminderFiredForRef.current = nextRun;
     436                    const mins = Math.round(clamped / 60);
     437                    const label = mins >= 1 ? mins + " min" : clamped + " sec";
     438                    pushToast(label + " remaining until next cache clear.", "info");
     439                }
     440            }
     441            tick();
     442            const id = window.setInterval(tick, 1e3);
     443            return () => window.clearInterval(id);
     444        }, [ nextRun ]);
     445        useEffect(() => {
     446            const id = window.setInterval(() => fetchSettings(false, true), 15e3);
     447            return () => window.clearInterval(id);
     448        }, []);
     449        function saveSettings(overrides, successMessage) {
     450            const newInterval = "interval_minutes" in overrides ? overrides.interval_minutes : intervalRef.current;
     451            setSaving(true);
     452            apiFetch({
     453                path: NS + "/settings",
     454                method: "POST",
     455                data: {
     456                    interval_minutes: newInterval,
     457                    toast_enabled: "toast_enabled" in overrides ? overrides.toast_enabled : toastEnabledRef.current,
     458                    cache_public: "cache_public" in overrides ? overrides.cache_public : cachePublicRef.current,
     459                    cache_private: "cache_private" in overrides ? overrides.cache_private : cachePrivateRef.current,
     460                    cache_object: "cache_object" in overrides ? overrides.cache_object : cacheObjectRef.current,
     461                    cache_browser: "cache_browser" in overrides ? overrides.cache_browser : cacheBrowserRef.current,
     462                    delete_data_on_uninstall: "delete_data_on_uninstall" in overrides ? overrides.delete_data_on_uninstall : deleteOnUninstallRef.current
     463                }
     464            }).then(res => {
     465                setNextRun(res.next_run_timestamp);
     466                setSaving(false);
     467                pushToast(successMessage || "Settings saved.", "success");
     468            }).catch(err => {
     469                setSaving(false);
     470                const detail = err && (err.message || err.code) || "unknown error";
     471                pushToast("Failed to save settings: " + detail, "error");
     472                console.error("ACC save settings error:", err);
     473            });
     474        }
     475        function handleIntervalChange(e) {
     476            const val = parseInt(e.target.value, 10);
     477            setInterval_(val);
     478            saveSettings({
     479                interval_minutes: val
     480            }, "Settings saved — auto-clear every " + (INTERVAL_LABELS[val] || val + " min") + ".");
     481        }
     482        function handleToastToggle() {
     483            const newVal = !toastEnabled;
     484            setToastEnabled(newVal);
     485            toastEnabledRef.current = newVal;
     486            saveSettings({
     487                toast_enabled: newVal
     488            });
     489        }
     490        function handleCacheToggle(key) {
     491            const setters = {
     492                cache_public: [ cachePublic, setCachePublic, cachePublicRef, "Public Cache" ],
     493                cache_private: [ cachePrivate, setCachePrivate, cachePrivateRef, "Private Cache" ],
     494                cache_object: [ cacheObject, setCacheObject, cacheObjectRef, "Object Cache" ],
     495                cache_browser: [ cacheBrowser, setCacheBrowser, cacheBrowserRef, "Browser Cache" ]
     496            };
     497            const [current, setState, ref, label] = setters[key];
     498            const newVal = !current;
     499            setState(newVal);
     500            ref.current = newVal;
     501            saveSettings({
     502                [key]: newVal
     503            }, label + " turned " + (newVal ? "ON" : "OFF") + ".");
     504        }
     505        function handleDeleteOnUninstallToggle() {
     506            const newVal = !deleteOnUninstall;
     507            const apply = () => {
     508                setDeleteOnUninstall(newVal);
     509                deleteOnUninstallRef.current = newVal;
     510                saveSettings({
     511                    delete_data_on_uninstall: newVal
     512                }, "Delete all data on uninstall turned " + (newVal ? "ON" : "OFF") + ".");
     513            };
     514            if (!newVal) {
     515                apply();
     516                return;
     517            }
     518            askConfirm({
     519                tone: "danger",
     520                title: "Delete all data on uninstall?",
     521                message: "When you delete CacheNova from Plugins → Installed Plugins, everything it created will be removed. This cannot be undone.",
     522                points: [ "All CacheNova settings and reports", "All cached files", "One final full-site cache clear" ],
     523                confirmLabel: "Yes, turn it on",
     524                onConfirm: apply
     525            });
     526        }
     527        function saveObjectCacheSettings(overrides, onDone) {
     528            setOcSaving(true);
     529            apiFetch({
     530                path: NS + "/settings",
     531                method: "POST",
     532                data: {
     533                    interval_minutes: intervalRef.current,
     534                    toast_enabled: toastEnabledRef.current,
     535                    cache_public: cachePublicRef.current,
     536                    cache_private: cachePrivateRef.current,
     537                    cache_object: cacheObjectRef.current,
     538                    cache_browser: cacheBrowserRef.current,
     539                    delete_data_on_uninstall: deleteOnUninstallRef.current,
     540                    object_cache_backend: ocBackendRef.current,
     541                    object_cache_redis_host: ocRedisHostRef.current,
     542                    object_cache_redis_port: ocRedisPortRef.current,
     543                    object_cache_memcached_host: ocMemcachedHostRef.current,
     544                    object_cache_memcached_port: ocMemcachedPortRef.current,
     545                    ...overrides
     546                }
     547            }).then(res => {
     548                setOcSaving(false);
     549                setOcEnabled(!!res.object_cache_enabled);
     550                setOcBackend(res.object_cache_backend || ocBackendRef.current);
     551                setOcDropinActive(!!res.object_cache_dropin_active);
     552                const result = res.object_cache_result;
     553                if (result) {
     554                    if (result.success) {
     555                        setOcTestStatus("ok");
     556                        setOcTestMessage(result.message);
     557                        pushToast(result.message, "success");
     558                    } else {
     559                        setOcTestStatus("error");
     560                        setOcTestMessage(result.message);
     561                        pushToast(result.message, "error");
     562                    }
     563                } else {
     564                    pushToast("Object cache settings saved.", "success");
     565                }
     566                if (onDone) onDone(result);
     567            }).catch(err => {
     568                setOcSaving(false);
     569                const detail = err && (err.message || err.code) || "unknown error";
     570                pushToast("Failed to save object cache settings: " + detail, "error");
     571            });
     572        }
     573        function handleOcToggle() {
     574            const newVal = !ocEnabled;
     575            if (newVal) {
     576                askConfirm({
     577                    tone: "info",
     578                    title: "Enable Persistent Object Cache?",
     579                    message: "CacheNova will install wp-content/object-cache.php, which changes how WordPress caches data across the whole site. It automatically picks the fastest backend your server has working.",
     580                    points: [ "Backend order: Redis, Memcached, APCu, then file-based", "No manual setup needed" ],
     581                    confirmLabel: "Enable",
     582                    onConfirm: () => {
     583                        setOcTestStatus("testing");
     584                        saveObjectCacheSettings({
     585                            object_cache_enabled: newVal,
     586                            object_cache_backend: "auto"
     587                        });
     588                    }
     589                });
     590                return;
     591            }
     592            saveObjectCacheSettings({
     593                object_cache_enabled: newVal
     594            });
     595        }
     596        function handleOcRedetect() {
     597            setOcTestStatus("testing");
     598            saveObjectCacheSettings({
     599                object_cache_enabled: true,
     600                object_cache_backend: "auto"
     601            });
     602        }
     603        function handleOcFieldCommit() {
     604            setOcTestStatus("idle");
     605            if (ocEnabled) {
     606                saveObjectCacheSettings({});
     607            }
     608        }
     609        function handleEarlyCacheToggle() {
     610            const newVal = !ecEnabled;
     611            if (newVal) {
     612                if (!cachePublic) {
     613                    pushToast("Turn on Public Cache first — early-bypass caching serves the same cached files, just before WordPress loads.", "error");
     614                    return;
     615                }
     616                askConfirm({
     617                    tone: "info",
     618                    title: "Enable Early-Bypass Page Cache?",
     619                    message: "CacheNova will install wp-content/advanced-cache.php and add define('WP_CACHE', true) to wp-config.php, so cached pages are served before WordPress even loads.",
     620                    points: [ "Anonymous visitors only; logged-in users get Private Cache", "Removed automatically when you turn it off" ],
     621                    confirmLabel: "Enable",
     622                    onConfirm: () => runEarlyCache(newVal)
     623                });
     624                return;
     625            }
     626            runEarlyCache(newVal);
     627        }
     628        function runEarlyCache(newVal) {
     629            setEcSaving(true);
     630            apiFetch({
     631                path: NS + "/settings",
     632                method: "POST",
     633                data: {
     634                    interval_minutes: intervalRef.current,
     635                    toast_enabled: toastEnabledRef.current,
     636                    cache_public: cachePublicRef.current,
     637                    cache_private: cachePrivateRef.current,
     638                    cache_object: cacheObjectRef.current,
     639                    cache_browser: cacheBrowserRef.current,
     640                    delete_data_on_uninstall: deleteOnUninstallRef.current,
     641                    early_cache_enabled: newVal
     642                }
     643            }).then(res => {
     644                setEcSaving(false);
     645                setEcEnabled(!!res.early_cache_enabled);
     646                setEcDropinActive(!!res.advanced_cache_dropin_active);
     647                setEcWpCacheTrue(!!res.wp_cache_constant_true);
     648                setLocalSslFixActive(!!res.local_ssl_fix_active);
     649                setDiviActive(!!res.divi_active);
     650                setDiviPerRequest(!!res.divi_per_request_purge);
     651                const result = res.early_cache_result;
     652                if (result) {
     653                    setEcResultStatus(result.success ? "ok" : "error");
     654                    setEcResultMessage(result.message);
     655                    pushToast(result.message, result.success ? "success" : "error");
     656                } else {
     657                    pushToast("Early-bypass cache settings saved.", "success");
     658                }
     659            }).catch(err => {
     660                setEcSaving(false);
     661                const detail = err && (err.message || err.code) || "unknown error";
     662                pushToast("Failed to save early-bypass cache settings: " + detail, "error");
     663            });
     664        }
     665        function toggleDiviPerRequest() {
     666            if (diviPurgeSaving) return;
     667            const enabled = !diviPerRequest;
     668            if (enabled) {
     669                askConfirm({
     670                    tone: "warning",
     671                    title: "Enable Divi cache compatibility?",
     672                    message: "CacheNova will delete the Divi et-cache folder after every frontend request. This can noticeably slow your website, so use it only for troubleshooting.",
     673                    confirmLabel: "Enable anyway",
     674                    onConfirm: () => runDiviPerRequest(enabled)
     675                });
     676                return;
     677            }
     678            runDiviPerRequest(enabled);
     679        }
     680        function runDiviPerRequest(enabled) {
     681            setDiviPurgeSaving(true);
     682            apiFetch({path: NS + "/divi-per-request", method: "POST", data: {enabled}}).then(res => {
     683                setDiviPerRequest(!!res.enabled);
     684                pushToast(res.enabled ? (res.folder_deleted ? "Divi et-cache folder deleted. Automatic deletion enabled." : "Divi setting enabled, but et-cache folder could not be deleted. Check filesystem permissions.") : "Divi Cache Compatibility disabled. No per-request folder deletion.", res.enabled && !res.folder_deleted ? "error" : "success");
     685            }).catch(err => pushToast("Could not save Divi setting: " + (err.message || "Unknown error"), "error"))
     686              .finally(() => setDiviPurgeSaving(false));
     687        }
     688        function dbReportTotal(report) {
     689            if (!report) return 0;
     690            return Object.keys(report).filter(k => k !== "optimize_tables").reduce((sum, k) => sum + (typeof report[k] === "number" ? report[k] : 0), 0);
     691        }
     692        function switchTab(tab) {
     693            setActiveTab(tab);
     694            try {
     695                window.history.replaceState(null, "", tab === "database" ? "#database" : window.location.pathname + window.location.search);
     696            } catch (e) {}
     697        }
     698        function handleDbScheduleChange(e) {
     699            const val = e.target.value;
     700            if (val !== "off") {
     701                askConfirm({
     702                    tone: "danger",
     703                    title: "Turn on automatic optimization?",
     704                    message: "CacheNova will run " + (DB_SCHEDULE_LABELS[val] || val).toLowerCase() + " in the background. Every run permanently deletes the items below. This cannot be undone.",
     705                    points: [ "All post revisions and trashed posts", "Spam and trashed comments", "Expired transients, then optimizes tables" ],
     706                    confirmLabel: "Turn on",
     707                    onConfirm: () => runDbSchedule(val)
     708                });
     709                return;
     710            }
     711            runDbSchedule(val);
     712        }
     713        function runDbSchedule(val) {
     714            const previous = dbSchedule;
     715            setDbSchedule(val);
     716            setDbScheduleSaving(true);
     717            apiFetch({
     718                path: NS + "/settings",
     719                method: "POST",
     720                data: {
     721                    interval_minutes: intervalRef.current,
     722                    toast_enabled: toastEnabledRef.current,
     723                    db_schedule: val
     724                }
     725            }).then(res => {
     726                setDbScheduleSaving(false);
     727                setDbSchedule(res.db_schedule || val);
     728                setDbNextRun(res.db_next_run_timestamp || 0);
     729                pushToast(val === "off" ? "Automatic database optimization turned OFF." : "Automatic database optimization set: " + (DB_SCHEDULE_LABELS[val] || val) + ".", "success");
     730            }).catch(err => {
     731                setDbScheduleSaving(false);
     732                setDbSchedule(previous);
     733                const detail = err && (err.message || err.code) || "unknown error";
     734                pushToast("Failed to save database schedule: " + detail, "error");
     735            });
     736        }
     737        function toggleDbOption(key) {
     738            setDbOptions(prev => ({
     739                ...prev,
     740                [key]: !prev[key]
     741            }));
     742        }
     743        function handleDbOptimize() {
     744            const selected = Object.keys(dbOptions).filter(k => dbOptions[k]);
     745            if (!selected.length) {
     746                pushToast("Select at least one item to optimize.", "error");
     747                return;
     748            }
     749            askConfirm({
     750                tone: "danger",
     751                title: "Optimize database now?",
     752                message: "The selected items will be permanently deleted. Revisions, trash and spam cannot be recovered afterward.",
     753                confirmLabel: "Optimize now",
     754                onConfirm: runDbOptimize
     755            });
     756        }
     757        function runDbOptimize() {
     758            setDbRunning(true);
     759            apiFetch({
     760                path: NS + "/database/optimize",
     761                method: "POST",
     762                data: dbOptions
     763            }).then(res => {
     764                setDbRunning(false);
     765                setDbLastReport(res.report);
     766                setDbLastTimestamp(res.timestamp);
     767                setDbLastType("manual");
     768                const total = dbReportTotal(res.report);
     769                pushToast("Database optimized — " + total + " item(s) cleaned.", "success");
     770                fetchSettings(false, false);
     771            }).catch(err => {
     772                setDbRunning(false);
     773                const detail = err && (err.message || err.code) || "unknown error";
     774                pushToast("Database optimization failed: " + detail, "error");
     775                console.error("ACC db optimize error:", err);
     776            });
     777        }
     778        function handleClearNow() {
     779            setClearing(true);
     780            apiFetch({
     781                path: NS + "/clear",
     782                method: "POST"
     783            }).then(res => {
     784                setLastCleared(res.last_cleared_timestamp);
     785                lastNotifiedClearRef.current = res.last_cleared_timestamp;
     786                setNextRun(res.next_run_timestamp);
     787                reminderFiredForRef.current = null;
     788                setClearing(false);
     789                pushToast(res.divi_folder_deleted === false ? (res.divi_folder_message || "Cache cleared, but Divi et-cache still exists.") : "All theme & plugin caches cleared. Timer restarted.", res.divi_folder_deleted === false ? "error" : "success");
     790                if (res.last_clear_report) {
     791                    setLastClearReport(res.last_clear_report);
     792                } else {
     793                    setTimeout(() => fetchSettings(false, true), 1500);
     794                }
     795            }).catch(err => {
     796                setClearing(false);
     797                const detail = err && (err.message || err.code) || "unknown error";
     798                pushToast("Failed to clear cache: " + detail, "error");
     799                console.error("ACC clear cache error:", err);
     800            });
     801        }
     802        const REPORT_STATUS_META = {
     803            ok: {
     804                icon: "✓",
     805                text: "Verified",
     806                className: "rlcc-report-ok"
     807            },
     808            triggered: {
     809                icon: "↻",
     810                text: "Triggered",
     811                className: "rlcc-report-triggered"
     812            },
     813            failed: {
     814                icon: "✕",
     815                text: "Failed",
     816                className: "rlcc-report-failed"
     817            }
     818        };
     819        function ClearReportCard() {
     820            if (!lastClearReport || !lastClearReport.items || !Object.keys(lastClearReport.items).length) {
     821                return h("p", {
     822                    className: "rlcc-hint"
     823                }, 'No cache clear has run yet since this page loaded — trigger one with "Clear Cache Now" or wait for the scheduled timer.');
     824            }
     825            const items = lastClearReport.items;
     826            const failedCount = Object.values(items).filter(i => i.status === "failed").length;
     827            return h("div", null, h("p", {
     828                className: "rlcc-hint"
     829            }, ("full" === lastClearReport.type ? "Full clear" : "Scheduled (light) clear") + " — " + (failedCount ? failedCount + " layer(s) failed to verify." : "every checked layer verified successfully.")), h("ul", {
     830                className: "rlcc-report-list"
     831            }, Object.keys(items).map(key => {
     832                const item = items[key];
     833                const meta = REPORT_STATUS_META[item.status] || REPORT_STATUS_META.triggered;
     834                return h("li", {
     835                    key: key,
     836                    className: "rlcc-report-row " + meta.className
     837                }, h("span", {
     838                    className: "rlcc-report-icon"
     839                }, meta.icon), h("span", {
     840                    className: "rlcc-report-label"
     841                }, item.label), h("span", {
     842                    className: "rlcc-report-badge"
     843                }, meta.text));
     844            })));
     845        }
     846        function renderDbTab() {
     847            const scheduleOn = dbSchedule !== "off";
     848            return h("div", {
     849                className: "rlcc-card-grid rlcc-card-grid--three"
     850            }, h("div", {
     851                className: "rlcc-card"
     852            }, h("h3", {
     853                className: "rlcc-card-title"
     854            }, h("span", {
     855                className: "rlcc-card-symbol",
     856                "aria-hidden": "true"
     857            }, "◷"), "Automatic Optimization", h(InfoTip, {
     858                text: "Runs in the background on WordPress cron. Each run clears ALL post revisions, trashed posts, spam comments, trashed comments and expired transients, then optimizes the database tables. Off by default."
     859            })), h("div", {
     860                className: "rlcc-grid"
     861            }, h("div", {
     862                className: "rlcc-field"
     863            }, h("label", null, "Auto-optimize every"), h("select", {
     864                value: dbSchedule,
     865                onChange: handleDbScheduleChange,
     866                disabled: dbScheduleSaving,
     867                className: "rlcc-select"
     868            }, Object.keys(DB_SCHEDULE_LABELS).map(k => h("option", {
     869                key: k,
     870                value: k
     871            }, DB_SCHEDULE_LABELS[k])))), h("div", {
     872                className: "rlcc-field"
     873            }, h("label", null, "Status"), h("div", {
     874                className: "rlcc-db-status " + (scheduleOn ? "rlcc-db-status--on" : "rlcc-db-status--off")
     875            }, scheduleOn ? "Active — next run " + formatDateTime(dbNextRun) : "Off — nothing runs automatically")))), h("div", {
     876                className: "rlcc-card"
     877            }, h("h3", {
     878                className: "rlcc-card-title"
     879            }, h("span", {
     880                className: "rlcc-card-symbol",
     881                "aria-hidden": "true"
     882            }, "▤"), "Manual Optimization", h(InfoTip, {
     883                text: "Cleans up post revisions, trashed items, spam comments, and expired transients, and defragments your database tables. Nothing runs until you click the button below, and only for the items you've checked."
     884            })), h("div", {
     885                className: "rlcc-db-grid"
     886            }, [ [ "revisions", "Post Revisions" ], [ "trashed_posts", "Trashed Posts" ], [ "spam_comments", "Spam Comments" ], [ "trashed_comments", "Trashed Comments" ], [ "expired_transients", "Expired Transients" ] ].map(([key, label]) => h("label", {
     887                key: key,
     888                className: "rlcc-db-checkbox"
     889            }, h("input", {
     890                type: "checkbox",
     891                checked: !!dbOptions[key],
     892                onChange: () => toggleDbOption(key)
     893            }), h("span", null, label), h("span", {
     894                className: "rlcc-db-count"
     895            }, dbCounts[key] || 0))), h("label", {
     896                className: "rlcc-db-checkbox"
     897            }, h("input", {
     898                type: "checkbox",
     899                checked: !!dbOptions.optimize_tables,
     900                onChange: () => toggleDbOption("optimize_tables")
     901            }), h("span", null, "Optimize Database Tables"))), h("button", {
     902                className: "rlcc-clear-btn",
     903                onClick: handleDbOptimize,
     904                disabled: dbRunning,
     905                type: "button"
     906            }, dbRunning ? "Optimizing…" : "Optimize Database Now"), dbLastTimestamp > 0 && h("p", {
     907                className: "rlcc-hint"
     908            }, "Last run: ", timeAgo(dbLastTimestamp), " (" + (dbLastType === "auto" ? "automatic" : "manual") + ")", dbLastReport ? " — " + dbReportTotal(dbLastReport) + " item(s) cleaned" + (dbLastReport.optimize_tables ? ", " + dbLastReport.optimize_tables + " table(s) optimized" : "") : "")), h("div", {
     909            className: "rlcc-card rlcc-card--danger"
     910        }, h("h3", {
     911            className: "rlcc-card-title rlcc-card-title--danger"
     912        }, "Danger Zone"), h("div", {
     913            className: "rlcc-danger-row"
     914        }, h("div", {
     915            className: "rlcc-danger-text"
     916        }, h("label", null, "Delete all data on uninstall", h(InfoTip, {
     917            text: "Off by default. When ON, removing this plugin from Plugins → Installed Plugins deletes all of its settings and cached files, and runs one last full-site cache clear in the background."
     918        }))), h("button", {
     919            className: "rlcc-switch rlcc-switch--danger " + (deleteOnUninstall ? "rlcc-switch--on" : "rlcc-switch--off"),
     920            onClick: handleDeleteOnUninstallToggle,
     921            type: "button"
     922        }, h("span", {
     923            className: "rlcc-switch-knob"
     924        }), h("span", {
     925            className: "rlcc-switch-label"
     926        }, deleteOnUninstall ? "On" : "Off")))));
     927        }
     928        if (loading) {
     929            return h("div", {
     930                className: "rlcc-dashboard-loading"
     931            }, "Loading settings…");
     932        }
     933        return h("div", {
     934            className: "rlcc-dashboard"
     935        }, h("div", {
     936            className: "rlcc-hero"
     937        }, h("div", {
     938            className: "rlcc-hero-top"
     939        }, h("div", {
     940            className: "rlcc-hero-title-block"
     941        }, h("h2", {
     942            className: "rlcc-hero-title"
     943        }, h("span", {className:"rlcc-brand-mark", "aria-hidden":"true"}, "✦"), "CacheNova", h("span", {className:"rlcc-version"}, "v1.1.0")), h("div", {
     944            className: "rlcc-status-pill"
     945        }, h("span", {
     946            className: "rlcc-status-dot"
     947        }), h("span", null, "Running in background"))), h("button", {
     948            className: "rlcc-clear-btn",
     949            onClick: handleClearNow,
     950            disabled: clearing
     951        }, clearing ? "Clearing…" : "Clear Cache Now")), h("div", {
     952            className: "rlcc-hero-meta"
     953        }, h("div", {
     954            className: "rlcc-countdown-row"
     955        }, h("span", null, "Next automatic clear in"), h("span", {
     956            className: "rlcc-countdown"
     957        }, formatCountdown(secondsLeft))), h("div", {
     958            className: "rlcc-hero-meta-item"
     959        }, "Last cleared: ", h("strong", null, timeAgo(lastCleared))), diviActive ? h("div", {
     960            className: "rlcc-hero-meta-item rlcc-hero-meta-item--divi"
     961        }, "Divi detected — cache cleared on updates and scheduled runs") : h("div", {
     962            className: "rlcc-hero-meta-item"
     963        }, "Works with WordPress themes — automatic cache clearing enabled"))), h("div", {
     964            className: "rlcc-tabs",
     965            role: "tablist"
     966        }, [ [ "cache", "Cache Settings" ], [ "database", "Database Optimization" ] ].map(([key, label]) => h("button", {
     967            key: key,
     968            type: "button",
     969            role: "tab",
     970            "aria-selected": activeTab === key,
     971            className: "rlcc-tab" + (activeTab === key ? " rlcc-tab--active" : ""),
     972            onClick: () => switchTab(key)
     973        }, label))), diviActive && activeTab === "cache" && h("div", {className:"rlcc-card rlcc-divi-compat"},
     974            h("h3", {className:"rlcc-card-title"}, "Divi Cache Compatibility"),
     975            h("p", null, "Delete the entire wp-content/et-cache folder immediately when enabled and after frontend requests. Divi may recreate it. Can significantly slow your site."),
     976            h("button", {type:"button", className:"rlcc-switch " + (diviPerRequest ? "rlcc-switch--on" : "rlcc-switch--off"), onClick:toggleDiviPerRequest, disabled:diviPurgeSaving, "aria-pressed":diviPerRequest},
     977                h("span", {className:"rlcc-switch-knob"}), h("span", {className:"rlcc-switch-label"}, diviPerRequest ? "On" : "Off"))
     978        ), activeTab === "database" ? renderDbTab() : h("div", {
     979            className: "rlcc-card-grid"
     980        }, h("div", {
     981            className: "rlcc-card"
     982        }, h("h3", {
     983            className: "rlcc-card-title"
     984        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "◷"), "Schedule & Notifications", h(InfoTip, {
     985            text: "Cache also clears instantly every time you save a page or post — Divi Builder, Gutenberg, or any other editor — no manual reload needed."
     986        })), h("div", {
     987            className: "rlcc-grid"
     988        }, h("div", {
     989            className: "rlcc-field"
     990        }, h("label", null, "Auto-clear every"), h("select", {
     991            value: interval_,
     992            onChange: handleIntervalChange,
     993            disabled: saving,
     994            className: "rlcc-select"
     995        }, RLCC_DATA.allowedIntervals.map(m => h("option", {
     996            key: m,
     997            value: m
     998        }, INTERVAL_LABELS[m] || m + " min")))), h("div", {
     999            className: "rlcc-field"
     1000        }, h("label", null, "Toast notifications"), h("button", {
     1001            className: "rlcc-switch " + (toastEnabled ? "rlcc-switch--on" : "rlcc-switch--off"),
     1002            onClick: handleToastToggle,
     1003            type: "button"
     1004        }, h("span", {
     1005            className: "rlcc-switch-knob"
     1006        }), h("span", {
     1007            className: "rlcc-switch-label"
     1008        }, toastEnabled ? "On" : "Off"))))), h("div", {
     1009            className: "rlcc-card"
     1010        }, h("h3", {
     1011            className: "rlcc-card-title"
     1012        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "▦"), "Cache Types", h(InfoTip, {
     1013            text: "Whatever you switch ON here gets stored; the timer above is what clears it back out again."
     1014        })), h("div", {
     1015            className: "rlcc-grid rlcc-grid--4"
     1016        }, h("div", {
     1017            className: "rlcc-field"
     1018        }, h("label", null, "Public Cache"), h("button", {
     1019            className: "rlcc-switch " + (cachePublic ? "rlcc-switch--on" : "rlcc-switch--off"),
     1020            onClick: () => handleCacheToggle("cache_public"),
     1021            type: "button"
     1022        }, h("span", {
     1023            className: "rlcc-switch-knob"
     1024        }), h("span", {
     1025            className: "rlcc-switch-label"
     1026        }, cachePublic ? "On" : "Off"))), h("div", {
     1027            className: "rlcc-field"
     1028        }, h("label", null, "Private Cache"), h("button", {
     1029            className: "rlcc-switch " + (cachePrivate ? "rlcc-switch--on" : "rlcc-switch--off"),
     1030            onClick: () => handleCacheToggle("cache_private"),
     1031            type: "button"
     1032        }, h("span", {
     1033            className: "rlcc-switch-knob"
     1034        }), h("span", {
     1035            className: "rlcc-switch-label"
     1036        }, cachePrivate ? "On" : "Off"))), h("div", {
     1037            className: "rlcc-field"
     1038        }, h("label", null, "Object Cache"), h("button", {
     1039            className: "rlcc-switch " + (cacheObject ? "rlcc-switch--on" : "rlcc-switch--off"),
     1040            onClick: () => handleCacheToggle("cache_object"),
     1041            type: "button"
     1042        }, h("span", {
     1043            className: "rlcc-switch-knob"
     1044        }), h("span", {
     1045            className: "rlcc-switch-label"
     1046        }, cacheObject ? "On" : "Off"))), h("div", {
     1047            className: "rlcc-field"
     1048        }, h("label", null, "Browser Cache"), h("button", {
     1049            className: "rlcc-switch " + (cacheBrowser ? "rlcc-switch--on" : "rlcc-switch--off"),
     1050            onClick: () => handleCacheToggle("cache_browser"),
     1051            type: "button"
     1052        }, h("span", {
     1053            className: "rlcc-switch-knob"
     1054        }), h("span", {
     1055            className: "rlcc-switch-label"
     1056        }, cacheBrowser ? "On" : "Off"))))), h("div", {
     1057            className: "rlcc-card"
     1058        }, h("h3", {
     1059            className: "rlcc-card-title"
     1060        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "◇"), "Persistent Object Cache", h(InfoTip, {
     1061            text: "A persistent object cache lets WordPress reuse database query results across requests. Off by default. Turning it on automatically detects and uses the fastest backend this server actually has working — Redis, then Memcached, then APCu, then a universal file-based fallback — with no manual picking or testing required."
     1062        })), ocForeignDropin && h("p", {
     1063            className: "rlcc-oc-unavailable"
     1064        }, "An existing object-cache.php is already installed (possibly by the host or an inactive plugin). CacheNova will not overwrite it. Identify its owner and back it up before switching cache providers."), h("div", {
     1065            className: "rlcc-field"
     1066        }, h("label", null, "Enable (" + (ocDropinActive ? "drop-in active" : "not active") + ")"), h("button", {
     1067            className: "rlcc-switch " + (ocEnabled ? "rlcc-switch--on" : "rlcc-switch--off"),
     1068            onClick: handleOcToggle,
     1069            type: "button",
     1070            disabled: ocSaving || ocForeignDropin
     1071        }, h("span", {
     1072            className: "rlcc-switch-knob"
     1073        }), h("span", {
     1074            className: "rlcc-switch-label"
     1075        }, ocEnabled ? "On" : "Off"))), ocEnabled && h("div", {
     1076            className: "rlcc-oc-auto-status"
     1077        }, h("span", null, "Backend in use: ", h("strong", null, OC_BACKEND_LABELS[ocBackend] || ocBackend)), h("button", {
     1078            className: "rlcc-oc-redetect-btn",
     1079            onClick: handleOcRedetect,
     1080            type: "button",
     1081            disabled: ocSaving
     1082        }, ocSaving ? "Checking…" : "Re-detect best backend")), ocEnabled && "redis" === ocBackend && h("div", {
     1083            className: "rlcc-oc-backend-row"
     1084        }, h("div", {
     1085            className: "rlcc-field"
     1086        }, h("label", null, "Redis Host"), h("input", {
     1087            type: "text",
     1088            className: "rlcc-input",
     1089            value: ocRedisHost,
     1090            onChange: e => {
     1091                setOcRedisHost(e.target.value);
     1092                ocRedisHostRef.current = e.target.value;
     1093            },
     1094            onBlur: handleOcFieldCommit
     1095        })), h("div", {
     1096            className: "rlcc-field"
     1097        }, h("label", null, "Redis Port"), h("input", {
     1098            type: "number",
     1099            className: "rlcc-input",
     1100            value: ocRedisPort,
     1101            onChange: e => {
     1102                setOcRedisPort(e.target.value);
     1103                ocRedisPortRef.current = e.target.value;
     1104            },
     1105            onBlur: handleOcFieldCommit
     1106        }))), ocEnabled && "memcached" === ocBackend && h("div", {
     1107            className: "rlcc-oc-backend-row"
     1108        }, h("div", {
     1109            className: "rlcc-field"
     1110        }, h("label", null, "Memcache Host"), h("input", {
     1111            type: "text",
     1112            className: "rlcc-input",
     1113            value: ocMemcachedHost,
     1114            onChange: e => {
     1115                setOcMemcachedHost(e.target.value);
     1116                ocMemcachedHostRef.current = e.target.value;
     1117            },
     1118            onBlur: handleOcFieldCommit
     1119        })), h("div", {
     1120            className: "rlcc-field"
     1121        }, h("label", null, "Memcache Port"), h("input", {
     1122            type: "number",
     1123            className: "rlcc-input",
     1124            value: ocMemcachedPort,
     1125            onChange: e => {
     1126                setOcMemcachedPort(e.target.value);
     1127                ocMemcachedPortRef.current = e.target.value;
     1128            },
     1129            onBlur: handleOcFieldCommit
     1130        }))), "idle" !== ocTestStatus && h("div", {
     1131            className: "rlcc-oc-status rlcc-oc-status--" + ("testing" === ocTestStatus ? "idle" : ocTestStatus)
     1132        }, "testing" === ocTestStatus ? "Checking for the fastest available backend…" : ocTestMessage)), h("div", {
     1133            className: "rlcc-card"
     1134        }, h("h3", {
     1135            className: "rlcc-card-title"
     1136        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "⚡"), "Early-Bypass Page Cache", h(InfoTip, {
     1137            text: "Serves cached pages before WordPress even loads. Only anonymous, cookie-free visitors; everyone else gets Private Cache instead. This is what actually improves server response time (TTFB) on speed tests."
     1138        })), ecForeignDropin && h("p", {
     1139            className: "rlcc-oc-unavailable"
     1140        }, "An existing advanced-cache.php is already installed (possibly by the host or an inactive plugin). CacheNova will not overwrite it. Identify its owner and back it up before switching cache providers."), !cachePublic && !ecEnabled && h("p", {
     1141            className: "rlcc-oc-unavailable"
     1142        }, "Turn on Public Cache first — early-bypass caching serves the same cached files, just before WordPress loads."), h("div", {
     1143            className: "rlcc-field"
     1144        }, h("label", null, "Enable (" + (ecDropinActive ? "drop-in active" : "not active") + (ecEnabled ? ", WP_CACHE " + (ecWpCacheTrue ? "on" : "NOT set") : "") + ")"), h("button", {
     1145            className: "rlcc-switch " + (ecEnabled ? "rlcc-switch--on" : "rlcc-switch--off"),
     1146            onClick: handleEarlyCacheToggle,
     1147            type: "button",
     1148            disabled: ecSaving || ecForeignDropin || !cachePublic && !ecEnabled
     1149        }, h("span", {
     1150            className: "rlcc-switch-knob"
     1151        }), h("span", {
     1152            className: "rlcc-switch-label"
     1153        }, ecEnabled ? "On" : "Off"))), "idle" !== ecResultStatus && h("div", {
     1154            className: "rlcc-oc-status rlcc-oc-status--" + ecResultStatus
     1155        }, ecResultMessage), !ecWpConfigWritable && h("p", {
     1156            className: "rlcc-oc-unavailable"
     1157        }, "wp-config.php doesn't look writable — if enabling doesn't take effect, add define('WP_CACHE', true); yourself near the top, right after the opening <?php tag.")), h("div", {
     1158            className: "rlcc-card rlcc-card--wide"
     1159        }, h("h3", {
     1160            className: "rlcc-card-title"
     1161        }, h("span", {className:"rlcc-card-symbol", "aria-hidden":"true"}, "▤"), "Last Clear Report"), h(ClearReportCard))), h(ConfirmModal, {
     1162            modal: modal,
     1163            onCancel: closeModal,
     1164            onConfirm: acceptModal
     1165        }), h(ToastList, {
     1166            toasts: toasts,
     1167            remove: removeToast
     1168        }));
     1169    }
     1170    onReady(function() {
     1171        const root = document.getElementById("rlcc-settings-root");
     1172        if (!root) return;
     1173        try {
     1174            const tree = h(RLCCErrorBoundary, null, h(App));
     1175            if (wp.element.createRoot) {
     1176                wp.element.createRoot(root).render(tree);
     1177            } else {
     1178                wp.element.render(tree, root);
     1179            }
     1180        } catch (err) {
     1181            renderFatal("dashboard failed to load", err && (err.message || String(err)) || "Unknown error.");
     1182            console.error("CacheNova dashboard mount error:", err);
     1183        }
     1184    });
     1185})();
  • cachenova/trunk/cachenova.php

    r3738364 r3777263  
    44 * Plugin Name: CacheNova
    55 * Plugin URI:  https://wordpress.org/plugins/cachenova/
    6  * Description: Automatically clears and manages WordPress cache layers on a scheduled basis, helping keep the site fresh and optimized without impacting performance.
    7  * Version: 1.0.0
     6 * Description: Auto clear cache on a schedule or on save. Clear browser, page and object cache, plus database optimization. Works with Divi, Elementor, WooCommerce.
     7 * Version: 1.1.0
    88 * Requires at least: 7.0
    99 * Requires PHP:      8.0
    … …  
    1818if ( ! defined( 'ABSPATH' ) ) exit;
    1919
    20 define( 'RLCC_PLUGIN_VERSION', '1.0.0' );
     20define( 'RLCC_PLUGIN_VERSION', '1.1.0' );
    2121
    2222define( 'RLCC_PLUGIN_FILE', __FILE__ );
    … …  
    3838        const DEFERRED_HOOK = 'rlcc_deferred_full_clear_event';
    3939
    40         const ALLOWED_INTERVALS = [ 1, 3, 5, 10, 20, 30, 60];
     40        const ALLOWED_INTERVALS = [ 10, 20, 30, 60, 1440, 10080, 43200 ];
     41
     42        const DB_CRON_HOOK        = 'rlcc_db_optimize_event';
     43        const DB_SCHEDULES        = [
     44                'daily'   => 86400,
     45                'weekly'  => 604800,
     46                'monthly' => 2592000,
     47        ];
    4148
    4249        const PUBLIC_CACHE_DIR  = 'public';
    … …  
    8693
    8794                add_action( self::CRON_HOOK, [ $this, 'light_clear' ] );
     95                add_action( self::DB_CRON_HOOK, [ $this, 'run_scheduled_db_optimize' ] );
     96                add_action( 'init', [ $this, 'sync_db_cron' ], 30 );
    8897
    8998                add_action( 'save_post', [ $this, 'full_clear' ] );
     99                add_action( 'deleted_post', [ $this, 'full_clear' ] );
     100                add_action( 'trashed_post', [ $this, 'full_clear' ] );
     101                add_action( 'untrashed_post', [ $this, 'full_clear' ] );
     102                add_action( 'wp_update_nav_menu', [ $this, 'full_clear' ] );
     103                add_action( 'update_option_theme_mods_' . get_stylesheet(), [ $this, 'full_clear' ] );
    90104                add_action( 'et_save_post', [ $this, 'full_clear' ] );
    91105                add_action( 'customize_save_after', [ $this, 'full_clear' ] );
    … …  
    103117
    104118                add_action( 'send_headers', [ $this, 'maybe_send_browser_cache_headers' ] );
     119                add_filter( 'style_loader_src', [ $this, 'bust_browser_cache_src' ], 20 );
     120                add_filter( 'script_loader_src', [ $this, 'bust_browser_cache_src' ], 20 );
    105121
    106122                add_action( 'admin_menu', [ $this->admin, 'add_settings_page' ] );
    … …  
    111127                        add_action( 'init', [ $this->divi, 'disable_static_css' ], 20 );
    112128
    113                         add_action( 'shutdown', [ $this->divi, 'purge_et_cache_dir' ] );
     129                        // Opt-in only: expensive, and can reduce cache performance.
     130            add_action( 'shutdown', [ $this, 'maybe_purge_divi_after_request' ], PHP_INT_MAX - 1 );
    114131                }
    115132                add_action( 'init', [ $this, 'maybe_run_overdue_clear' ] );
    … …  
    117134                add_action( 'admin_post_rlcc_manual_clear', [ $this->admin, 'manual_clear_fallback' ] );
    118135                add_action( 'admin_bar_menu', [ $this->admin, 'add_admin_bar_button' ], 100 );
     136                add_action( 'admin_enqueue_scripts', [ $this->admin, 'enqueue_admin_bar_assets' ] );
     137                add_action( 'wp_enqueue_scripts', [ $this->admin, 'enqueue_admin_bar_assets' ] );
     138        }
     139
     140        /** Optional compatibility mode; never run for admin, AJAX, REST, or cron requests. */
     141        public function maybe_purge_divi_after_request() {
     142                $settings = $this->get_settings();
     143                if ( empty( $settings['divi_per_request_purge'] ) || ! $this->divi->is_active() || is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
     144                        return;
     145                }
     146                // A short lock (an option row, no file handles) stops concurrent requests repeatedly deleting the same folder.
     147                $now = time();
     148                if ( ! add_option( 'rlcc_divi_purge_lock', $now, '', false ) ) {
     149                        if ( ( $now - (int) get_option( 'rlcc_divi_purge_lock', 0 ) ) < 30 ) {
     150                                return;
     151                        }
     152                        update_option( 'rlcc_divi_purge_lock', $now, false ); // Stale lock from a crashed request.
     153                }
     154                try {
     155                        $this->divi->purge_et_cache_dir();
     156                } finally {
     157                        delete_option( 'rlcc_divi_purge_lock' );
     158                }
    119159        }
    120160
    … …  
    128168                        'interval_minutes' => 60,
    129169                        'toast_enabled'    => true,
     170            'divi_per_request_purge' => false,
    130171                       
    131172                        'cache_public'     => true,
    … …  
    135176                       
    136177                        'delete_data_on_uninstall' => false,
     178
     179                        'db_schedule'      => 'off',
    137180                       
    138181                        'object_cache_enabled'        => false,
    … …  
    156199        }
    157200
    158         public function save_settings( $interval_minutes, $toast_enabled, $cache_public = null, $cache_private = null, $cache_object = null, $cache_browser = null, $delete_data_on_uninstall = null ) {
     201        public function save_settings( $interval_minutes, $toast_enabled, $cache_public = null, $cache_private = null, $cache_object = null, $cache_browser = null, $delete_data_on_uninstall = null, $db_schedule = null ) {
    159202                $current = $this->get_settings();
    160203
    161204                $interval_minutes = in_array( (int) $interval_minutes, self::ALLOWED_INTERVALS, true )
    162205                        ? (int) $interval_minutes
    163                         : 5;
     206                        : 60;
    164207
    165208                $settings = array_merge( $current, [
    … …  
    173216                                ? (bool) $current['delete_data_on_uninstall']
    174217                                : (bool) $delete_data_on_uninstall,
     218                        'db_schedule'      => null === $db_schedule ? $this->sanitize_db_schedule( $current['db_schedule'] ) : $this->sanitize_db_schedule( $db_schedule ),
    175219                ] );
    176220                $this->update_settings_option( $settings );
    177                 $this->reschedule_cron();
     221
     222                // Only restart the cache-clear timer when the interval really changed (or nothing is scheduled).
     223                if ( (int) $current['interval_minutes'] !== $interval_minutes || ! wp_next_scheduled( self::CRON_HOOK ) ) {
     224                        $this->reschedule_cron();
     225                }
     226                if ( $current['db_schedule'] !== $settings['db_schedule'] ) {
     227                        $this->reschedule_db_cron();
     228                }
    178229
    179230                if ( $current['cache_public'] && ! $settings['cache_public'] ) {
    … …  
    198249                        wp_unschedule_event( $timestamp, self::CRON_HOOK );
    199250                }
    200                 wp_schedule_event( time(), self::CRON_SCHEDULE_ID, self::CRON_HOOK );
     251                wp_schedule_event( time() + max( 60, (int) $this->get_settings()['interval_minutes'] * 60 ), self::CRON_SCHEDULE_ID, self::CRON_HOOK );
     252        }
     253
     254        public function sanitize_db_schedule( $value ) {
     255                $value = sanitize_key( (string) $value );
     256                return isset( self::DB_SCHEDULES[ $value ] ) ? $value : 'off';
     257        }
     258
     259        /** (Re)create the automatic database optimization event for the saved schedule. */
     260        public function reschedule_db_cron() {
     261                wp_clear_scheduled_hook( self::DB_CRON_HOOK );
     262                $schedule = $this->sanitize_db_schedule( $this->get_settings()['db_schedule'] );
     263                if ( 'off' === $schedule ) {
     264                        return;
     265                }
     266                wp_schedule_event( time() + self::DB_SCHEDULES[ $schedule ], 'rlcc_db_' . $schedule, self::DB_CRON_HOOK );
     267        }
     268
     269        /** Keep the cron event in step with the saved setting (covers upgrades, restored sites, cleared cron tables). */
     270        public function sync_db_cron() {
     271                $schedule = $this->sanitize_db_schedule( $this->get_settings()['db_schedule'] );
     272                $next     = wp_next_scheduled( self::DB_CRON_HOOK );
     273                if ( 'off' === $schedule ) {
     274                        if ( $next ) {
     275                                wp_clear_scheduled_hook( self::DB_CRON_HOOK );
     276                        }
     277                        return;
     278                }
     279                if ( ! $next ) {
     280                        $this->reschedule_db_cron();
     281                }
     282        }
     283
     284        /** Automatic run: clears ALL selectable data types and optimizes tables. */
     285        public function run_scheduled_db_optimize() {
     286                if ( 'off' === $this->sanitize_db_schedule( $this->get_settings()['db_schedule'] ) ) {
     287                        return;
     288                }
     289                $this->admin->optimize_database( [
     290                        'revisions'          => true,
     291                        'trashed_posts'      => true,
     292                        'spam_comments'      => true,
     293                        'trashed_comments'   => true,
     294                        'expired_transients' => true,
     295                        'optimize_tables'    => true,
     296                ], 'auto' );
    201297        }
    202298
    … …  
    230326                        'display'  => sprintf( 'Every %d Minute(s) (CacheNova)', $minutes ),
    231327                ];
     328                $schedules['rlcc_db_daily']   = [ 'interval' => self::DB_SCHEDULES['daily'],   'display' => 'Every 1 day (CacheNova database)' ];
     329                $schedules['rlcc_db_weekly']  = [ 'interval' => self::DB_SCHEDULES['weekly'],  'display' => 'Every 7 days (CacheNova database)' ];
     330                $schedules['rlcc_db_monthly'] = [ 'interval' => self::DB_SCHEDULES['monthly'], 'display' => 'Every month (CacheNova database)' ];
    232331                return $schedules;
    233332        }
    … …  
    295394                update_option( 'rlcc_installed_version', RLCC_PLUGIN_VERSION );
    296395                if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
    297                         wp_schedule_event( time(), self::CRON_SCHEDULE_ID, self::CRON_HOOK );
     396                        wp_schedule_event( time() + max( 60, (int) $this->get_settings()['interval_minutes'] * 60 ), self::CRON_SCHEDULE_ID, self::CRON_HOOK );
    298397                }
    299398                $this->divi->disable_static_css();
    300399                $this->divi->purge_et_cache_dir();
     400                $this->sync_db_cron();
    301401                $this->ensure_cache_dir( self::PUBLIC_CACHE_DIR );
    302402                $this->ensure_cache_dir( self::PRIVATE_CACHE_DIR );
    … …  
    313413                        wp_unschedule_event( $deferred, self::DEFERRED_HOOK );
    314414                }
     415                wp_clear_scheduled_hook( self::DB_CRON_HOOK );
    315416               
    316417                $this->drop_in->remove_object_cache_dropin();
    … …  
    336437                        $report['divi_core_cache'] = [ 'label' => 'Divi core cache', 'status' => 'triggered' ];
    337438                }
    338                 if ( class_exists( '\ET\Builder\Framework\Utility\CacheUtility' ) ) {
     439                if ( class_exists( '\\ET\\Builder\\Framework\\Utility\\CacheUtility' ) ) {
    339440                        delete_transient( 'et_core_page_resource_fallback' );
    340441                }
    341                
     442
    342443                $et_cache_path = trailingslashit( WP_CONTENT_DIR ) . 'et-cache';
    343                 if ( is_dir( $et_cache_path ) ) {
    344                         $this->divi->purge_et_cache_dir();
    345                         $report['divi_et_cache'] = [ 'label' => 'Divi et-cache folder', 'status' => ! is_dir( $et_cache_path ) ? 'ok' : 'failed' ];
    346                 }
     444                if ( file_exists( $et_cache_path ) || is_link( $et_cache_path ) ) {
     445                        $removed = $this->divi->purge_et_cache_dir();
     446                        clearstatcache( true, $et_cache_path );
     447                        $report['divi_et_cache'] = [ 'label' => 'Divi et-cache folder', 'status' => ( $removed && ! file_exists( $et_cache_path ) ) ? 'ok' : 'failed' ];
     448                }
     449        }
     450
     451        /** Clear known theme, page-builder and cache plugins. Every call is guarded, so missing plugins are simply skipped. */
     452        private function purge_third_party_caches( array &$report ) {
     453        $run = function ( $key, $label, $callback ) use ( &$report ) {
     454                try {
     455                        call_user_func( $callback );
     456                        $report[ $key ] = [ 'label' => $label, 'status' => 'triggered' ];
     457                } catch ( \Throwable $e ) {
     458                        $report[ $key ] = [ 'label' => $label, 'status' => 'failed' ];
     459                }
     460        };
     461
     462        if ( function_exists( 'rocket_clean_domain' ) ) {
     463                $run( 'wp_rocket', 'WP Rocket', 'rocket_clean_domain' );
     464        }
     465        if ( function_exists( 'w3tc_flush_all' ) ) {
     466                $run( 'w3_total_cache', 'W3 Total Cache', 'w3tc_flush_all' );
     467        }
     468        if ( function_exists( 'wp_cache_clear_cache' ) ) {
     469                $run( 'wp_super_cache', 'WP Super Cache', 'wp_cache_clear_cache' );
     470        }
     471        if ( class_exists( 'LiteSpeed_Cache_API' ) || defined( 'LSCWP_V' ) ) {
     472                $run( 'litespeed_cache', 'LiteSpeed Cache', function () {
     473                        do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     474                } );
     475        }
     476        if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
     477                $run( 'sg_optimizer', 'SiteGround Optimizer', 'sg_cachepress_purge_cache' );
     478        }
     479        if ( function_exists( 'autoptimize_flush_pagecache' ) ) {
     480                $run( 'autoptimize', 'Autoptimize', 'autoptimize_flush_pagecache' );
     481        }
     482        if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
     483                $run( 'autoptimize_files', 'Autoptimize files', [ 'autoptimizeCache', 'clearall' ] );
     484        }
     485        if ( function_exists( 'wpfc_clear_all_cache' ) ) {
     486                $run( 'wp_fastest_cache', 'WP Fastest Cache', function () { wpfc_clear_all_cache( true ); } );
     487        }
     488        if ( class_exists( 'Breeze_PurgeCache' ) && method_exists( 'Breeze_PurgeCache', 'breeze_cache_flush' ) ) {
     489                $run( 'breeze', 'Breeze', [ 'Breeze_PurgeCache', 'breeze_cache_flush' ] );
     490        }
     491        if ( class_exists( 'WP_Optimize' ) && function_exists( 'WP_Optimize' ) && method_exists( WP_Optimize(), 'get_page_cache' ) ) {
     492                $run( 'wp_optimize', 'WP-Optimize', function () { WP_Optimize()->get_page_cache()->purge(); } );
     493        }
     494        if ( class_exists( 'Cache_Enabler' ) && method_exists( 'Cache_Enabler', 'clear_complete_cache' ) ) {
     495                $run( 'cache_enabler', 'Cache Enabler', [ 'Cache_Enabler', 'clear_complete_cache' ] );
     496        }
     497        if ( has_action( 'wphb_clear_page_cache' ) ) {
     498                $run( 'hummingbird', 'Hummingbird', function () { do_action( 'wphb_clear_page_cache' ); } ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     499        }
     500        if ( class_exists( '\\NitroPack\\SDK\\Api\\Cache' ) || has_action( 'nitropack_purge_all' ) ) {
     501                $run( 'nitropack', 'NitroPack', function () { do_action( 'nitropack_purge_all' ); } ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     502        }
     503        if ( function_exists( 'flush_pagely_cache' ) || class_exists( 'PagelyCachePurge' ) ) {
     504                $run( 'pagely', 'Pagely', function () { do_action( 'pagely_purge_all' ); } ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     505        }
     506        if ( class_exists( 'WpeCommon' ) ) {
     507                $run( 'wp_engine', 'WP Engine', function () {
     508                        if ( method_exists( 'WpeCommon', 'purge_memcached' ) ) { WpeCommon::purge_memcached(); }
     509                        if ( method_exists( 'WpeCommon', 'purge_varnish_cache' ) ) { WpeCommon::purge_varnish_cache(); }
     510                } );
     511        }
     512        if ( function_exists( 'kinsta_clear_cache' ) || has_action( 'kinsta_clear_cache_all' ) ) {
     513                $run( 'kinsta', 'Kinsta Cache', function () { do_action( 'kinsta_clear_cache_all' ); } ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     514        }
     515        if ( function_exists( 'pantheon_clear_edge_all' ) ) {
     516                $run( 'pantheon', 'Pantheon Edge', 'pantheon_clear_edge_all' );
     517        }
     518        if ( has_action( 'cloudflare_purge_everything' ) ) {
     519                $run( 'cloudflare', 'Cloudflare', function () { do_action( 'cloudflare_purge_everything' ); } ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     520        }
     521
     522        // Page builders and themes that keep their own generated CSS/JS/data caches.
     523        if ( class_exists( '\\Elementor\\Plugin' ) && isset( \Elementor\Plugin::$instance->files_manager ) ) {
     524                $run( 'elementor', 'Elementor CSS cache', function () { \Elementor\Plugin::$instance->files_manager->clear_cache(); } );
     525        }
     526        if ( class_exists( 'FLBuilderModel' ) && method_exists( 'FLBuilderModel', 'delete_asset_cache_for_all_posts' ) ) {
     527                $run( 'beaver_builder', 'Beaver Builder cache', function () { FLBuilderModel::delete_asset_cache_for_all_posts(); } );
     528        }
     529        if ( function_exists( 'oxygen_vsb_cache_universal_css' ) ) {
     530                $run( 'oxygen', 'Oxygen cache', 'oxygen_vsb_cache_universal_css' );
     531        }
     532        if ( class_exists( '\\Bricks\\Assets' ) && method_exists( '\\Bricks\\Assets', 'delete_assets_cache' ) ) {
     533                $run( 'bricks', 'Bricks cache', function () { \Bricks\Assets::delete_assets_cache(); } );
     534        }
     535        if ( function_exists( 'wc_delete_product_transients' ) ) {
     536                $run( 'woocommerce', 'WooCommerce transients', function () {
     537                        wc_delete_product_transients();
     538                        if ( function_exists( 'wc_delete_shop_order_transients' ) ) { wc_delete_shop_order_transients(); }
     539                        if ( class_exists( 'WC_Cache_Helper' ) ) { WC_Cache_Helper::get_transient_version( 'product', true ); }
     540                } );
     541        }
     542
     543        // Lets any other theme or plugin attach its own cache cleanup.
     544        do_action( 'rlcc_purge_third_party_caches' );
    347545        }
    348546
    … …  
    356554                }
    357555                $settings = $this->get_settings();
    358                 $grace    = max( 60, (int) $settings['interval_minutes'] * 60 );
     556                $grace    = max( 3600, (int) $settings['interval_minutes'] * 60 );
    359557                if ( time() < $next + $grace ) {
    360558                        return;
    … …  
    364562                }
    365563                set_transient( 'rlcc_overdue_clear_lock', 1, 30 );
    366                 $this->light_clear();
    367                 $this->reschedule_cron();
     564                // Do not run expensive cache/database work during a visitor request.
     565                // WP-Cron will process the scheduled clear asynchronously.
    368566        }
    369567
    … …  
    418616                $report['page_cache_private'] = [ 'label' => 'Private Cache files', 'status' => $this->purge_page_cache( self::PRIVATE_CACHE_DIR ) ? 'ok' : 'failed' ];
    419617
    420                 if ( function_exists( 'rocket_clean_domain' ) ) {
    421                         rocket_clean_domain();
    422                         $report['wp_rocket'] = [ 'label' => 'WP Rocket', 'status' => 'triggered' ];
    423                 }
    424                 if ( function_exists( 'w3tc_flush_all' ) ) {
    425                         w3tc_flush_all();
    426                         $report['w3_total_cache'] = [ 'label' => 'W3 Total Cache', 'status' => 'triggered' ];
    427                 }
    428                 if ( function_exists( 'wp_cache_clear_cache' ) ) {
    429                         wp_cache_clear_cache();
    430                         $report['wp_super_cache'] = [ 'label' => 'WP Super Cache', 'status' => 'triggered' ];
    431                 }
    432                 if ( class_exists( 'LiteSpeed_Cache_API' ) ) {
    433                         do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    434                         $report['litespeed_cache'] = [ 'label' => 'LiteSpeed Cache', 'status' => 'triggered' ];
    435                 }
    436                 if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
    437                         sg_cachepress_purge_cache();
    438                         $report['sg_optimizer'] = [ 'label' => 'SiteGround Optimizer', 'status' => 'triggered' ];
    439                 }
    440                 if ( function_exists( 'autoptimize_flush_pagecache' ) ) {
    441                         autoptimize_flush_pagecache();
    442                         $report['autoptimize'] = [ 'label' => 'Autoptimize', 'status' => 'triggered' ];
     618                $this->purge_third_party_caches( $report );
     619
     620                // Other cache integrations may regenerate Divi assets; remove the root last.
     621                $divi_path    = trailingslashit( WP_CONTENT_DIR ) . 'et-cache';
     622                $divi_deleted = $this->divi->purge_et_cache_dir();
     623                clearstatcache( true, $divi_path );
     624                if ( file_exists( $divi_path ) || is_link( $divi_path ) || $this->divi->is_active() ) {
     625                        $report['divi_et_cache'] = [
     626                                'label'  => 'Divi et-cache folder',
     627                                'status' => ( $divi_deleted && ! file_exists( $divi_path ) ) ? 'ok' : 'failed',
     628                        ];
    443629                }
    444630
    … …  
    449635
    450636                do_action( 'rlcc_after_cache_clear' );
     637
     638                // Integrations hooked above may recreate et-cache; purge again now and once more at the end of the request.
     639                if ( $this->divi->is_active() ) {
     640                        $divi_deleted = $this->divi->purge_et_cache_dir();
     641                        clearstatcache( true, $divi_path );
     642                        $report['divi_et_cache'] = [
     643                                'label'  => 'Divi et-cache folder',
     644                                'status' => ( $divi_deleted && ! file_exists( $divi_path ) ) ? 'ok' : 'failed',
     645                        ];
     646                        update_option( 'rlcc_last_clear_report', [ 'type' => 'full', 'items' => $report ] );
     647                        $this->divi->schedule_shutdown_purge();
     648                }
    451649        }
    452650
    … …  
    457655                        if ( $item === '.' || $item === '..' ) continue;
    458656                        $path = $dir . '/' . $item;
    459                         if ( is_dir( $path ) ) {
     657                        if ( is_dir( $path ) && ! is_link( $path ) ) {
    460658                                $this->recursive_clear_directory( $path );
    461659                                $this->fs()->rmdir( $path );
    … …  
    624822        }
    625823
     824        /**
     825         * Browser cache clear: after every cache clear the stored asset version changes, and this adds it to the
     826         * URLs of this site's own CSS/JS files so visitors' browsers download fresh copies. Opt-in; front end only.
     827         */
     828        public function bust_browser_cache_src( $src ) {
     829                if ( is_admin() || ! is_string( $src ) || '' === $src ) {
     830                        return $src;
     831                }
     832                $settings = $this->get_settings();
     833                if ( empty( $settings['cache_browser'] ) ) {
     834                        return $src;
     835                }
     836                $version = (int) get_option( 'rlcc_asset_version', 0 );
     837                if ( $version < 1 ) {
     838                        return $src;
     839                }
     840                $src_host = wp_parse_url( $src, PHP_URL_HOST );
     841                if ( $src_host && strtolower( $src_host ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) {
     842                        return $src; // Only this site's own files; never third-party URLs.
     843                }
     844                return add_query_arg( 'rlcc_v', $version, $src );
     845        }
     846
    626847        public function maybe_send_browser_cache_headers() {
    627848                if ( is_admin() || headers_sent() ) {
    … …  
    633854                $settings = $this->get_settings();
    634855                if ( empty( $settings['cache_browser'] ) ) {
     856                        return;
     857                }
     858                $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
     859                if ( ! in_array( $method, [ 'GET', 'HEAD' ], true ) ) {
     860                        return;
     861                }
     862                // Never let browsers or proxies store personal, dynamic or private pages.
     863                if ( is_user_logged_in() || ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) ) {
     864                        return;
     865                }
     866                if ( is_404() || is_search() || is_preview() || is_customize_preview() || ( is_singular() && post_password_required() ) ) {
     867                        return;
     868                }
     869                if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || ( function_exists( 'is_account_page' ) && is_account_page() ) ) ) {
    635870                        return;
    636871                }
  • cachenova/trunk/includes/admin/admin.php

    r3738364 r3777263  
    3131                                        'cache_browser'    => [ 'required' => false ],
    3232                                        'delete_data_on_uninstall' => [ 'required' => false ],
     33                                        'db_schedule'      => [ 'required' => false ],
    3334                                        'object_cache_enabled'        => [ 'required' => false ],
    3435                                        'object_cache_backend'        => [ 'required' => false ],
    … …  
    4243                ] );
    4344
     45                register_rest_route( RLCC_Cache_Cleaner::REST_NAMESPACE, '/divi-per-request', [
     46                        'methods' => 'POST',
     47                        'callback' => [ $this, 'rest_divi_per_request' ],
     48                        'permission_callback' => [ $this, 'rest_permissions' ],
     49                ] );
     50
    4451                register_rest_route( RLCC_Cache_Cleaner::REST_NAMESPACE, '/object-cache/test', [
    4552                        'methods'             => 'POST',
    … …  
    5966                        'permission_callback' => [ $this, 'rest_permissions' ],
    6067                ] );
     68        }
     69
     70        public function rest_divi_per_request( $request ) {
     71                $enabled = rest_sanitize_boolean( $request->get_param( 'enabled' ) );
     72                $settings = $this->plugin->get_settings();
     73                $settings['divi_per_request_purge'] = $enabled;
     74                $this->plugin->update_settings_option( $settings );
     75                $deleted = null;
     76                if ( $enabled ) {
     77                        $deleted = $this->plugin->divi->purge_et_cache_dir();
     78                }
     79                return rest_ensure_response( [ 'enabled' => $enabled, 'folder_deleted' => $deleted ] );
    6180        }
    6281
    … …  
    7796                $settings['advanced_cache_dropin_active']    = $this->plugin->drop_in->is_our_advanced_cache_dropin_active();
    7897                $settings['advanced_cache_foreign_dropin']   = $this->plugin->drop_in->is_foreign_advanced_cache_dropin_present();
     98                $settings['object_cache_dropin_details'] = $this->plugin->drop_in->describe_foreign_dropin( 'object-cache.php' );
     99                $settings['advanced_cache_dropin_details'] = $this->plugin->drop_in->describe_foreign_dropin( 'advanced-cache.php' );
    79100                $settings['wp_cache_constant_true']          = $this->plugin->drop_in->is_wp_cache_constant_true();
    80101                $settings['wp_config_writable']              = $this->plugin->drop_in->wp_config_writable();
    81102                $settings['local_ssl_fix_active']            = has_filter( 'https_local_ssl_verify', '__return_false' );
    82103                $settings['divi_active']                     = $this->plugin->divi->is_active();
     104        $settings['divi_per_request_purge'] = ! empty( $settings['divi_per_request_purge'] );
    83105                $settings['last_db_optimize']                = get_option( 'rlcc_last_db_optimize', null );
    84106                $settings['db_counts']                       = $this->get_database_counts();
     107                $settings['db_next_run_timestamp']           = wp_next_scheduled( RLCC_Cache_Cleaner::DB_CRON_HOOK );
     108                $settings['db_schedule']                     = $this->plugin->sanitize_db_schedule( $settings['db_schedule'] );
    85109                return rest_ensure_response( $settings );
    86110        }
    … …  
    112136                $delete_data_on_uninstall = null !== $request->get_param( 'delete_data_on_uninstall' ) ? (bool) $request->get_param( 'delete_data_on_uninstall' ) : null;
    113137
    114                 $saved = $this->plugin->save_settings( $interval_minutes, $toast_enabled, $cache_public, $cache_private, $cache_object, $cache_browser, $delete_data_on_uninstall );
     138                $db_schedule = null !== $request->get_param( 'db_schedule' ) ? sanitize_key( (string) $request->get_param( 'db_schedule' ) ) : null;
     139
     140                $saved = $this->plugin->save_settings( $interval_minutes, $toast_enabled, $cache_public, $cache_private, $cache_object, $cache_browser, $delete_data_on_uninstall, $db_schedule );
     141                $saved['db_next_run_timestamp'] = wp_next_scheduled( RLCC_Cache_Cleaner::DB_CRON_HOOK );
    115142
    116143                $oc_enabled = $request->get_param( 'object_cache_enabled' );
    … …  
    169196
    170197        public function rest_clear_cache( $request ) {
     198                // Complete the purge before responding, so the dashboard reports the actual result.
    171199                $this->plugin->reschedule_cron();
    172                 $data = [
     200                $this->plugin->full_clear();
     201                $report = get_option( 'rlcc_last_clear_report', [] );
     202                $divi_result = isset( $report['items']['divi_et_cache']['status'] ) ? $report['items']['divi_et_cache']['status'] : 'not_present';
     203                return rest_ensure_response( [
    173204                        'cleared'                => true,
    174                         'last_cleared_timestamp' => time(),
     205                        'last_cleared_timestamp' => (int) get_option( 'rlcc_last_cleared', time() ),
    175206                        'next_run_timestamp'     => wp_next_scheduled( RLCC_Cache_Cleaner::CRON_HOOK ),
    176                 ];
    177 
    178                 if ( function_exists( 'fastcgi_finish_request' ) ) {
    179                         add_filter( 'rest_pre_serve_request', function ( $served, $result, $req, $server ) use ( $data ) {
    180                                 if ( $served ) {
    181                                         return $served;
    182                                 }
    183                                 status_header( 200 );
    184                                 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
    185                                 echo wp_json_encode( $data );
    186                                 fastcgi_finish_request();
    187                                 $this->plugin->full_clear();
    188                                 return true;
    189                         }, 10, 4 );
    190                         return rest_ensure_response( $data );
    191                 }
    192 
    193                 $this->plugin->full_clear();
    194                 return rest_ensure_response( $data );
    195         }
    196 
    197         public function optimize_database( $options ) {
     207                        'last_clear_report'      => $report,
     208                        'divi_folder_deleted'   => ! is_dir( trailingslashit( WP_CONTENT_DIR ) . 'et-cache' ),
     209                        'divi_folder_status'    => $divi_result,
     210                        'divi_folder_exists'    => is_dir( trailingslashit( WP_CONTENT_DIR ) . 'et-cache' ),
     211                        'divi_folder_message'   => is_dir( trailingslashit( WP_CONTENT_DIR ) . 'et-cache' ) ? 'Divi et-cache exists after the purge. It may have been recreated, or the web server lacks deletion permissions.' : 'Divi et-cache directory is absent after clearing.',
     212                ] );
     213        }
     214
     215        public function optimize_database( $options, $type = 'manual' ) {
    198216                global $wpdb;
    199217                $report = [];
    … …  
    235253
    236254                if ( ! empty( $options['expired_transients'] ) ) {
    237                         $count = (int) $wpdb->query( "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b WHERE a.option_name LIKE '\_transient\_timeout\_%' AND b.option_name = CONCAT('_transient_', SUBSTRING(a.option_name, 20)) AND a.option_value < UNIX_TIMESTAMP()" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    238                         $report['expired_transients'] = max( 0, (int) ( $count / 2 ) );
     255                        // Use WordPress APIs so both database and external object-cache transients are handled safely, in batches.
     256                        $deleted = 0;
     257                        for ( $batch = 0; $batch < 20; $batch++ ) {
     258                                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     259                                $expired = $wpdb->get_col( $wpdb->prepare(
     260                                        "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %d LIMIT 500",
     261                                        $wpdb->esc_like( '_transient_timeout_' ) . '%', time()
     262                                ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     263                                if ( empty( $expired ) ) {
     264                                        break;
     265                                }
     266                                $batch_deleted = 0;
     267                                foreach ( $expired as $name ) {
     268                                        $key = substr( $name, strlen( '_transient_timeout_' ) );
     269                                        if ( delete_transient( $key ) ) {
     270                                                $batch_deleted++;
     271                                        } else {
     272                                                // Orphaned timeout row: remove it directly so the loop always makes progress.
     273                                                delete_option( $name );
     274                                                $batch_deleted++;
     275                                        }
     276                                }
     277                                $deleted += $batch_deleted;
     278                                if ( count( $expired ) < 500 ) {
     279                                        break;
     280                                }
     281                        }
     282                        $report['expired_transients'] = $deleted;
    239283                }
    240284
    … …  
    258302
    259303                wp_cache_delete( 'rlcc_db_counts', 'cachenova' );
    260                 update_option( 'rlcc_last_db_optimize', [ 'timestamp' => time(), 'report' => $report ] );
     304                update_option( 'rlcc_last_db_optimize', [ 'timestamp' => time(), 'report' => $report, 'type' => 'auto' === $type ? 'auto' : 'manual' ] );
    261305                return $report;
    262306        }
    … …  
    297341                }
    298342
    299                 $css_file = 'assets/css/cn-admin.min.css';
    300                 $js_file  = 'assets/js/cn-admin.min.js';
     343                $css_file = 'assets/css/cn-admin.css';
     344                $js_file  = 'assets/js/cn-admin.js';
    301345
    302346                wp_enqueue_style(
    … …  
    311355                        plugins_url( $js_file, RLCC_PLUGIN_FILE ),
    312356                        [ 'wp-element', 'wp-api-fetch', 'wp-i18n' ],
    313                         RLCC_PLUGIN_VERSION,
     357                        filemtime( plugin_dir_path( RLCC_PLUGIN_FILE ) . $js_file ),
    314358                        true
    315359                );
    … …  
    341385        }
    342386
     387        /**
     388         * Top-bar "Clear Cache Now": spinning icon + toast, without a page reload.
     389         * The link keeps its normal href, so it still works as a plain fallback if JavaScript fails.
     390         */
     391        public function enqueue_admin_bar_assets() {
     392                if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) {
     393                        return;
     394                }
     395                $settings = $this->plugin->get_settings();
     396
     397                $css = '#wpadminbar #wp-admin-bar-rlcc-clear-cache.rlcc-is-clearing>.ab-item{opacity:.85;cursor:progress}'
     398                        . '.rlcc-bar-toast{position:fixed;top:48px;right:20px;z-index:999999;display:flex;align-items:center;gap:10px;max-width:min(380px,calc(100vw - 32px));padding:13px 18px;border-radius:12px;background:#fff;color:#18223b;font:600 13.5px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;border:1px solid #e7eaf2;border-left:4px solid #635bdf;box-shadow:0 12px 35px rgba(20,27,55,.2);cursor:pointer;animation:rlccBarToastIn .22s ease}'
     399                        . '.rlcc-bar-toast--success{border-left-color:#1f9d63}.rlcc-bar-toast--error{border-left-color:#c0392b}'
     400                        . '.rlcc-bar-toast-icon{flex:none;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:800;color:#fff;background:#635bdf}'
     401                        . '.rlcc-bar-toast--success .rlcc-bar-toast-icon{background:#1f9d63}.rlcc-bar-toast--error .rlcc-bar-toast-icon{background:#c0392b}'
     402                        . '.rlcc-bar-toast.rlcc-bar-toast--out{opacity:0;transform:translateY(-6px);transition:opacity .25s ease,transform .25s ease}'
     403                        . '@keyframes rlccBarToastIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}'
     404                        . '@media(max-width:782px){.rlcc-bar-toast{top:54px;right:12px}}'
     405                        . '@media (prefers-reduced-motion:reduce){.rlcc-bar-toast{animation:none}}';
     406                wp_register_style( 'cachenova-adminbar', false, [], RLCC_PLUGIN_VERSION );
     407                wp_enqueue_style( 'cachenova-adminbar' );
     408                wp_add_inline_style( 'cachenova-adminbar', $css );
     409
     410                $config = [
     411                        'url'          => esc_url_raw( rest_url( RLCC_Cache_Cleaner::REST_NAMESPACE . '/clear' ) ),
     412                        'nonce'        => wp_create_nonce( 'wp_rest' ),
     413                        'toast'        => ! empty( $settings['toast_enabled'] ),
     414                        'msgOk'        => __( 'All theme & plugin caches cleared.', 'cachenova' ),
     415                        'msgDone'      => __( 'Cache cleared.', 'cachenova' ),
     416                        'msgFail'      => __( 'Could not clear the cache. Please try again.', 'cachenova' ),
     417                        'msgDivi'      => __( 'Cache cleared, but the Divi et-cache folder still exists.', 'cachenova' ),
     418                        'msgWorking'   => __( 'Clearing cache…', 'cachenova' ),
     419                ];
     420                $js = <<<'JS'
     421(function(){
     422        var C = window.RLCC_BAR; if (!C) return;
     423        function toast(msg, type){
     424                if (!C.toast) return;
     425                var old = document.querySelector('.rlcc-bar-toast'); if (old) old.remove();
     426                var t = document.createElement('div');
     427                t.className = 'rlcc-bar-toast rlcc-bar-toast--' + (type || 'info');
     428                t.setAttribute('role', 'status');
     429                var i = document.createElement('span'); i.className = 'rlcc-bar-toast-icon';
     430                i.textContent = type === 'success' ? '\u2713' : (type === 'error' ? '\u2715' : 'i');
     431                var m = document.createElement('span'); m.textContent = msg;
     432                t.appendChild(i); t.appendChild(m); document.body.appendChild(t);
     433                var close = function(){ t.classList.add('rlcc-bar-toast--out'); setTimeout(function(){ t.remove(); }, 260); };
     434                t.addEventListener('click', close); setTimeout(close, 3500);
     435        }
     436        function init(){
     437                var node = document.getElementById('wp-admin-bar-rlcc-clear-cache'); if (!node) return;
     438                var link = node.querySelector('a'); if (!link) return;
     439                var busy = false;
     440                var spin = node.querySelector('animateTransform');
     441                function setSpin(sec){ if (spin) { spin.setAttribute('dur', sec + 's'); } }
     442                if (spin && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { spin.parentNode.removeChild(spin); spin = null; }
     443                // Fallback path (JS-less click that reloaded the page): confirm it with a toast, then tidy the URL.
     444                if (/[?&]cleared=1/.test(window.location.search)) {
     445                        toast(C.msgOk, 'success');
     446                        try { var u = new URL(window.location.href); u.searchParams.delete('cleared'); window.history.replaceState(null, '', u.toString()); } catch (e) {}
     447                }
     448                link.addEventListener('click', function(e){
     449                        e.preventDefault();
     450                        if (busy) return;
     451                        busy = true; setSpin(0.6); node.classList.add('rlcc-is-clearing'); toast(C.msgWorking, 'info');
     452                        fetch(C.url, { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': C.nonce, 'Content-Type': 'application/json' }, body: '{}' })
     453                                .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
     454                                .then(function(res){
     455                                        if (res && res.divi_folder_deleted === false) { toast(C.msgDivi, 'error'); }
     456                                        else { toast(C.msgOk, 'success'); }
     457                                })
     458                                .catch(function(){ toast(C.msgFail, 'error'); })
     459                                .then(function(){ busy = false; setSpin(4); node.classList.remove('rlcc-is-clearing'); });
     460                });
     461        }
     462        if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }
     463})();
     464JS;
     465                wp_register_script( 'cachenova-adminbar', false, [], RLCC_PLUGIN_VERSION, true );
     466                wp_enqueue_script( 'cachenova-adminbar' );
     467                wp_add_inline_script( 'cachenova-adminbar', 'window.RLCC_BAR=' . wp_json_encode( $config ) . ';', 'before' );
     468                wp_add_inline_script( 'cachenova-adminbar', $js );
     469        }
     470
    343471        public function add_admin_bar_button( $wp_admin_bar ) {
    344472                if ( ! current_user_can( 'manage_options' ) ) return;
     473                // Inline SVG (CacheNova sparkle mark) so the button is recognisable on every admin and front-end page, with no extra CSS file.
     474                $icon = '<span class="ab-icon rlcc-ab-icon" aria-hidden="true" style="display:inline-block;width:22px;height:22px;margin:-4px 7px 0 0;padding:0;vertical-align:middle;float:none;background:#7c6cf5;border-radius:6px;line-height:0;">'
     475                        . '<svg class="rlcc-ab-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22" style="display:block;">'
     476                        . '<g class="rlcc-ab-star"><path fill="#ffffff" d="M11 3.8l1.7 4.7 4.7 1.7-4.7 1.7L11 16.6l-1.7-4.7-4.7-1.7 4.7-1.7z"/><animateTransform attributeName="transform" type="rotate" from="0 11 10.2" to="360 11 10.2" dur="4s" repeatCount="indefinite"/></g>'
     477                        . '<circle cx="16.6" cy="5.4" r="1.2" fill="#ffd86b"/>'
     478                        . '</svg></span>';
    345479                $wp_admin_bar->add_node( [
    346480                        'id'    => 'rlcc-clear-cache',
    347                         'title' => 'Clear Cache Now',
     481                        'title' => $icon . '<span class="ab-label">' . esc_html__( 'Clear Cache Now', 'cachenova' ) . '</span>',
    348482                        'href'  => wp_nonce_url( admin_url( 'admin-post.php?action=rlcc_manual_clear' ), 'rlcc_manual_clear_action', 'rlcc_nonce' ),
     483                        'meta'  => [ 'title' => esc_attr__( 'CacheNova — clear all caches', 'cachenova' ) ],
    349484                ] );
    350485        }
  • cachenova/trunk/includes/core/class-cache-drop-in-manager.php

    r3737033 r3777263  
    1010        public function __construct( $plugin ) {
    1111                $this->plugin = $plugin;
     12        }
     13
     14        /** Report existing drop-ins without overwriting third-party code or exposing secrets. */
     15        public function describe_foreign_dropin( $filename ) {
     16                if ( ! in_array( $filename, [ 'object-cache.php', 'advanced-cache.php' ], true ) ) return 'Unknown file';
     17                $path = trailingslashit( WP_CONTENT_DIR ) . $filename;
     18                if ( ! is_file( $path ) ) return 'No drop-in found';
     19                $head = @file_get_contents( $path, false, null, 0, 2048 );
     20                if ( false === $head ) return 'Drop-in exists but cannot be read';
     21                if ( false !== strpos( $head, 'cachenova-config/' ) || false !== strpos( $head, 'RLCC_' ) ) return 'CacheNova drop-in';
     22                if ( false !== stripos( $head, 'LiteSpeed' ) ) return 'Existing LiteSpeed drop-in';
     23                if ( false !== stripos( $head, 'Redis' ) ) return 'Existing Redis drop-in';
     24                if ( false !== stripos( $head, 'W3 Total Cache' ) ) return 'Existing W3 Total Cache drop-in';
     25                if ( false !== stripos( $head, 'WP Rocket' ) ) return 'Existing WP Rocket drop-in';
     26                return 'Existing WordPress drop-in (owner not identified)';
    1227        }
    1328
    … …  
    5570                }
    5671                $head = @file_get_contents( $path, false, null, 0, 4096 );
    57                 return $head && false !== strpos( $head, RLCC_Cache_Cleaner::OBJECT_CACHE_DROPIN_SIGNATURE );
     72                // Signature for current installs; config-path fingerprint for files written by earlier builds (which had no signature).
     73                return $head && ( false !== strpos( $head, RLCC_Cache_Cleaner::OBJECT_CACHE_DROPIN_SIGNATURE ) || false !== strpos( $head, 'cachenova-config/object-cache-config.json' ) );
    5874        }
    5975
    … …  
    247263                }
    248264                $head = @file_get_contents( $path, false, null, 0, 4096 );
    249                 return $head && false !== strpos( $head, RLCC_Cache_Cleaner::ADVANCED_CACHE_DROPIN_SIGNATURE );
     265                return $head && ( false !== strpos( $head, RLCC_Cache_Cleaner::ADVANCED_CACHE_DROPIN_SIGNATURE ) || false !== strpos( $head, 'cachenova-config/early-cache-config.json' ) );
    250266        }
    251267
  • cachenova/trunk/integration/class-rlcc-divi-integration.php

    r3737033 r3777263  
    4040        }
    4141
     42        /** Ask Divi itself to drop its generated resources first (best effort, never fatal). */
     43        private function ask_divi_to_clear() {
     44                try {
     45                        if ( function_exists( 'et_core_clear_wp_cache' ) ) {
     46                                et_core_clear_wp_cache();
     47                        }
     48                        if ( class_exists( 'ET_Core_PageResource' ) && method_exists( 'ET_Core_PageResource', 'remove_static_resources' ) ) {
     49                                ET_Core_PageResource::remove_static_resources( 'all', 'all', true );
     50                        }
     51                } catch ( \Throwable $e ) {
     52                        // Divi internals differ between versions; the filesystem purge below is the real work.
     53                }
     54        }
     55
     56        /** Safety check: only ever delete wp-content/et-cache, with Windows-safe path comparison. */
     57        private function is_safe_et_cache_path( $path ) {
     58                $root   = realpath( WP_CONTENT_DIR );
     59                $actual = realpath( $path );
     60                if ( ! $root || ! $actual ) return false;
     61                $root   = untrailingslashit( wp_normalize_path( $root ) );
     62                $actual = untrailingslashit( wp_normalize_path( $actual ) );
     63                $parent = dirname( $actual );
     64                if ( DIRECTORY_SEPARATOR === '\\' ) {
     65                        $root   = strtolower( $root );
     66                        $parent = strtolower( $parent );
     67                }
     68                return $parent === $root && 'et-cache' === strtolower( basename( $actual ) );
     69        }
     70
     71        /** WP_Filesystem handle shared with the main plugin (direct method). */
     72        private function fs() {
     73                return ( $this->plugin && method_exists( $this->plugin, 'fs' ) ) ? $this->plugin->fs() : null;
     74        }
     75
     76        /** Delete one file or link; fixes read-only permissions (common on Windows/Laragon/XAMPP) and retries once. */
     77        private function remove_file( $path ) {
     78                wp_delete_file( $path );
     79                clearstatcache( true, $path );
     80                if ( file_exists( $path ) || is_link( $path ) ) {
     81                        $fs = $this->fs();
     82                        if ( $fs ) {
     83                                $fs->chmod( $path, 0666 );
     84                        }
     85                        wp_delete_file( $path );
     86                        clearstatcache( true, $path );
     87                }
     88                return ! ( file_exists( $path ) || is_link( $path ) );
     89        }
     90
     91        /** Remove an EMPTY directory (non-recursive), fixing permissions and retrying once. */
     92        private function remove_empty_dir( $dir ) {
     93                $fs = $this->fs();
     94                if ( ! $fs ) {
     95                        return false;
     96                }
     97                $fs->delete( $dir, false, 'd' );
     98                clearstatcache( true, $dir );
     99                if ( file_exists( $dir ) ) {
     100                        $fs->chmod( $dir, 0777 );
     101                        $fs->delete( $dir, false, 'd' );
     102                        clearstatcache( true, $dir );
     103                }
     104                return ! file_exists( $dir );
     105        }
     106
     107        /** Remove a symbolic link / junction itself, never its target. */
     108        private function remove_link( $path ) {
     109                if ( ! $this->remove_file( $path ) && is_link( $path ) ) {
     110                        $fs = $this->fs();
     111                        if ( $fs ) {
     112                                $fs->delete( $path, false, 'd' ); // Windows junctions are removed like empty directories.
     113                        }
     114                }
     115                clearstatcache( true, $path );
     116                return ! ( file_exists( $path ) || is_link( $path ) );
     117        }
     118
     119        /** Recursively delete a tree; never follows links. */
     120        private function remove_tree( $dir ) {
     121                $items = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     122                if ( false === $items ) {
     123                        $fs = $this->fs();
     124                        if ( $fs ) {
     125                                $fs->chmod( $dir, 0755 );
     126                        }
     127                        $items = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     128                        if ( false === $items ) return false;
     129                }
     130                $ok = true;
     131                foreach ( $items as $name ) {
     132                        if ( '.' === $name || '..' === $name ) continue;
     133                        $item = $dir . DIRECTORY_SEPARATOR . $name;
     134                        if ( is_link( $item ) ) {
     135                                if ( ! $this->remove_link( $item ) ) $ok = false;
     136                        } elseif ( is_dir( $item ) ) {
     137                                if ( ! $this->remove_tree( $item ) ) $ok = false;
     138                        } elseif ( ! $this->remove_file( $item ) ) {
     139                                $ok = false;
     140                        }
     141                }
     142                if ( ! $this->remove_empty_dir( $dir ) ) $ok = false;
     143                return $ok;
     144        }
     145
     146        /** Remove the entire Divi et-cache directory; never follows symbolic links. */
    42147        public function purge_et_cache_dir() {
    43148                $path = trailingslashit( WP_CONTENT_DIR ) . 'et-cache';
    44                 if ( is_dir( $path ) ) {
    45                         $this->plugin->recursive_clear_directory( $path );
    46                         $this->plugin->fs()->rmdir( $path );
     149                clearstatcache( true, $path );
     150                if ( ! file_exists( $path ) && ! is_link( $path ) ) return true;
     151
     152                $this->ask_divi_to_clear();
     153
     154                clearstatcache( true, $path );
     155                if ( ! file_exists( $path ) && ! is_link( $path ) ) return true;
     156
     157                // A symlinked et-cache: remove only the link itself, never the target.
     158                if ( is_link( $path ) ) {
     159                        return $this->remove_link( $path );
    47160                }
     161                if ( ! is_dir( $path ) ) {
     162                        return $this->remove_file( $path );
     163                }
     164                if ( ! $this->is_safe_et_cache_path( $path ) ) return false;
     165
     166                $this->remove_tree( $path );
     167                clearstatcache( true, $path );
     168                if ( file_exists( $path ) ) {
     169                        // One retry: Divi or the OS may have been holding a file open a moment ago.
     170                        usleep( 150000 );
     171                        $this->remove_tree( $path );
     172                        clearstatcache( true, $path );
     173                }
     174                return ! file_exists( $path );
    48175        }
     176
     177        /** Delete et-cache once more at the very end of the request, after Divi's own shutdown writers have run. */
     178        public function schedule_shutdown_purge() {
     179                static $registered = false;
     180                if ( $registered ) return;
     181                $registered = true;
     182                add_action( 'shutdown', [ $this, 'purge_et_cache_dir' ], PHP_INT_MAX );
     183        }
     184
    49185}
    50186
  • cachenova/trunk/languages/cachenova.pot

    r3737033 r3777263  
    11msgid ""
    22msgstr ""
    3 "Project-Id-Version: CacheNova 1.0.0\n"
     3"Project-Id-Version: CacheNova 1.1.0\n"
    44"Report-Msgid-Bugs-To: https://shaoun18.github.io/\n"
    55"MIME-Version: 1.0\n"
    66"Content-Type: text/plain; charset=UTF-8\n"
    77"Content-Transfer-Encoding: 8bit\n"
    8 "POT-Creation-Date: 2026-09-25T00:00:00+00:00\n"
    9 "PO-Revision-Date: 2026-09-25T00:00:00+00:00\n"
     8"POT-Creation-Date: 2026-10-23T00:00:00+00:00\n"
     9"PO-Revision-Date: 2026-10-23T00:00:00+00:00\n"
    1010"X-Generator: manual\n"
    1111"X-Domain: cachenova\n"
  • cachenova/trunk/readme.txt

    r3738344 r3777263  
    1 === CacheNova ===
     1=== CacheNova – Auto Clear Cache, Browser Cache & Database Optimizer ===
    22Contributors: shaounchandrashill
    3 Tags: cache, performance, object cache, page cache, developer tools
     3Tags: clear cache, auto clear cache, browser cache, cache, performance
    44Requires at least: 7.0
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.0
     7Stable tag: 1.1.0
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Automatically clears and manages WordPress cache layers on a scheduled basis, helping keep the site fresh and optimized without impacting performance.
     11Auto clear cache on a schedule or on save. Clear browser, page and object cache, plus database optimization. Works with Divi, Elementor, WooCommerce.
    1212
    1313== Description ==
    1414
    15 A general-purpose cache-clearing plugin for any WordPress site. It works identically regardless of which theme or page builder you use — Divi, Elementor, Gutenberg, a classic theme, all of it — because it hooks into WordPress's own standard save/activate/deactivate events, not anything builder-specific.
     15**CacheNova is an auto cache clear plugin for WordPress.** It clears your site's cache automatically, on a schedule you choose and every time you change something, so visitors always see your latest edits. Need it right now? Click **Clear Cache Now** in the top admin bar, on any page, with no page reload.
    1616
    17 **What it does, on any site:**
     17Works with any theme and any page builder: Divi, Elementor, Beaver Builder, Bricks, Oxygen, the block editor and classic themes.
    1818
    19 * Instantly clears every relevant cache the moment you save a page or post — the block editor, the Customizer, Divi Builder, or any other editor — no manual reload required.
    20 * Runs a lightweight background job on a custom schedule (1, 3, 5, 10, 20, 30, or 60 minutes — your choice) to keep object cache and expired transients tidy, without touching site performance.
    21 * Also clears automatically whenever any plugin on the site is activated, deactivated, or deleted.
    22 * Detects and flushes WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache, SiteGround Optimizer, and Autoptimize automatically if they're active — no extra configuration.
    23 * Optional built-in Public/Private page caching, with a true early-bypass mode that serves cached pages before WordPress even loads.
    24 * Optional persistent object cache — APCu, Redis, Memcached, or a zero-dependency File-based fallback — auto-detected and auto-selected, no manual backend picking or connection testing required.
    25 * Automatically refreshes its own page-cache and object-cache files whenever the plugin itself is updated, so bug fixes and speed improvements actually take effect instead of a stale copy silently running forever.
    26 * Optional Database Optimization — clean post revisions, trashed posts, spam comments, and expired transients, plus a table-optimization pass. Off by default, only runs when explicitly requested.
    27 * Ships with a modern React-based settings panel (built on WordPress's own bundled React — no separate build step, no bloat) with live toast notifications and a real-time countdown to the next scheduled clear.
     19= Clear cache automatically (auto cache clear) =
    2820
    29 **Why "light" vs "full" clearing matters**
     21* **Auto clear cache on a schedule** — every 10, 20 or 30 minutes, every hour, every day, every 7 days or every 30 days.
     22* **Auto clear cache on save** — clears the moment you update a post or page, save in the Customizer, edit a menu, switch theme, or activate, deactivate, update or delete a plugin.
     23* **Clear Cache Now button** in the WordPress admin bar and inside the plugin dashboard, with a live toast message and a spinning icon while it works.
     24* **Smart light and full clears** — the frequent scheduled clear is lightweight; the full clear only runs when something really changed.
    3025
    31 Earlier cache-clearing approaches run a full clear (OPcache reset, full cache-plugin flush, filesystem walks) on every timer tick — which can itself slow a site down if it happens too often. This plugin splits the work:
     26= Browser cache clear =
    3227
    33 * **Light clear** (runs on your background schedule): only the cache types you've switched on. Cheap, frequent, invisible.
    34 * **Full clear** (runs only when something actually changed — a save, manual click, theme switch, or plugin update): everything, including OPcache and third-party cache plugin flushes.
     28Turn on **Browser Cache** and every cache clear gives your site's own CSS and JavaScript files a fresh version number, so visitors' browsers download the newest files instead of showing an old copy. Cache-Control headers are sent only to logged-out visitors, and never on cart, checkout, account, search, 404, preview or password-protected pages. It is off by default.
    3529
    36 **Automatic bonus support for Divi**
     30= Clears every cache layer =
    3731
    38 If Divi happens to be active on the site, the plugin detects it automatically and adds continuous purging of Divi's `et-cache` directory — a common source of "I edited it but nothing changed" on Divi sites — plus disabling Divi 4's Static CSS File Generation setting so the folder is never created in the first place. On Divi 5 (which removed that setting entirely), the plugin purges the folder after every request instead. None of this runs, and none of it is needed, on a site that isn't using Divi — the rest of the plugin works exactly the same either way.
     32* Object cache and expired transients
     33* PHP OPcache
     34* Built-in public and private page cache, plus an optional early-bypass mode that serves cached pages before WordPress loads
     35* Optional persistent object cache: Redis, Memcached, APCu or a file-based fallback, picked automatically
     36* **Other cache plugins, when active:** WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache, SiteGround Optimizer, Autoptimize, WP Fastest Cache, Breeze, WP-Optimize, Cache Enabler, Hummingbird and NitroPack
     37* **Page builders and WooCommerce, when active:** Elementor, Beaver Builder, Bricks, Oxygen and WooCommerce product transients
     38* **Hosting caches, when detected:** WP Engine, Kinsta, Pantheon, Pagely and Cloudflare plugin hooks
     39* Developers can hook their own cleanup into `rlcc_purge_third_party_caches`
     40
     41= Database optimization (automatic or manual) =
     42
     43A dedicated **Database Optimization** tab keeps your database small and fast:
     44
     45* Auto-optimize **every 1 day, every 7 days or every month**. Each run clears all post revisions, trashed posts, spam comments, trashed comments and expired transients, then optimizes your database tables.
     46* Or run a manual cleanup whenever you want, choosing exactly which items to clean.
     47* Off by default. Nothing is deleted until you turn it on and confirm.
     48
     49= Divi cache clear (automatic bonus support) =
     50
     51If Divi is active, CacheNova clears Divi's `et-cache` folder every time you clear the cache, fixing the common "I edited it but nothing changed" problem. It handles read-only files and symlinks safely, and only ever removes the `et-cache` folder inside wp-content. On Divi 4 it also turns off Static CSS File Generation. Nothing Divi-specific runs on sites that don't use Divi.
     52
     53= A modern, simple dashboard =
     54
     55* Built on WordPress's own bundled React, with no extra bundles or build step
     56* Clear confirmation windows before anything risky, and live toast notifications
     57* Real-time countdown to the next automatic clear
     58* Last clear report that shows exactly what was cleared and verified
    3959
    4060== Installation ==
    4161
    42 1. Upload the plugin folder to `/wp-content/plugins/`, or install the zip directly through Plugins → Add New → Upload Plugin.
    43 2. Activate the plugin through the 'Plugins' menu in WordPress.
    44 3. Go to Settings → CacheNova to choose your auto-clear interval and toggle toast notifications.
    45 
    46 No further configuration is required — Divi's Static CSS File Generation is disabled automatically the moment the plugin activates.
     621. Upload the plugin folder to `/wp-content/plugins/`, or go to Plugins → Add New → Upload Plugin and upload the zip.
     632. Activate the plugin through the Plugins menu in WordPress.
     643. Go to Settings → CacheNova to choose how often to auto clear the cache. Optional features such as Browser Cache and Database Optimization are off until you turn them on.
    4765
    4866== Frequently Asked Questions ==
    4967
    50 = Does this only work with Divi, or with any theme and plugin? =
     68= How do I clear the cache in WordPress? =
    5169
    52 Any theme, any plugin. The core of this plugin — clearing transients, object cache, OPcache, its own optional page cache, and popular third-party cache plugins, on a schedule and on every save — hooks into WordPress's own standard events (`save_post`, `switch_theme`, `activated_plugin`, and so on), which fire identically no matter what theme or page builder is active. Divi support is a bonus that's detected and applied automatically only if Divi happens to be installed; nothing Divi-specific runs, and nothing is required, on a site that isn't using it.
     70Click **Clear Cache Now** in the top admin bar, or open Settings → CacheNova and use the button at the top. It clears every cache layer in one click and shows a message when it is done.
    5371
    54 = Will this conflict with my other caching plugin? =
     72= How do I auto clear cache in WordPress? =
    5573
    56 No. It detects common caching plugins (WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache, SiteGround Optimizer, Autoptimize) and flushes them as part of a full clear, rather than working against them.
     74Open Settings → CacheNova and choose **Auto-clear every** (10, 20 or 30 minutes, 1 hour, 1 day, 7 days or 30 days). CacheNova also clears automatically whenever you save content, change a menu or theme, or activate, deactivate or update a plugin.
     75
     76= How do I clear the browser cache for my WordPress site? =
     77
     78Turn on **Browser Cache** under Settings → CacheNova → Cache Types. After that, every cache clear changes the version number on your site's own CSS and JavaScript files, so visitors' browsers fetch fresh copies. This works for all visitors without asking them to press Ctrl+F5.
     79
     80= Does it work with Divi, Elementor, WooCommerce and other themes and plugins? =
     81
     82Yes. CacheNova hooks into WordPress's own standard events, so it works with any theme and page builder. It also clears the caches of Divi, Elementor, Beaver Builder, Bricks, Oxygen and WooCommerce product transients when they are active.
     83
     84= Will it conflict with my other caching plugin? =
     85
     86No. It detects popular caching plugins (WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache, SiteGround Optimizer, Autoptimize, WP Fastest Cache, Breeze, WP-Optimize, Cache Enabler, Hummingbird and NitroPack) and clears them as part of a full clear instead of working against them.
     87
     88= Is Browser Cache safe for logged-in users and WooCommerce? =
     89
     90Yes. Cache headers are never sent to logged-in users, on WooCommerce cart, checkout or account pages, on search, 404 or preview pages, on password-protected posts, or on non-GET requests.
     91
     92= Is the automatic database optimization safe? =
     93
     94It permanently deletes post revisions, trashed posts, spam comments, trashed comments and expired transients, so it is **off by default** and asks you to confirm before it turns on. Take a backup first if you want to keep your revisions.
     95
     96= Does the background schedule slow my site down? =
     97
     98No. The scheduled run is a lightweight clear. The heavier full clear only runs when something changes, or when you click Clear Cache Now.
     99
     100= The Divi et-cache folder was not being removed. Is that fixed? =
     101
     102Yes. Version 1.1.0 removes the folder reliably, including on Windows and local servers, and checks again at the end of the request in case Divi recreates it. If your server still blocks deleting the folder (for example because of file ownership), the last clear report shows it as failed instead of pretending it worked.
    57103
    58104= Can I re-enable Divi's Static CSS File Generation? =
    59105
    60 On Divi 4 sites: not while this plugin is active — it's re-enforced on every page load. On Divi 5: there is no longer a setting for this at all (Elegant Themes removed it), so the question doesn't apply — the plugin purges the et-cache folder after every request instead.
     106On Divi 4: not while this plugin is active. On Divi 5 the setting no longer exists, so CacheNova simply removes the et-cache folder when you clear the cache.
    61107
    62 = Does this fully prevent the et-cache folder from ever existing on Divi 5? =
     108= What does "Delete all data on uninstall" delete? =
    63109
    64 Not for the full duration of a request — Divi 5 has no opt-out for dynamic CSS generation, so it may briefly create files while serving a page. This plugin removes them again immediately after each request finishes, so the folder never persists or accumulates stale files, but it can't be prevented from being written to at all on Divi 5 the way it can on Divi 4.
     110With it ON, deleting the plugin (not just deactivating) removes its settings, reports, schedules and the wp-content/cache/cachenova folder, then runs one final full-site cache clear. It never touches Divi's own et-cache folder. It is OFF by default. You can find it on the Database Optimization tab.
    65111
    66 = Does the background schedule slow my site down? =
     112= What is the Persistent Object Cache, and is it safe to turn on? =
    67113
    68 No — the scheduled background tick only performs a lightweight clear (object cache and expired transients). The heavier full clear only runs when you actually save content in Divi or click "Clear Cache Now" manually.
    69 
    70 = What does "Delete all data on uninstall" actually delete? =
    71 
    72 With that toggle ON, deleting the plugin (not just deactivating it) removes its settings, its last-cleared/last-clear-type records, and the wp-content/cache/cachenova folder it uses for the built-in Public/Private page cache, then runs one final full-site cache clear. It does not touch Divi's own et-cache folder, since that belongs to Divi, not to this plugin. It's OFF by default, so a normal delete leaves your settings in place in case you reinstall later.
    73 
    74 = What is the Persistent Object Cache feature, and is it safe to turn on? =
    75 
    76 A persistent object cache lets WordPress reuse database query results across page loads (via APCu, Redis, or Memcached) instead of re-querying every time, which typically speeds up admin and front-end response times. It's OFF by default. Turning it on runs a real connection test first — nothing is installed if that test fails. If it succeeds, the plugin writes a wp-content/object-cache.php drop-in plus a small config file, and clears the cache once so it starts clean. It will never overwrite a different object-cache.php that's already there (from WP Redis, W3 Total Cache, etc.) — it detects that and asks you to remove the existing one first. Deactivating this plugin always removes the drop-in again, so caching goes back to WordPress's normal per-request behavior the moment you switch it off.
    77 
    78 = Does this work outside of Divi? =
    79 
    80 The cache-plugin detection and general WordPress cache clearing work on any site. The Static CSS File Generation override is Divi-specific and has no effect if Divi isn't active.
     114It lets WordPress reuse database results across page loads using Redis, Memcached or APCu, or a file-based fallback. It is OFF by default and runs a connection test before installing anything. It never overwrites a different object-cache.php that is already there, and deactivating the plugin removes its drop-in again.
    81115
    82116== Screenshots ==
    … …  
    87121== Changelog ==
    88122
    89 = 1.0.0 =
    90 * Initial public release. Auto-clears transients, object cache, OPcache, and page cache on save or schedule, on any WordPress theme or plugin.
    91 * Optional built-in Public/Private page cache with a true early-bypass mode (serves cached pages before WordPress even loads).
    92 * Optional persistent object cache with automatic backend detection — Redis, Memcached, APCu, or a zero-dependency file-based fallback — no manual setup required.
    93 * Database Optimization tool: clean post revisions, trashed posts, spam and trashed comments, and expired transients, plus a table-optimization pass. Off by default, only runs when explicitly requested.
    94 * Automatically refreshes its own cache files whenever the plugin itself is updated, so bug fixes and improvements always take effect instead of a stale copy silently running forever.
    95 * If Divi is active, also keeps its et-cache folder from ever accumulating stale files (Divi 4 and Divi 5).
    96 * React-based settings dashboard (built on WordPress's own bundled React, no build step) with live status, toast notifications, and a real-time countdown to the next scheduled clear.
    97 
    98 == Upgrade Notice ==
     123= 1.1.0 =
     124* Added: Database Optimization with scheduled and manual cleanup.
     125* Added: Improved admin bar cache clearing with toast notifications.
     126* Added: Browser cache refresh through CSS/JS version updates.
     127* Improved: Cache clearing support for Divi, Elementor, WooCommerce, and other builders and cache systems.
     128* Improved: Modern confirmation dialogs and uninstall cleanup.
     129* Fixed: Divi cache deletion, browser cache headers, and object-cache drop-in handling.
     130* Fixed: Cleanup reports and automatic cache-clear scheduling.
    99131
    100132= 1.0.0 =
    101 Initial public release.
     133* Initial release.
  • cachenova/trunk/templates/advanced-cache-template.php

    r3737033 r3777263  
    11<?php
     2/**
     3 * Advanced Cache Name: CacheNova Early-Bypass Page Cache
     4 * Installed and managed by the CacheNova plugin. Signature: RLCC_ADVANCED_CACHE_DROPIN
     5 */
    26
    37if ( ! defined( 'ABSPATH' ) ) {
  • cachenova/trunk/templates/object-cache-template.php

    r3737033 r3777263  
    11<?php
     2/**
     3 * Object Cache Name: CacheNova Persistent Object Cache
     4 * Installed and managed by the CacheNova plugin. Signature: RLCC_PERSISTENT_OBJECT_CACHE_DROPIN
     5 */
    26
    37if ( ! defined( 'ABSPATH' ) ) {
  • cachenova/trunk/uninstall.php

    r3737033 r3777263  
    8888}
    8989
    90 $rlcc_cron_hooks = [ 'rlcc_background_clear_event', 'rlcc_deferred_full_clear_event' ];
     90$rlcc_cron_hooks = [ 'rlcc_background_clear_event', 'rlcc_deferred_full_clear_event', 'rlcc_db_optimize_event' ];
    9191foreach ( $rlcc_cron_hooks as $rlcc_hook ) {
    9292        $rlcc_timestamp = wp_next_scheduled( $rlcc_hook );
    … …  
    103103        'rlcc_last_clear_type',
    104104        'rlcc_asset_version',
     105        'rlcc_last_clear_report',
     106        'rlcc_last_db_optimize',
     107        'rlcc_installed_version',
     108        'rlcc_divi_purge_lock',
    105109];
    106110foreach ( $rlcc_options as $rlcc_option ) {
Note: See TracChangeset for help on using the changeset viewer.