Plugin Directory

Changeset 3752017


Ignore:
Timestamp:
10/17/2026 02:30:43 PM (3 weeks ago)
Author:
wpunicorn
Message:

1.1.0: Smart Rules (keywords, writing systems, links), restorable Quarantine, Manage Comments panel, dashboard with charts and insights, redesigned Bulk Clean and Settings. Tag 1.1.0.

Location:
ultimate-comment-cleaner
Files:
14 added
2 deleted
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • ultimate-comment-cleaner/tags/1.1.0/assets/css/admin.css

    r3446581 r3752017  
    10431043}
    10441044
     1045/* ------------------------------------------------------------------ */
     1046/* Smart Rules, Quarantine and Manage Comments                         */
     1047/* ------------------------------------------------------------------ */
     1048
     1049.ulticocl-count {
     1050    display: inline-block;
     1051    min-width: 18px;
     1052    padding: 0 6px;
     1053    margin-left: 6px;
     1054    border-radius: 9px;
     1055    background: var(--remove-comments-warning);
     1056    color: #fff;
     1057    font-size: 11px;
     1058    line-height: 18px;
     1059    text-align: center;
     1060}
     1061
     1062.ulticocl-badge {
     1063    display: inline-block;
     1064    padding: 2px 8px;
     1065    border-radius: 3px;
     1066    background: var(--remove-comments-gray-200);
     1067    color: var(--remove-comments-gray-700);
     1068    font-size: 11px;
     1069    font-weight: 600;
     1070    line-height: 1.6;
     1071    text-transform: uppercase;
     1072    letter-spacing: 0.02em;
     1073}
     1074
     1075.ulticocl-badge-approved { background: #d1fae5; color: #065f46; }
     1076.ulticocl-badge-pending { background: #fef3c7; color: #92400e; }
     1077.ulticocl-badge-spam { background: #fee2e2; color: #991b1b; }
     1078.ulticocl-badge-quarantine,
     1079.ulticocl-badge-action-quarantine { background: #fef3c7; color: #92400e; }
     1080.ulticocl-badge-action-spam { background: #fee2e2; color: #991b1b; }
     1081.ulticocl-badge-action-delete { background: #1f2937; color: #fff; }
     1082
     1083.ulticocl-chip {
     1084    display: inline-block;
     1085    margin-left: 4px;
     1086    padding: 0 6px;
     1087    border: 1px solid var(--remove-comments-gray-300);
     1088    border-radius: 10px;
     1089    background: var(--remove-comments-gray-50);
     1090    color: var(--remove-comments-gray-600);
     1091    font-size: 11px;
     1092    line-height: 18px;
     1093}
     1094
     1095.ulticocl-callout {
     1096    margin: 0 0 24px;
     1097    padding: 20px 24px;
     1098    border-left: 4px solid var(--remove-comments-primary);
     1099    border-radius: 4px;
     1100    background: var(--remove-comments-gray-50);
     1101}
     1102
     1103.ulticocl-callout h2 { margin-top: 0; }
     1104
     1105/* Quick-start presets: one line that glides left and right with the pointer. */
     1106.ulticocl-presets {
     1107    display: flex;
     1108    align-items: center;
     1109    gap: 14px;
     1110    min-width: 0;
     1111    margin-bottom: 16px;
     1112}
     1113
     1114.ulticocl-presets-title {
     1115    flex: none;
     1116    color: var(--remove-comments-gray-700);
     1117    font-size: 13px;
     1118}
     1119
     1120.ulticocl-presets-viewport {
     1121    flex: 1;
     1122    min-width: 0;
     1123    padding: 5px 6px;
     1124    overflow-x: auto;
     1125    overflow-y: hidden;
     1126    scrollbar-width: none;
     1127    -ms-overflow-style: none;
     1128}
     1129
     1130.ulticocl-presets-viewport::-webkit-scrollbar { display: none; }
     1131
     1132/* Fade the edge that has more buttons behind it. */
     1133.ulticocl-presets-viewport.has-right {
     1134    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
     1135    mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
     1136}
     1137
     1138.ulticocl-presets-viewport.has-left {
     1139    -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 40px), transparent);
     1140    mask-image: linear-gradient(to left, #000 calc(100% - 40px), transparent);
     1141}
     1142
     1143.ulticocl-presets-viewport.has-left.has-right {
     1144    -webkit-mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
     1145    mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
     1146}
     1147
     1148.ulticocl-presets-track {
     1149    display: flex;
     1150    flex-wrap: nowrap;
     1151    gap: 8px;
     1152    width: max-content;
     1153}
     1154
     1155.ulticocl-preset {
     1156    display: inline-flex;
     1157    flex: none;
     1158    align-items: center;
     1159    gap: 8px;
     1160    height: 38px;
     1161    margin: 0;
     1162    padding: 0 14px 0 8px;
     1163    border: 1px solid var(--remove-comments-gray-300);
     1164    border-radius: 4px;
     1165    background: #fff;
     1166    color: var(--remove-comments-gray-700);
     1167    font-size: 13px;
     1168    font-weight: 500;
     1169    line-height: 1;
     1170    white-space: nowrap;
     1171    cursor: pointer;
     1172    box-shadow: 0 1px 2px rgba(17, 24, 39, 0.05);
     1173    transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
     1174}
     1175
     1176.ulticocl-preset .dashicons {
     1177    display: inline-flex;
     1178    align-items: center;
     1179    justify-content: center;
     1180    width: 24px;
     1181    height: 24px;
     1182    border-radius: 4px;
     1183    background: #eef2ff;
     1184    color: var(--remove-comments-primary);
     1185    font-size: 15px;
     1186    line-height: 1;
     1187    transition: background-color 0.15s ease, color 0.15s ease;
     1188}
     1189
     1190.ulticocl-preset:hover,
     1191.ulticocl-preset:focus-visible {
     1192    border-color: var(--remove-comments-primary);
     1193    background: #f7f7ff;
     1194    color: var(--remove-comments-primary-dark);
     1195    box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
     1196    outline: none;
     1197}
     1198
     1199.ulticocl-preset:hover .dashicons,
     1200.ulticocl-preset:focus-visible .dashicons {
     1201    background: var(--remove-comments-primary);
     1202    color: #fff;
     1203}
     1204
     1205.ulticocl-preset:active { transform: translateY(1px); }
     1206
     1207@media (max-width: 600px) {
     1208    .ulticocl-presets { flex-direction: column; align-items: flex-start; gap: 6px; }
     1209    .ulticocl-presets-viewport { width: 100%; }
     1210}
     1211
     1212.ulticocl-scan-result {
     1213    margin: 16px 0;
     1214    padding: 16px 20px;
     1215    border: 1px solid var(--remove-comments-gray-200);
     1216    border-left: 4px solid var(--remove-comments-info);
     1217    border-radius: 4px;
     1218    background: #fff;
     1219}
     1220
     1221.ulticocl-scan-result p:first-child { margin-top: 0; }
     1222.ulticocl-error { color: var(--remove-comments-danger); font-weight: 600; }
     1223.ulticocl-samples { margin: 8px 0 12px 18px; list-style: disc; }
     1224
     1225.ulticocl-progress {
     1226    height: 8px;
     1227    border-radius: 4px;
     1228    background: var(--remove-comments-gray-200);
     1229    overflow: hidden;
     1230}
     1231
     1232.ulticocl-progress > div {
     1233    height: 100%;
     1234    background: var(--remove-comments-primary);
     1235    transition: width 0.2s ease;
     1236}
     1237
     1238.ulticocl-progress-box {
     1239    position: fixed;
     1240    top: 50%;
     1241    left: 50%;
     1242    transform: translate(-50%, -50%);
     1243    z-index: 100001;
     1244    padding: 20px;
     1245    border: 2px solid var(--remove-comments-primary);
     1246    border-radius: 4px;
     1247    background: #fff;
     1248    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
     1249}
     1250
     1251.remove-comments-modal-content.ulticocl-wide { max-width: 760px; }
     1252
     1253.ulticocl-fieldset {
     1254    margin: 0 0 20px;
     1255    padding: 12px 16px 4px;
     1256    border: 1px solid var(--remove-comments-gray-200);
     1257    border-radius: 4px;
     1258}
     1259
     1260.ulticocl-fieldset legend {
     1261    padding: 0 6px;
     1262    font-weight: 600;
     1263    color: var(--remove-comments-gray-700);
     1264}
     1265
     1266.ulticocl-grid-2,
     1267.ulticocl-grid-3 {
     1268    display: grid;
     1269    gap: 0 16px;
     1270}
     1271
     1272.ulticocl-grid-2 { grid-template-columns: 1fr 1fr; }
     1273.ulticocl-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
     1274
     1275.remove-comments-modal-content textarea,
     1276.remove-comments-modal-content input[type="text"],
     1277.remove-comments-modal-content input[type="number"] {
     1278    width: 100%;
     1279    box-sizing: border-box;
     1280}
     1281
     1282.remove-comments-modal-content input.small-text { width: 70px; }
     1283.remove-comments-form-group input[type="number"].small-text { width: 90px; }
     1284
     1285.ulticocl-inline-checks {
     1286    display: flex;
     1287    flex-wrap: wrap;
     1288    align-items: center;
     1289    gap: 6px 16px;
     1290    margin-top: 8px;
     1291}
     1292
     1293.ulticocl-inline-checks > span {
     1294    color: var(--remove-comments-gray-600);
     1295    font-size: 13px;
     1296    font-weight: 600;
     1297}
     1298
     1299/* Small on/off switches in the brand colour (a real checkbox underneath). */
     1300.ulticocl-toggle {
     1301    display: inline-flex;
     1302    align-items: center;
     1303    gap: 8px;
     1304    margin: 0;
     1305    color: var(--remove-comments-gray-700);
     1306    font-size: 13px;
     1307    font-weight: 400;
     1308    line-height: 1.2;
     1309    cursor: pointer;
     1310    user-select: none;
     1311}
     1312
     1313.remove-comments-toggle-switch.ulticocl-mini { width: 32px; height: 18px; }
     1314.ulticocl-mini .remove-comments-toggle-slider { border-radius: 18px; }
     1315
     1316.ulticocl-mini .remove-comments-toggle-slider:before {
     1317    width: 12px;
     1318    height: 12px;
     1319    left: 3px;
     1320    bottom: 3px;
     1321    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
     1322}
     1323
     1324.ulticocl-mini input:checked + .remove-comments-toggle-slider:before { transform: translateX(14px); }
     1325.ulticocl-toggle:hover .remove-comments-toggle-slider { background-color: var(--remove-comments-gray-400); }
     1326.ulticocl-toggle:hover input:checked + .remove-comments-toggle-slider { background-color: var(--remove-comments-primary-dark); }
     1327
     1328/* A switch that sits in a row of fields lines up with the inputs beside it. */
     1329.ulticocl-field-toggle { display: flex; align-items: flex-end; }
     1330.ulticocl-field-toggle .ulticocl-toggle { height: var(--ulticocl-field, 40px); }
     1331/* Six tabs do not fit a phone: let the bar scroll sideways instead of overflowing the page. */
     1332@media (max-width: 782px) {
     1333    .remove-comments-tabs {
     1334        overflow-x: auto;
     1335        overflow-y: hidden;
     1336        scrollbar-width: thin;
     1337    }
     1338
     1339    .remove-comments-tab {
     1340        flex: none;
     1341        white-space: nowrap;
     1342    }
     1343}
     1344
     1345.ulticocl-filters {
     1346    display: flex;
     1347    flex-wrap: wrap;
     1348    align-items: center;
     1349    gap: 8px 14px;
     1350    margin-bottom: 12px;
     1351}
     1352
     1353.ulticocl-filters input[type="search"] { min-width: 280px; }
     1354.ulticocl-filters label { display: inline-flex; align-items: center; gap: 4px; }
     1355
     1356.remove-comments-comments-panel .subsubsub { float: none; margin: 0 0 8px; }
     1357.remove-comments-comments-panel .wp-list-table .column-cb { width: 2.2em; }
     1358.remove-comments-comments-panel .wp-list-table .column-author { width: 18%; }
     1359.remove-comments-comments-panel .wp-list-table .column-date { width: 130px; }
     1360
     1361@media (max-width: 768px) {
     1362    .ulticocl-grid-2,
     1363    .ulticocl-grid-3 { grid-template-columns: 1fr; }
     1364    .ulticocl-filters input[type="search"] { min-width: 0; width: 100%; }
     1365}
     1366
     1367/* One field height. WordPress gives selects extra height (53px next to 40px inputs), so every
     1368   input and select in the plugin shares one box and rows line up. */
     1369.remove-comments-wrap { --ulticocl-field: 40px; }
     1370
     1371.remove-comments-wrap .remove-comments-select,
     1372.remove-comments-wrap .remove-comments-form-group select,
     1373.remove-comments-wrap .remove-comments-form-group input[type="text"],
     1374.remove-comments-wrap .remove-comments-form-group input[type="email"],
     1375.remove-comments-wrap .remove-comments-form-group input[type="number"]:not(.small-text),
     1376.remove-comments-wrap input.remove-comments-form-input {
     1377    box-sizing: border-box;
     1378    height: var(--ulticocl-field);
     1379    min-height: 0;
     1380    margin: 0;
     1381    padding-top: 0;
     1382    padding-bottom: 0;
     1383    line-height: 1.3;
     1384}
     1385
     1386/* The inline percentage field sits inside a sentence, so it stays compact. */
     1387.remove-comments-wrap .remove-comments-form-group input[type="number"].small-text {
     1388    box-sizing: border-box;
     1389    width: 80px;
     1390    height: 34px;
     1391    padding: 0 10px;
     1392}
     1393
     1394/* ===================================================================== */
     1395/* Design pass: one set of tokens for every panel, matching the dashboard */
     1396/* Controls use a 4px radius, surfaces (cards, tables, dialogs) 12px.     */
     1397/* Spacing follows a 4/8px rhythm.                                        */
     1398/* ===================================================================== */
     1399
     1400.remove-comments-wrap {
     1401    --ulticocl-field: 40px;
     1402    --uc-radius: 4px;
     1403    --uc-radius-lg: 12px;
     1404    --uc-line: #e5e7eb;
     1405    --uc-line-soft: #f3f4f6;
     1406    --uc-ink: #1f2937;
     1407    --uc-ink-2: #4b5563;
     1408    --uc-muted: #6b7280;
     1409    --uc-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
     1410    --uc-shadow-lg: 0 16px 40px rgba(17, 24, 39, 0.18);
     1411    --uc-focus: 0 0 0 3px rgba(99, 102, 241, 0.2);
     1412}
     1413
     1414/* ---- Panel header ------------------------------------------------------- */
     1415
     1416.remove-comments-panel-header {
     1417    margin-bottom: 24px;
     1418    padding-bottom: 16px;
     1419    border-bottom: 1px solid var(--uc-line);
     1420}
     1421
     1422.remove-comments-panel-header h2 {
     1423    margin: 0 0 4px;
     1424    padding: 0;
     1425    color: var(--uc-ink);
     1426    font-size: 20px;
     1427    font-weight: 700;
     1428    letter-spacing: -0.01em;
     1429    line-height: 1.3;
     1430}
     1431
     1432.remove-comments-panel-header p {
     1433    max-width: 72ch;
     1434    margin: 0;
     1435    color: var(--uc-muted);
     1436    font-size: 13px;
     1437    line-height: 1.55;
     1438}
     1439
     1440.ulticocl-panel-head {
     1441    display: flex;
     1442    align-items: flex-start;
     1443    justify-content: space-between;
     1444    gap: 24px;
     1445}
     1446
     1447.ulticocl-panel-head-text { min-width: 0; }
     1448.ulticocl-panel-head-actions { flex: none; }
     1449
     1450/* Destructive action: quiet until hovered, then unmistakably red. */
     1451.ulticocl-danger-btn {
     1452    display: inline-flex;
     1453    align-items: center;
     1454    gap: 8px;
     1455    height: var(--ulticocl-field);
     1456    margin: 0;
     1457    padding: 0 16px;
     1458    border: 1px solid #fca5a5;
     1459    border-radius: var(--uc-radius);
     1460    background: #fff;
     1461    color: #b91c1c;
     1462    font-size: 13px;
     1463    font-weight: 600;
     1464    line-height: 1;
     1465    white-space: nowrap;
     1466    cursor: pointer;
     1467    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     1468}
     1469
     1470.ulticocl-danger-btn .dashicons { width: 16px; height: 16px; font-size: 16px; }
     1471
     1472.ulticocl-danger-btn:hover {
     1473    border-color: var(--remove-comments-danger);
     1474    background: var(--remove-comments-danger);
     1475    color: #fff;
     1476}
     1477
     1478.ulticocl-danger-btn:focus-visible {
     1479    outline: none;
     1480    box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
     1481}
     1482
     1483@media (max-width: 782px) {
     1484    .ulticocl-panel-head { flex-direction: column; gap: 16px; }
     1485}
     1486
     1487/* ---- Buttons ------------------------------------------------------------ */
     1488
     1489.remove-comments-wrap .button,
     1490.remove-comments-wrap .button-secondary {
     1491    display: inline-flex;
     1492    align-items: center;
     1493    justify-content: center;
     1494    gap: 6px;
     1495    box-sizing: border-box;
     1496    height: var(--ulticocl-field);
     1497    min-height: 0;
     1498    margin: 0;
     1499    padding: 0 16px;
     1500    border: 1px solid var(--remove-comments-gray-300);
     1501    border-radius: var(--uc-radius);
     1502    background: #fff;
     1503    color: var(--uc-ink);
     1504    font-size: 13px;
     1505    font-weight: 500;
     1506    line-height: 1;
     1507    box-shadow: none;
     1508    text-decoration: none;
     1509    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     1510}
     1511
     1512.remove-comments-wrap .button:hover,
     1513.remove-comments-wrap .button-secondary:hover {
     1514    border-color: var(--remove-comments-primary);
     1515    background: #f7f7ff;
     1516    color: var(--remove-comments-primary-dark);
     1517}
     1518
     1519.remove-comments-wrap .button:focus-visible,
     1520.remove-comments-wrap .button-secondary:focus-visible {
     1521    outline: none;
     1522    border-color: var(--remove-comments-primary);
     1523    box-shadow: var(--uc-focus);
     1524}
     1525
     1526.remove-comments-wrap .button.button-primary {
     1527    border-color: var(--remove-comments-primary);
     1528    background: var(--remove-comments-primary);
     1529    color: #fff;
     1530}
     1531
     1532.remove-comments-wrap .button.button-primary:hover {
     1533    border-color: var(--remove-comments-primary-dark);
     1534    background: var(--remove-comments-primary-dark);
     1535    color: #fff;
     1536}
     1537
     1538.remove-comments-wrap .button[disabled],
     1539.remove-comments-wrap .button.disabled { opacity: 0.5; cursor: not-allowed; }
     1540
     1541.remove-comments-wrap .button-small { height: 32px; padding: 0 12px; font-size: 12px; }
     1542.remove-comments-wrap .button-hero { height: 44px; padding: 0 22px; font-size: 14px; font-weight: 600; }
     1543
     1544.ulticocl-seg button { height: 38px; padding: 0 16px; }
     1545.ulticocl-seg-small button { height: 28px; padding: 0 12px; }
     1546
     1547/* ---- Fields ------------------------------------------------------------- */
     1548
     1549.remove-comments-wrap .remove-comments-form-group > label,
     1550.ulticocl-sublabel {
     1551    display: block;
     1552    margin: 0 0 8px;
     1553    color: var(--remove-comments-gray-700);
     1554    font-size: 13px;
     1555    font-weight: 600;
     1556    line-height: 1.4;
     1557}
     1558
     1559.remove-comments-wrap .remove-comments-select,
     1560.remove-comments-wrap .remove-comments-form-group select {
     1561    max-width: none; /* WordPress caps selects at 25rem, which left one select narrower than its neighbours */
     1562    padding-right: 34px;
     1563    padding-left: 12px;
     1564    border-color: var(--remove-comments-gray-300);
     1565    -webkit-appearance: none;
     1566    appearance: none;
     1567    background-color: #fff;
     1568    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 7.5L10 12l4.5-4.5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
     1569    background-repeat: no-repeat;
     1570    background-position: right 10px center;
     1571    background-size: 18px;
     1572}
     1573
     1574.remove-comments-wrap .remove-comments-select:focus,
     1575.remove-comments-wrap .remove-comments-form-group select:focus,
     1576.remove-comments-wrap .remove-comments-form-group input[type="text"]:focus,
     1577.remove-comments-wrap .remove-comments-form-group input[type="email"]:focus,
     1578.remove-comments-wrap .remove-comments-form-group input[type="number"]:focus,
     1579.remove-comments-wrap .remove-comments-form-group textarea:focus,
     1580.remove-comments-wrap input.remove-comments-form-input:focus {
     1581    outline: none;
     1582    border-color: var(--remove-comments-primary);
     1583    box-shadow: var(--uc-focus);
     1584}
     1585
     1586.remove-comments-wrap .remove-comments-form-group textarea,
     1587.remove-comments-wrap textarea.remove-comments-form-input {
     1588    box-sizing: border-box;
     1589    width: 100%;
     1590    padding: 10px 12px;
     1591    border: 1px solid var(--remove-comments-gray-300);
     1592    border-radius: var(--uc-radius);
     1593    font-size: 13px;
     1594    line-height: 1.6;
     1595}
     1596
     1597.remove-comments-wrap #ulticocl-rule-keywords {
     1598    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
     1599    font-size: 12.5px;
     1600}
     1601
     1602/* Helper text sits flush with the field above it, not indented like a toggle's. */
     1603.remove-comments-wrap .remove-comments-form-group .description,
     1604.ulticocl-fieldset > .description {
     1605    margin: 8px 0 0;
     1606    color: var(--uc-muted);
     1607    font-size: 12px;
     1608    font-style: normal;
     1609    line-height: 1.55;
     1610}
     1611
     1612/* The row above ends with a 20px group margin, so pull the text up to sit 8px below its fields. */
     1613.ulticocl-fieldset > .description { margin: -12px 0 0; }
     1614
     1615/* A sentence with a field in the middle: keep the text and the box on one centre line. */
     1616.remove-comments-wrap .remove-comments-form-group .description.ulticocl-inline-field {
     1617    display: flex;
     1618    flex-wrap: wrap;
     1619    align-items: center;
     1620    gap: 4px 8px;
     1621}
     1622
     1623/* ---- Toggle fields: switches laid out on an aligned grid ------------------ */
     1624
     1625.ulticocl-toggle-grid {
     1626    display: grid;
     1627    grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
     1628    /* 16px between columns, the same as the input grids, so column edges line up down the form. */
     1629    gap: 12px 16px;
     1630}
     1631
     1632.ulticocl-toggle-grid[data-cols="2"] { --cols: 2; }
     1633.ulticocl-toggle-grid[data-cols="3"] { --cols: 3; }
     1634.ulticocl-toggle-grid[data-cols="4"] { --cols: 4; }
     1635
     1636.ulticocl-toggle-label {
     1637    min-width: 0;
     1638    overflow: hidden;
     1639    text-overflow: ellipsis;
     1640    white-space: nowrap;
     1641}
     1642
     1643.ulticocl-toggle-grid .ulticocl-toggle,
     1644.ulticocl-field-toggle .ulticocl-toggle {
     1645    box-sizing: border-box;
     1646    width: 100%;
     1647    height: var(--ulticocl-field);
     1648    padding: 0 12px;
     1649    border: 1px solid var(--remove-comments-gray-300);
     1650    border-radius: var(--uc-radius);
     1651    background: #fff;
     1652    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     1653}
     1654
     1655.ulticocl-toggle-grid .ulticocl-toggle:hover,
     1656.ulticocl-field-toggle .ulticocl-toggle:hover { border-color: var(--remove-comments-gray-400); }
     1657
     1658.ulticocl-toggle-grid .ulticocl-toggle:has(input:checked),
     1659.ulticocl-field-toggle .ulticocl-toggle:has(input:checked) {
     1660    border-color: #c7d2fe;
     1661    background: #f5f6ff;
     1662    color: #3730a3;
     1663}
     1664
     1665.ulticocl-toggle-grid .ulticocl-toggle:has(input:focus-visible),
     1666.ulticocl-field-toggle .ulticocl-toggle:has(input:focus-visible) {
     1667    border-color: var(--remove-comments-primary);
     1668    box-shadow: var(--uc-focus);
     1669}
     1670
     1671/* The tile already shows focus, so the switch itself does not need its own ring. */
     1672.ulticocl-toggle input:focus + .remove-comments-toggle-slider { box-shadow: none; }
     1673
     1674@media (max-width: 600px) {
     1675    .ulticocl-toggle-grid[data-cols="3"],
     1676    .ulticocl-toggle-grid[data-cols="4"] { --cols: 2; }
     1677}
     1678
     1679/* ---- Rule dialog -------------------------------------------------------- */
     1680
     1681.remove-comments-modal { background: rgba(17, 24, 39, 0.55); }
     1682
     1683.remove-comments-modal-content {
     1684    border-radius: var(--uc-radius-lg);
     1685    box-shadow: var(--uc-shadow-lg);
     1686}
     1687
     1688.remove-comments-modal-content.ulticocl-wide { padding: 28px 28px 24px; }
     1689
     1690.remove-comments-modal-content > h2 {
     1691    margin: 0 0 24px;
     1692    padding-right: 40px;
     1693    color: var(--uc-ink);
     1694    font-size: 20px;
     1695    font-weight: 700;
     1696    letter-spacing: -0.01em;
     1697    line-height: 1.3;
     1698}
     1699
     1700.remove-comments-modal-content > .remove-comments-modal-close { top: 22px; right: 26px; }
     1701
     1702.ulticocl-fieldset {
     1703    margin: 0 0 20px;
     1704    padding: 12px 20px 20px; /* the legend sits on the top border, so less is needed above than below */
     1705    border: 1px solid var(--uc-line);
     1706    border-radius: var(--uc-radius-lg);
     1707    background: #fff;
     1708}
     1709
     1710.ulticocl-fieldset legend {
     1711    margin-left: -4px;
     1712    padding: 0 8px;
     1713    color: var(--uc-ink);
     1714    font-size: 13px;
     1715    font-weight: 700;
     1716}
     1717
     1718.ulticocl-grid-2, .ulticocl-grid-3 { gap: 0 16px; }
     1719
     1720.ulticocl-modal-footer-row,
     1721.remove-comments-modal-content .remove-comments-form-actions {
     1722    align-items: center;
     1723    gap: 12px;
     1724    margin-top: 24px;
     1725    padding-top: 20px;
     1726    border-top: 1px solid var(--uc-line);
     1727}
     1728
     1729.remove-comments-modal-content .remove-comments-form-actions .remove-comments-btn {
     1730    height: var(--ulticocl-field);
     1731    padding: 0 18px;
     1732    font-size: 13px;
     1733    font-weight: 600;
     1734}
     1735
     1736.remove-comments-modal-content .remove-comments-modal-close-btn {
     1737    width: var(--ulticocl-field);
     1738    height: var(--ulticocl-field);
     1739    margin-left: auto;
     1740    border-radius: var(--uc-radius);
     1741}
     1742
     1743/* ---- Filter bar --------------------------------------------------------- */
     1744
     1745.ulticocl-filters {
     1746    gap: 12px 20px;
     1747    margin: 0 0 16px;
     1748    padding: 12px 16px;
     1749    border: 1px solid var(--uc-line);
     1750    border-radius: var(--uc-radius-lg);
     1751    background: #fff;
     1752    box-shadow: var(--uc-shadow);
     1753}
     1754
     1755.ulticocl-filters input[type="search"],
     1756.ulticocl-filters input[type="date"] {
     1757    box-sizing: border-box;
     1758    height: var(--ulticocl-field);
     1759    margin: 0;
     1760    padding: 0 12px;
     1761    border: 1px solid var(--remove-comments-gray-300);
     1762    border-radius: var(--uc-radius);
     1763    background: #fff;
     1764    color: var(--uc-ink);
     1765    font-size: 13px;
     1766    line-height: 1.3;
     1767}
     1768
     1769.ulticocl-filters input[type="search"] { flex: 1 1 260px; min-width: 200px; }
     1770
     1771.ulticocl-filters input[type="search"]:focus,
     1772.ulticocl-filters input[type="date"]:focus {
     1773    outline: none;
     1774    border-color: var(--remove-comments-primary);
     1775    box-shadow: var(--uc-focus);
     1776}
     1777
     1778.ulticocl-filter-field {
     1779    display: inline-flex;
     1780    align-items: center;
     1781    gap: 8px;
     1782    margin: 0;
     1783    color: var(--uc-ink-2);
     1784    font-size: 13px;
     1785    font-weight: 500;
     1786}
     1787
     1788.ulticocl-reset {
     1789    color: var(--uc-muted);
     1790    font-size: 13px;
     1791    text-decoration: none;
     1792}
     1793
     1794.ulticocl-reset:hover { color: var(--remove-comments-primary); text-decoration: underline; }
     1795
     1796/* ---- Status views, bulk bar and pagination ------------------------------------ */
     1797
     1798.remove-comments-comments-panel .subsubsub {
     1799    display: flex;
     1800    flex-wrap: wrap;
     1801    gap: 6px;
     1802    float: none;
     1803    margin: 0 0 16px;
     1804    padding: 0;
     1805    font-size: 0; /* hides the "|" separators WordPress prints between views */
     1806}
     1807
     1808.remove-comments-comments-panel .subsubsub li { margin: 0; padding: 0; font-size: 0; }
     1809
     1810.remove-comments-comments-panel .subsubsub a {
     1811    display: inline-flex;
     1812    align-items: center;
     1813    gap: 6px;
     1814    height: 32px;
     1815    padding: 0 12px;
     1816    border: 1px solid transparent;
     1817    border-radius: var(--uc-radius);
     1818    color: var(--uc-ink-2);
     1819    font-size: 13px;
     1820    font-weight: 500;
     1821    line-height: 1;
     1822    text-decoration: none;
     1823    transition: background-color 0.15s ease, color 0.15s ease;
     1824}
     1825
     1826.remove-comments-comments-panel .subsubsub a:hover { background: var(--remove-comments-gray-100); color: var(--uc-ink); }
     1827
     1828.remove-comments-comments-panel .subsubsub a.current {
     1829    border-color: #e0e7ff;
     1830    background: #eef2ff;
     1831    color: #4338ca;
     1832    font-weight: 600;
     1833}
     1834
     1835.remove-comments-comments-panel .subsubsub .count { color: var(--uc-muted); font-weight: 400; }
     1836.remove-comments-comments-panel .subsubsub a.current .count { color: var(--remove-comments-primary); }
     1837
     1838.remove-comments-comments-panel .tablenav {
     1839    display: flex;
     1840    flex-wrap: wrap;
     1841    align-items: center;
     1842    gap: 12px;
     1843    height: auto;
     1844    margin: 0 0 12px;
     1845    padding: 0;
     1846}
     1847
     1848.remove-comments-comments-panel .tablenav.bottom { margin: 12px 0 0; }
     1849.remove-comments-comments-panel .tablenav .actions { display: inline-flex; align-items: center; gap: 8px; padding: 0; overflow: visible; }
     1850.remove-comments-comments-panel .tablenav .alignleft,
     1851.remove-comments-comments-panel .tablenav .alignright { float: none; }
     1852
     1853.remove-comments-comments-panel .tablenav .actions select {
     1854    box-sizing: border-box;
     1855    height: var(--ulticocl-field);
     1856    min-width: 170px;
     1857    margin: 0;
     1858    padding: 0 34px 0 12px;
     1859    border: 1px solid var(--remove-comments-gray-300);
     1860    border-radius: var(--uc-radius);
     1861    -webkit-appearance: none;
     1862    appearance: none;
     1863    background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 7.5L10 12l4.5-4.5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 18px;
     1864    color: var(--uc-ink);
     1865    font-size: 13px;
     1866    line-height: 1.3;
     1867}
     1868
     1869.remove-comments-comments-panel .tablenav .actions select:focus { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     1870
     1871.remove-comments-comments-panel .tablenav-pages {
     1872    display: inline-flex;
     1873    align-items: center;
     1874    gap: 10px;
     1875    margin: 0 0 0 auto;
     1876    color: var(--uc-muted);
     1877    font-size: 13px;
     1878}
     1879
     1880.remove-comments-comments-panel .tablenav-pages .pagination-links { display: inline-flex; align-items: center; gap: 6px; }
     1881.remove-comments-comments-panel .tablenav-pages .paging-input { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
     1882
     1883.remove-comments-comments-panel .tablenav-pages .button,
     1884.remove-comments-comments-panel .tablenav-pages .tablenav-pages-navspan {
     1885    display: inline-flex;
     1886    align-items: center;
     1887    justify-content: center;
     1888    box-sizing: border-box;
     1889    min-width: var(--ulticocl-field);
     1890    height: var(--ulticocl-field);
     1891    padding: 0 10px;
     1892    border: 1px solid var(--remove-comments-gray-300);
     1893    border-radius: var(--uc-radius);
     1894    background: #fff;
     1895    font-size: 16px;
     1896    line-height: 1;
     1897}
     1898
     1899.remove-comments-comments-panel .tablenav-pages .tablenav-pages-navspan { opacity: 0.45; border-style: dashed; }
     1900
     1901.remove-comments-comments-panel .tablenav-pages .current-page {
     1902    box-sizing: border-box;
     1903    width: 52px;
     1904    height: var(--ulticocl-field);
     1905    margin: 0;
     1906    padding: 0 6px;
     1907    border: 1px solid var(--remove-comments-gray-300);
     1908    border-radius: var(--uc-radius);
     1909    text-align: center;
     1910}
     1911
     1912.remove-comments-comments-panel .tablenav-pages .current-page:focus { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     1913
     1914/* ---- The comment table --------------------------------------------------- */
     1915
     1916.remove-comments-comments-panel .wp-list-table {
     1917    border: 1px solid var(--uc-line);
     1918    border-radius: var(--uc-radius-lg);
     1919    border-collapse: separate;
     1920    border-spacing: 0;
     1921    overflow: hidden;
     1922    background: #fff;
     1923    box-shadow: var(--uc-shadow);
     1924}
     1925
     1926.remove-comments-comments-panel .wp-list-table thead th,
     1927.remove-comments-comments-panel .wp-list-table thead td,
     1928.remove-comments-comments-panel .wp-list-table tfoot th,
     1929.remove-comments-comments-panel .wp-list-table tfoot td {
     1930    padding: 12px 16px;
     1931    border: 0;
     1932    border-bottom: 1px solid var(--uc-line);
     1933    background: #f9fafb;
     1934    color: var(--uc-muted);
     1935    font-size: 12px;
     1936    font-weight: 600;
     1937    letter-spacing: 0.04em;
     1938    line-height: 1.4;
     1939    text-transform: uppercase;
     1940}
     1941
     1942.remove-comments-comments-panel .wp-list-table tfoot th,
     1943.remove-comments-comments-panel .wp-list-table tfoot td { border-top: 1px solid var(--uc-line); border-bottom: 0; }
     1944
     1945.remove-comments-comments-panel .wp-list-table tbody th,
     1946.remove-comments-comments-panel .wp-list-table tbody td {
     1947    padding: 16px;
     1948    border-bottom: 1px solid var(--uc-line-soft);
     1949    background: transparent;
     1950    color: var(--uc-ink-2);
     1951    font-size: 13px;
     1952    line-height: 1.55;
     1953    vertical-align: top;
     1954}
     1955
     1956.remove-comments-comments-panel .wp-list-table tbody tr:last-child th,
     1957.remove-comments-comments-panel .wp-list-table tbody tr:last-child td { border-bottom: 0; }
     1958
     1959/* No zebra stripes: the hairlines carry the structure. */
     1960.remove-comments-comments-panel .wp-list-table.striped > tbody > tr,
     1961.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:nth-child(odd) { background: #fff; }
     1962
     1963.remove-comments-comments-panel .wp-list-table tbody tr { transition: background-color 0.12s ease; }
     1964.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:hover { background: #fafbff; }
     1965.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:has(.check-column input:checked) { background: #f5f6ff; }
     1966
     1967.remove-comments-comments-panel .wp-list-table .check-column {
     1968    width: 44px;
     1969    padding: 16px 0 0 16px;
     1970    vertical-align: top;
     1971}
     1972
     1973.remove-comments-comments-panel .wp-list-table thead .check-column,
     1974.remove-comments-comments-panel .wp-list-table tfoot .check-column { padding: 12px 0 12px 16px; vertical-align: middle; }
     1975
     1976/* The comment is what people read, so it gets the room; the rest stay compact. */
     1977.remove-comments-comments-panel .wp-list-table .column-author { width: 17%; }
     1978.remove-comments-comments-panel .wp-list-table .column-comment { width: 40%; }
     1979.remove-comments-comments-panel .wp-list-table .column-post,
     1980.remove-comments-comments-panel .wp-list-table .column-caught { width: 17%; }
     1981.remove-comments-comments-panel .wp-list-table .column-purge,
     1982.remove-comments-comments-panel .wp-list-table .column-date { width: 120px; }
     1983
     1984.remove-comments-comments-panel .wp-list-table .ulticocl-date { display: block; color: var(--uc-ink-2); white-space: nowrap; }
     1985.remove-comments-comments-panel .wp-list-table .ulticocl-time { display: block; margin-top: 2px; color: var(--uc-muted); font-size: 12px; }
     1986
     1987/* Author cell: name, then quiet detail lines with an even 2px between them. */
     1988.remove-comments-comments-panel .wp-list-table .ulticocl-author-name { margin: 0; }
     1989.remove-comments-comments-panel .wp-list-table .ulticocl-author-line { display: block; margin-top: 2px; color: var(--uc-muted); font-size: 12.5px; overflow-wrap: anywhere; }
     1990.remove-comments-comments-panel .wp-list-table code.ulticocl-author-line { display: inline-block; margin-top: 8px; }
     1991
     1992/* Comment cell: status, text, signals, actions, each separated by the same step. */
     1993.remove-comments-comments-panel .wp-list-table .ulticocl-comment-status { margin-bottom: 8px; }
     1994.remove-comments-comments-panel .wp-list-table .ulticocl-comment-text { color: var(--uc-ink); overflow-wrap: anywhere; }
     1995.remove-comments-comments-panel .wp-list-table .ulticocl-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
     1996.remove-comments-comments-panel .wp-list-table .ulticocl-chip { margin: 0; white-space: nowrap; }
     1997
     1998.remove-comments-comments-panel .wp-list-table .column-author strong { display: block; margin-bottom: 2px; color: var(--uc-ink); font-size: 13.5px; }
     1999.remove-comments-comments-panel .wp-list-table .column-author code { padding: 1px 6px; border-radius: var(--uc-radius); background: var(--remove-comments-gray-100); color: var(--uc-ink-2); font-size: 12px; }
     2000.remove-comments-comments-panel .wp-list-table .column-author a,
     2001.remove-comments-comments-panel .wp-list-table .column-post a { color: var(--remove-comments-primary); text-decoration: none; }
     2002.remove-comments-comments-panel .wp-list-table .column-author a:hover,
     2003.remove-comments-comments-panel .wp-list-table .column-post a:hover { color: var(--remove-comments-primary-dark); text-decoration: underline; }
     2004
     2005.remove-comments-comments-panel .wp-list-table .column-comment { color: var(--uc-ink); }
     2006.remove-comments-comments-panel .wp-list-table .column-caught strong { color: var(--uc-ink); }
     2007
     2008.remove-comments-comments-panel .wp-list-table .row-actions { margin-top: 6px; padding: 0; color: var(--remove-comments-gray-300); font-size: 12px; }
     2009.remove-comments-comments-panel .wp-list-table .row-actions a { color: var(--remove-comments-primary); font-weight: 500; text-decoration: none; }
     2010.remove-comments-comments-panel .wp-list-table .row-actions a:hover { text-decoration: underline; }
     2011.remove-comments-comments-panel .wp-list-table .row-actions .delete a,
     2012.remove-comments-comments-panel .wp-list-table .row-actions .submitdelete { color: #b91c1c; }
     2013
     2014.remove-comments-comments-panel .wp-list-table tr.no-items td { padding: 48px 16px; color: var(--uc-muted); font-size: 14px; text-align: center; }
     2015
     2016/* Brand checkboxes for the table, so the select boxes match the switches elsewhere. */
     2017.remove-comments-comments-panel .wp-list-table input[type="checkbox"] {
     2018    -webkit-appearance: none;
     2019    appearance: none;
     2020    position: relative;
     2021    flex: none;
     2022    width: 16px;
     2023    height: 16px;
     2024    margin: 0;
     2025    border: 1px solid var(--remove-comments-gray-300);
     2026    border-radius: 4px;
     2027    background: #fff;
     2028    box-shadow: none;
     2029    vertical-align: middle;
     2030    cursor: pointer;
     2031    transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
     2032}
     2033
     2034.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:hover { border-color: var(--remove-comments-primary); }
     2035
     2036.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:checked {
     2037    border-color: var(--remove-comments-primary);
     2038    background: var(--remove-comments-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8.5l2.7 2.7L12 5.5' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
     2039}
     2040
     2041.remove-comments-comments-panel .wp-list-table input[type="checkbox"]::before { content: none !important; display: none !important; }
     2042.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:focus { outline: none; box-shadow: var(--uc-focus); }
     2043
     2044.remove-comments-comments-panel .notice.inline { margin: 0 0 16px; border-radius: var(--uc-radius); }
     2045
     2046/* ---- Smart Rules page ------------------------------------------------------ */
     2047
     2048.ulticocl-rules-toolbar {
     2049    display: flex;
     2050    align-items: center;
     2051    gap: 16px;
     2052    margin: 0 0 20px;
     2053}
     2054
     2055.ulticocl-rules-toolbar .ulticocl-presets { flex: 1; min-width: 0; margin: 0; }
     2056
     2057/* The one primary action on the page. */
     2058.ulticocl-add-btn {
     2059    display: inline-flex;
     2060    flex: none;
     2061    align-items: center;
     2062    gap: 8px;
     2063    height: var(--ulticocl-field);
     2064    margin: 0;
     2065    padding: 0 18px;
     2066    border: 1px solid var(--remove-comments-primary);
     2067    border-radius: var(--uc-radius);
     2068    background: var(--remove-comments-primary);
     2069    color: #fff;
     2070    font-size: 13px;
     2071    font-weight: 600;
     2072    line-height: 1;
     2073    white-space: nowrap;
     2074    cursor: pointer;
     2075    box-shadow: 0 1px 2px rgba(99, 102, 241, 0.35);
     2076    transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
     2077}
     2078
     2079.ulticocl-add-btn .dashicons { width: 16px; height: 16px; font-size: 16px; }
     2080
     2081.ulticocl-add-btn:hover { border-color: var(--remove-comments-primary-dark); background: var(--remove-comments-primary-dark); }
     2082.ulticocl-add-btn:focus-visible { outline: none; box-shadow: var(--uc-focus); }
     2083
     2084@media (max-width: 782px) {
     2085    .ulticocl-rules-toolbar { flex-direction: column; align-items: stretch; }
     2086    .ulticocl-add-btn { justify-content: center; }
     2087}
     2088
     2089.remove-comments-triggers-list { display: flex; flex-direction: column; gap: 16px; margin: 0; }
     2090
     2091.ulticocl-rule {
     2092    padding: 20px 24px;
     2093    border: 1px solid var(--uc-line);
     2094    border-radius: var(--uc-radius-lg);
     2095    background: #fff;
     2096    box-shadow: var(--uc-shadow);
     2097    transition: border-color 0.15s ease, box-shadow 0.15s ease;
     2098}
     2099
     2100.ulticocl-rule:hover { border-color: var(--remove-comments-gray-300); box-shadow: 0 6px 16px rgba(17, 24, 39, 0.06); }
     2101
     2102.ulticocl-rule-head {
     2103    display: flex;
     2104    align-items: center;
     2105    justify-content: space-between;
     2106    gap: 16px;
     2107    margin-bottom: 16px;
     2108}
     2109
     2110.ulticocl-rule-head h3 { margin: 0; padding: 0; color: var(--uc-ink); font-size: 16px; font-weight: 600; line-height: 1.3; }
     2111.ulticocl-rule-badges { display: flex; flex: none; align-items: center; gap: 8px; }
     2112
     2113.ulticocl-rule-meta {
     2114    display: grid;
     2115    grid-template-columns: repeat(3, minmax(0, 1fr));
     2116    gap: 16px 24px;
     2117    margin: 0 0 20px;
     2118    padding: 0;
     2119}
     2120
     2121.ulticocl-rule-meta > div { min-width: 0; }
     2122.ulticocl-rule-meta > .is-wide { grid-column: 1 / -1; }
     2123
     2124.ulticocl-rule-meta dt {
     2125    margin: 0 0 4px;
     2126    color: var(--uc-muted);
     2127    font-size: 11px;
     2128    font-weight: 600;
     2129    letter-spacing: 0.06em;
     2130    text-transform: uppercase;
     2131}
     2132
     2133.ulticocl-rule-meta dd { margin: 0; color: var(--uc-ink); font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
     2134
     2135.ulticocl-rule-actions {
     2136    display: flex;
     2137    flex-wrap: wrap;
     2138    align-items: center;
     2139    gap: 8px;
     2140    padding-top: 16px;
     2141    border-top: 1px solid var(--uc-line-soft);
     2142}
     2143
     2144.ulticocl-rbtn {
     2145    display: inline-flex;
     2146    align-items: center;
     2147    gap: 6px;
     2148    height: 36px;
     2149    margin: 0;
     2150    padding: 0 14px 0 12px;
     2151    border: 1px solid var(--remove-comments-gray-300);
     2152    border-radius: var(--uc-radius);
     2153    background: #fff;
     2154    color: var(--uc-ink);
     2155    font-size: 13px;
     2156    font-weight: 500;
     2157    line-height: 1;
     2158    white-space: nowrap;
     2159    cursor: pointer;
     2160    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     2161}
     2162
     2163.ulticocl-rbtn .dashicons { width: 16px; height: 16px; font-size: 16px; color: var(--uc-muted); transition: color 0.15s ease; }
     2164
     2165.ulticocl-rbtn:hover { border-color: var(--remove-comments-primary); background: #f7f7ff; color: var(--remove-comments-primary-dark); }
     2166.ulticocl-rbtn:hover .dashicons { color: var(--remove-comments-primary); }
     2167.ulticocl-rbtn:focus-visible { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     2168
     2169.ulticocl-rbtn.is-primary { border-color: var(--remove-comments-primary); background: var(--remove-comments-primary); color: #fff; }
     2170.ulticocl-rbtn.is-primary .dashicons { color: #fff; }
     2171.ulticocl-rbtn.is-primary:hover { border-color: var(--remove-comments-primary-dark); background: var(--remove-comments-primary-dark); color: #fff; }
     2172
     2173/* Delete sits apart at the far end and stays quiet until hovered. */
     2174.ulticocl-rbtn.is-danger { margin-left: auto; border-color: transparent; background: transparent; color: #b91c1c; }
     2175.ulticocl-rbtn.is-danger .dashicons { color: #b91c1c; }
     2176.ulticocl-rbtn.is-danger:hover { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
     2177.ulticocl-rbtn.is-danger:focus-visible { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25); }
     2178
     2179.ulticocl-badge { display: inline-flex; align-items: center; gap: 6px; }
     2180.ulticocl-badge-on { background: #d1fae5; color: #065f46; }
     2181.ulticocl-badge-off { background: var(--remove-comments-gray-100); color: var(--uc-ink-2); }
     2182.ulticocl-badge-on::before,
     2183.ulticocl-badge-off::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
     2184
     2185.remove-comments-empty-state {
     2186    padding: 40px 24px;
     2187    border: 1px dashed var(--remove-comments-gray-300);
     2188    border-radius: var(--uc-radius-lg);
     2189    background: var(--remove-comments-gray-50);
     2190    color: var(--uc-muted);
     2191    text-align: center;
     2192}
     2193
     2194.remove-comments-empty-state p { margin: 0; font-size: 14px; }
     2195
     2196@media (max-width: 782px) {
     2197    .ulticocl-rule-meta { grid-template-columns: 1fr; }
     2198    .ulticocl-rule-head { align-items: flex-start; flex-direction: column; gap: 8px; }
     2199}
     2200
     2201/* ---- Bulk Clean and Settings: same cards, switches and footer as the rest ------------- */
     2202
     2203/* Every on/off switch in the plugin is the small brand switch. */
     2204.remove-comments-toggle-switch { width: 32px; height: 18px; }
     2205.remove-comments-toggle-slider { border-radius: 18px; }
     2206
     2207.remove-comments-toggle-slider:before {
     2208    width: 12px;
     2209    height: 12px;
     2210    left: 3px;
     2211    bottom: 3px;
     2212    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
     2213}
     2214
     2215.remove-comments-toggle-switch input:checked + .remove-comments-toggle-slider:before { transform: translateX(14px); }
     2216
     2217.remove-comments-toggle-label {
     2218    color: var(--uc-ink);
     2219    font-size: 13px;
     2220    font-weight: 500;
     2221    line-height: 1.3;
     2222}
     2223
     2224/* Sections become cards, like the dashboard's. */
     2225.remove-comments-form-section {
     2226    margin: 0 0 16px;
     2227    padding: 20px 24px;
     2228    border: 1px solid var(--uc-line);
     2229    border-radius: var(--uc-radius-lg);
     2230    background: #fff;
     2231    box-shadow: var(--uc-shadow);
     2232}
     2233
     2234.remove-comments-form-section h3 {
     2235    margin: 0 0 16px;
     2236    padding: 0;
     2237    border: 0;
     2238    color: var(--uc-ink);
     2239    font-size: 15px;
     2240    font-weight: 600;
     2241    line-height: 1.3;
     2242}
     2243
     2244.remove-comments-form-section > :last-child,
     2245.remove-comments-form-section > .remove-comments-form-group:last-child { margin-bottom: 0; }
     2246.remove-comments-form-section .ulticocl-grid-2 > .remove-comments-form-group { margin-bottom: 0; }
     2247.remove-comments-form-section .ulticocl-row-after-toggles { margin-top: 16px; }
     2248
     2249/* Switch groups become the same aligned tiles as in the rule dialog. */
     2250.remove-comments-form-section .remove-comments-checkbox-group {
     2251    display: grid;
     2252    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
     2253    gap: 12px 16px;
     2254    margin: 0;
     2255}
     2256
     2257.remove-comments-form-section .remove-comments-checkbox-group label {
     2258    display: flex;
     2259    align-items: center;
     2260    gap: 10px;
     2261    box-sizing: border-box;
     2262    height: var(--ulticocl-field);
     2263    margin: 0;
     2264    padding: 0 12px;
     2265    border: 1px solid var(--remove-comments-gray-300);
     2266    border-radius: var(--uc-radius);
     2267    background: #fff;
     2268    cursor: pointer;
     2269    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     2270}
     2271
     2272.remove-comments-form-section .remove-comments-checkbox-group label:hover { border-color: var(--remove-comments-gray-400); background: #fff; }
     2273
     2274.remove-comments-form-section .remove-comments-checkbox-group label:has(input:checked) {
     2275    border-color: #c7d2fe;
     2276    background: #f5f6ff;
     2277}
     2278
     2279.remove-comments-form-section .remove-comments-checkbox-group label:has(input:checked) .remove-comments-toggle-label { color: #3730a3; }
     2280
     2281.remove-comments-form-section .remove-comments-checkbox-group label:has(input:focus-visible) {
     2282    border-color: var(--remove-comments-primary);
     2283    box-shadow: var(--uc-focus);
     2284}
     2285
     2286.remove-comments-checkbox-group .remove-comments-toggle-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     2287
     2288/* Date range: two fields on the same 16px grid as everything else. */
     2289.remove-comments-date-range { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
     2290
     2291.remove-comments-date-input label {
     2292    display: block;
     2293    margin: 0 0 8px;
     2294    color: var(--remove-comments-gray-700);
     2295    font-size: 13px;
     2296    font-weight: 600;
     2297}
     2298
     2299.remove-comments-wrap .remove-comments-date-input input[type="date"] {
     2300    box-sizing: border-box;
     2301    width: 100%;
     2302    height: var(--ulticocl-field);
     2303    margin: 0;
     2304    padding: 0 12px;
     2305    border: 1px solid var(--remove-comments-gray-300);
     2306    border-radius: var(--uc-radius);
     2307    background: #fff;
     2308    color: var(--uc-ink);
     2309    font-size: 13px;
     2310    line-height: 1.3;
     2311}
     2312
     2313.remove-comments-wrap .remove-comments-date-input input[type="date"]:focus {
     2314    outline: none;
     2315    border-color: var(--remove-comments-primary);
     2316    box-shadow: var(--uc-focus);
     2317}
     2318
     2319/* Settings: a switch, its label, and helper text lined up under the label. */
     2320.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) { gap: 12px; }
     2321
     2322.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) + .description {
     2323    margin: 4px 0 0 44px; /* 32px switch + 12px gap, so the text sits under its label */
     2324}
     2325
     2326.remove-comments-wrap #ulticocl_quarantine_days {
     2327    box-sizing: border-box;
     2328    width: 110px;
     2329    height: var(--ulticocl-field);
     2330    padding: 0 12px;
     2331}
     2332
     2333/* ---- Form footer: secondary actions left, the one primary action right ------------------- */
     2334
     2335.remove-comments-form-actions {
     2336    display: flex;
     2337    flex-wrap: wrap;
     2338    align-items: center;
     2339    gap: 12px;
     2340    margin-top: 8px;
     2341    padding-top: 20px;
     2342    border-top: 1px solid var(--uc-line);
     2343}
     2344
     2345.remove-comments-form-actions .remove-comments-btn {
     2346    height: var(--ulticocl-field);
     2347    padding: 0 18px;
     2348    font-size: 13px;
     2349    font-weight: 600;
     2350}
     2351
     2352.ulticocl-form-footer .ulticocl-rbtn { height: var(--ulticocl-field); padding: 0 16px 0 14px; }
     2353.ulticocl-form-footer .ulticocl-apply { margin-left: auto; }
     2354
     2355/* The Apply button turns red when the chosen action cannot be undone. */
     2356.ulticocl-apply[data-action="delete"] {
     2357    border-color: var(--remove-comments-danger);
     2358    background: var(--remove-comments-danger);
     2359    box-shadow: 0 1px 2px rgba(239, 68, 68, 0.35);
     2360}
     2361
     2362.ulticocl-apply[data-action="delete"]:hover { border-color: #dc2626; background: #dc2626; }
     2363.ulticocl-apply[data-action="delete"]:focus-visible { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25); }
     2364
     2365.remove-comments-preview-result {
     2366    margin-top: 16px;
     2367    padding: 12px 16px;
     2368    border: 1px solid #bfdbfe;
     2369    border-left: 4px solid var(--remove-comments-info);
     2370    border-radius: var(--uc-radius);
     2371    background: #f5f9ff;
     2372    color: var(--uc-ink);
     2373    font-size: 13px;
     2374}
     2375
     2376@media (max-width: 782px) {
     2377    .remove-comments-date-range { grid-template-columns: 1fr; gap: 16px; }
     2378    .ulticocl-form-footer .ulticocl-apply { margin-left: 0; width: 100%; justify-content: center; }
     2379}
     2380
     2381/* ---- Card rhythm: one 16px gap, whatever is inside (also holds when a field is hidden) ---- */
     2382
     2383.remove-comments-form-section { display: flex; flex-direction: column; gap: 16px; }
     2384.remove-comments-form-section > * { margin: 0; }
     2385.remove-comments-form-section > .remove-comments-form-group { margin: 0; }
     2386.remove-comments-form-section .ulticocl-row-after-toggles { margin-top: 0; }
     2387.remove-comments-form-section h3 { margin: 0; }
     2388
     2389.remove-comments-panel-header p { max-width: 84ch; }
     2390
     2391/* Settings switch rows: the helper text sits right under its label. */
     2392.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) { margin-bottom: 0; }
     2393
     2394/* Label -> control is 8px everywhere: the date fields were adding a flex gap on top of the label margin. */
     2395.remove-comments-date-input { gap: 0; }
     2396
     2397/* Helper text hugs the select it explains instead of floating a full row below it. */
     2398.remove-comments-form-section > .description { margin-top: -8px; }
     2399
     2400/* ---- Phones: one column of tiles, full-width actions, snugger cards ------------------------ */
     2401
     2402@media (max-width: 600px) {
     2403    .remove-comments-form-section { padding: 16px; }
     2404    .remove-comments-form-section .ulticocl-toggle-grid[data-cols] { --cols: 1; }
     2405    .remove-comments-form-section .ulticocl-toggle-label { white-space: normal; line-height: 1.2; }
     2406    .remove-comments-form-section .ulticocl-grid-2 { grid-template-columns: 1fr; }
     2407
     2408    .ulticocl-form-footer { flex-direction: column; align-items: stretch; }
     2409    .ulticocl-form-footer > button { width: 100%; justify-content: center; }
     2410}
     2411
     2412/* ---- Rule dialog: the on/off switch is a status card, not a footnote ----------------------- */
     2413
     2414.remove-comments-toggle-switch.ulticocl-large { flex: none; width: 44px; height: 24px; }
     2415.ulticocl-large .remove-comments-toggle-slider { border-radius: 24px; }
     2416
     2417.ulticocl-large .remove-comments-toggle-slider:before {
     2418    width: 18px;
     2419    height: 18px;
     2420    left: 3px;
     2421    bottom: 3px;
     2422}
     2423
     2424.ulticocl-large input:checked + .remove-comments-toggle-slider:before { transform: translateX(20px); }
     2425
     2426.remove-comments-modal .ulticocl-status-card {
     2427    display: flex;
     2428    align-items: center;
     2429    gap: 16px;
     2430    box-sizing: border-box;
     2431    margin: 0 0 20px;
     2432    padding: 14px 16px;
     2433    border: 1px solid var(--remove-comments-gray-300);
     2434    border-radius: var(--uc-radius);
     2435    background: var(--remove-comments-gray-50);
     2436    cursor: pointer;
     2437    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     2438}
     2439
     2440.remove-comments-modal .ulticocl-status-card:hover { border-color: var(--remove-comments-gray-400); }
     2441
     2442.remove-comments-modal .ulticocl-status-card:has(input:checked) {
     2443    border-color: #c7d2fe;
     2444    background: #f5f6ff;
     2445}
     2446
     2447.remove-comments-modal .ulticocl-status-card:has(input:focus-visible) {
     2448    border-color: var(--remove-comments-primary);
     2449    box-shadow: var(--uc-focus);
     2450}
     2451
     2452.ulticocl-status-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
     2453
     2454.ulticocl-status-title { color: var(--uc-ink); font-size: 14px; font-weight: 600; line-height: 1.3; }
     2455.ulticocl-status-card:has(input:checked) .ulticocl-status-title { color: #3730a3; }
     2456
     2457.ulticocl-status-help { color: var(--uc-muted); font-size: 12.5px; line-height: 1.5; }
  • ultimate-comment-cleaner/tags/1.1.0/assets/js/admin.js

    r3446581 r3752017  
    11jQuery(document).ready(function($) {
    22    'use strict';
    3    
     3
     4    const { __, _n, sprintf } = wp.i18n;
     5    const td = 'ultimate-comment-cleaner';
     6
    47    // Helper function to show native alert
    58    function showAlert(message, type) {
    69        if (type === 'error') {
    7             alert('Error: ' + message);
    8         } else if (type === 'success') {
    9             alert('Success: ' + message);
     10            alert(sprintf(__('Error: %s', td), message));
    1011        } else {
    1112            alert(message);
    1213        }
    1314    }
    14    
    15     // Helper function to show native confirm
    16     function showConfirm(message, callback) {
    17         if (confirm(message)) {
    18             if (callback) callback();
    19         }
    20     }
    21    
    22     // Delete Comments Form
     15
     16    function errorMessage(response) {
     17        return (response && response.data && response.data.message) || __('An error occurred. Please try again.', td);
     18    }
     19
     20    function post(data) {
     21        return $.post(ulticoclData.ajaxurl, $.extend({ nonce: ulticoclData.nonce }, data));
     22    }
     23
     24    function isOn(value) {
     25        return value === true || value === 1 || value === '1';
     26    }
     27
     28    // ---------------------------------------------------------------------
     29    // Bulk Clean: count first, confirm with the real number, then work in batches
     30    // ---------------------------------------------------------------------
     31
     32    function confirmText(action, count) {
     33        const n = count.toLocaleString();
     34        if (action === 'delete') {
     35            /* translators: %s: number of comments */
     36            return sprintf(_n('Permanently delete %s comment? This cannot be undone.', 'Permanently delete %s comments? This cannot be undone.', count, td), n);
     37        }
     38        if (action === 'spam') {
     39            /* translators: %s: number of comments */
     40            return sprintf(_n('Mark %s comment as spam?', 'Mark %s comments as spam?', count, td), n);
     41        }
     42        /* translators: %s: number of comments */
     43        return sprintf(_n('Move %s comment to quarantine? You can restore it from the Quarantine tab.', 'Move %s comments to quarantine? You can restore them from the Quarantine tab.', count, td), n);
     44    }
     45
     46    function runBulk(filters) {
     47        post($.extend({ action: 'ulticocl_preview_count' }, filters)).done(function(response) {
     48            if (!response.success) {
     49                showAlert(errorMessage(response), 'error');
     50                return;
     51            }
     52            const count = response.data.count;
     53            if (count === 0) {
     54                showAlert(__('No comments match your criteria.', td));
     55                return;
     56            }
     57            if (confirm(confirmText(filters.clean_action, count))) {
     58                processBulk(filters, count);
     59            }
     60        }).fail(function() {
     61            showAlert(__('An error occurred. Please try again.', td), 'error');
     62        });
     63    }
     64
     65    function processBulk(filters, expected) {
     66        const progress = $('<div>').addClass('ulticocl-progress-box').attr('role', 'status');
     67        $('body').append(progress);
     68
     69        function step(processedSoFar) {
     70            progress.text(sprintf(
     71                /* translators: 1: comments processed so far, 2: total comments */
     72                __('Working... %1$s of %2$s', td),
     73                processedSoFar.toLocaleString(),
     74                expected.toLocaleString()
     75            ));
     76            post($.extend({ action: 'ulticocl_bulk_process', processed_so_far: processedSoFar }, filters)).done(function(response) {
     77                if (!response.success) {
     78                    progress.remove();
     79                    showAlert(errorMessage(response), 'error');
     80                    return;
     81                }
     82                if (!response.data.done) {
     83                    step(response.data.total);
     84                    return;
     85                }
     86                progress.remove();
     87                let message = response.data.message;
     88                if (response.data.remaining > 0) {
     89                    message += '\n' + sprintf(
     90                        /* translators: %s: number of comments */
     91                        __('%s matching comments could not be changed.', td),
     92                        response.data.remaining.toLocaleString()
     93                    );
     94                }
     95                showAlert(message);
     96                location.reload();
     97            }).fail(function() {
     98                progress.remove();
     99                showAlert(__('An error occurred while processing comments.', td), 'error');
     100            });
     101        }
     102
     103        step(0);
     104    }
     105
    23106    $('#remove-comments-delete-form').on('submit', function(e) {
    24107        e.preventDefault();
    25        
    26         const formData = $(this).serialize();
    27         const selectedStatus = $('input[name="status[]"]:checked').length;
    28        
    29         if (selectedStatus === 0) {
    30             showAlert('Please select at least one status to delete.', 'error');
     108
     109        if ($('input[name="status[]"]:checked').length === 0) {
     110            showAlert(__('Please select at least one status.', td), 'error');
    31111            return;
    32112        }
    33        
    34         showConfirm('Are you sure? This will permanently delete the selected comments. This action cannot be undone!', function() {
    35             deleteComments(formData);
    36         });
    37     });
    38    
     113        runBulk(getFormData());
     114    });
     115
     116    // Quarantine tab: delete everything quarantined
     117    $('#ulticocl-empty-quarantine').on('click', function() {
     118        runBulk({ status: ['quarantine'], clean_action: 'delete' });
     119    });
     120
    39121    // Preview Count
    40122    $('#remove-comments-preview-count').on('click', function() {
    41         const formData = $('#remove-comments-delete-form').serialize() + '&action=ulticocl_preview_count';
    42        
    43         $.ajax({
    44             url: ulticoclData.ajaxurl,
    45             type: 'POST',
    46             data: {
    47                 action: 'ulticocl_preview_count',
    48                 nonce: ulticoclData.nonce,
    49                 ...getFormData()
    50             },
    51             beforeSend: function() {
    52                 $('#remove-comments-preview-count').prop('disabled', true).text('Counting...');
    53             },
    54             success: function(response) {
    55                 if (response.success) {
    56                     $('#remove-comments-preview-result').html(
    57                         `Found <strong>${response.data.count}</strong> comments matching your criteria.`
    58                     ).show();
    59                 } else {
    60                     showAlert(response.data.message || 'An error occurred.', 'error');
    61                 }
    62             },
    63             complete: function() {
    64                 $('#remove-comments-preview-count').prop('disabled', false).text('Preview Count');
    65             }
    66         });
    67     });
    68    
     123        const button = $(this);
     124        const label = button.find('.ulticocl-btn-label');
     125        const originalText = label.text();
     126        button.prop('disabled', true);
     127        label.text(__('Counting...', td));
     128
     129        post($.extend({ action: 'ulticocl_preview_count' }, getFormData())).done(function(response) {
     130            if (response.success) {
     131                const count = response.data.count;
     132                $('#remove-comments-preview-result').text(sprintf(
     133                    /* translators: %s: number of comments */
     134                    _n('Found %s comment matching your criteria.', 'Found %s comments matching your criteria.', count, td),
     135                    count.toLocaleString()
     136                )).show();
     137            } else {
     138                showAlert(errorMessage(response), 'error');
     139            }
     140        }).always(function() {
     141            button.prop('disabled', false);
     142            label.text(originalText);
     143        });
     144    });
     145
     146    // Bulk Clean: describe what the chosen action does, and let the button's colour follow the consequence.
     147    (function() {
     148        const select = $('#remove-comments-clean-action');
     149        if (!select.length) {
     150            return;
     151        }
     152        const help = {
     153            quarantine: __('Hidden from your site. You can restore them from the Quarantine tab.', td),
     154            spam: __('Moved to the spam folder.', td),
     155            'delete': __('Removed permanently. This cannot be undone.', td)
     156        };
     157        const sync = function() {
     158            const action = select.val();
     159            $('#ulticocl-action-help').text(help[action] || '');
     160            $('#remove-comments-delete-btn').attr('data-action', action);
     161        };
     162        select.on('change', sync);
     163        sync();
     164    })();
     165
    69166    // Export Comments
    70167    $('#remove-comments-export-btn, #remove-comments-export-all').on('click', function() {
    71168        const formData = getFormData();
     169        delete formData.clean_action;
    72170        formData.action = 'ulticocl_export_comments';
    73171        formData.nonce = ulticoclData.nonce;
    74        
     172        // The dashboard button has no filter form: export everything.
     173        if (!formData.status) {
     174            formData.status = ['all'];
     175        }
     176
    75177        // Create form and submit
    76178        const form = $('<form>', {
    … …  
    79181            target: '_blank'
    80182        });
    81        
     183
    82184        Object.keys(formData).forEach(key => {
    83             form.append($('<input>', {
    84                 type: 'hidden',
    85                 name: key,
    86                 value: formData[key]
    87             }));
    88         });
    89        
     185            // Arrays must be sent as key[] so PHP receives them as arrays.
     186            [].concat(formData[key]).forEach(value => {
     187                form.append($('<input>', {
     188                    type: 'hidden',
     189                    name: Array.isArray(formData[key]) ? key + '[]' : key,
     190                    value: value
     191                }));
     192            });
     193        });
     194
    90195        $('body').append(form);
    91196        form.submit();
    92197        form.remove();
    93198    });
    94    
    95     // Add Trigger
    96     $('#remove-comments-add-trigger').on('click', function() {
    97         $('#remove-comments-trigger-form')[0].reset();
    98         $('#remove-comments-trigger-id').val('');
    99         $('#remove-comments-trigger-modal').show();
    100     });
    101    
    102     // Edit Trigger
    103     $(document).on('click', '.remove-comments-edit-trigger', function() {
    104         const triggerId = $(this).data('id');
    105        
    106         $.ajax({
    107             url: ulticoclData.ajaxurl,
    108             type: 'POST',
    109             data: {
    110                 action: 'ulticocl_get_trigger',
    111                 nonce: ulticoclData.nonce,
    112                 trigger_id: triggerId
    113             },
    114             success: function(response) {
    115                 if (response.success) {
    116                     const trigger = response.data;
    117                     $('#remove-comments-trigger-id').val(triggerId);
    118                     $('#remove-comments-trigger-name').val(trigger.name);
    119                     $('#remove-comments-trigger-status').val(trigger.status);
    120                     $('#remove-comments-trigger-older').val(trigger.older_than);
    121                     $('#remove-comments-trigger-schedule').val(trigger.schedule);
    122                     $('#remove-comments-trigger-active').prop('checked', trigger.active);
    123                     $('#remove-comments-trigger-modal').show();
    124                 }
    125             }
    126         });
    127     });
    128    
    129     // Delete Trigger
    130     $(document).on('click', '.remove-comments-delete-trigger', function() {
    131         const triggerId = $(this).data('id');
    132        
    133         showConfirm('Are you sure you want to delete this trigger?', function() {
    134             $.ajax({
    135                 url: ulticoclData.ajaxurl,
    136                 type: 'POST',
    137                 data: {
    138                     action: 'ulticocl_delete_trigger',
    139                     nonce: ulticoclData.nonce,
    140                     trigger_id: triggerId
    141                 },
    142                 success: function(response) {
    143                     if (response.success) {
    144                         showAlert('Trigger has been deleted.', 'success');
    145                         location.reload();
    146                     } else {
    147                         showAlert(response.data.message || 'An error occurred.', 'error');
    148                     }
    149                 }
    150             });
    151         });
    152     });
    153    
    154     // Save Trigger
    155     $('#remove-comments-trigger-form').on('submit', function(e) {
    156         e.preventDefault();
    157        
    158         const formData = {
    159             action: 'ulticocl_save_trigger',
    160             nonce: ulticoclData.nonce,
    161             trigger_id: $('#remove-comments-trigger-id').val(),
    162             name: $('#remove-comments-trigger-name').val(),
    163             status: $('#remove-comments-trigger-status').val(),
    164             older_than: $('#remove-comments-trigger-older').val(),
    165             schedule: $('#remove-comments-trigger-schedule').val(),
    166             active: $('#remove-comments-trigger-active').is(':checked') ? 1 : 0
    167         };
    168        
    169         $.ajax({
    170             url: ulticoclData.ajaxurl,
    171             type: 'POST',
    172             data: formData,
    173             success: function(response) {
    174                 if (response.success) {
    175                     showAlert('Trigger has been saved successfully.', 'success');
    176                     setTimeout(function() {
    177                         location.reload();
    178                     }, 1000);
    179                 } else {
    180                     showAlert(response.data.message || 'An error occurred.', 'error');
    181                 }
    182             }
    183         });
    184     });
    185    
    186     // Close Modal
    187     $('.remove-comments-modal-close').on('click', function() {
    188         $('#remove-comments-trigger-modal').hide();
    189     });
    190    
    191     // Close modal on outside click
    192     $(window).on('click', function(e) {
    193         if ($(e.target).hasClass('rc-modal')) {
    194             $('.remove-comments-modal').hide();
    195         }
    196     });
    197    
    198     // Helper Functions
    199     function deleteComments(formData) {
    200         const data = {
    201             action: 'ulticocl_delete_comments',
    202             nonce: ulticoclData.nonce,
    203             ...getFormData()
    204         };
    205        
    206         // Show loading message
    207         const loadingMsg = $('<div>').text('Deleting comments... Please wait.').css({
    208             position: 'fixed',
    209             top: '50%',
    210             left: '50%',
    211             transform: 'translate(-50%, -50%)',
    212             background: '#fff',
    213             padding: '20px',
    214             border: '2px solid #6366f1',
    215             borderRadius: '4px',
    216             zIndex: 9999,
    217             boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
    218         });
    219         $('body').append(loadingMsg);
    220        
    221         $.ajax({
    222             url: ulticoclData.ajaxurl,
    223             type: 'POST',
    224             data: data,
    225             success: function(response) {
    226                 loadingMsg.remove();
    227                 if (response.success) {
    228                     showAlert(response.data.message, 'success');
    229                     setTimeout(function() {
    230                         location.reload();
    231                     }, 1000);
    232                 } else {
    233                     showAlert(response.data.message || 'An error occurred.', 'error');
    234                 }
    235             },
    236             error: function() {
    237                 loadingMsg.remove();
    238                 showAlert('An error occurred while deleting comments.', 'error');
    239             }
    240         });
    241     }
    242    
     199
    243200    function getFormData() {
    244201        const data = {};
    245        
     202
    246203        // Get status checkboxes
    247204        const statuses = [];
    … …  
    252209            data.status = statuses;
    253210        }
    254        
     211
    255212        // Get date range
    256213        const dateFrom = $('#remove-comments-date-from').val();
    … …  
    258215        if (dateFrom) data.date_from = dateFrom;
    259216        if (dateTo) data.date_to = dateTo;
    260        
     217
    261218        // Get post types
    262219        const postTypes = [];
    … …  
    267224            data.post_type = postTypes;
    268225        }
    269        
     226
    270227        // Get author
    271228        const author = $('#remove-comments-author-filter').val();
    … …  
    273230            data.author = author;
    274231        }
    275        
     232
     233        // Search text
     234        const search = $('#remove-comments-search').val();
     235        if (search && search.trim() !== '') {
     236            data.search = search.trim();
     237        }
     238
    276239        // Additional filters
    277240        if ($('input[name="has_url"]').is(':checked')) {
    … …  
    284247            data.short_comments = 1;
    285248        }
    286        
     249
    287250        const minWords = $('input[name="min_words"]').val();
    288251        const maxWords = $('input[name="max_words"]').val();
    289252        if (minWords) data.min_words = minWords;
    290253        if (maxWords) data.max_words = maxWords;
    291        
     254
     255        data.clean_action = $('#remove-comments-clean-action').val() || 'quarantine';
     256
    292257        return data;
    293258    }
    294    
    295     // Auto-refresh stats every 30 seconds on dashboard
    296     if ($('.remove-comments-dashboard').length) {
    297         setInterval(function() {
    298             $.ajax({
    299                 url: ulticoclData.ajaxurl,
    300                 type: 'POST',
    301                 data: {
    302                     action: 'ulticocl_get_stats',
    303                     nonce: ulticoclData.nonce
    304                 },
    305                 success: function(response) {
    306                     if (response.success) {
    307                         updateStats(response.data);
    308                     }
    309                 }
     259
     260    // ---------------------------------------------------------------------
     261    // Smart Rules
     262    // ---------------------------------------------------------------------
     263
     264    const $modal = $('#ulticocl-rule-modal');
     265    const $form = $('#ulticocl-rule-form');
     266    const $result = $('#ulticocl-scan-result');
     267
     268    function setChecks(name, values) {
     269        values = [].concat(values || []);
     270        $form.find('input[name="' + name + '[]"]').each(function() {
     271            $(this).prop('checked', values.indexOf($(this).val()) !== -1);
     272        });
     273    }
     274
     275    function checked(name) {
     276        return $form.find('input[name="' + name + '[]"]:checked').map(function() {
     277            return this.value;
     278        }).get();
     279    }
     280
     281    function toggleScope() {
     282        $('.ulticocl-existing-only').toggle($('#ulticocl-rule-scope').val() !== 'new');
     283    }
     284
     285    // The status card reads "active" or "paused" to match the switch.
     286    function syncStatusCard() {
     287        const on = $('#ulticocl-rule-active').is(':checked');
     288        $('.ulticocl-status-title, .ulticocl-status-help').each(function() {
     289            $(this).text($(this).attr(on ? 'data-on' : 'data-off'));
     290        });
     291    }
     292
     293    function fillRuleForm(rule, title) {
     294        rule = $.extend({}, ulticoclData.ruleDefaults, rule);
     295        $('#ulticocl-rule-title').text(title);
     296        $('#ulticocl-rule-id').val(rule.id || '');
     297        $('#ulticocl-rule-name').val(rule.name);
     298        $('#ulticocl-rule-scope').val(rule.scope);
     299        $('#ulticocl-rule-action').val(rule.action);
     300        $('#ulticocl-rule-mode').val(rule.mode);
     301        $('#ulticocl-rule-schedule').val(rule.schedule);
     302        $('#ulticocl-rule-keywords').val([].concat(rule.keywords || []).join('\n'));
     303        setChecks('fields', rule.fields);
     304        setChecks('scripts', rule.scripts);
     305        setChecks('status', rule.status);
     306        $('#ulticocl-rule-pct').val(rule.script_min_pct);
     307        $('#ulticocl-rule-links').val(rule.min_links);
     308        $('#ulticocl-rule-words').val(rule.max_words);
     309        $('#ulticocl-rule-older').val(rule.older_than);
     310        $('#ulticocl-rule-has-url').prop('checked', isOn(rule.has_url));
     311        $('#ulticocl-rule-active').prop('checked', isOn(rule.active));
     312        syncStatusCard();
     313        toggleScope();
     314        $modal.show();
     315        $('#ulticocl-rule-name').trigger('focus');
     316    }
     317
     318    function rulePayload() {
     319        return {
     320            rule_id: $('#ulticocl-rule-id').val(),
     321            name: $('#ulticocl-rule-name').val(),
     322            scope: $('#ulticocl-rule-scope').val(),
     323            rule_action: $('#ulticocl-rule-action').val(),
     324            mode: $('#ulticocl-rule-mode').val(),
     325            schedule: $('#ulticocl-rule-schedule').val(),
     326            keywords: $('#ulticocl-rule-keywords').val(),
     327            fields: checked('fields'),
     328            scripts: checked('scripts'),
     329            status: checked('status'),
     330            script_min_pct: $('#ulticocl-rule-pct').val(),
     331            min_links: $('#ulticocl-rule-links').val(),
     332            max_words: $('#ulticocl-rule-words').val(),
     333            older_than: $('#ulticocl-rule-older').val(),
     334            has_url: $('#ulticocl-rule-has-url').is(':checked') ? 1 : 0,
     335            active: $('#ulticocl-rule-active').is(':checked') ? 1 : 0
     336        };
     337    }
     338
     339    $('#ulticocl-rule-scope').on('change', toggleScope);
     340    $('#ulticocl-rule-active').on('change', syncStatusCard);
     341
     342    $('#ulticocl-add-rule').on('click', function() {
     343        fillRuleForm({}, __('New Smart Rule', td));
     344    });
     345
     346    // Quick-start presets sit in one row that glides left and right with the pointer.
     347    (function() {
     348        const viewport = document.getElementById('ulticocl-presets');
     349        if (!viewport) {
     350            return;
     351        }
     352        const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
     353        let target = 0;
     354        let frame = 0;
     355        const range = function() {
     356            return viewport.scrollWidth - viewport.clientWidth;
     357        };
     358        // Fade the side that still has buttons behind it.
     359        const edges = function() {
     360            viewport.classList.toggle('has-left', viewport.scrollLeft > 2);
     361            viewport.classList.toggle('has-right', viewport.scrollLeft < range() - 2);
     362        };
     363        const glide = function() {
     364            const diff = target - viewport.scrollLeft;
     365            if (Math.abs(diff) < 0.5) {
     366                viewport.scrollLeft = target;
     367                frame = 0;
     368                return;
     369            }
     370            viewport.scrollLeft += diff * 0.14;
     371            frame = requestAnimationFrame(glide);
     372        };
     373        viewport.addEventListener('pointermove', function(event) {
     374            if (event.pointerType === 'touch' || range() <= 0) {
     375                return; // touch scrolls natively
     376            }
     377            const box = viewport.getBoundingClientRect();
     378            // The outer 12% on each side is the dead zone that reaches the first and last button.
     379            const ratio = Math.min(1, Math.max(0, ((event.clientX - box.left) / box.width - 0.12) / 0.76));
     380            target = ratio * range();
     381            if (reduceMotion) {
     382                viewport.scrollLeft = target;
     383            } else if (!frame) {
     384                frame = requestAnimationFrame(glide);
     385            }
     386        });
     387        viewport.addEventListener('scroll', edges, { passive: true });
     388        window.addEventListener('resize', edges);
     389        edges();
     390    })();
     391
     392    // Quick start: open a ready-made rule for review before saving
     393    $('.ulticocl-preset').on('click', function() {
     394        fillRuleForm(ulticoclData.presets[$(this).data('preset')], __('New Smart Rule', td));
     395    });
     396
     397    $(document).on('click', '.ulticocl-edit-rule', function() {
     398        const id = $(this).data('id');
     399        post({ action: 'ulticocl_get_rule', rule_id: id }).done(function(response) {
     400            if (response.success) {
     401                fillRuleForm($.extend({ id: id }, response.data), __('Edit Smart Rule', td));
     402            } else {
     403                showAlert(errorMessage(response), 'error');
     404            }
     405        });
     406    });
     407
     408    $(document).on('click', '.ulticocl-delete-rule', function() {
     409        const id = $(this).data('id');
     410        if (!confirm(__('Delete this rule? Comments it already caught stay where they are.', td))) {
     411            return;
     412        }
     413        post({ action: 'ulticocl_delete_rule', rule_id: id }).done(function(response) {
     414            if (response.success) {
     415                location.reload();
     416            } else {
     417                showAlert(errorMessage(response), 'error');
     418            }
     419        });
     420    });
     421
     422    $form.on('submit', function(e) {
     423        e.preventDefault();
     424        post($.extend({ action: 'ulticocl_save_rule' }, rulePayload())).done(function(response) {
     425            if (response.success) {
     426                location.reload();
     427            } else {
     428                showAlert(errorMessage(response), 'error');
     429            }
     430        }).fail(function() {
     431            showAlert(__('An error occurred. Please try again.', td), 'error');
     432        });
     433    });
     434
     435    // Close Modal
     436    $('.remove-comments-modal-close').on('click', function() {
     437        $modal.hide();
     438    });
     439
     440    // Close modal on outside click
     441    $(window).on('click', function(e) {
     442        if ($(e.target).is('.remove-comments-modal')) {
     443            $('.remove-comments-modal').hide();
     444        }
     445    });
     446
     447    // Deep links from the dashboard: ?preset=cyrillic or ?keywords=a,b,c open the dialog prefilled
     448    if ($modal.length) {
     449        const params = new URLSearchParams(window.location.search);
     450        const preset = params.get('preset');
     451        const keywords = params.get('keywords');
     452        if (preset && ulticoclData.presets[preset]) {
     453            fillRuleForm(ulticoclData.presets[preset], __('New Smart Rule', td));
     454        } else if (keywords) {
     455            fillRuleForm({
     456                name: __('Spam words', td),
     457                keywords: keywords.split(',').map(function(word) { return word.trim(); }).filter(Boolean).slice(0, 50)
     458            }, __('New Smart Rule', td));
     459        }
     460    }
     461
     462    function doneText(action, n) {
     463        const count = n.toLocaleString();
     464        if (action === 'delete') {
     465            /* translators: %s: number of comments */
     466            return sprintf(_n('Deleted %s comment.', 'Deleted %s comments.', n, td), count);
     467        }
     468        if (action === 'spam') {
     469            /* translators: %s: number of comments */
     470            return sprintf(_n('Marked %s comment as spam.', 'Marked %s comments as spam.', n, td), count);
     471        }
     472        /* translators: %s: number of comments */
     473        return sprintf(_n('Moved %s comment to quarantine.', 'Moved %s comments to quarantine.', n, td), count);
     474    }
     475
     476    // Scan existing comments with a rule in batches, showing progress. dry = test only.
     477    function scanRule(payload, dry, ruleName, action) {
     478        const totals = { scanned: 0, matched: 0, acted: 0, total: 0, samples: [] };
     479        $result.show().empty().append($('<p>').text(__('Starting...', td)));
     480        $result[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
     481
     482        function progress() {
     483            const pct = totals.total ? Math.min(100, Math.round(100 * totals.scanned / totals.total)) : 0;
     484            $result.empty().append(
     485                $('<p>').text(sprintf(
     486                    /* translators: 1: comments checked so far, 2: total comments to check, 3: comments matched so far */
     487                    __('Checking... %1$s of %2$s comments, %3$s matched so far', td),
     488                    totals.scanned.toLocaleString(), totals.total.toLocaleString(), totals.matched.toLocaleString()
     489                )),
     490                $('<div>').addClass('ulticocl-progress').append($('<div>').css('width', pct + '%'))
     491            );
     492        }
     493
     494        function finish() {
     495            $result.empty();
     496            if (dry) {
     497                $result.append($('<p>').append($('<strong>').text(sprintf(
     498                    /* translators: 1: comments matched, 2: comments checked, 3: rule name */
     499                    __('Test finished: %1$s of %2$s existing comments match "%3$s". Nothing was changed.', td),
     500                    totals.matched.toLocaleString(), totals.scanned.toLocaleString(), ruleName
     501                ))));
     502                if (totals.scanned >= 10 && totals.matched / totals.scanned >= 0.5) {
     503                    $result.append($('<p>').addClass('ulticocl-error').text(
     504                        __('This rule matches a very large share of your comments. Check its keywords before running it.', td)
     505                    ));
     506                }
     507                if (totals.samples.length) {
     508                    const list = $('<ul>').addClass('ulticocl-samples');
     509                    totals.samples.forEach(function(s) {
     510                        list.append($('<li>').append(
     511                            $('<strong>').text(s.author || '—'),
     512                            document.createTextNode(': ' + s.excerpt + ' '),
     513                            $('<em>').text('(' + s.reason + ')')
     514                        ));
     515                    });
     516                    $result.append(list);
     517                }
     518                if (totals.matched > 0) {
     519                    $result.append($('<p>').append($('<a>', {
     520                        href: ulticoclData.reviewUrl,
     521                        'class': 'button button-primary',
     522                        text: __('Review these comments', td)
     523                    })));
     524                }
     525            } else {
     526                $result.append($('<p>').append($('<strong>').text(doneText(action, totals.acted))));
     527                $result.append($('<p>').append($('<a>', { href: window.location.href, text: __('Refresh the list', td) })));
     528            }
     529        }
     530
     531        function step(cursor) {
     532            post($.extend({
     533                action: 'ulticocl_scan_rule',
     534                dry: dry ? 1 : 0,
     535                cursor: cursor,
     536                acted_so_far: totals.acted
     537            }, payload)).done(function(response) {
     538                if (!response.success) {
     539                    $result.empty().append($('<p>').addClass('ulticocl-error').text(errorMessage(response)));
     540                    return;
     541                }
     542                const d = response.data;
     543                totals.scanned += d.scanned;
     544                totals.matched += d.matched;
     545                totals.acted += d.acted;
     546                totals.samples = totals.samples.concat(d.samples).slice(0, 5);
     547                if (d.total !== undefined) {
     548                    totals.total = d.total;
     549                }
     550                if (d.done) {
     551                    finish();
     552                } else {
     553                    progress();
     554                    step(d.cursor);
     555                }
     556            }).fail(function() {
     557                $result.empty().append($('<p>').addClass('ulticocl-error').text(__('An error occurred. Please try again.', td)));
    310558            });
    311         }, 30000);
    312     }
    313    
    314     function updateStats(stats) {
    315         // Update stat cards
    316         $('.remove-comments-stat-card').each(function() {
    317             const label = $(this).find('.remove-comments-stat-label').text().trim();
    318             let value = 0;
    319            
    320             if (label.includes('Total')) value = stats.total;
    321             else if (label.includes('Approved')) value = stats.approved;
    322             else if (label.includes('Pending')) value = stats.pending;
    323             else if (label.includes('Spam')) value = stats.spam;
    324             else if (label.includes('Trash')) value = stats.trash;
    325             else if (label.includes('Triggers')) value = stats.triggers;
    326            
    327             $(this).find('.remove-comments-stat-value').text(value.toLocaleString());
    328         });
    329     }
    330    
     559        }
     560
     561        step(0);
     562    }
     563
     564    // Test the rule currently in the form, without saving it
     565    $('#ulticocl-test-form').on('click', function() {
     566        const payload = rulePayload();
     567        $modal.hide();
     568        scanRule(payload, true, payload.name);
     569    });
     570
     571    // Test or run a saved rule from the list
     572    $(document).on('click', '.ulticocl-scan-saved', function() {
     573        const button = $(this);
     574        const dry = String(button.data('dry')) === '1';
     575        const name = button.closest('.ulticocl-rule').find('h3').text();
     576        const action = button.data('action');
     577        if (!dry) {
     578            const messages = {
     579                quarantine: __('Run this rule now? Matching comments will be moved to quarantine.', td),
     580                spam: __('Run this rule now? Matching comments will be marked as spam.', td),
     581                'delete': __('Run this rule now? Matching comments will be permanently deleted.', td)
     582            };
     583            if (!confirm(messages[action] || messages.quarantine)) {
     584                return;
     585            }
     586        }
     587        scanRule({ rule_id: button.data('id') }, dry, name, action);
     588    });
     589
     590    // ---------------------------------------------------------------------
     591    // Comment lists: confirm permanent deletes
     592    // ---------------------------------------------------------------------
     593
     594    $(document).on('click', '.remove-comments-comments-panel a.submitdelete', function(e) {
     595        if (!confirm(__('Permanently delete this comment? This cannot be undone.', td))) {
     596            e.preventDefault();
     597        }
     598    });
     599
     600    $('.remove-comments-comments-panel form[method="post"]').on('submit', function(e) {
     601        const form = $(this);
     602        const selected = form.find('input[name="ids[]"]:checked').length;
     603        const action = [form.find('select[name="action"]').val(), form.find('select[name="action2"]').val()].filter(function(v) {
     604            return v && v !== '-1';
     605        })[0];
     606        if (action === 'delete' && selected > 0 && !confirm(sprintf(
     607            /* translators: %s: number of comments */
     608            _n('Permanently delete %s comment? This cannot be undone.', 'Permanently delete %s comments? This cannot be undone.', selected, td),
     609            selected.toLocaleString()
     610        ))) {
     611            e.preventDefault();
     612        }
     613    });
     614
     615    // ---------------------------------------------------------------------
     616    // Dashboard, filters and settings
     617    // ---------------------------------------------------------------------
     618
    331619    // Checkbox group handling
    332620    $('input[name="status[]"]').on('change', function() {
     621        if (!$(this).closest('#remove-comments-delete-form').length) {
     622            return;
     623        }
    333624        if ($(this).val() === 'all' && $(this).is(':checked')) {
    334625            $('input[name="status[]"]').not(this).prop('checked', false);
    … …  
    337628        }
    338629    });
    339    
     630
    340631    $('input[name="post_type[]"]').on('change', function() {
    341632        if ($(this).val() === 'all' && $(this).is(':checked')) {
    … …  
    345636        }
    346637    });
    347    
     638
    348639    // Toggle email field visibility based on notification checkbox
    349640    $('input[name="send_email_notifications"]').on('change', function() {
    … …  
    354645        }
    355646    });
    356    
     647
    357648    // Settings Form - Use AJAX for better UX
    358649    $('#remove-comments-settings-form').on('submit', function(e) {
    359650        e.preventDefault();
    360        
     651
    361652        const notificationEmail = $('#rc_notification_email').val();
    362653        if ($('input[name="send_email_notifications"]').is(':checked') && (!notificationEmail || !notificationEmail.trim())) {
    363             showAlert('Please enter an email address for notifications.', 'error');
     654            showAlert(__('Please enter an email address for notifications.', td), 'error');
    364655            return;
    365656        }
    366        
    367         const formData = {
     657
     658        const submitButton = $(this).find('button[type="submit"]');
     659        const originalText = submitButton.text();
     660        submitButton.prop('disabled', true).text(__('Saving...', td));
     661
     662        post({
    368663            action: 'ulticocl_save_settings',
    369             nonce: ulticoclData.nonce,
    370664            enable_logging: $('input[name="enable_logging"]').is(':checked') ? 1 : 0,
    371665            send_email_notifications: $('input[name="send_email_notifications"]').is(':checked') ? 1 : 0,
    372             notification_email: notificationEmail
    373         };
    374        
    375         const submitButton = $(this).find('button[type="submit"]');
    376         const originalText = submitButton.text();
    377        
    378         $.ajax({
    379             url: ulticoclData.ajaxurl,
    380             type: 'POST',
    381             data: formData,
    382             beforeSend: function() {
    383                 submitButton.prop('disabled', true).text('Saving...');
    384             },
    385             success: function(response) {
    386                 if (response.success) {
    387                     showAlert(response.data.message || 'Settings have been saved successfully.', 'success');
    388                     // Reload to show updated settings and notice
    389                     setTimeout(function() {
    390                         const url = new URL(window.location.href);
    391                         url.searchParams.set('tab', 'settings');
    392                         url.searchParams.set('settings-updated', 'true');
    393                         window.location.href = url.toString();
    394                     }, 1000);
    395                 } else {
    396                     showAlert(response.data.message || 'An error occurred while saving settings.', 'error');
    397                 }
    398             },
    399             error: function() {
    400                 showAlert('An error occurred while saving settings.', 'error');
    401             },
    402             complete: function() {
     666            notification_email: notificationEmail,
     667            quarantine_days: $('#ulticocl_quarantine_days').val(),
     668            allowlist: $('#ulticocl_allowlist').val()
     669        }).done(function(response) {
     670            if (response.success) {
     671                // Reload to show updated settings and notice
     672                const url = new URL(window.location.href);
     673                url.searchParams.set('tab', 'settings');
     674                url.searchParams.set('settings-updated', 'true');
     675                window.location.href = url.toString();
     676            } else {
     677                showAlert(errorMessage(response), 'error');
    403678                submitButton.prop('disabled', false).text(originalText);
    404679            }
     680        }).fail(function() {
     681            showAlert(__('An error occurred while saving settings.', td), 'error');
     682            submitButton.prop('disabled', false).text(originalText);
    405683        });
    406684    });
  • ultimate-comment-cleaner/tags/1.1.0/readme.txt

    r3446581 r3752017  
    11=== Ultimate Comment Cleaner ===
    22Contributors: wpunicorn
    3 Tags: delete comments, bulk delete comment, remove comment,stop spam, clean comment
    4 Requires at least: 5.0
    5 Tested up to: 6.9
    6 Stable tag: 1.0.0
     3Tags: spam, delete comments, comment cleaner, quarantine, bulk delete
     4Requires at least: 5.5
     5Tested up to: 7.1
     6Requires PHP: 7.4
     7Stable tag: 1.1.0
    78License: GPLv2 or later
    89License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1011Author URI: https://profiles.wordpress.org/wpunicorn/
    1112
    12 Bulk delete WordPress comments with advanced filters, triggers, and a modern dashboard. Delete by status, date, author, and more.
     13Stop comment spam with smart rules: keywords, Cyrillic/Chinese text, links. Quarantine, review and bulk-clean comments. Restore any mistake.
    1314
    1415== Description ==
    1516
    16 **Ultimate Comment Cleaner** is a comprehensive WordPress comment management plugin that allows you to efficiently delete comments in bulk with advanced filtering options. Whether you need to clean up spam, remove old comments, or manage comments by specific criteria, this plugin provides all the tools you need.
     17**Ultimate Comment Cleaner** catches comment spam by *what it says*, keeps what it catches in a quarantine you can restore from, and gives you a proper screen to search, filter and clean up every comment on your site.
    1718
    18 = Key Features =
     19Getting hit by Russian gambling spam, Chinese link farms or "buy followers" offers? Pick a ready-made rule, test it against your existing comments to see exactly what it would catch, then switch it on.
    1920
    20 * **Creative Dashboard** - Beautiful, modern dashboard with real-time statistics and visual cards
    21 * **Bulk Delete** - Delete comments by status, date range, post type, author, and more
    22 * **Advanced Filters** - Filter by URL presence, word count, and custom criteria
    23 * **Auto Triggers** - Set up automatic deletion rules that run on schedule
    24 * **Export Functionality** - Export comments to CSV before deletion
    25 * **Preview Count** - See how many comments match your criteria before deleting
    26 * **Activity Log** - Track all deletion activities
    27 * **Real-time Stats** - Live statistics that update automatically
    28 * **WordPress Standards** - Follows WordPress coding standards and best practices
     21= Smart Rules =
    2922
    30 = Dashboard Features =
     23Rules check **new comments as they arrive** and can clean up **existing comments on a schedule**. A rule can look for:
    3124
    32 * Visual stat cards showing comment counts by status
    33 * Total comments, approved, pending, spam, and trash counts
    34 * Active triggers counter
    35 * Recent activity log
    36 * Quick action buttons
     25* **Keywords and phrases** - whole word (`casino`), word start (`casino*`), anywhere (`*casino*`) or a regular expression. English, Russian and any other language.
     26* **Writing system** - flag comments written mostly in Cyrillic, Chinese, Japanese, Korean, Arabic, Thai, Hebrew, Hindi or Greek.
     27* **Links** - comments with N or more links, or a website filled in.
     28* **Short comments** - fewer than N words. Combine with "ALL" for rules such as "short comment with a link".
     29* **Where to look** - comment text, name, email or website.
     30* **Status and age** - for existing comments, such as "pending comments older than 30 days".
    3731
    38 = Bulk Delete Options =
     32Ready-made rules to start from: Russian/Cyrillic text, Gambling spam (English and Russian), Chinese/Japanese/Korean text, Link stuffing, Short comment with a link, Pharmacy spam, Crypto and get-rich-quick, SEO and promotion spam, Adult content.
    3933
    40 * Filter by status (All, Approved, Pending, Spam, Trash)
    41 * Date range filtering
    42 * Post type filtering
    43 * Author filtering
    44 * Comments with/without URLs
    45 * Short comments (less than 10 words)
    46 * Custom word count range (min/max words)
    47 * Preview count before deletion
    48 * Export to CSV
     34What a rule does when it matches is up to you: **move to quarantine** (recommended), **mark as spam** or **delete permanently**.
    4935
    50 = Auto Triggers =
     36= Test before you trust it =
    5137
    52 * Create automatic deletion rules
    53 * Schedule: hourly, daily, or weekly
    54 * Filter by status and age (older than X days)
    55 * Enable/disable individually
    56 * View execution history
     38Every rule has a **Test on existing comments** button. It shows how many comments would match, a sample of them and why, and changes nothing. One click opens the matches in the comment list, where you can approve, spam, quarantine or delete them individually or in bulk.
     39
     40= Quarantine =
     41
     42Caught comments are hidden from your site but not lost. The Quarantine screen shows which rule caught each comment and why, lets you **restore** or delete them, and removes them automatically after the number of days you choose. A comment you restore is never caught by your rules again.
     43
     44= Dashboard =
     45
     46See what is happening at a glance. Stat tiles with trends, comments per day by status, how many comments your rules stopped, when spam arrives, and which writing systems, websites and words show up in spam. A rule performance table shows how many comments each rule caught and how often you had to restore them, and plain-language suggestions point out what needs attention. Switch between the last 7, 30 and 90 days. Every chart has a table view, and nothing is loaded from outside your site.
     47
     48= Manage Comments =
     49
     50A full comment list inside the plugin: search by text, name, email, website or IP, filter by links, website and date, see spam signals under each comment (links, writing system), and approve, unapprove, mark as spam, quarantine, edit or delete in bulk.
     51
     52= Bulk Clean =
     53
     54Delete, mark as spam or quarantine comments in bulk with filters for status, date range, post type, author, search text, links and word count. Preview the count first. Large clean-ups run in batches with a progress counter, so they do not time out. Export matching comments to CSV before you delete.
     55
     56= Built to be safe =
     57
     58* Quarantine is the default action, and everything in it can be restored.
     59* Rules that could match every comment are refused.
     60* Comments by moderators and administrators are never touched. Add trusted email addresses or domains to the allowlist.
     61* Only real comments, pingbacks and trackbacks are managed. WooCommerce reviews and order notes are left alone.
     62* Confirmations show the exact number of comments before anything permanent happens.
    5763
    5864== Installation ==
    … …  
    6470== Frequently Asked Questions ==
    6571
    66 = How do I delete all spam comments? =
     72= How do I stop Russian gambling spam? =
    6773
    68 Go to **Tools → Ultimate Comment Cleaner → Bulk Delete**, select "Spam" in the status filter, and click "Delete Comments".
     74Go to **Tools → Ultimate Comment Cleaner → Smart Rules** and click **Gambling spam** (keywords in English and Russian). Click **Test on existing comments** to see what it would catch, then **Save Rule**. For comments written in Russian generally, also add **Russian / Cyrillic text**.
    6975
    70 = Can I preview how many comments will be deleted? =
     76= What happens to a comment a rule catches? =
    7177
    72 Yes! Use the "Preview Count" button to see how many comments match your criteria before deleting.
     78By default it is moved to quarantine: hidden from your site and from the normal comment screen, but kept so you can restore it. Choose "Mark as spam" or "Delete permanently" per rule if you prefer.
    7379
    74 = How do I set up automatic spam cleanup? =
     80= Can a rule catch good comments by mistake? =
    7581
    76 Go to **Tools → Ultimate Comment Cleaner → Auto Triggers**, click "Add New Trigger", set the status to "Spam", choose a schedule, and enable it.
     82It can, which is why rules default to quarantine and have a test button. Restore a good comment from the Quarantine tab and it is never caught by your rules again. You can also add trusted email addresses or domains to the allowlist in Settings.
     83
     84= Does it check new comments or only old ones? =
     85
     86Both. Each rule can apply to new comments as they arrive, to existing comments on an hourly, daily or weekly schedule, or to both.
     87
     88= How long do quarantined comments stay? =
     89
     9014 days by default. Change it in **Settings**, or set 0 to keep them until you delete them.
     91
     92= Does it work with Akismet? =
     93
     94Yes. Comments Akismet already marks as spam are left to Akismet. Your rules add extra checks on top, such as keywords and writing systems.
     95
     96= Where did my quarantined comments go if I deactivate the plugin? =
     97
     98Quarantined comments stay hidden while the plugin is deactivated. Reactivate it to see them again. If you delete the plugin, they are handed back to WordPress as spam.
     99
     100= I used Auto Triggers in version 1.0. What happened to them? =
     101
     102They are imported as Smart Rules that delete exactly as before. A trigger that had no age limit (and so could have deleted every approved or pending comment) is imported switched off. Review it under Smart Rules.
     103
     104= Can I preview how many comments will be affected? =
     105
     106Yes. Bulk Clean has a "Preview Count" button, rules have a "Test on existing comments" button, and every confirmation shows the exact number.
    77107
    78108= Can I export comments before deleting them? =
    79109
    80 Yes! Use the "Export Selected" or "Export All Comments" buttons to download comments as a CSV file.
     110Yes. Use "Export Selected" or "Export All Comments" to download comments as a CSV file.
    81111
    82112= What permissions are required? =
    83113
    84114You need the `manage_options` capability (typically Administrator role) to use this plugin.
    85 
    86 = Why are some comments left behind after deletion? =
    87 
    88 This has been fixed in version 1.0.0. The plugin now uses WordPress's `wp_delete_comment()` function which properly cleans up all comment data including meta and post counts.
    89115
    90116== Screenshots ==
    … …  
    96122
    97123== Changelog ==
     124
     125= 1.1.0 =
     126* New: Smart Rules - catch spam by keywords, writing system (Cyrillic, Chinese, Japanese, Korean and more), links, website and comment length, on new comments and on existing ones
     127* New: nine ready-made rules, including Russian/Cyrillic text and Gambling spam in English and Russian
     128* New: test any rule on your existing comments before using it, then review the matches in the comment list
     129* New: Quarantine - caught comments are hidden but restorable, and are deleted automatically after the number of days you set
     130* New: Manage Comments - search, filter, edit and bulk-manage all comments inside the plugin
     131* New: redesigned dashboard with trend charts, spam insights, rule accuracy and suggestions for the last 7, 30 or 90 days
     132* New: Bulk Clean can quarantine, mark as spam or delete, and can search by text
     133* New: allowlist of trusted email addresses and domains that rules never touch
     134* New: confirmations for permanent deletes in the comment lists
     135* Change: "Auto Triggers" are now Smart Rules; existing triggers are imported automatically
     136* Fix: Creating a new auto trigger no longer overwrites the previous one
     137* Fix: "Export Selected" now exports only the comments matching your filters, including word-count filters
     138* Fix: Activity logging and email notifications can now be turned off
     139* Fix: Scheduled rules no longer drift on sites with a non-UTC timezone
     140* Fix: Clicking outside the rule dialog now closes it
     141* Safety: Invalid or missing filters now match nothing instead of all comments
     142* Safety: Rules that would match every comment are refused
     143* Safety: Exported CSV cells are protected against spreadsheet formula injection
     144* Improvement: Bulk actions run in batches with a progress counter, so large clean-ups no longer time out
     145* Improvement: Confirmation shows the exact number of comments affected
     146* Improvement: Faster deletion (deferred comment counting, no site-wide cache flush)
     147* Improvement: Large exports are streamed in chunks to save memory
     148* Improvement: All interface messages are now translatable
     149* Improvement: Scheduled events are removed on deactivation; plugin data is removed on uninstall
     150* Requires WordPress 5.5 and PHP 7.4 or newer
    98151
    99152= 1.0.0 =
    … …  
    111164== Upgrade Notice ==
    112165
     166= 1.1.0 =
     167Big update: Smart Rules catch spam by keywords, language and links, with a restorable quarantine and a full comment manager. Fixes new triggers overwriting each other. Existing triggers are imported as rules.
     168
    113169= 1.0.0 =
    114170Initial release of Ultimate Comment Cleaner. Install to start managing your comments efficiently!
  • ultimate-comment-cleaner/tags/1.1.0/ultimate-comment-cleaner.php

    r3446581 r3752017  
    33 * Plugin Name: Ultimate Comment Cleaner
    44 * Plugin URI: https://wordpress.org/plugins/ultimate-comment-cleaner/
    5  * Description: A powerful WordPress plugin to bulk delete comments with advanced filters, triggers, and a beautiful creative dashboard. Delete comments by status, date range, author, post type, and more.
    6  * Version: 1.0.0
     5 * Description: Stop comment spam with smart rules (keywords, writing system, links), a restorable quarantine, a full comment manager and bulk clean-up tools.
     6 * Version: 1.1.0
    77 * Author: wpunicorn
    88 * Author URI: https://profiles.wordpress.org/wpunicorn/
    … …  
    1010 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1111 * Text Domain: ultimate-comment-cleaner
    12  * Requires at least: 5.0
    13  * Tested up to: 6.9
     12 * Requires at least: 5.5
     13 * Requires PHP: 7.4
     14 * Tested up to: 7.1
    1415 */
    1516
    … …  
    2526 */
    2627if (!defined('ULTICOCL_PLUGIN_VERSION')) {
    27     define('ULTICOCL_PLUGIN_VERSION', '1.0.0');
     28    define('ULTICOCL_PLUGIN_VERSION', '1.1.0');
    2829}
    2930if (!defined('ULTICOCL_PLUGIN_DIR')) {
    … …  
    3738}
    3839
     40require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-matcher.php';
     41require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-insights.php';
     42
    3943class ULTICOCL_Plugin {
    40    
     44
     45    /**
     46     * Comments handled per request / per cron batch. Keeps each request well under host time limits.
     47     */
     48    const BATCH_SIZE = 500;
     49
     50    /**
     51     * Seconds one cron run may spend scanning rules before it carries on next time.
     52     */
     53    const CRON_BUDGET = 20;
     54
     55    /**
     56     * comment_approved value for quarantined comments. Core ignores unknown statuses,
     57     * so quarantined comments stay out of the site, the comment screen and trash handling.
     58     */
     59    const QUARANTINE = 'ulticocl_quarantine';
     60
     61    /**
     62     * Allowed status filter values mapped to comment_approved column values.
     63     */
     64    const STATUS_MAP = array(
     65        'approved'   => '1',
     66        'pending'    => '0',
     67        'spam'       => 'spam',
     68        'trash'      => 'trash',
     69        'quarantine' => self::QUARANTINE,
     70    );
     71
     72    /**
     73     * Comment types this plugin manages. Other types (WooCommerce notes, reviews...) are never touched.
     74     */
     75    const TYPES = array('comment', 'pingback', 'trackback');
     76
     77    const SCHEDULES = array('hourly', 'daily', 'weekly');
     78
     79    const COMMENT_ACTIONS = array('approve', 'unapprove', 'spam', 'quarantine', 'restore', 'delete');
     80
    4181    private static $instance = null;
    42    
     82
     83    /** @var array|null Rule that flagged the comment currently being inserted: array(id, rule, reason). */
     84    private $pending = null;
     85
     86    /** @var int[] New comments to delete once WordPress has finished inserting them. */
     87    private $deferred_deletes = array();
     88
    4389    public static function get_instance() {
    4490        if (null === self::$instance) {
    … …  
    4793        return self::$instance;
    4894    }
    49    
     95
    5096    private function __construct() {
    5197        $this->init_hooks();
    5298    }
    53    
     99
    54100    private function init_hooks() {
    55101        add_action('admin_menu', array($this, 'add_admin_menu'));
    56102        add_action('admin_enqueue_scripts', array($this, 'enqueue_scripts'));
    57         add_action('wp_ajax_ulticocl_delete_comments', array($this, 'ajax_delete_comments'));
    58         add_action('wp_ajax_ulticocl_get_stats', array($this, 'ajax_get_stats'));
     103        add_action('wp_ajax_ulticocl_bulk_process', array($this, 'ajax_bulk_process'));
     104        add_action('wp_ajax_ulticocl_dashboard', array($this, 'ajax_dashboard'));
    59105        add_action('wp_ajax_ulticocl_export_comments', array($this, 'ajax_export_comments'));
    60         add_action('wp_ajax_ulticocl_save_trigger', array($this, 'ajax_save_trigger'));
    61         add_action('wp_ajax_ulticocl_delete_trigger', array($this, 'ajax_delete_trigger'));
    62         add_action('wp_ajax_ulticocl_get_trigger', array($this, 'ajax_get_trigger'));
     106        add_action('wp_ajax_ulticocl_save_rule', array($this, 'ajax_save_rule'));
     107        add_action('wp_ajax_ulticocl_delete_rule', array($this, 'ajax_delete_rule'));
     108        add_action('wp_ajax_ulticocl_get_rule', array($this, 'ajax_get_rule'));
     109        add_action('wp_ajax_ulticocl_scan_rule', array($this, 'ajax_scan_rule'));
    63110        add_action('wp_ajax_ulticocl_preview_count', array($this, 'ajax_preview_count'));
    64111        add_action('wp_ajax_ulticocl_save_settings', array($this, 'ajax_save_settings'));
    65        
    66         // Handle settings form submission (non-AJAX fallback)
     112
     113        // Settings form (non-AJAX fallback) and row/bulk actions on the comment lists
    67114        add_action('admin_init', array($this, 'handle_settings_save'));
    68        
    69         // Scheduled triggers
    70         add_action('ulticocl_check_triggers', array($this, 'check_triggers'));
    71         if (!wp_next_scheduled('ulticocl_check_triggers')) {
    72             wp_schedule_event(time(), 'hourly', 'ulticocl_check_triggers');
    73         }
    74        
     115        add_action('admin_init', array($this, 'handle_comment_actions'));
     116
     117        // Screen new comments as they arrive
     118        add_filter('pre_comment_approved', array($this, 'screen_new_comment'), 20, 2);
     119        add_action('comment_post', array($this, 'after_new_comment'), 5);
     120
     121        add_action('init', array($this, 'maybe_upgrade'), 1);
     122
     123        // Scheduled rule runs and quarantine clean-up
     124        add_action('ulticocl_run_rules', array($this, 'run_scheduled_rules'));
     125        add_action('ulticocl_purge_quarantine', array($this, 'purge_quarantine'));
     126        if (!wp_next_scheduled('ulticocl_run_rules')) {
     127            wp_schedule_event(time(), 'hourly', 'ulticocl_run_rules');
     128        }
     129        if (!wp_next_scheduled('ulticocl_purge_quarantine')) {
     130            wp_schedule_event(time(), 'daily', 'ulticocl_purge_quarantine');
     131        }
     132
    75133        // Add settings link to plugins page
    76134        add_filter('plugin_action_links_' . ULTICOCL_PLUGIN_BASENAME, array($this, 'add_plugin_action_links'));
    … …  
    103161       
    104162        // Enqueue scripts
    105         wp_enqueue_script('jquery');
    106163        wp_enqueue_script(
    107164            'ulticocl-admin-script',
    108165            ULTICOCL_PLUGIN_URL . 'assets/js/admin.js',
    109             array('jquery'),
     166            array('jquery', 'wp-i18n'),
    110167            ULTICOCL_PLUGIN_VERSION,
    111168            true
    112169        );
    113        
    114         // Note: SweetAlert2 removed - external CDN resources not allowed
    115         // Using WordPress native confirmations via JavaScript confirm() instead
    116        
    117         // Localize script
     170        wp_set_script_translations('ulticocl-admin-script', 'ultimate-comment-cleaner');
     171
     172        $presets = array();
     173        foreach (ULTICOCL_Matcher::presets() as $key => $preset) {
     174            $presets[$key] = $preset['rule'];
     175        }
    118176        wp_localize_script('ulticocl-admin-script', 'ulticoclData', array(
    119177            'ajaxurl' => admin_url('admin-ajax.php'),
    120178            'nonce' => wp_create_nonce('ulticocl_nonce'),
    121             'strings' => array(
    122                 'confirm_delete' => __('Are you sure you want to delete these comments? This action cannot be undone.', 'ultimate-comment-cleaner'),
    123                 'deleting' => __('Deleting comments...', 'ultimate-comment-cleaner'),
    124                 'success' => __('Comments deleted successfully!', 'ultimate-comment-cleaner'),
    125                 'error' => __('An error occurred. Please try again.', 'ultimate-comment-cleaner'),
    126             )
     179            'presets' => $presets,
     180            'ruleDefaults' => ULTICOCL_Matcher::defaults(),
     181            'reviewUrl' => add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => 'manage', 'review' => 1), admin_url('tools.php')),
    127182        ));
     183
     184        // The charts only exist on the dashboard, so only load them there.
     185        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter is for navigation only.
     186        $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard';
     187        if ('dashboard' === $tab) {
     188            wp_enqueue_style('ulticocl-dashboard-style', ULTICOCL_PLUGIN_URL . 'assets/css/dashboard.css', array('ulticocl-admin-style'), ULTICOCL_PLUGIN_VERSION);
     189            wp_enqueue_script('ulticocl-dashboard-script', ULTICOCL_PLUGIN_URL . 'assets/js/dashboard.js', array('ulticocl-admin-script', 'wp-i18n'), ULTICOCL_PLUGIN_VERSION, true);
     190            wp_set_script_translations('ulticocl-dashboard-script', 'ultimate-comment-cleaner');
     191            // First paint uses this data; the script refreshes it over AJAX. Hex-escaped so it is safe inside a script tag.
     192            wp_add_inline_script(
     193                'ulticocl-dashboard-script',
     194                'window.ulticoclDash = ' . wp_json_encode(ULTICOCL_Insights::collect($this->dashboard_days(), $this->get_comment_stats()), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';',
     195                'before'
     196            );
     197        }
    128198    }
    129199   
    … …  
    134204       
    135205        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter is for navigation only, not form submission
    136         $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'dashboard';
    137        
     206        $current_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard';
     207        if ('triggers' === $current_tab) {
     208            $current_tab = 'rules'; // Old bookmarks from version 1.0.
     209        }
     210        if (!in_array($current_tab, array('dashboard', 'manage', 'quarantine', 'rules', 'delete', 'settings'), true)) {
     211            $current_tab = 'dashboard';
     212        }
     213        $quarantined = (int) get_comments(array('count' => true, 'status' => self::QUARANTINE));
     214        $tabs = array(
     215            'dashboard'  => array('dashicons-dashboard', __('Dashboard', 'ultimate-comment-cleaner')),
     216            'manage'     => array('dashicons-admin-comments', __('Manage Comments', 'ultimate-comment-cleaner')),
     217            'quarantine' => array('dashicons-shield', __('Quarantine', 'ultimate-comment-cleaner')),
     218            'rules'      => array('dashicons-filter', __('Smart Rules', 'ultimate-comment-cleaner')),
     219            'delete'     => array('dashicons-trash', __('Bulk Clean', 'ultimate-comment-cleaner')),
     220            'settings'   => array('dashicons-admin-settings', __('Settings', 'ultimate-comment-cleaner')),
     221        );
     222
    138223        ?>
    139224        <div class="wrap remove-comments-wrap">
    … …  
    142227                    <?php echo esc_html__('Ultimate Comment Cleaner', 'ultimate-comment-cleaner'); ?>
    143228                </h1>
    144                 <p class="remove-comments-subtitle"><?php echo esc_html__('Advanced comment management with powerful bulk deletion tools', 'ultimate-comment-cleaner'); ?></p>
     229                <p class="remove-comments-subtitle"><?php echo esc_html__('Smart rules, quarantine and bulk tools to keep your comments clean', 'ultimate-comment-cleaner'); ?></p>
    145230            </div>
    146            
     231
    147232            <nav class="remove-comments-tabs">
    148                 <a href="?page=ultimate-comment-cleaner&tab=dashboard" class="remove-comments-tab <?php echo $current_tab === 'dashboard' ? 'active' : ''; ?>">
    149                     <span class="dashicons dashicons-dashboard"></span>
    150                     <?php echo esc_html__('Dashboard', 'ultimate-comment-cleaner'); ?>
    151                 </a>
    152                 <a href="?page=ultimate-comment-cleaner&tab=delete" class="remove-comments-tab <?php echo $current_tab === 'delete' ? 'active' : ''; ?>">
    153                     <span class="dashicons dashicons-trash"></span>
    154                     <?php echo esc_html__('Bulk Delete', 'ultimate-comment-cleaner'); ?>
    155                 </a>
    156                 <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-tab <?php echo $current_tab === 'triggers' ? 'active' : ''; ?>">
    157                     <span class="dashicons dashicons-clock"></span>
    158                     <?php echo esc_html__('Auto Triggers', 'ultimate-comment-cleaner'); ?>
    159                 </a>
    160                 <a href="?page=ultimate-comment-cleaner&tab=settings" class="remove-comments-tab <?php echo $current_tab === 'settings' ? 'active' : ''; ?>">
    161                     <span class="dashicons dashicons-admin-settings"></span>
    162                     <?php echo esc_html__('Settings', 'ultimate-comment-cleaner'); ?>
    163                 </a>
     233                <?php foreach ($tabs as $slug => $tab) : ?>
     234                    <a href="<?php echo esc_url(add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => $slug), admin_url('tools.php'))); ?>" class="remove-comments-tab <?php echo $current_tab === $slug ? 'active' : ''; ?>">
     235                        <span class="dashicons <?php echo esc_attr($tab[0]); ?>"></span>
     236                        <?php echo esc_html($tab[1]); ?>
     237                        <?php if ('quarantine' === $slug && $quarantined > 0) : ?>
     238                            <span class="ulticocl-count"><?php echo esc_html(number_format_i18n($quarantined)); ?></span>
     239                        <?php endif; ?>
     240                    </a>
     241                <?php endforeach; ?>
    164242            </nav>
    165            
     243
    166244            <div class="remove-comments-content">
    167245                <?php
     246                $this->render_notice();
    168247                switch ($current_tab) {
    169248                    case 'dashboard':
    170249                        $this->render_dashboard_tab();
    171250                        break;
     251                    case 'manage':
     252                    case 'quarantine':
     253                        $this->render_comments_tab($current_tab);
     254                        break;
     255                    case 'rules':
     256                        $this->render_rules_tab();
     257                        break;
    172258                    case 'delete':
    173259                        $this->render_delete_tab();
    174                         break;
    175                     case 'triggers':
    176                         $this->render_triggers_tab();
    177260                        break;
    178261                    case 'settings':
    … …  
    187270   
    188271    private function render_dashboard_tab() {
    189         $stats = $this->get_comment_stats();
    190272        ?>
    191         <div class="remove-comments-dashboard">
    192             <div class="remove-comments-stats-grid">
    193                 <div class="remove-comments-stat-card remove-comments-stat-primary">
    194                     <span class="dashicons dashicons-admin-comments"></span>
    195                     <div class="remove-comments-stat-content">
    196                         <div class="remove-comments-stat-label"><?php esc_html_e('Total Comments', 'ultimate-comment-cleaner'); ?></div>
    197                         <div class="remove-comments-stat-value"><?php echo number_format($stats['total']); ?></div>
    198                     </div>
     273        <div id="ulticocl-dash" class="ulticocl-dash">
     274            <div class="ulticocl-filterbar">
     275                <div class="ulticocl-seg" id="ulticocl-range" role="group" aria-label="<?php esc_attr_e('Date range', 'ultimate-comment-cleaner'); ?>">
     276                    <?php foreach (ULTICOCL_Insights::RANGES as $range) : ?>
     277                        <button type="button" data-days="<?php echo (int) $range; ?>" aria-pressed="false">
     278                            <?php
     279                            /* translators: %d: number of days */
     280                            echo esc_html(sprintf(__('Last %d days', 'ultimate-comment-cleaner'), $range));
     281                            ?>
     282                        </button>
     283                    <?php endforeach; ?>
    199284                </div>
    200                
    201                 <div class="remove-comments-stat-card remove-comments-stat-success">
    202                     <span class="dashicons dashicons-yes-alt"></span>
    203                     <div class="remove-comments-stat-content">
    204                         <div class="remove-comments-stat-label"><?php esc_html_e('Approved', 'ultimate-comment-cleaner'); ?></div>
    205                         <div class="remove-comments-stat-value"><?php echo number_format($stats['approved']); ?></div>
    206                     </div>
    207                 </div>
    208                
    209                 <div class="remove-comments-stat-card remove-comments-stat-warning">
    210                     <span class="dashicons dashicons-clock"></span>
    211                     <div class="remove-comments-stat-content">
    212                         <div class="remove-comments-stat-label"><?php esc_html_e('Pending', 'ultimate-comment-cleaner'); ?></div>
    213                         <div class="remove-comments-stat-value"><?php echo number_format($stats['pending']); ?></div>
    214                     </div>
    215                 </div>
    216                
    217                 <div class="remove-comments-stat-card remove-comments-stat-danger">
    218                     <span class="dashicons dashicons-warning"></span>
    219                     <div class="remove-comments-stat-content">
    220                         <div class="remove-comments-stat-label"><?php esc_html_e('Spam', 'ultimate-comment-cleaner'); ?></div>
    221                         <div class="remove-comments-stat-value"><?php echo number_format($stats['spam']); ?></div>
    222                     </div>
    223                 </div>
    224                
    225                 <div class="remove-comments-stat-card remove-comments-stat-gray">
    226                     <span class="dashicons dashicons-trash"></span>
    227                     <div class="remove-comments-stat-content">
    228                         <div class="remove-comments-stat-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></div>
    229                         <div class="remove-comments-stat-value"><?php echo number_format($stats['trash']); ?></div>
    230                     </div>
    231                 </div>
    232                
    233                 <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-stat-card remove-comments-stat-info remove-comments-stat-clickable">
    234                     <span class="dashicons dashicons-admin-settings"></span>
    235                     <div class="remove-comments-stat-content">
    236                         <div class="remove-comments-stat-label"><?php esc_html_e('Triggers', 'ultimate-comment-cleaner'); ?></div>
    237                         <div class="remove-comments-stat-value"><?php echo number_format($stats['triggers']); ?></div>
    238                     </div>
    239                     <span class="dashicons dashicons-arrow-right-alt remove-comments-stat-arrow"></span>
    240                 </a>
     285                <span class="ulticocl-updated" id="ulticocl-updated" aria-live="polite"></span>
     286                <button type="button" class="ulticocl-linkbtn" id="ulticocl-refresh">
     287                    <span class="dashicons dashicons-update" aria-hidden="true"></span>
     288                    <?php esc_html_e('Refresh', 'ultimate-comment-cleaner'); ?>
     289                </button>
     290                <span class="ulticocl-filterbar-actions">
     291                    <a class="button" href="?page=ultimate-comment-cleaner&tab=manage"><?php esc_html_e('Manage comments', 'ultimate-comment-cleaner'); ?></a>
     292                    <a class="button" href="?page=ultimate-comment-cleaner&tab=rules"><?php esc_html_e('Smart Rules', 'ultimate-comment-cleaner'); ?></a>
     293                    <button type="button" class="button" id="remove-comments-export-all"><?php esc_html_e('Export all', 'ultimate-comment-cleaner'); ?></button>
     294                </span>
    241295            </div>
    242            
    243             <div class="remove-comments-quick-actions">
    244                 <h2><?php esc_html_e('Quick Actions', 'ultimate-comment-cleaner'); ?></h2>
    245                 <div class="remove-comments-action-buttons">
    246                     <a href="?page=ultimate-comment-cleaner&tab=delete" class="remove-comments-action-btn remove-comments-btn-danger">
    247                         <span class="dashicons dashicons-trash"></span>
    248                         <?php esc_html_e('Delete Comments', 'ultimate-comment-cleaner'); ?>
    249                     </a>
    250                     <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-action-btn remove-comments-btn-primary">
    251                         <span class="dashicons dashicons-admin-settings"></span>
    252                         <?php esc_html_e('Manage Triggers', 'ultimate-comment-cleaner'); ?>
    253                     </a>
    254                     <button type="button" class="remove-comments-action-btn remove-comments-btn-success" id="remove-comments-export-all">
    255                         <span class="dashicons dashicons-download"></span>
    256                         <?php esc_html_e('Export All Comments', 'ultimate-comment-cleaner'); ?>
    257                     </button>
    258                 </div>
     296
     297            <section class="ulticocl-hero" id="ulticocl-hero" aria-labelledby="ulticocl-hero-title"></section>
     298            <div class="ulticocl-tiles" id="ulticocl-tiles"></div>
     299
     300            <h2 class="ulticocl-h"><?php esc_html_e('Trends', 'ultimate-comment-cleaner'); ?></h2>
     301            <div class="ulticocl-grid">
     302                <?php
     303                $this->dash_card('comments', 8, __('Comments over time', 'ultimate-comment-cleaner'), __('New comments per day, by status', 'ultimate-comment-cleaner'), true);
     304                $this->dash_card('insights', 4, __('Insights', 'ultimate-comment-cleaner'), __('What needs your attention', 'ultimate-comment-cleaner'));
     305                $this->dash_card('status', 4, __('Where comments stand', 'ultimate-comment-cleaner'), __('All comments right now', 'ultimate-comment-cleaner'));
     306                $this->dash_card('caught', 8, __('Caught by Smart Rules', 'ultimate-comment-cleaner'), __('Comments stopped per day', 'ultimate-comment-cleaner'), true);
     307                ?>
    259308            </div>
    260            
    261             <div class="remove-comments-recent-activity">
    262                 <h2><?php esc_html_e('Recent Activity', 'ultimate-comment-cleaner'); ?></h2>
    263                 <div class="remove-comments-activity-list">
    264                     <?php $this->render_recent_activity(); ?>
    265                 </div>
     309
     310            <h2 class="ulticocl-h"><?php esc_html_e('Spam intelligence', 'ultimate-comment-cleaner'); ?></h2>
     311            <div class="ulticocl-grid">
     312                <?php
     313                $this->dash_card('hours', 8, __('When spam arrives', 'ultimate-comment-cleaner'), __('Comments by hour of day. Spam in red, everything else in gray.', 'ultimate-comment-cleaner'), true);
     314                $this->dash_card('scripts', 4, __('What spam is written in', 'ultimate-comment-cleaner'), __('Writing system of recent spam', 'ultimate-comment-cleaner'));
     315                $this->dash_card('sources', 4, __('Where spam comes from', 'ultimate-comment-cleaner'), __('Most common email domains and websites', 'ultimate-comment-cleaner'));
     316                $this->dash_card('posts', 4, __('Most targeted posts', 'ultimate-comment-cleaner'), __('Spam received per post', 'ultimate-comment-cleaner'));
     317                $this->dash_card('words', 4, __('Words that stand out', 'ultimate-comment-cleaner'), __('Common in spam and not covered by a rule', 'ultimate-comment-cleaner'));
     318                ?>
     319            </div>
     320
     321            <h2 class="ulticocl-h"><?php esc_html_e('Automation and activity', 'ultimate-comment-cleaner'); ?></h2>
     322            <div class="ulticocl-grid">
     323                <?php
     324                $this->dash_card('rules', 8, __('Rule performance', 'ultimate-comment-cleaner'), __('Comments caught in this period, and how often you restored them', 'ultimate-comment-cleaner'));
     325                $this->dash_card('automation', 4, __('Automation', 'ultimate-comment-cleaner'), __('What is running on its own', 'ultimate-comment-cleaner'));
     326                $this->dash_card('latest', 6, __('Latest caught', 'ultimate-comment-cleaner'), __('Newest comments in quarantine', 'ultimate-comment-cleaner'));
     327                $this->dash_card('activity', 6, __('Recent activity', 'ultimate-comment-cleaner'), __('Clean-ups, rule runs and settings changes', 'ultimate-comment-cleaner'));
     328                ?>
    266329            </div>
    267330        </div>
    268331        <?php
    269332    }
    270    
     333
     334    /**
     335     * A small on/off switch in the brand colour. It is a real checkbox, so it submits and
     336     * is read exactly like one; only the look differs.
     337     */
     338    private function mini_toggle($name, $value, $label, $id = '', $checked = false, $title = '') {
     339        ?>
     340        <label class="ulticocl-toggle"<?php echo '' !== $title ? ' title="' . esc_attr($title) . '"' : ''; ?>>
     341            <span class="remove-comments-toggle-switch ulticocl-mini">
     342                <input type="checkbox" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>"<?php echo '' !== $id ? ' id="' . esc_attr($id) . '"' : ''; ?> <?php checked($checked); ?>>
     343                <span class="remove-comments-toggle-slider"></span>
     344            </span>
     345            <span class="ulticocl-toggle-label"><?php echo esc_html($label); ?></span>
     346        </label>
     347        <?php
     348    }
     349
     350    /**
     351     * One dashboard card. The dashboard script fills its body from the insights data.
     352     */
     353    private function dash_card($slot, $span, $title, $subtitle = '', $table_toggle = false) {
     354        ?>
     355        <section class="ulticocl-card ulticocl-span-<?php echo (int) $span; ?>" data-card="<?php echo esc_attr($slot); ?>">
     356            <header class="ulticocl-card-head">
     357                <div>
     358                    <h3><?php echo esc_html($title); ?></h3>
     359                    <?php if ('' !== $subtitle) : ?>
     360                        <p class="ulticocl-sub"><?php echo esc_html($subtitle); ?></p>
     361                    <?php endif; ?>
     362                </div>
     363                <?php if ($table_toggle) : ?>
     364                    <div class="ulticocl-seg ulticocl-seg-small" role="group" aria-label="<?php esc_attr_e('View', 'ultimate-comment-cleaner'); ?>" data-toggle="<?php echo esc_attr($slot); ?>">
     365                        <button type="button" data-view="chart" aria-pressed="true"><?php esc_html_e('Chart', 'ultimate-comment-cleaner'); ?></button>
     366                        <button type="button" data-view="table" aria-pressed="false"><?php esc_html_e('Table', 'ultimate-comment-cleaner'); ?></button>
     367                    </div>
     368                <?php endif; ?>
     369            </header>
     370            <div class="ulticocl-card-body" data-slot="<?php echo esc_attr($slot); ?>"></div>
     371        </section>
     372        <?php
     373    }
     374
     375    /**
     376     * Date range the dashboard opens with: the last one this user picked.
     377     */
     378    private function dashboard_days() {
     379        $days = (int) get_user_meta(get_current_user_id(), 'ulticocl_dash_days', true);
     380        return in_array($days, ULTICOCL_Insights::RANGES, true) ? $days : 30;
     381    }
     382
    271383    private function render_delete_tab() {
    272384        $post_types = get_post_types(array('public' => true), 'objects');
    … …  
    275387        <div class="remove-comments-delete-panel">
    276388            <div class="remove-comments-panel-header">
    277                 <h2><?php esc_html_e('Bulk Delete Comments', 'ultimate-comment-cleaner'); ?></h2>
    278                 <p><?php esc_html_e('Select filters to find and delete comments matching your criteria', 'ultimate-comment-cleaner'); ?></p>
     389                <h2><?php esc_html_e('Bulk Clean Comments', 'ultimate-comment-cleaner'); ?></h2>
     390                <p><?php esc_html_e('Select filters, then quarantine, mark as spam or delete everything that matches. Quarantined comments can be restored.', 'ultimate-comment-cleaner'); ?></p>
    279391            </div>
    280392           
    … …  
    318430                            <span class="remove-comments-toggle-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></span>
    319431                        </label>
     432                        <label>
     433                            <div class="remove-comments-toggle-switch">
     434                                <input type="checkbox" name="status[]" value="quarantine">
     435                                <span class="remove-comments-toggle-slider"></span>
     436                            </div>
     437                            <span class="remove-comments-toggle-label"><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></span>
     438                        </label>
    320439                    </div>
    321440                </div>
    322                
     441
     442                <div class="remove-comments-form-section">
     443                    <h3><?php esc_html_e('Search Text', 'ultimate-comment-cleaner'); ?></h3>
     444                    <input type="search" name="search" id="remove-comments-search" class="remove-comments-form-input" placeholder="<?php esc_attr_e('Word or phrase in the comment, name, email or website', 'ultimate-comment-cleaner'); ?>">
     445                </div>
     446
    323447                <div class="remove-comments-form-section">
    324448                    <h3><?php esc_html_e('Filter by Date Range', 'ultimate-comment-cleaner'); ?></h3>
    … …  
    377501                <div class="remove-comments-form-section">
    378502                    <h3><?php esc_html_e('Additional Filters', 'ultimate-comment-cleaner'); ?></h3>
    379                     <div class="remove-comments-advanced-filters">
    380                         <label>
    381                             <div class="remove-comments-toggle-switch">
    382                                 <input type="checkbox" name="has_url">
    383                                 <span class="remove-comments-toggle-slider"></span>
    384                             </div>
    385                             <span class="remove-comments-toggle-label"><?php esc_html_e('Comments with URL only', 'ultimate-comment-cleaner'); ?></span>
    386                         </label>
    387                         <label>
    388                             <div class="remove-comments-toggle-switch">
    389                                 <input type="checkbox" name="no_url">
    390                                 <span class="remove-comments-toggle-slider"></span>
    391                             </div>
    392                             <span class="remove-comments-toggle-label"><?php esc_html_e('Comments without URL', 'ultimate-comment-cleaner'); ?></span>
    393                         </label>
    394                         <label>
    395                             <div class="remove-comments-toggle-switch">
    396                                 <input type="checkbox" name="short_comments">
    397                                 <span class="remove-comments-toggle-slider"></span>
    398                             </div>
    399                             <span class="remove-comments-toggle-label"><?php esc_html_e('Short comments (less than 10 words)', 'ultimate-comment-cleaner'); ?></span>
    400                         </label>
    401                         <label>
    402                             <input type="number" name="min_words" placeholder="<?php esc_html_e('Minimum words', 'ultimate-comment-cleaner'); ?>" min="0">
    403                         </label>
    404                         <label>
    405                             <input type="number" name="max_words" placeholder="<?php esc_html_e('Maximum words', 'ultimate-comment-cleaner'); ?>" min="0">
    406                         </label>
     503                    <div class="ulticocl-toggle-grid" data-cols="3">
     504                        <?php
     505                        $this->mini_toggle('has_url', '1', __('Comments with URL only', 'ultimate-comment-cleaner'));
     506                        $this->mini_toggle('no_url', '1', __('Comments without URL', 'ultimate-comment-cleaner'));
     507                        $this->mini_toggle('short_comments', '1', __('Short comments (under 10 words)', 'ultimate-comment-cleaner'));
     508                        ?>
     509                    </div>
     510                    <div class="ulticocl-grid-2 ulticocl-row-after-toggles">
     511                        <div class="remove-comments-form-group">
     512                            <label for="ulticocl-min-words"><?php esc_html_e('Minimum words', 'ultimate-comment-cleaner'); ?></label>
     513                            <input type="number" name="min_words" id="ulticocl-min-words" min="0" placeholder="0">
     514                        </div>
     515                        <div class="remove-comments-form-group">
     516                            <label for="ulticocl-max-words"><?php esc_html_e('Maximum words', 'ultimate-comment-cleaner'); ?></label>
     517                            <input type="number" name="max_words" id="ulticocl-max-words" min="0" placeholder="0">
     518                        </div>
    407519                    </div>
    408520                </div>
    409                
    410                 <div class="remove-comments-form-actions">
    411                     <button type="button" id="remove-comments-preview-count" class="remove-comments-btn remove-comments-btn-secondary">
    412                         <?php esc_html_e('Preview Count', 'ultimate-comment-cleaner'); ?>
     521
     522                <div class="remove-comments-form-section">
     523                    <h3><?php esc_html_e('What to do with matching comments', 'ultimate-comment-cleaner'); ?></h3>
     524                    <select name="clean_action" id="remove-comments-clean-action" class="remove-comments-select">
     525                        <option value="quarantine"><?php esc_html_e('Move to quarantine (can be restored)', 'ultimate-comment-cleaner'); ?></option>
     526                        <option value="spam"><?php esc_html_e('Mark as spam', 'ultimate-comment-cleaner'); ?></option>
     527                        <option value="delete"><?php esc_html_e('Delete permanently', 'ultimate-comment-cleaner'); ?></option>
     528                    </select>
     529                    <p class="description" id="ulticocl-action-help"></p>
     530                </div>
     531
     532                <div class="remove-comments-form-actions ulticocl-form-footer">
     533                    <button type="button" id="remove-comments-preview-count" class="ulticocl-rbtn">
     534                        <span class="dashicons dashicons-visibility" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Preview Count', 'ultimate-comment-cleaner'); ?></span>
    413535                    </button>
    414                     <button type="submit" id="remove-comments-delete-btn" class="remove-comments-btn remove-comments-btn-danger">
    415                         <span class="dashicons dashicons-trash"></span>
    416                         <?php esc_html_e('Delete Comments', 'ultimate-comment-cleaner'); ?>
     536                    <button type="button" id="remove-comments-export-btn" class="ulticocl-rbtn">
     537                        <span class="dashicons dashicons-download" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Export Selected', 'ultimate-comment-cleaner'); ?></span>
    417538                    </button>
    418                     <button type="button" id="remove-comments-export-btn" class="remove-comments-btn remove-comments-btn-success">
    419                         <span class="dashicons dashicons-download"></span>
    420                         <?php esc_html_e('Export Selected', 'ultimate-comment-cleaner'); ?>
     539                    <button type="submit" id="remove-comments-delete-btn" class="ulticocl-add-btn ulticocl-apply" data-action="quarantine">
     540                        <span class="dashicons dashicons-yes" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Apply to Matching Comments', 'ultimate-comment-cleaner'); ?></span>
    421541                    </button>
    422542                </div>
    423                
     543
    424544                <div id="remove-comments-preview-result" class="remove-comments-preview-result" style="display:none;"></div>
    425545            </form>
    … …  
    428548    }
    429549   
    430     private function render_triggers_tab() {
    431         $triggers = get_option('ulticocl_triggers', array());
     550    private function render_rules_tab() {
     551        $rules = $this->get_rules();
     552        $state = get_option('ulticocl_rule_state', array());
     553        $scripts = ULTICOCL_Matcher::scripts();
     554        $actions = $this->action_labels();
     555        $scopes = array(
     556            'new'      => __('New comments', 'ultimate-comment-cleaner'),
     557            'existing' => __('Existing comments', 'ultimate-comment-cleaner'),
     558            'both'     => __('New and existing comments', 'ultimate-comment-cleaner'),
     559        );
    432560        ?>
    433561        <div class="remove-comments-triggers-panel">
    434562            <div class="remove-comments-panel-header">
    435                 <h2><?php esc_html_e('Auto Delete Triggers', 'ultimate-comment-cleaner'); ?></h2>
    436                 <p><?php esc_html_e('Set up automatic triggers to find and delete comments based on conditions', 'ultimate-comment-cleaner'); ?></p>
     563                <h2><?php esc_html_e('Smart Rules', 'ultimate-comment-cleaner'); ?></h2>
     564                <p><?php esc_html_e('Catch spam by what it says. Rules check new comments as they arrive and can clean up existing ones on a schedule. Quarantined comments can always be restored.', 'ultimate-comment-cleaner'); ?></p>
    437565            </div>
    438            
    439             <button type="button" id="remove-comments-add-trigger" class="remove-comments-btn remove-comments-btn-primary">
    440                 <span class="dashicons dashicons-plus"></span>
    441                 <?php esc_html_e('Add New Trigger', 'ultimate-comment-cleaner'); ?>
    442             </button>
    443            
    444             <div id="remove-comments-triggers-list" class="remove-comments-triggers-list">
    445                 <?php if (empty($triggers)): ?>
     566
     567            <div class="ulticocl-rules-toolbar">
     568                <div class="ulticocl-presets">
     569                    <strong class="ulticocl-presets-title"><?php esc_html_e('Quick start', 'ultimate-comment-cleaner'); ?></strong>
     570                    <div class="ulticocl-presets-viewport" id="ulticocl-presets">
     571                        <div class="ulticocl-presets-track">
     572                            <?php foreach (ULTICOCL_Matcher::presets() as $key => $preset) : ?>
     573                                <button type="button" class="ulticocl-preset" data-preset="<?php echo esc_attr($key); ?>" title="<?php echo esc_attr($preset['description']); ?>">
     574                                    <span class="dashicons dashicons-<?php echo esc_attr($preset['icon']); ?>" aria-hidden="true"></span>
     575                                    <span><?php echo esc_html($preset['label']); ?></span>
     576                                </button>
     577                            <?php endforeach; ?>
     578                        </div>
     579                    </div>
     580                </div>
     581                <button type="button" id="ulticocl-add-rule" class="ulticocl-add-btn">
     582                    <span class="dashicons dashicons-plus" aria-hidden="true"></span>
     583                    <?php esc_html_e('Add New Rule', 'ultimate-comment-cleaner'); ?>
     584                </button>
     585            </div>
     586
     587            <div id="ulticocl-scan-result" class="ulticocl-scan-result" style="display:none;" role="status" aria-live="polite"></div>
     588
     589            <div id="ulticocl-rules-list" class="remove-comments-triggers-list">
     590                <?php if (empty($rules)) : ?>
    446591                    <div class="remove-comments-empty-state">
    447                         <p><?php esc_html_e('No triggers configured. Click "Add New Trigger" to create one.', 'ultimate-comment-cleaner'); ?></p>
     592                        <p><?php esc_html_e('No rules yet. Pick a Quick start above or click "Add New Rule".', 'ultimate-comment-cleaner'); ?></p>
    448593                    </div>
    449                 <?php else: ?>
    450                     <?php foreach ($triggers as $id => $trigger): ?>
    451                         <div class="remove-comments-trigger-item" data-trigger-id="<?php echo esc_attr($id); ?>">
    452                             <div class="remove-comments-trigger-header">
    453                                 <h3><?php echo esc_html($trigger['name']); ?></h3>
    454                                 <div class="remove-comments-trigger-status">
    455                                     <span class="remove-comments-status-badge <?php echo $trigger['active'] ? 'active' : 'inactive'; ?>">
    456                                         <?php echo $trigger['active'] ? esc_html__('Active', 'ultimate-comment-cleaner') : esc_html__('Inactive', 'ultimate-comment-cleaner'); ?>
     594                <?php else : ?>
     595                    <?php foreach ($rules as $id => $rule) : ?>
     596                        <?php
     597                        $st = isset($state[$id]) ? $state[$id] : array();
     598                        $last = !empty($st['last_run']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $st['last_run']) : __('Not yet', 'ultimate-comment-cleaner');
     599                        ?>
     600                        <article class="ulticocl-rule" data-rule-id="<?php echo esc_attr($id); ?>">
     601                            <header class="ulticocl-rule-head">
     602                                <h3><?php echo esc_html($rule['name']); ?></h3>
     603                                <div class="ulticocl-rule-badges">
     604                                    <span class="ulticocl-badge ulticocl-badge-action-<?php echo esc_attr($rule['action']); ?>"><?php echo esc_html($actions[$rule['action']]); ?></span>
     605                                    <span class="ulticocl-badge <?php echo $rule['active'] ? 'ulticocl-badge-on' : 'ulticocl-badge-off'; ?>">
     606                                        <?php echo $rule['active'] ? esc_html__('Active', 'ultimate-comment-cleaner') : esc_html__('Inactive', 'ultimate-comment-cleaner'); ?>
    457607                                    </span>
    458608                                </div>
    459                             </div>
    460                             <div class="remove-comments-trigger-body">
    461                                 <p><strong><?php esc_html_e('Conditions:', 'ultimate-comment-cleaner'); ?></strong> <?php echo esc_html($this->format_trigger_conditions($trigger)); ?></p>
    462                                 <p><strong><?php esc_html_e('Schedule:', 'ultimate-comment-cleaner'); ?></strong> <?php echo esc_html($trigger['schedule']); ?></p>
    463                                 <p><strong><?php esc_html_e('Last Run:', 'ultimate-comment-cleaner'); ?></strong> <?php echo isset($trigger['last_run']) ? esc_html($trigger['last_run']) : esc_html__('Never', 'ultimate-comment-cleaner'); ?></p>
    464                             </div>
    465                             <div class="remove-comments-trigger-actions">
    466                                 <button type="button" class="remove-comments-btn-small remove-comments-btn-primary remove-comments-edit-trigger" data-id="<?php echo esc_attr($id); ?>">
    467                                     <?php esc_html_e('Edit', 'ultimate-comment-cleaner'); ?>
     609                            </header>
     610                            <dl class="ulticocl-rule-meta">
     611                                <div class="is-wide">
     612                                    <dt><?php esc_html_e('Checks', 'ultimate-comment-cleaner'); ?></dt>
     613                                    <dd><?php echo esc_html($this->rule_summary($rule)); ?></dd>
     614                                </div>
     615                                <div>
     616                                    <dt><?php esc_html_e('Applies to', 'ultimate-comment-cleaner'); ?></dt>
     617                                    <dd><?php echo esc_html($scopes[$rule['scope']]); ?></dd>
     618                                </div>
     619                                <div>
     620                                    <dt><?php esc_html_e('Caught so far', 'ultimate-comment-cleaner'); ?></dt>
     621                                    <dd><?php echo esc_html(number_format_i18n(isset($st['hits']) ? (int) $st['hits'] : 0)); ?></dd>
     622                                </div>
     623                                <div>
     624                                    <dt><?php esc_html_e('Last full scan', 'ultimate-comment-cleaner'); ?></dt>
     625                                    <dd><?php echo 'new' !== $rule['scope'] ? esc_html($last) : '&mdash;'; ?></dd>
     626                                </div>
     627                            </dl>
     628                            <footer class="ulticocl-rule-actions">
     629                                <button type="button" class="ulticocl-rbtn is-primary ulticocl-edit-rule" data-id="<?php echo esc_attr($id); ?>">
     630                                    <span class="dashicons dashicons-edit" aria-hidden="true"></span><?php esc_html_e('Edit', 'ultimate-comment-cleaner'); ?>
    468631                                </button>
    469                                 <button type="button" class="remove-comments-btn-small remove-comments-btn-danger remove-comments-delete-trigger" data-id="<?php echo esc_attr($id); ?>">
    470                                     <?php esc_html_e('Delete', 'ultimate-comment-cleaner'); ?>
     632                                <button type="button" class="ulticocl-rbtn ulticocl-scan-saved" data-id="<?php echo esc_attr($id); ?>" data-dry="1">
     633                                    <span class="dashicons dashicons-visibility" aria-hidden="true"></span><?php esc_html_e('Test on existing', 'ultimate-comment-cleaner'); ?>
    471634                                </button>
    472                             </div>
    473                         </div>
     635                                <button type="button" class="ulticocl-rbtn ulticocl-scan-saved" data-id="<?php echo esc_attr($id); ?>" data-dry="0" data-action="<?php echo esc_attr($rule['action']); ?>">
     636                                    <span class="dashicons dashicons-controls-play" aria-hidden="true"></span><?php esc_html_e('Run now', 'ultimate-comment-cleaner'); ?>
     637                                </button>
     638                                <button type="button" class="ulticocl-rbtn is-danger ulticocl-delete-rule" data-id="<?php echo esc_attr($id); ?>">
     639                                    <span class="dashicons dashicons-trash" aria-hidden="true"></span><?php esc_html_e('Delete', 'ultimate-comment-cleaner'); ?>
     640                                </button>
     641                            </footer>
     642                        </article>
    474643                    <?php endforeach; ?>
    475644                <?php endif; ?>
    476645            </div>
    477            
    478             <!-- Trigger Modal -->
    479             <div id="remove-comments-trigger-modal" class="remove-comments-modal" style="display:none;">
    480                 <div class="remove-comments-modal-content">
     646
     647            <!-- Rule Modal -->
     648            <div id="ulticocl-rule-modal" class="remove-comments-modal" style="display:none;">
     649                <div class="remove-comments-modal-content ulticocl-wide" role="dialog" aria-modal="true" aria-labelledby="ulticocl-rule-title">
    481650                    <span class="remove-comments-modal-close">&times;</span>
    482                     <h2><?php esc_html_e('Create/Edit Trigger', 'ultimate-comment-cleaner'); ?></h2>
    483                     <form id="remove-comments-trigger-form">
    484                         <input type="hidden" name="trigger_id" id="remove-comments-trigger-id">
     651                    <h2 id="ulticocl-rule-title"><?php esc_html_e('Smart Rule', 'ultimate-comment-cleaner'); ?></h2>
     652                    <form id="ulticocl-rule-form">
     653                        <input type="hidden" name="rule_id" id="ulticocl-rule-id">
     654
    485655                        <div class="remove-comments-form-group">
    486                             <label><?php esc_html_e('Trigger Name', 'ultimate-comment-cleaner'); ?></label>
    487                             <input type="text" name="name" id="remove-comments-trigger-name" required>
     656                            <label for="ulticocl-rule-name"><?php esc_html_e('Rule name', 'ultimate-comment-cleaner'); ?></label>
     657                            <input type="text" name="name" id="ulticocl-rule-name" required>
    488658                        </div>
    489                         <div class="remove-comments-form-group">
    490                             <label><?php esc_html_e('Status', 'ultimate-comment-cleaner'); ?></label>
    491                             <select name="status" id="remove-comments-trigger-status" class="remove-comments-select">
    492                                 <option value="approved"><?php esc_html_e('Approved', 'ultimate-comment-cleaner'); ?></option>
    493                                 <option value="pending"><?php esc_html_e('Pending', 'ultimate-comment-cleaner'); ?></option>
    494                                 <option value="spam"><?php esc_html_e('Spam', 'ultimate-comment-cleaner'); ?></option>
    495                             </select>
     659
     660                        <label class="ulticocl-status-card" for="ulticocl-rule-active">
     661                            <span class="remove-comments-toggle-switch ulticocl-large">
     662                                <input type="checkbox" name="active" value="1" id="ulticocl-rule-active" checked>
     663                                <span class="remove-comments-toggle-slider"></span>
     664                            </span>
     665                            <span class="ulticocl-status-text">
     666                                <strong class="ulticocl-status-title"
     667                                    data-on="<?php echo esc_attr__('Rule is active', 'ultimate-comment-cleaner'); ?>"
     668                                    data-off="<?php echo esc_attr__('Rule is paused', 'ultimate-comment-cleaner'); ?>"><?php esc_html_e('Rule is active', 'ultimate-comment-cleaner'); ?></strong>
     669                                <span class="ulticocl-status-help"
     670                                    data-on="<?php echo esc_attr__('It checks new comments as they arrive and runs its scheduled scans.', 'ultimate-comment-cleaner'); ?>"
     671                                    data-off="<?php echo esc_attr__('Nothing is checked or moved until you turn it back on. Your settings are kept.', 'ultimate-comment-cleaner'); ?>"><?php esc_html_e('It checks new comments as they arrive and runs its scheduled scans.', 'ultimate-comment-cleaner'); ?></span>
     672                            </span>
     673                        </label>
     674
     675                        <div class="ulticocl-grid-2">
     676                            <div class="remove-comments-form-group">
     677                                <label for="ulticocl-rule-scope"><?php esc_html_e('Check', 'ultimate-comment-cleaner'); ?></label>
     678                                <select name="scope" id="ulticocl-rule-scope" class="remove-comments-select">
     679                                    <?php foreach ($scopes as $value => $label) : ?>
     680                                        <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
     681                                    <?php endforeach; ?>
     682                                </select>
     683                            </div>
     684                            <div class="remove-comments-form-group">
     685                                <label for="ulticocl-rule-action"><?php esc_html_e('When it matches', 'ultimate-comment-cleaner'); ?></label>
     686                                <select name="rule_action" id="ulticocl-rule-action" class="remove-comments-select">
     687                                    <?php foreach ($actions as $value => $label) : ?>
     688                                        <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
     689                                    <?php endforeach; ?>
     690                                </select>
     691                            </div>
    496692                        </div>
    497                         <div class="remove-comments-form-group">
    498                             <label><?php esc_html_e('Older Than (days)', 'ultimate-comment-cleaner'); ?></label>
    499                             <input type="number" name="older_than" id="remove-comments-trigger-older" min="0">
    500                         </div>
    501                         <div class="remove-comments-form-group">
    502                             <label><?php esc_html_e('Schedule', 'ultimate-comment-cleaner'); ?></label>
    503                             <select name="schedule" id="remove-comments-trigger-schedule">
    504                                 <option value="hourly"><?php esc_html_e('Hourly', 'ultimate-comment-cleaner'); ?></option>
    505                                 <option value="daily"><?php esc_html_e('Daily', 'ultimate-comment-cleaner'); ?></option>
    506                                 <option value="weekly"><?php esc_html_e('Weekly', 'ultimate-comment-cleaner'); ?></option>
    507                             </select>
    508                         </div>
    509                         <div class="remove-comments-form-group">
    510                             <label>
    511                                 <div class="remove-comments-toggle-switch">
    512                                     <input type="checkbox" name="active" id="remove-comments-trigger-active" checked>
    513                                     <span class="remove-comments-toggle-slider"></span>
     693
     694                        <fieldset class="ulticocl-fieldset">
     695                            <legend><?php esc_html_e('What to look for', 'ultimate-comment-cleaner'); ?></legend>
     696
     697                            <div class="remove-comments-form-group">
     698                                <label for="ulticocl-rule-mode"><?php esc_html_e('Flag a comment when', 'ultimate-comment-cleaner'); ?></label>
     699                                <select name="mode" id="ulticocl-rule-mode" class="remove-comments-select">
     700                                    <option value="any"><?php esc_html_e('ANY of the checks below match', 'ultimate-comment-cleaner'); ?></option>
     701                                    <option value="all"><?php esc_html_e('ALL of the checks below match', 'ultimate-comment-cleaner'); ?></option>
     702                                </select>
     703                            </div>
     704
     705                            <div class="remove-comments-form-group">
     706                                <label for="ulticocl-rule-keywords"><?php esc_html_e('Keywords', 'ultimate-comment-cleaner'); ?></label>
     707                                <textarea name="keywords" id="ulticocl-rule-keywords" rows="6" spellcheck="false"></textarea>
     708                                <p class="description"><?php esc_html_e('One per line. "casino" matches the whole word only, "casino*" also matches casinos and casino88, "*casino*" matches anywhere, and /regular expression/ is supported. Not case sensitive.', 'ultimate-comment-cleaner'); ?></p>
     709                            </div>
     710
     711                            <div class="remove-comments-form-group">
     712                                <span class="ulticocl-sublabel"><?php esc_html_e('Search in', 'ultimate-comment-cleaner'); ?></span>
     713                                <div class="ulticocl-toggle-grid" data-cols="4">
     714                                    <?php
     715                                    $fields = array(
     716                                        'content' => __('Comment text', 'ultimate-comment-cleaner'),
     717                                        'author'  => __('Name', 'ultimate-comment-cleaner'),
     718                                        'email'   => __('Email', 'ultimate-comment-cleaner'),
     719                                        'url'     => __('Website', 'ultimate-comment-cleaner'),
     720                                    );
     721                                    foreach ($fields as $value => $label) {
     722                                        $this->mini_toggle('fields[]', $value, $label);
     723                                    }
     724                                    ?>
    514725                                </div>
    515                                 <span class="remove-comments-toggle-label"><?php esc_html_e('Active', 'ultimate-comment-cleaner'); ?></span>
    516                             </label>
    517                         </div>
     726                            </div>
     727
     728                            <div class="remove-comments-form-group">
     729                                <span class="ulticocl-sublabel"><?php esc_html_e('Writing system', 'ultimate-comment-cleaner'); ?></span>
     730                                <div class="ulticocl-toggle-grid" data-cols="3">
     731                                    <?php
     732                                    foreach ($scripts as $value => $script) {
     733                                        // Short name on the switch, the full name on hover.
     734                                        $this->mini_toggle('scripts[]', $value, ULTICOCL_Matcher::short_label($value), '', false, $script[1]);
     735                                    }
     736                                    ?>
     737                                </div>
     738                                <p class="description ulticocl-inline-field">
     739                                    <?php esc_html_e('Flag comments written mostly in the selected alphabets. At least', 'ultimate-comment-cleaner'); ?>
     740                                    <input type="number" name="script_min_pct" id="ulticocl-rule-pct" min="1" max="100" class="small-text">
     741                                    <?php esc_html_e('% of the letters.', 'ultimate-comment-cleaner'); ?>
     742                                </p>
     743                            </div>
     744
     745                            <div class="ulticocl-grid-3">
     746                                <div class="remove-comments-form-group">
     747                                    <label for="ulticocl-rule-links"><?php esc_html_e('At least N links', 'ultimate-comment-cleaner'); ?></label>
     748                                    <input type="number" name="min_links" id="ulticocl-rule-links" min="0">
     749                                </div>
     750                                <div class="remove-comments-form-group">
     751                                    <label for="ulticocl-rule-words"><?php esc_html_e('Shorter than N words', 'ultimate-comment-cleaner'); ?></label>
     752                                    <input type="number" name="max_words" id="ulticocl-rule-words" min="0">
     753                                </div>
     754                                <div class="remove-comments-form-group ulticocl-field-toggle">
     755                                    <?php $this->mini_toggle('has_url', '1', __('Has a website', 'ultimate-comment-cleaner'), 'ulticocl-rule-has-url'); ?>
     756                                </div>
     757                            </div>
     758                            <p class="description"><?php esc_html_e('Use 0 to ignore links and length. Combine with "ALL" for rules like "short comment with a link".', 'ultimate-comment-cleaner'); ?></p>
     759                        </fieldset>
     760
     761                        <fieldset class="ulticocl-fieldset ulticocl-existing-only">
     762                            <legend><?php esc_html_e('Existing comments', 'ultimate-comment-cleaner'); ?></legend>
     763
     764                            <div class="remove-comments-form-group">
     765                                <span class="ulticocl-sublabel"><?php esc_html_e('Status', 'ultimate-comment-cleaner'); ?></span>
     766                                <div class="ulticocl-toggle-grid" data-cols="3">
     767                                    <?php $this->mini_toggle('status[]', 'approved', __('Approved', 'ultimate-comment-cleaner')); ?>
     768                                    <?php $this->mini_toggle('status[]', 'pending', __('Pending', 'ultimate-comment-cleaner')); ?>
     769                                    <?php $this->mini_toggle('status[]', 'spam', __('Spam', 'ultimate-comment-cleaner')); ?>
     770                                </div>
     771                            </div>
     772
     773                            <div class="ulticocl-grid-2">
     774                                <div class="remove-comments-form-group">
     775                                    <label for="ulticocl-rule-older"><?php esc_html_e('Only older than (days)', 'ultimate-comment-cleaner'); ?></label>
     776                                    <input type="number" name="older_than" id="ulticocl-rule-older" min="0">
     777                                </div>
     778                                <div class="remove-comments-form-group">
     779                                    <label for="ulticocl-rule-schedule"><?php esc_html_e('Scan', 'ultimate-comment-cleaner'); ?></label>
     780                                    <select name="schedule" id="ulticocl-rule-schedule" class="remove-comments-select">
     781                                        <option value="hourly"><?php esc_html_e('Hourly', 'ultimate-comment-cleaner'); ?></option>
     782                                        <option value="daily"><?php esc_html_e('Daily', 'ultimate-comment-cleaner'); ?></option>
     783                                        <option value="weekly"><?php esc_html_e('Weekly', 'ultimate-comment-cleaner'); ?></option>
     784                                    </select>
     785                                </div>
     786                            </div>
     787                            <p class="description"><?php esc_html_e('Without any checks above, a rule needs an age of at least 1 day, or must target spam only.', 'ultimate-comment-cleaner'); ?></p>
     788                        </fieldset>
     789
    518790                        <div class="remove-comments-form-actions">
    519                             <button type="submit" class="remove-comments-btn remove-comments-btn-primary"><?php esc_html_e('Save Trigger', 'ultimate-comment-cleaner'); ?></button>
     791                            <button type="submit" class="remove-comments-btn remove-comments-btn-primary"><?php esc_html_e('Save Rule', 'ultimate-comment-cleaner'); ?></button>
     792                            <button type="button" id="ulticocl-test-form" class="remove-comments-btn remove-comments-btn-secondary"><?php esc_html_e('Test on existing comments', 'ultimate-comment-cleaner'); ?></button>
    520793                            <button type="button" class="remove-comments-modal-close-btn remove-comments-modal-close" title="<?php esc_attr_e('Close', 'ultimate-comment-cleaner'); ?>">
    521794                                <span class="dashicons dashicons-no-alt"></span>
    … …  
    528801        <?php
    529802    }
    530    
     803
     804    /**
     805     * Manage Comments and Quarantine tabs.
     806     */
     807    private function render_comments_tab($mode) {
     808        require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
     809        require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-comments-table.php';
     810        $table = new ULTICOCL_Comments_Table($mode);
     811        $table->prepare_items();
     812        $days = (int) $this->get_settings()['quarantine_days'];
     813        ?>
     814        <div class="remove-comments-comments-panel">
     815            <div class="remove-comments-panel-header ulticocl-panel-head">
     816                <div class="ulticocl-panel-head-text">
     817                <?php if ('quarantine' === $mode) : ?>
     818                    <h2><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></h2>
     819                    <p>
     820                        <?php
     821                        echo esc_html(
     822                            $days > 0
     823                                /* translators: %d: number of days */
     824                                ? sprintf(_n('Comments caught by your rules wait here for %d day, then are deleted. Restore the ones that are not spam.', 'Comments caught by your rules wait here for %d days, then are deleted. Restore the ones that are not spam.', $days, 'ultimate-comment-cleaner'), $days)
     825                                : __('Comments caught by your rules wait here until you restore or delete them. Automatic deletion is off (see Settings).', 'ultimate-comment-cleaner')
     826                        );
     827                        echo ' ' . esc_html__('A restored comment is never caught by your rules again.', 'ultimate-comment-cleaner');
     828                        ?>
     829                    </p>
     830                <?php else : ?>
     831                    <h2><?php esc_html_e('Manage Comments', 'ultimate-comment-cleaner'); ?></h2>
     832                    <p><?php esc_html_e('Search, filter and clean up comments in bulk. Spam signals such as links and writing system are shown under each comment.', 'ultimate-comment-cleaner'); ?></p>
     833                <?php endif; ?>
     834                </div>
     835                <?php if ('quarantine' === $mode) : ?>
     836                    <div class="ulticocl-panel-head-actions">
     837                        <button type="button" id="ulticocl-empty-quarantine" class="ulticocl-danger-btn">
     838                            <span class="dashicons dashicons-trash" aria-hidden="true"></span>
     839                            <?php esc_html_e('Delete all quarantined comments', 'ultimate-comment-cleaner'); ?>
     840                        </button>
     841                    </div>
     842                <?php endif; ?>
     843            </div>
     844
     845            <?php if ($table->is_review()) : ?>
     846                <?php
     847                $preview = get_transient('ulticocl_preview_' . get_current_user_id());
     848                $name = is_array($preview) && !empty($preview['name']) ? $preview['name'] : '';
     849                ?>
     850                <div class="notice notice-info inline">
     851                    <p>
     852                        <?php
     853                        /* translators: %s: rule name */
     854                        echo esc_html(sprintf(__('Showing comments that the rule "%s" would catch. Select the ones to act on, or review them first.', 'ultimate-comment-cleaner'), $name));
     855                        ?>
     856                        <a href="<?php echo esc_url(add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => 'manage'), admin_url('tools.php'))); ?>"><?php esc_html_e('Show all comments', 'ultimate-comment-cleaner'); ?></a>
     857                    </p>
     858                </div>
     859            <?php endif; ?>
     860
     861            <?php $table->render_filters(); ?>
     862            <?php $table->views(); ?>
     863            <form method="post">
     864                <?php $table->display(); ?>
     865            </form>
     866        </div>
     867        <?php
     868    }
     869
     870    private function render_notice() {
     871        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Display-only result message.
     872        if (empty($_GET['ulticocl_notice'])) {
     873            return;
     874        }
     875        $n = isset($_GET['ulticocl_n']) ? absint($_GET['ulticocl_n']) : 0;
     876        $action = isset($_GET['ulticocl_a']) ? sanitize_key(wp_unslash($_GET['ulticocl_a'])) : '';
     877        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     878        $messages = array(
     879            /* translators: %d: number of comments */
     880            'approve'    => _n('%d comment approved.', '%d comments approved.', $n, 'ultimate-comment-cleaner'),
     881            /* translators: %d: number of comments */
     882            'unapprove'  => _n('%d comment unapproved.', '%d comments unapproved.', $n, 'ultimate-comment-cleaner'),
     883            /* translators: %d: number of comments */
     884            'spam'       => _n('%d comment marked as spam.', '%d comments marked as spam.', $n, 'ultimate-comment-cleaner'),
     885            /* translators: %d: number of comments */
     886            'quarantine' => _n('%d comment moved to quarantine.', '%d comments moved to quarantine.', $n, 'ultimate-comment-cleaner'),
     887            /* translators: %d: number of comments */
     888            'restore'    => _n('%d comment restored.', '%d comments restored.', $n, 'ultimate-comment-cleaner'),
     889            /* translators: %d: number of comments */
     890            'delete'     => _n('%d comment deleted.', '%d comments deleted.', $n, 'ultimate-comment-cleaner'),
     891        );
     892        if (isset($messages[$action])) {
     893            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(sprintf($messages[$action], $n)) . '</p></div>';
     894        }
     895    }
     896
    531897    private function render_settings_tab() {
    532         $settings = get_option('ulticocl_settings', array());
     898        $settings = $this->get_settings();
    533899        ?>
    534900        <div class="remove-comments-settings-panel">
    535901            <div class="remove-comments-panel-header">
    536902                <h2><?php esc_html_e('Plugin Settings', 'ultimate-comment-cleaner'); ?></h2>
     903                <p><?php esc_html_e('Choose what gets logged, who is told about it, and how long quarantined comments are kept.', 'ultimate-comment-cleaner'); ?></p>
    537904            </div>
    538905           
    … …  
    588955                    </div>
    589956                </div>
     957
     958                <div class="remove-comments-form-section">
     959                    <h3><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></h3>
     960                    <div class="remove-comments-form-group">
     961                        <label for="ulticocl_quarantine_days"><?php esc_html_e('Delete quarantined comments after (days)', 'ultimate-comment-cleaner'); ?></label>
     962                        <input type="number" name="quarantine_days" id="ulticocl_quarantine_days" min="0" max="365" value="<?php echo esc_attr((int) $settings['quarantine_days']); ?>" class="small-text">
     963                        <p class="description"><?php esc_html_e('Quarantined comments are hidden from your site and can be restored until then. Use 0 to keep them until you delete them yourself.', 'ultimate-comment-cleaner'); ?></p>
     964                    </div>
     965                    <div class="remove-comments-form-group">
     966                        <label for="ulticocl_allowlist"><?php esc_html_e('Never touch comments from', 'ultimate-comment-cleaner'); ?></label>
     967                        <textarea name="allowlist" id="ulticocl_allowlist" rows="4" class="remove-comments-form-input" placeholder="friend@example.com&#10;@mycompany.com"><?php echo esc_textarea($settings['allowlist']); ?></textarea>
     968                        <p class="description"><?php esc_html_e('One email address or @domain per line. Rules skip these commenters. Comments by users who can moderate comments are always skipped.', 'ultimate-comment-cleaner'); ?></p>
     969                    </div>
     970                </div>
    590971               
    591972                <div class="remove-comments-form-actions">
    … …  
    599980    private function get_comment_stats() {
    600981        global $wpdb;
    601        
    602         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct queries needed for comment counts, caching not practical for real-time stats
    603         $stats = array(
    604             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for total comments count
    605             'total' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_type = 'comment'"),
    606             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for approved comments count
    607             'approved' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type = 'comment'"),
    608             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for pending comments count
    609             'pending' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = '0' AND comment_type = 'comment'"),
    610             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for spam comments count
    611             'spam' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = 'spam' AND comment_type = 'comment'"),
    612             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for trash comments count
    613             'trash' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = 'trash' AND comment_type = 'comment'"),
    614             'triggers' => count(get_option('ulticocl_triggers', array()))
     982
     983        $types = $this->types_sql();
     984        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- One grouped count; $types is prepared in types_sql().
     985        $rows = $wpdb->get_results("SELECT comment_approved AS status, COUNT(*) AS n FROM $wpdb->comments WHERE $types GROUP BY comment_approved", OBJECT_K);
     986        $rows = is_array($rows) ? $rows : array();
     987        $count = function ($status) use ($rows) {
     988            return isset($rows[$status]) ? (int) $rows[$status]->n : 0;
     989        };
     990
     991        return array(
     992            'total'      => (int) array_sum(wp_list_pluck($rows, 'n')),
     993            'approved'   => $count('1'),
     994            'pending'    => $count('0'),
     995            'spam'       => $count('spam'),
     996            'trash'      => $count('trash'),
     997            'quarantine' => $count(self::QUARANTINE),
     998            'rules'      => count($this->get_rules()),
     999            'caught'     => (int) array_sum(wp_list_pluck((array) get_option('ulticocl_rule_state', array()), 'hits')),
    6151000        );
    616        
    617         return $stats;
    618     }
    619    
     1001    }
     1002
     1003    /**
     1004     * Prepared "comment_type IN (...)" fragment for the types this plugin manages.
     1005     */
     1006    private function types_sql() {
     1007        global $wpdb;
     1008        return $wpdb->prepare('comment_type IN (' . implode(',', array_fill(0, count(self::TYPES), '%s')) . ')', self::TYPES);
     1009    }
     1010
    6201011    private function get_comment_authors() {
    6211012        global $wpdb;
    622         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for author list, caching not practical
    623         return $wpdb->get_results("SELECT DISTINCT comment_author, comment_author_email FROM $wpdb->comments WHERE comment_type = 'comment' AND comment_author_email != '' ORDER BY comment_author ASC LIMIT 200");
    624     }
    625    
     1013        $types = $this->types_sql();
     1014        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct query needed for author list, caching not practical; $types is prepared in types_sql().
     1015        return $wpdb->get_results("SELECT DISTINCT comment_author, comment_author_email FROM $wpdb->comments WHERE $types AND comment_author_email != '' ORDER BY comment_author ASC LIMIT 200");
     1016    }
     1017
    6261018    private function render_recent_activity() {
    6271019        $activities = get_option('ulticocl_activity_log', array());
    … …  
    6301022            return;
    6311023        }
    632        
     1024
    6331025        $activities = array_slice(array_reverse($activities), 0, 10);
    6341026        foreach ($activities as $activity) {
    … …  
    6391031        }
    6401032    }
    641    
    642     private function format_trigger_conditions($trigger) {
    643         $conditions = array();
    644         if (isset($trigger['status'])) {
    645             $conditions[] = esc_html__('Status:', 'ultimate-comment-cleaner') . ' ' . esc_html($trigger['status']);
    646         }
    647         if (isset($trigger['older_than'])) {
    648             $conditions[] = esc_html__('Older than:', 'ultimate-comment-cleaner') . ' ' . intval($trigger['older_than']) . ' ' . esc_html__('days', 'ultimate-comment-cleaner');
    649         }
    650         return implode(', ', $conditions);
    651     }
    652    
    653     public function ajax_delete_comments() {
     1033
     1034    private function action_labels() {
     1035        return array(
     1036            'quarantine' => __('Quarantine', 'ultimate-comment-cleaner'),
     1037            'spam'       => __('Mark as spam', 'ultimate-comment-cleaner'),
     1038            'delete'     => __('Delete permanently', 'ultimate-comment-cleaner'),
     1039        );
     1040    }
     1041
     1042    private function action_message($action, $n) {
     1043        $messages = array(
     1044            /* translators: %d: number of comments */
     1045            'delete'     => _n('Deleted %d comment.', 'Deleted %d comments.', $n, 'ultimate-comment-cleaner'),
     1046            /* translators: %d: number of comments */
     1047            'quarantine' => _n('Moved %d comment to quarantine.', 'Moved %d comments to quarantine.', $n, 'ultimate-comment-cleaner'),
     1048            /* translators: %d: number of comments */
     1049            'spam'       => _n('Marked %d comment as spam.', 'Marked %d comments as spam.', $n, 'ultimate-comment-cleaner'),
     1050        );
     1051        return sprintf($messages[$action], $n);
     1052    }
     1053
     1054    /**
     1055     * One-line description of what a rule checks, for the rules list.
     1056     */
     1057    private function rule_summary($rule) {
     1058        $parts = array();
     1059        if (!empty($rule['keywords'])) {
     1060            $shown = array_slice($rule['keywords'], 0, 3);
     1061            $more = count($rule['keywords']) - count($shown);
     1062            /* translators: 1: sample keywords, 2: number of additional keywords */
     1063            $parts[] = $more > 0 ? sprintf(__('keywords %1$s (+%2$d more)', 'ultimate-comment-cleaner'), implode(', ', $shown), $more) : sprintf(__('keywords %s', 'ultimate-comment-cleaner'), implode(', ', $shown));
     1064        }
     1065        if (!empty($rule['scripts'])) {
     1066            $scripts = ULTICOCL_Matcher::scripts();
     1067            $labels = array();
     1068            foreach ($rule['scripts'] as $key) {
     1069                if (isset($scripts[$key])) {
     1070                    $labels[] = ULTICOCL_Matcher::short_label($key);
     1071                }
     1072            }
     1073            /* translators: 1: writing systems, 2: percentage */
     1074            $parts[] = sprintf(__('%1$s text (%2$d%%+)', 'ultimate-comment-cleaner'), implode('/', $labels), (int) $rule['script_min_pct']);
     1075        }
     1076        if (!empty($rule['min_links'])) {
     1077            /* translators: %d: number of links */
     1078            $parts[] = sprintf(_n('%d+ link', '%d+ links', (int) $rule['min_links'], 'ultimate-comment-cleaner'), (int) $rule['min_links']);
     1079        }
     1080        if (!empty($rule['has_url'])) {
     1081            $parts[] = __('has a website', 'ultimate-comment-cleaner');
     1082        }
     1083        if (!empty($rule['max_words'])) {
     1084            /* translators: %d: number of words */
     1085            $parts[] = sprintf(__('under %d words', 'ultimate-comment-cleaner'), (int) $rule['max_words']);
     1086        }
     1087        $glue = 'all' === $rule['mode'] ? ' ' . __('AND', 'ultimate-comment-cleaner') . ' ' : ' ' . __('OR', 'ultimate-comment-cleaner') . ' ';
     1088        $text = implode($glue, $parts);
     1089
     1090        if ('new' !== $rule['scope']) {
     1091            $scope = array();
     1092            $scope[] = sprintf(
     1093                /* translators: %s: comment statuses */
     1094                __('status: %s', 'ultimate-comment-cleaner'),
     1095                implode(', ', (array) $rule['status'])
     1096            );
     1097            if (!empty($rule['older_than'])) {
     1098                /* translators: %d: number of days */
     1099                $scope[] = sprintf(_n('older than %d day', 'older than %d days', (int) $rule['older_than'], 'ultimate-comment-cleaner'), (int) $rule['older_than']);
     1100            }
     1101            $text .= ('' !== $text ? ' (' : '') . implode(', ', $scope) . ('' !== $text ? ')' : '');
     1102        }
     1103        return $text;
     1104    }
     1105
     1106    // phpcs:disable WordPress.Security.NonceVerification.Missing -- The AJAX handlers below verify the nonce in verify_ajax().
     1107
     1108    private function verify_ajax() {
    6541109        check_ajax_referer('ulticocl_nonce', 'nonce');
    655        
     1110
    6561111        if (!current_user_can('manage_options')) {
    6571112            wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    6581113        }
    659        
     1114    }
     1115
     1116    /**
     1117     * Process one batch of matching comments. The admin JS calls this repeatedly
     1118     * until `done` is true, so large clean-ups never hit request time limits.
     1119     */
     1120    public function ajax_bulk_process() {
     1121        $this->verify_ajax();
     1122
    6601123        $filters = $this->extract_filter_params();
    661         $deleted = $this->delete_comments_by_filters($filters);
    662        
    663         // translators: %d: number of comments deleted
    664         $this->log_activity(sprintf(esc_html__('Deleted %d comments', 'ultimate-comment-cleaner'), $deleted));
    665         $this->send_email_notification($deleted, 'manual');
    666        
     1124        $action = $filters['clean_action'];
     1125        $processed = $this->process_by_filters($filters);
     1126        $remaining = $this->count_comments_by_filters($filters);
     1127        $total = (isset($_POST['processed_so_far']) ? absint($_POST['processed_so_far']) : 0) + $processed;
     1128        // Nothing processed this round means we are finished, or blocked (e.g. by another plugin): stop either way.
     1129        $done = 0 === $processed || 0 === $remaining;
     1130
     1131        if ($done && $total > 0) {
     1132            $this->log_activity($this->action_message($action, $total));
     1133            $this->send_email_notification($total, 'manual', $action);
     1134        }
     1135
    6671136        wp_send_json_success(array(
    668             // translators: %d: number of comments deleted
    669             'message' => sprintf(esc_html__('Successfully deleted %d comments.', 'ultimate-comment-cleaner'), $deleted),
    670             'count' => $deleted
     1137            'processed' => $processed,
     1138            'total' => $total,
     1139            'remaining' => $remaining,
     1140            'done' => $done,
     1141            'message' => $this->action_message($action, $total),
    6711142        ));
    6721143    }
    673    
    674     public function ajax_get_stats() {
    675         check_ajax_referer('ulticocl_nonce', 'nonce');
    676         wp_send_json_success($this->get_comment_stats());
    677     }
    678    
     1144
     1145    /**
     1146     * Dashboard data for a date range. The range is remembered per user.
     1147     */
     1148    public function ajax_dashboard() {
     1149        $this->verify_ajax();
     1150
     1151        $days = isset($_POST['days']) ? absint($_POST['days']) : 30;
     1152        if (!in_array($days, ULTICOCL_Insights::RANGES, true)) {
     1153            $days = 30;
     1154        }
     1155        update_user_meta(get_current_user_id(), 'ulticocl_dash_days', $days);
     1156        wp_send_json_success(ULTICOCL_Insights::collect($days, $this->get_comment_stats()));
     1157    }
     1158
    6791159    public function ajax_export_comments() {
    680         check_ajax_referer('ulticocl_nonce', 'nonce');
    681        
    682         if (!current_user_can('manage_options')) {
    683             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    684         }
    685        
     1160        $this->verify_ajax();
     1161
    6861162        $filters = $this->extract_filter_params();
     1163        unset($filters['clean_action']); // Export everything that matches, including quarantined comments.
    6871164        $this->export_comments($filters);
    6881165    }
    689    
    690     public function ajax_save_trigger() {
    691         check_ajax_referer('ulticocl_nonce', 'nonce');
    692        
    693         if (!current_user_can('manage_options')) {
    694             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    695         }
    696        
    697         $triggers = get_option('ulticocl_triggers', array());
    698         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : uniqid('trigger_');
    699        
    700         // Preserve existing trigger data if editing
    701         $existing_trigger = isset($triggers[$trigger_id]) ? $triggers[$trigger_id] : array();
    702        
    703         $triggers[$trigger_id] = array(
    704             'name' => isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '',
    705             'status' => isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'approved',
    706             'older_than' => isset($_POST['older_than']) ? intval($_POST['older_than']) : 0,
    707             'schedule' => isset($_POST['schedule']) ? sanitize_text_field(wp_unslash($_POST['schedule'])) : 'hourly',
    708             'active' => isset($_POST['active']) && intval($_POST['active']) === 1,
    709             'created' => isset($existing_trigger['created']) ? $existing_trigger['created'] : current_time('mysql'),
    710             'last_run' => isset($existing_trigger['last_run']) ? $existing_trigger['last_run'] : null
    711         );
    712        
    713         update_option('ulticocl_triggers', $triggers);
    714         wp_send_json_success(array('message' => __('Trigger saved successfully.', 'ultimate-comment-cleaner')));
    715     }
    716    
    717     public function ajax_delete_trigger() {
    718         check_ajax_referer('ulticocl_nonce', 'nonce');
    719        
    720         if (!current_user_can('manage_options')) {
    721             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    722         }
    723        
    724         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : '';
    725         $triggers = get_option('ulticocl_triggers', array());
    726        
    727         if (isset($triggers[$trigger_id])) {
    728             unset($triggers[$trigger_id]);
    729             update_option('ulticocl_triggers', $triggers);
    730             wp_send_json_success(array('message' => __('Trigger deleted successfully.', 'ultimate-comment-cleaner')));
    731         }
    732        
    733         wp_send_json_error(array('message' => __('Trigger not found.', 'ultimate-comment-cleaner')));
    734     }
    735    
    736     public function ajax_get_trigger() {
    737         check_ajax_referer('ulticocl_nonce', 'nonce');
    738        
    739         if (!current_user_can('manage_options')) {
    740             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    741         }
    742        
    743         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : '';
    744         $triggers = get_option('ulticocl_triggers', array());
    745        
    746         if (isset($triggers[$trigger_id])) {
    747             wp_send_json_success($triggers[$trigger_id]);
    748         }
    749        
    750         wp_send_json_error(array('message' => __('Trigger not found.', 'ultimate-comment-cleaner')));
    751     }
    752    
     1166
    7531167    public function ajax_preview_count() {
    754         check_ajax_referer('ulticocl_nonce', 'nonce');
    755        
    756         if (!current_user_can('manage_options')) {
    757             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    758         }
    759        
    760         $filters = $this->extract_filter_params();
    761         $count = $this->count_comments_by_filters($filters);
    762        
    763         wp_send_json_success(array('count' => $count));
    764     }
    765    
     1168        $this->verify_ajax();
     1169
     1170        wp_send_json_success(array('count' => $this->count_comments_by_filters($this->extract_filter_params())));
     1171    }
     1172
     1173    public function ajax_save_rule() {
     1174        $this->verify_ajax();
     1175
     1176        $rule = $this->rule_from_post();
     1177        if (is_wp_error($rule)) {
     1178            wp_send_json_error(array('message' => $rule->get_error_message()));
     1179        }
     1180
     1181        $rules = $this->get_rules();
     1182        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1183        // Only reuse an ID that already exists; anything else is a new rule.
     1184        if ('' === $id || !isset($rules[$id])) {
     1185            $id = uniqid('rule_');
     1186        }
     1187        $rule['created'] = !empty($rules[$id]['created']) ? $rules[$id]['created'] : current_time('mysql');
     1188        $rules[$id] = $rule;
     1189        update_option('ulticocl_rules', $rules, false);
     1190
     1191        // An edited rule is scanned from the start again.
     1192        $state = get_option('ulticocl_rule_state', array());
     1193        $state[$id] = array_merge(array('hits' => 0, 'last_run' => 0), isset($state[$id]) ? $state[$id] : array(), array('cursor' => 0));
     1194        update_option('ulticocl_rule_state', $state, false);
     1195
     1196        wp_send_json_success(array('message' => __('Rule saved.', 'ultimate-comment-cleaner'), 'id' => $id));
     1197    }
     1198
     1199    public function ajax_delete_rule() {
     1200        $this->verify_ajax();
     1201
     1202        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1203        $rules = $this->get_rules();
     1204        if (!isset($rules[$id])) {
     1205            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1206        }
     1207        unset($rules[$id]);
     1208        update_option('ulticocl_rules', $rules, false);
     1209        $state = get_option('ulticocl_rule_state', array());
     1210        unset($state[$id]);
     1211        update_option('ulticocl_rule_state', $state, false);
     1212        wp_send_json_success(array('message' => __('Rule deleted.', 'ultimate-comment-cleaner')));
     1213    }
     1214
     1215    public function ajax_get_rule() {
     1216        $this->verify_ajax();
     1217
     1218        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1219        $rules = $this->get_rules();
     1220        if (!isset($rules[$id])) {
     1221            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1222        }
     1223        wp_send_json_success($rules[$id]);
     1224    }
     1225
     1226    /**
     1227     * Scan one chunk of existing comments with a rule. With `dry` set nothing is changed:
     1228     * the matches are counted and sampled, and their IDs are kept for "Review matches".
     1229     */
     1230    public function ajax_scan_rule() {
     1231        $this->verify_ajax();
     1232
     1233        $dry = !empty($_POST['dry']);
     1234        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1235        $rules = $this->get_rules();
     1236
     1237        if (!$dry) {
     1238            if (!isset($rules[$id])) {
     1239                wp_send_json_error(array('message' => __('Save the rule before running it.', 'ultimate-comment-cleaner')));
     1240            }
     1241            $rule = $rules[$id];
     1242        } elseif (isset($_POST['name'])) {
     1243            $rule = $this->rule_from_post();
     1244            if (is_wp_error($rule)) {
     1245                wp_send_json_error(array('message' => $rule->get_error_message()));
     1246            }
     1247        } elseif (isset($rules[$id])) {
     1248            $rule = $rules[$id];
     1249        } else {
     1250            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1251        }
     1252
     1253        $cursor = isset($_POST['cursor']) ? absint($_POST['cursor']) : 0;
     1254        $result = $this->scan_rule($rule, $cursor, self::BATCH_SIZE, $dry, $id);
     1255        if (is_wp_error($result)) {
     1256            wp_send_json_error(array('message' => $result->get_error_message()));
     1257        }
     1258
     1259        if (0 === $cursor) {
     1260            $result['total'] = $this->count_scope($rule);
     1261        }
     1262
     1263        if ($dry) {
     1264            $key = 'ulticocl_preview_' . get_current_user_id();
     1265            $preview = get_transient($key);
     1266            if (0 === $cursor || !is_array($preview)) {
     1267                $preview = array('name' => $rule['name'], 'ids' => array());
     1268            }
     1269            $preview['ids'] = array_slice(array_merge($preview['ids'], $result['ids']), 0, 1000);
     1270            set_transient($key, $preview, HOUR_IN_SECONDS);
     1271        } else {
     1272            $this->add_hits($id, $result['acted']);
     1273            $this->record_stat('scan', $result['acted'], $id);
     1274            $acted = (isset($_POST['acted_so_far']) ? absint($_POST['acted_so_far']) : 0) + $result['acted'];
     1275            if ($result['done'] && $acted > 0) {
     1276                /* translators: 1: rule name, 2: result sentence */
     1277                $this->log_activity(sprintf(__('Rule "%1$s" run manually: %2$s', 'ultimate-comment-cleaner'), $rule['name'], $this->action_message($rule['action'], $acted)));
     1278                $this->send_email_notification($acted, $rule['name'], $rule['action']);
     1279            }
     1280        }
     1281        unset($result['ids']);
     1282
     1283        wp_send_json_success($result);
     1284    }
     1285
    7661286    /**
    7671287     * Extract and sanitize filter parameters from $_POST
    … …  
    7721292    private function extract_filter_params() {
    7731293        $filters = array();
    774        
     1294
    7751295        // Status filter (array)
    7761296        if (isset($_POST['status']) && is_array($_POST['status'])) {
    777             $filters['status'] = array_map('sanitize_text_field', wp_unslash($_POST['status']));
    778         }
    779        
     1297            $filters['status'] = array_map('sanitize_key', wp_unslash($_POST['status']));
     1298        }
     1299
    7801300        // Date range
    781         if (isset($_POST['date_from']) && !empty($_POST['date_from'])) {
     1301        if (!empty($_POST['date_from'])) {
    7821302            $filters['date_from'] = sanitize_text_field(wp_unslash($_POST['date_from']));
    7831303        }
    784         if (isset($_POST['date_to']) && !empty($_POST['date_to'])) {
     1304        if (!empty($_POST['date_to'])) {
    7851305            $filters['date_to'] = sanitize_text_field(wp_unslash($_POST['date_to']));
    7861306        }
    787        
     1307
    7881308        // Author filter
    789         if (isset($_POST['author']) && !empty($_POST['author'])) {
     1309        if (!empty($_POST['author'])) {
    7901310            $filters['author'] = sanitize_text_field(wp_unslash($_POST['author']));
    7911311        }
    792        
     1312
     1313        // Search text
     1314        if (!empty($_POST['search'])) {
     1315            $filters['search'] = sanitize_text_field(wp_unslash($_POST['search']));
     1316        }
     1317
    7931318        // Post type filter (array)
    7941319        if (isset($_POST['post_type']) && is_array($_POST['post_type'])) {
    7951320            $filters['post_type'] = array_map('sanitize_key', wp_unslash($_POST['post_type']));
    7961321        }
    797        
     1322
    7981323        // Additional filters (boolean flags)
    799         if (isset($_POST['has_url']) && !empty($_POST['has_url'])) {
    800             $filters['has_url'] = 1;
    801         }
    802         if (isset($_POST['no_url']) && !empty($_POST['no_url'])) {
    803             $filters['no_url'] = 1;
    804         }
    805         if (isset($_POST['short_comments']) && !empty($_POST['short_comments'])) {
    806             $filters['short_comments'] = 1;
    807         }
    808        
     1324        $filters['has_url'] = !empty($_POST['has_url']);
     1325        $filters['no_url'] = !empty($_POST['no_url']);
     1326        $filters['short_comments'] = !empty($_POST['short_comments']);
     1327
    8091328        // Word count filters
    810         if (isset($_POST['min_words']) && !empty($_POST['min_words'])) {
    811             $filters['min_words'] = intval($_POST['min_words']);
    812         }
    813         if (isset($_POST['max_words']) && !empty($_POST['max_words'])) {
    814             $filters['max_words'] = intval($_POST['max_words']);
    815         }
    816        
     1329        $filters['min_words'] = isset($_POST['min_words']) ? absint($_POST['min_words']) : 0;
     1330        $filters['max_words'] = isset($_POST['max_words']) ? absint($_POST['max_words']) : 0;
     1331
     1332        // What to do with the matches; anything unknown falls back to the safe, restorable option.
     1333        $action = isset($_POST['clean_action']) ? sanitize_key(wp_unslash($_POST['clean_action'])) : '';
     1334        $filters['clean_action'] = in_array($action, ULTICOCL_Matcher::ACTIONS, true) ? $action : 'quarantine';
     1335
    8171336        return $filters;
    8181337    }
    819    
     1338
     1339    /**
     1340     * Build a rule from the rule form in $_POST.
     1341     *
     1342     * @return array|WP_Error Canonical rule, or an error describing what to fix.
     1343     */
     1344    private function rule_from_post() {
     1345        $p = wp_unslash($_POST); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Every field is sanitized below.
     1346        $text = function ($key) use ($p) {
     1347            return isset($p[$key]) && is_string($p[$key]) ? sanitize_text_field($p[$key]) : '';
     1348        };
     1349        $keys = function ($key) use ($p) {
     1350            return isset($p[$key]) && is_array($p[$key]) ? array_map('sanitize_key', $p[$key]) : array();
     1351        };
     1352        $int = function ($key, $max) use ($p) {
     1353            return isset($p[$key]) ? min($max, absint($p[$key])) : 0;
     1354        };
     1355
     1356        $rule = ULTICOCL_Matcher::defaults();
     1357        $rule['name'] = $text('name');
     1358        $rule['active'] = !empty($p['active']);
     1359        $rule['mode'] = 'all' === $text('mode') ? 'all' : 'any';
     1360        $scope = $text('scope');
     1361        $rule['scope'] = in_array($scope, ULTICOCL_Matcher::SCOPES, true) ? $scope : 'both';
     1362        $action = $text('rule_action');
     1363        $rule['action'] = in_array($action, ULTICOCL_Matcher::ACTIONS, true) ? $action : 'quarantine';
     1364        $schedule = $text('schedule');
     1365        $rule['schedule'] = in_array($schedule, self::SCHEDULES, true) ? $schedule : 'daily';
     1366
     1367        $raw = isset($p['keywords']) && is_string($p['keywords']) ? sanitize_textarea_field($p['keywords']) : '';
     1368        // Not /\R/: without the u flag it treats byte 0x85 as a newline and would cut Cyrillic letters in half.
     1369        $lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $raw)), 'strlen');
     1370        $rule['keywords'] = array_slice(array_values(array_unique($lines)), 0, 500);
     1371        $invalid = array();
     1372        foreach ($rule['keywords'] as $keyword) {
     1373            if (null === ULTICOCL_Matcher::keyword_regex($keyword)) {
     1374                $invalid[] = $keyword;
     1375            }
     1376        }
     1377
     1378        $fields = array_values(array_intersect($keys('fields'), ULTICOCL_Matcher::FIELDS));
     1379        $rule['fields'] = $fields ? $fields : array('content', 'author', 'url');
     1380        $rule['scripts'] = array_values(array_intersect($keys('scripts'), array_keys(ULTICOCL_Matcher::scripts())));
     1381        $rule['script_min_pct'] = max(1, $int('script_min_pct', 100));
     1382        $rule['min_links'] = $int('min_links', 100);
     1383        $rule['has_url'] = !empty($p['has_url']);
     1384        $rule['max_words'] = $int('max_words', 1000);
     1385        $status = array_values(array_intersect($keys('status'), ULTICOCL_Matcher::RULE_STATUSES));
     1386        $rule['status'] = $status ? $status : ULTICOCL_Matcher::RULE_STATUSES;
     1387        $rule['older_than'] = $int('older_than', 3650);
     1388
     1389        if ('' === $rule['name']) {
     1390            return new WP_Error('name', __('Please enter a rule name.', 'ultimate-comment-cleaner'));
     1391        }
     1392        if ($invalid) {
     1393            /* translators: %s: invalid keyword lines */
     1394            return new WP_Error('keywords', sprintf(__('These keywords are not valid: %s', 'ultimate-comment-cleaner'), implode(', ', array_slice($invalid, 0, 3))));
     1395        }
     1396        if (!ULTICOCL_Matcher::has_signals($rule)) {
     1397            if ('new' === $rule['scope']) {
     1398                return new WP_Error('signals', __('Rules for new comments need at least one check: keywords, a writing system, links, a website or short comments.', 'ultimate-comment-cleaner'));
     1399            }
     1400            $rule['scope'] = 'existing'; // Nothing to look for in a new comment.
     1401        }
     1402        if (!ULTICOCL_Matcher::scope_is_safe($rule)) {
     1403            return new WP_Error('unsafe', __('This rule would match every comment. Add a check, set "Only older than" to at least 1 day, or select Spam only.', 'ultimate-comment-cleaner'));
     1404        }
     1405
     1406        return $rule;
     1407    }
     1408
     1409    // phpcs:enable WordPress.Security.NonceVerification.Missing
     1410
     1411    /**
     1412     * Build the SQL WHERE clause shared by preview count, bulk clean and export.
     1413     *
     1414     * Fails closed: a filter that is present but invalid matches nothing, so a bad
     1415     * request can never widen a bulk action to every comment.
     1416     *
     1417     * @param array $filters Sanitized filters.
     1418     * @return string|false WHERE clause, or false when nothing can match.
     1419     */
     1420    private function build_where($filters) {
     1421        global $wpdb;
     1422
     1423        $where = array($this->types_sql());
     1424
     1425        // Status is required; 'all' means no status restriction.
     1426        if (empty($filters['status'])) {
     1427            return false;
     1428        }
     1429        if (!in_array('all', $filters['status'], true)) {
     1430            $statuses = array_values(array_intersect_key(self::STATUS_MAP, array_flip($filters['status'])));
     1431            if (empty($statuses)) {
     1432                return false;
     1433            }
     1434            $where[] = $wpdb->prepare('comment_approved IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')', $statuses);
     1435        }
     1436
     1437        // Skip comments the action cannot change, so each batch makes progress.
     1438        if (isset($filters['clean_action'])) {
     1439            if ('quarantine' === $filters['clean_action']) {
     1440                $where[] = $wpdb->prepare('comment_approved != %s', self::QUARANTINE);
     1441            } elseif ('spam' === $filters['clean_action']) {
     1442                $where[] = $wpdb->prepare('comment_approved NOT IN (%s, %s)', 'spam', self::QUARANTINE);
     1443            }
     1444        }
     1445
     1446        // Date range (site local time, as entered by the admin)
     1447        if (!empty($filters['date_from'])) {
     1448            if (!$this->is_valid_date($filters['date_from'])) {
     1449                return false;
     1450            }
     1451            $where[] = $wpdb->prepare('comment_date >= %s', $filters['date_from'] . ' 00:00:00');
     1452        }
     1453        if (!empty($filters['date_to'])) {
     1454            if (!$this->is_valid_date($filters['date_to'])) {
     1455                return false;
     1456            }
     1457            $where[] = $wpdb->prepare('comment_date <= %s', $filters['date_to'] . ' 23:59:59');
     1458        }
     1459
     1460        // Author filter
     1461        if (!empty($filters['author']) && 'all' !== $filters['author']) {
     1462            if (!is_email($filters['author'])) {
     1463                return false;
     1464            }
     1465            $where[] = $wpdb->prepare('comment_author_email = %s', $filters['author']);
     1466        }
     1467
     1468        // Search text
     1469        if (!empty($filters['search'])) {
     1470            $like = '%' . $wpdb->esc_like($filters['search']) . '%';
     1471            $where[] = $wpdb->prepare('(comment_content LIKE %s OR comment_author LIKE %s OR comment_author_email LIKE %s OR comment_author_url LIKE %s)', $like, $like, $like, $like);
     1472        }
     1473
     1474        // Post type filter
     1475        if (!empty($filters['post_type']) && !in_array('all', $filters['post_type'], true)) {
     1476            $where[] = $wpdb->prepare(
     1477                "comment_post_ID IN (SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($filters['post_type']), '%s')) . '))',
     1478                $filters['post_type']
     1479            );
     1480        }
     1481
     1482        // URL filters
     1483        if (!empty($filters['has_url'])) {
     1484            $where[] = "comment_author_url != ''";
     1485        }
     1486        if (!empty($filters['no_url'])) {
     1487            $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
     1488        }
     1489
     1490        // ponytail: word count = spaces + 1; newlines and double spaces skew it. Good enough for spam triage.
     1491        $words = "LENGTH(TRIM(comment_content)) - LENGTH(REPLACE(TRIM(comment_content), ' ', '')) + 1";
     1492        if (!empty($filters['short_comments'])) {
     1493            $where[] = "$words < 10";
     1494        }
     1495        if (!empty($filters['min_words'])) {
     1496            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $words is a fixed SQL expression
     1497            $where[] = $wpdb->prepare("$words >= %d", $filters['min_words']);
     1498        }
     1499        if (!empty($filters['max_words'])) {
     1500            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $words is a fixed SQL expression
     1501            $where[] = $wpdb->prepare("$words <= %d", $filters['max_words']);
     1502        }
     1503
     1504        return implode(' AND ', $where);
     1505    }
     1506
     1507    private function is_valid_date($value) {
     1508        $date = DateTime::createFromFormat('Y-m-d', $value);
     1509        return $date && $date->format('Y-m-d') === $value;
     1510    }
     1511
    8201512    private function count_comments_by_filters($filters) {
    8211513        global $wpdb;
    822        
    823         $where = array("comment_type = 'comment'");
    824        
    825         // Status filter
    826         if (isset($filters['status']) && is_array($filters['status'])) {
    827             $statuses = array();
    828             foreach ($filters['status'] as $status) {
    829                 if ($status === 'all') continue;
    830                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    831             }
    832             if (!empty($statuses)) {
    833                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    834             }
    835         }
    836        
    837         // Date range
    838         if (!empty($filters['date_from'])) {
    839             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    840         }
    841         if (!empty($filters['date_to'])) {
    842             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    843         }
    844        
    845         // Author filter
    846         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    847             $author_email = sanitize_email($filters['author']);
    848             if (is_email($author_email)) {
    849                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    850             }
    851         }
    852        
    853         // Post type filter
    854         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    855             // Sanitize post types
    856             $post_types = array_map('sanitize_key', $filters['post_type']);
    857             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed, caching not practical
    858             $post_ids = $wpdb->get_col($wpdb->prepare(
    859                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($post_types), '%s')) . ")",
    860                 ...$post_types
    861             ));
    862             if (!empty($post_ids)) {
    863                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    864             } else {
    865                 return 0; // No posts found, so no comments
    866             }
    867         }
    868        
    869         // Additional filters
    870         if (!empty($filters['has_url'])) {
    871             $where[] = "comment_author_url != ''";
    872         }
    873         if (!empty($filters['no_url'])) {
    874             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    875         }
    876        
    877         // Word count filters - sanitize inputs
    878         if (!empty($filters['short_comments'])) {
    879             $where[] = "LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 < 10";
    880         }
    881         if (!empty($filters['min_words'])) {
    882             $min_words = intval($filters['min_words']);
    883             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 >= %d", $min_words);
    884         }
    885         if (!empty($filters['max_words'])) {
    886             $max_words = intval($filters['max_words']);
    887             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 <= %d", $max_words);
    888         }
    889        
    890         $where_clause = implode(' AND ', $where);
    891        
    892         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    893         return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where_clause");
    894     }
    895    
    896     private function delete_comments_by_filters($filters) {
     1514
     1515        $where = $this->build_where($filters);
     1516        if (false === $where) {
     1517            return 0;
     1518        }
     1519
     1520        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1521        return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where");
     1522    }
     1523
     1524    /**
     1525     * Apply the chosen action to up to one batch of matching comments.
     1526     *
     1527     * @return int Number of comments changed.
     1528     */
     1529    private function process_by_filters($filters) {
    8971530        global $wpdb;
    898        
    899         $where = array("comment_type = 'comment'");
    900        
    901         // Status filter
    902         if (isset($filters['status']) && is_array($filters['status'])) {
    903             $statuses = array();
    904             foreach ($filters['status'] as $status) {
    905                 if ($status === 'all') continue;
    906                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    907             }
    908             if (!empty($statuses)) {
    909                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    910             }
    911         }
    912        
    913         // Date range
    914         if (!empty($filters['date_from'])) {
    915             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    916         }
    917         if (!empty($filters['date_to'])) {
    918             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    919         }
    920        
    921         // Author filter
    922         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    923             $author_email = sanitize_email($filters['author']);
    924             if (is_email($author_email)) {
    925                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    926             }
    927         }
    928        
    929         // Post type filter
    930         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    931             // Sanitize post types
    932             $post_types = array_map('sanitize_key', $filters['post_type']);
    933             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed, caching not practical
    934             $post_ids = $wpdb->get_col($wpdb->prepare(
    935                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($post_types), '%s')) . ")",
    936                 ...$post_types
    937             ));
    938             if (!empty($post_ids)) {
    939                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    940             } else {
    941                 return 0; // No posts found, so no comments
    942             }
    943         }
    944        
    945         // Additional filters
    946         if (!empty($filters['has_url'])) {
    947             $where[] = "comment_author_url != ''";
    948         }
    949         if (!empty($filters['no_url'])) {
    950             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    951         }
    952        
    953         // Word count filters - sanitize inputs
    954         if (!empty($filters['short_comments'])) {
    955             $where[] = "LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 < 10";
    956         }
    957         if (!empty($filters['min_words'])) {
    958             $min_words = intval($filters['min_words']);
    959             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 >= %d", $min_words);
    960         }
    961         if (!empty($filters['max_words'])) {
    962             $max_words = intval($filters['max_words']);
    963             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 <= %d", $max_words);
    964         }
    965        
    966         $where_clause = implode(' AND ', $where);
    967        
    968         // Get comment IDs and post IDs BEFORE deletion (WordPress standard way)
    969         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    970         $comments = $wpdb->get_results("SELECT comment_ID, comment_post_ID FROM $wpdb->comments WHERE $where_clause");
    971        
    972         if (empty($comments)) {
     1531
     1532        $where = $this->build_where($filters);
     1533        if (false === $where) {
    9731534            return 0;
    9741535        }
    975        
    976         $count = count($comments);
    977         $comment_ids = array();
    978         $post_ids = array();
    979        
    980         // Collect comment IDs and unique post IDs
    981         foreach ($comments as $comment) {
    982             $comment_ids[] = (int) $comment->comment_ID;
    983             $post_ids[(int) $comment->comment_post_ID] = true;
    984         }
    985        
    986         $post_ids = array_keys($post_ids);
    987        
    988         // Use WordPress function to properly delete comments and clean up
     1536
     1537        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1538        $comment_ids = $wpdb->get_col($wpdb->prepare("SELECT comment_ID FROM $wpdb->comments WHERE $where ORDER BY comment_ID LIMIT %d", self::BATCH_SIZE));
     1539
     1540        // Recount each affected post once at the end instead of after every comment.
     1541        wp_defer_comment_counting(true);
     1542        $changed = 0;
    9891543        foreach ($comment_ids as $comment_id) {
    990             wp_delete_comment($comment_id, true); // true = force delete (skip trash)
    991         }
    992        
    993         // Clean up post comment counts
    994         if (!empty($post_ids)) {
    995             foreach ($post_ids as $post_id) {
    996                 clean_post_cache($post_id);
    997                 wp_update_comment_count_now($post_id);
    998             }
    999         }
    1000        
    1001         // Clear caches
    1002         wp_cache_flush();
    1003         delete_transient('ulticocl_comments_count');
    1004        
    1005         return (int) $count;
    1006     }
    1007    
     1544            if ($this->comment_action((int) $comment_id, $filters['clean_action'])) {
     1545                $changed++;
     1546            }
     1547        }
     1548        wp_defer_comment_counting(false);
     1549
     1550        return $changed;
     1551    }
     1552
    10081553    private function export_comments($filters) {
    10091554        global $wpdb;
    1010        
    1011         $where = array("comment_type = 'comment'");
    1012        
    1013         // Status filter
    1014         if (isset($filters['status']) && is_array($filters['status'])) {
    1015             $statuses = array();
    1016             foreach ($filters['status'] as $status) {
    1017                 if ($status === 'all') continue;
    1018                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    1019             }
    1020             if (!empty($statuses)) {
    1021                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    1022             }
    1023         }
    1024        
    1025         // Date range
    1026         if (!empty($filters['date_from'])) {
    1027             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    1028         }
    1029         if (!empty($filters['date_to'])) {
    1030             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    1031         }
    1032        
    1033         // Author filter
    1034         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    1035             $author_email = sanitize_email($filters['author']);
    1036             if (is_email($author_email)) {
    1037                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    1038             }
    1039         }
    1040        
    1041         // Post type filter
    1042         $comments = array();
    1043         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    1044             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for post type filtering, caching not practical
    1045             $post_ids = $wpdb->get_col($wpdb->prepare(
    1046                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($filters['post_type']), '%s')) . ")",
    1047                 ...$filters['post_type']
     1555
     1556        $where = $this->build_where($filters);
     1557
     1558        nocache_headers();
     1559        header('Content-Type: text/csv; charset=utf-8');
     1560        header('Content-Disposition: attachment; filename="comments-export-' . gmdate('Y-m-d') . '.csv"');
     1561
     1562        $output = fopen('php://output', 'w');
     1563        fputcsv($output, array('ID', 'Post ID', 'Author', 'Email', 'URL', 'Content', 'Date', 'Status'), ',', '"', '');
     1564
     1565        // Stream in chunks so large exports don't exhaust memory.
     1566        $last_id = 0;
     1567        while (false !== $where) {
     1568            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1569            $comments = $wpdb->get_results($wpdb->prepare("SELECT comment_ID, comment_post_ID, comment_author, comment_author_email, comment_author_url, comment_content, comment_date, comment_approved FROM $wpdb->comments WHERE $where AND comment_ID > %d ORDER BY comment_ID LIMIT %d", $last_id, self::BATCH_SIZE));
     1570            if (empty($comments)) {
     1571                break;
     1572            }
     1573            foreach ($comments as $comment) {
     1574                fputcsv($output, array_map(array($this, 'csv_safe'), array_values((array) $comment)), ',', '"', '');
     1575                $last_id = $comment->comment_ID;
     1576            }
     1577        }
     1578
     1579        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output is a special stream, not a file
     1580        fclose($output);
     1581        exit;
     1582    }
     1583
     1584    /**
     1585     * Prefix cells that a spreadsheet would run as a formula (CSV injection).
     1586     */
     1587    private function csv_safe($value) {
     1588        return preg_match('/^[=+\-@\t\r]/', (string) $value) ? "'" . $value : $value;
     1589    }
     1590
     1591    // ---------------------------------------------------------------------
     1592    // Smart rules
     1593    // ---------------------------------------------------------------------
     1594
     1595    private function get_rules() {
     1596        $rules = get_option('ulticocl_rules', array());
     1597        if (!is_array($rules)) {
     1598            return array();
     1599        }
     1600        foreach ($rules as $id => $rule) {
     1601            $rules[$id] = wp_parse_args($rule, ULTICOCL_Matcher::defaults());
     1602        }
     1603        return $rules;
     1604    }
     1605
     1606    /**
     1607     * Add to a per-rule counter ('hits' = comments caught, 'restored' = caught by mistake).
     1608     */
     1609    private function add_hits($rule_id, $n, $field = 'hits') {
     1610        if ($n < 1 || '' === $rule_id) {
     1611            return;
     1612        }
     1613        $state = get_option('ulticocl_rule_state', array());
     1614        $current = isset($state[$rule_id]) ? $state[$rule_id] : array();
     1615        $state[$rule_id] = array_merge(array('hits' => 0, 'cursor' => 0, 'last_run' => 0), $current);
     1616        $state[$rule_id][$field] = (isset($state[$rule_id][$field]) ? (int) $state[$rule_id][$field] : 0) + (int) $n;
     1617        update_option('ulticocl_rule_state', $state, false);
     1618    }
     1619
     1620    /**
     1621     * Daily counters behind the dashboard charts, kept for 120 days.
     1622     *
     1623     * @param string $type    'new' (caught on arrival), 'scan' (caught among existing comments) or 'restored'.
     1624     * @param int    $n       How many.
     1625     * @param string $rule_id Rule responsible, if any.
     1626     */
     1627    private function record_stat($type, $n, $rule_id = '') {
     1628        if ($n < 1) {
     1629            return;
     1630        }
     1631        $stats = get_option('ulticocl_stats', array());
     1632        $stats = is_array($stats) ? $stats : array();
     1633        $day = current_time('Y-m-d');
     1634        $row = isset($stats['days'][$day]) ? $stats['days'][$day] : array();
     1635        $row[$type] = (isset($row[$type]) ? (int) $row[$type] : 0) + (int) $n;
     1636        if ('' !== $rule_id && 'restored' !== $type) {
     1637            $row['r'][$rule_id] = (isset($row['r'][$rule_id]) ? (int) $row['r'][$rule_id] : 0) + (int) $n;
     1638        }
     1639        $stats['days'][$day] = $row;
     1640        if (count($stats['days']) > 120) {
     1641            ksort($stats['days']);
     1642            $stats['days'] = array_slice($stats['days'], -120, null, true);
     1643        }
     1644        update_option('ulticocl_stats', $stats, false);
     1645    }
     1646
     1647    /**
     1648     * WHERE fragment limiting a rule scan to the comments it may touch: managed types,
     1649     * the chosen statuses (never trash or quarantine) and the minimum age.
     1650     */
     1651    private function rule_scope_where($rule) {
     1652        global $wpdb;
     1653
     1654        $statuses = array();
     1655        foreach ((array) $rule['status'] as $status) {
     1656            if (in_array($status, ULTICOCL_Matcher::RULE_STATUSES, true)) {
     1657                $statuses[] = self::STATUS_MAP[$status];
     1658            }
     1659        }
     1660        if (!$statuses) {
     1661            $statuses = array('1', '0', 'spam');
     1662        }
     1663        $where = $this->types_sql() . ' AND ' . $wpdb->prepare('comment_approved IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')', $statuses);
     1664        if (!empty($rule['older_than'])) {
     1665            $where .= $wpdb->prepare(' AND comment_date_gmt < %s', gmdate('Y-m-d H:i:s', time() - absint($rule['older_than']) * DAY_IN_SECONDS));
     1666        }
     1667        // Comments restored from quarantine are marked as fine and never caught again.
     1668        $where .= " AND NOT EXISTS (SELECT 1 FROM $wpdb->commentmeta ok WHERE ok.comment_id = $wpdb->comments.comment_ID AND ok.meta_key = '_ulticocl_ok')";
     1669        return $where;
     1670    }
     1671
     1672    private function count_scope($rule) {
     1673        global $wpdb;
     1674        $where = $this->rule_scope_where($rule);
     1675        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in rule_scope_where()
     1676        return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where");
     1677    }
     1678
     1679    /**
     1680     * Check one chunk of existing comments against a rule and, unless $dry, apply its action.
     1681     *
     1682     * ponytail: comments are matched in PHP (regex, Unicode scripts) in chunks of BATCH_SIZE by ID.
     1683     * A full pass over ~100k comments takes a few cron runs; add a SQL pre-filter if that is too slow.
     1684     *
     1685     * @return array|WP_Error scanned, matched, acted, cursor, done, samples, ids
     1686     */
     1687    private function scan_rule($rule, $cursor, $limit, $dry, $rule_id = '') {
     1688        global $wpdb;
     1689
     1690        $rule = wp_parse_args($rule, ULTICOCL_Matcher::defaults());
     1691        if (!ULTICOCL_Matcher::scope_is_safe($rule)) {
     1692            return new WP_Error('unsafe', __('This rule has no checks and no minimum age, so it was skipped to protect your comments. Edit the rule to continue.', 'ultimate-comment-cleaner'));
     1693        }
     1694
     1695        $where = $this->rule_scope_where($rule);
     1696        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in rule_scope_where()
     1697        $rows = $wpdb->get_results($wpdb->prepare("SELECT comment_ID, comment_post_ID, comment_author, comment_author_email, comment_author_url, comment_content, comment_approved, user_id FROM $wpdb->comments WHERE comment_ID > %d AND $where ORDER BY comment_ID LIMIT %d", $cursor, $limit));
     1698
     1699        $result = array('scanned' => count($rows), 'matched' => 0, 'acted' => 0, 'cursor' => $cursor, 'done' => count($rows) < $limit, 'samples' => array(), 'ids' => array());
     1700
     1701        if (!$dry) {
     1702            wp_defer_comment_counting(true);
     1703        }
     1704        foreach ($rows as $row) {
     1705            $result['cursor'] = (int) $row->comment_ID;
     1706            if ($this->is_protected($row)) {
     1707                continue;
     1708            }
     1709            $reason = ULTICOCL_Matcher::evaluate($rule, $row);
     1710            if (false === $reason) {
     1711                continue;
     1712            }
     1713            if ('' === $reason) {
     1714                $reason = __('Matched the age and status of the rule', 'ultimate-comment-cleaner');
     1715            }
     1716            $result['matched']++;
     1717            if (count($result['samples']) < 5) {
     1718                $result['samples'][] = array(
     1719                    'author'  => $row->comment_author,
     1720                    'excerpt' => wp_trim_words(wp_strip_all_tags($row->comment_content), 18),
     1721                    'reason'  => $reason,
     1722                );
     1723            }
     1724            if ($dry) {
     1725                $result['ids'][] = (int) $row->comment_ID;
     1726            } elseif ($this->apply_rule_action($row, $rule, $reason, null, $rule_id)) {
     1727                $result['acted']++;
     1728            }
     1729        }
     1730        if (!$dry) {
     1731            wp_defer_comment_counting(false);
     1732        }
     1733
     1734        return $result;
     1735    }
     1736
     1737    private function apply_rule_action($comment, $rule, $reason, $previous_status = null, $rule_id = '') {
     1738        switch ($rule['action']) {
     1739            case 'delete':
     1740                return (bool) wp_delete_comment((int) $comment->comment_ID, true);
     1741            case 'spam':
     1742                return 'spam' === (string) $comment->comment_approved ? false : (bool) wp_spam_comment((int) $comment->comment_ID);
     1743            default:
     1744                return $this->quarantine($comment, $rule['name'], $reason, $previous_status, $rule_id);
     1745        }
     1746    }
     1747
     1748    /**
     1749     * Commenters rules must never touch: moderators and the allowlist from Settings.
     1750     *
     1751     * @param object|array $comment Comment row or comment data.
     1752     */
     1753    private function is_protected($comment) {
     1754        static $moderators = array();
     1755        $c = (array) $comment;
     1756
     1757        $user_id = isset($c['user_id']) ? (int) $c['user_id'] : 0;
     1758        if ($user_id) {
     1759            if (!isset($moderators[$user_id])) {
     1760                $moderators[$user_id] = user_can($user_id, 'moderate_comments');
     1761            }
     1762            if ($moderators[$user_id]) {
     1763                return true;
     1764            }
     1765        }
     1766
     1767        $email = isset($c['comment_author_email']) ? strtolower(trim($c['comment_author_email'])) : '';
     1768        if ('' === $email) {
     1769            return false;
     1770        }
     1771        foreach (preg_split('/\r\n|\r|\n/', strtolower($this->get_settings()['allowlist']), -1, PREG_SPLIT_NO_EMPTY) as $entry) {
     1772            $entry = trim($entry);
     1773            if ('' === $entry) {
     1774                continue;
     1775            }
     1776            if ($entry === $email || ('@' === $entry[0] && substr($email, -strlen($entry)) === $entry)) {
     1777                return true;
     1778            }
     1779        }
     1780        return false;
     1781    }
     1782
     1783    // ---------------------------------------------------------------------
     1784    // New comments
     1785    // ---------------------------------------------------------------------
     1786
     1787    /**
     1788     * Filter on pre_comment_approved: hold back a new comment that a rule flags.
     1789     * It is saved as spam first, which keeps notification emails quiet, then
     1790     * after_new_comment() applies the rule's real action.
     1791     */
     1792    public function screen_new_comment($approved, $commentdata) {
     1793        $this->pending = null;
     1794
     1795        if (is_wp_error($approved) || 'spam' === $approved || !is_array($commentdata)) {
     1796            return $approved;
     1797        }
     1798        $type = isset($commentdata['comment_type']) ? $commentdata['comment_type'] : '';
     1799        if ('' !== $type && !in_array($type, self::TYPES, true)) {
     1800            return $approved;
     1801        }
     1802        if ($this->is_protected($commentdata)) {
     1803            return $approved;
     1804        }
     1805
     1806        foreach ($this->get_rules() as $id => $rule) {
     1807            if (empty($rule['active']) || 'existing' === $rule['scope'] || !ULTICOCL_Matcher::has_signals($rule)) {
     1808                continue;
     1809            }
     1810            $reason = ULTICOCL_Matcher::evaluate($rule, $commentdata);
     1811            if (false !== $reason) {
     1812                $this->pending = array($id, $rule, $reason);
     1813                return 'spam';
     1814            }
     1815        }
     1816        return $approved;
     1817    }
     1818
     1819    /**
     1820     * Action comment_post: finish handling a comment that screen_new_comment() flagged.
     1821     */
     1822    public function after_new_comment($comment_id) {
     1823        if (null === $this->pending) {
     1824            return;
     1825        }
     1826        list($id, $rule, $reason) = $this->pending;
     1827        $this->pending = null;
     1828
     1829        $comment = get_comment((int) $comment_id);
     1830        if (!$comment) {
     1831            return;
     1832        }
     1833        // Deleting now would pull the comment out from under core and other plugins that are
     1834        // still handling it. It is already saved as spam, so it stays hidden until shutdown.
     1835        if ('delete' === $rule['action']) {
     1836            if (!$this->deferred_deletes) {
     1837                add_action('shutdown', array($this, 'delete_deferred_comments'));
     1838            }
     1839            $this->deferred_deletes[] = (int) $comment_id;
     1840            $this->add_hits($id, 1);
     1841            $this->record_stat('new', 1, $id);
     1842            return;
     1843        }
     1844        // A new comment would have waited for moderation, so that is where a restore puts it back.
     1845        if ($this->apply_rule_action($comment, $rule, $reason, '0', $id) || 'spam' === $rule['action']) {
     1846            $this->add_hits($id, 1);
     1847            $this->record_stat('new', 1, $id);
     1848        }
     1849    }
     1850
     1851    // ---------------------------------------------------------------------
     1852    // Quarantine
     1853    // ---------------------------------------------------------------------
     1854
     1855    /**
     1856     * Action shutdown: delete new comments that a "delete" rule flagged.
     1857     */
     1858    public function delete_deferred_comments() {
     1859        foreach ($this->deferred_deletes as $comment_id) {
     1860            wp_delete_comment($comment_id, true);
     1861        }
     1862        $this->deferred_deletes = array();
     1863    }
     1864
     1865    private function status_name($status) {
     1866        $names = array('1' => 'approved', '0' => 'unapproved');
     1867        return isset($names[$status]) ? $names[$status] : $status;
     1868    }
     1869
     1870    /**
     1871     * Change comment_approved to any value, including our own quarantine status that
     1872     * the core status functions refuse. Fires the usual transition hooks.
     1873     */
     1874    private function set_status($comment, $new) {
     1875        global $wpdb;
     1876
     1877        $comment = get_comment($comment);
     1878        if (!$comment) {
     1879            return false;
     1880        }
     1881        $old = (string) $comment->comment_approved;
     1882        if ($old === $new) {
     1883            return true;
     1884        }
     1885        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core cannot set a custom comment status.
     1886        if (false === $wpdb->update($wpdb->comments, array('comment_approved' => $new), array('comment_ID' => (int) $comment->comment_ID))) {
     1887            return false;
     1888        }
     1889        clean_comment_cache((int) $comment->comment_ID);
     1890        wp_update_comment_count((int) $comment->comment_post_ID);
     1891        wp_transition_comment_status($this->status_name($new), $this->status_name($old), get_comment((int) $comment->comment_ID));
     1892        return true;
     1893    }
     1894
     1895    /**
     1896     * Hide a comment in quarantine, remembering why and what it was before.
     1897     */
     1898    private function quarantine($comment, $source, $reason, $previous_status = null, $rule_id = '') {
     1899        $comment = get_comment($comment);
     1900        if (!$comment || self::QUARANTINE === (string) $comment->comment_approved) {
     1901            return false;
     1902        }
     1903        $previous = null === $previous_status ? (string) $comment->comment_approved : $previous_status;
     1904        if (!$this->set_status($comment, self::QUARANTINE)) {
     1905            return false;
     1906        }
     1907        update_comment_meta((int) $comment->comment_ID, '_ulticocl_q_time', time());
     1908        update_comment_meta((int) $comment->comment_ID, '_ulticocl_q', array('prev' => $previous, 'source' => $source, 'reason' => $reason, 'rule' => $rule_id));
     1909        return true;
     1910    }
     1911
     1912    private function restore($comment) {
     1913        $comment = get_comment($comment);
     1914        if (!$comment || self::QUARANTINE !== (string) $comment->comment_approved) {
     1915            return false;
     1916        }
     1917        $info = get_comment_meta((int) $comment->comment_ID, '_ulticocl_q', true);
     1918        $previous = is_array($info) && isset($info['prev']) ? (string) $info['prev'] : '0';
     1919        if (!in_array($previous, array('1', '0', 'spam'), true)) {
     1920            $previous = '0';
     1921        }
     1922        if (!$this->set_status($comment, $previous)) {
     1923            return false;
     1924        }
     1925        $this->clear_quarantine_meta((int) $comment->comment_ID);
     1926        // The admin just said this one is fine, so scheduled rules must not catch it again.
     1927        update_comment_meta((int) $comment->comment_ID, '_ulticocl_ok', 1);
     1928        // A restore means the rule caught a good comment: count it against the rule's accuracy.
     1929        if (is_array($info) && !empty($info['rule'])) {
     1930            $this->add_hits($info['rule'], 1, 'restored');
     1931        }
     1932        $this->record_stat('restored', 1);
     1933        return true;
     1934    }
     1935
     1936    private function clear_quarantine_meta($comment_id) {
     1937        delete_comment_meta($comment_id, '_ulticocl_q');
     1938        delete_comment_meta($comment_id, '_ulticocl_q_time');
     1939    }
     1940
     1941    /**
     1942     * Delete quarantined comments older than the retention set in Settings (daily cron).
     1943     */
     1944    public function purge_quarantine() {
     1945        $days = (int) $this->get_settings()['quarantine_days'];
     1946        if ($days < 1) {
     1947            return;
     1948        }
     1949        $cutoff = time() - $days * DAY_IN_SECONDS;
     1950        $start = time();
     1951        $purged = 0;
     1952        do {
     1953            $ids = get_comments(array(
     1954                'status'     => self::QUARANTINE,
     1955                'fields'     => 'ids',
     1956                'number'     => self::BATCH_SIZE,
     1957                'orderby'    => 'comment_ID',
     1958                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Small, daily, batched query on the quarantine only.
     1959                'meta_query' => array(array('key' => '_ulticocl_q_time', 'value' => $cutoff, 'compare' => '<', 'type' => 'NUMERIC')),
    10481960            ));
    1049             if (!empty($post_ids)) {
    1050                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    1051             } else {
    1052                 // No posts found, so no comments - will export empty array
    1053             }
    1054         }
    1055        
    1056         // Additional filters
    1057         if (!empty($filters['has_url'])) {
    1058             $where[] = "comment_author_url != ''";
    1059         }
    1060         if (!empty($filters['no_url'])) {
    1061             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    1062         }
    1063        
    1064         // Only query if we have valid where conditions
    1065         if (!empty($where)) {
    1066             $where_clause = implode(' AND ', $where);
    1067             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    1068             $comments = $wpdb->get_results("SELECT * FROM $wpdb->comments WHERE $where_clause");
    1069         }
    1070        
    1071         header('Content-Type: text/csv');
    1072         header('Content-Disposition: attachment; filename="comments-export-' . gmdate('Y-m-d') . '.csv"');
    1073        
    1074         $output = fopen('php://output', 'w');
    1075         fputcsv($output, array('ID', 'Post ID', 'Author', 'Email', 'URL', 'Content', 'Date', 'Status'));
    1076        
    1077         foreach ($comments as $comment) {
    1078             fputcsv($output, array(
    1079                 $comment->comment_ID,
    1080                 $comment->comment_post_ID,
    1081                 $comment->comment_author,
    1082                 $comment->comment_author_email,
    1083                 $comment->comment_author_url,
    1084                 $comment->comment_content,
    1085                 $comment->comment_date,
    1086                 $comment->comment_approved
    1087             ));
    1088         }
    1089        
    1090         if (is_resource($output)) {
    1091             // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output is a special stream, not a file
    1092             fclose($output);
    1093         }
     1961            wp_defer_comment_counting(true);
     1962            foreach ($ids as $id) {
     1963                if (wp_delete_comment((int) $id, true)) {
     1964                    $purged++;
     1965                }
     1966            }
     1967            wp_defer_comment_counting(false);
     1968        } while (count($ids) === self::BATCH_SIZE && time() - $start < self::CRON_BUDGET);
     1969
     1970        if ($purged > 0) {
     1971            /* translators: 1: number of comments, 2: number of days */
     1972            $this->log_activity(sprintf(_n('Deleted %1$d quarantined comment older than %2$d days', 'Deleted %1$d quarantined comments older than %2$d days', $purged, 'ultimate-comment-cleaner'), $purged, $days));
     1973        }
     1974    }
     1975
     1976    // ---------------------------------------------------------------------
     1977    // Comment list actions (row links and bulk actions)
     1978    // ---------------------------------------------------------------------
     1979
     1980    /**
     1981     * Apply one action to one comment. Only managed comment types can be touched.
     1982     */
     1983    private function comment_action($id, $action) {
     1984        $comment = get_comment((int) $id);
     1985        if (!$comment || !in_array($comment->comment_type, array_merge(self::TYPES, array('')), true)) {
     1986            return false;
     1987        }
     1988        $id = (int) $comment->comment_ID;
     1989        $quarantined = self::QUARANTINE === (string) $comment->comment_approved;
     1990
     1991        switch ($action) {
     1992            case 'approve':
     1993                $ok = $quarantined ? $this->set_status($comment, '1') : wp_set_comment_status($id, 'approve');
     1994                break;
     1995            case 'unapprove':
     1996                $ok = $quarantined ? $this->set_status($comment, '0') : wp_set_comment_status($id, 'hold');
     1997                break;
     1998            case 'spam':
     1999                $ok = $quarantined ? $this->set_status($comment, 'spam') : wp_spam_comment($id);
     2000                break;
     2001            case 'quarantine':
     2002                return $this->quarantine($comment, __('Moved manually', 'ultimate-comment-cleaner'), '');
     2003            case 'restore':
     2004                return $this->restore($comment);
     2005            case 'delete':
     2006                return (bool) wp_delete_comment($id, true);
     2007            default:
     2008                return false;
     2009        }
     2010        if ($ok && $quarantined) {
     2011            $this->clear_quarantine_meta($id);
     2012        }
     2013        return (bool) $ok;
     2014    }
     2015
     2016    /**
     2017     * admin_init: handle a row link or a bulk action from the comment lists.
     2018     */
     2019    public function handle_comment_actions() {
     2020        // phpcs:disable WordPress.Security.NonceVerification -- Both paths below call check_admin_referer().
     2021        if (!isset($_GET['page']) || 'ultimate-comment-cleaner' !== $_GET['page']) {
     2022            return;
     2023        }
     2024
     2025        $ids = array();
     2026        $action = '';
     2027        if (!empty($_GET['ulticocl_do']) && isset($_GET['c'])) {
     2028            $action = sanitize_key(wp_unslash($_GET['ulticocl_do']));
     2029            $ids = array(absint($_GET['c']));
     2030            check_admin_referer('ulticocl_row_' . $action . '_' . $ids[0]);
     2031        } elseif (isset($_SERVER['REQUEST_METHOD']) && 'POST' === $_SERVER['REQUEST_METHOD'] && isset($_POST['ids']) && is_array($_POST['ids'])) {
     2032            foreach (array('action', 'action2') as $key) {
     2033                if (empty($action) && !empty($_POST[$key]) && '-1' !== $_POST[$key]) {
     2034                    $action = sanitize_key(wp_unslash($_POST[$key]));
     2035                }
     2036            }
     2037            check_admin_referer('bulk-ulticocl_comments');
     2038            $ids = array_filter(array_map('absint', wp_unslash($_POST['ids'])));
     2039        } else {
     2040            return;
     2041        }
     2042        // phpcs:enable WordPress.Security.NonceVerification
     2043
     2044        if (!current_user_can('manage_options')) {
     2045            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner'));
     2046        }
     2047
     2048        $url = remove_query_arg(array('ulticocl_do', 'c', '_wpnonce', '_wp_http_referer', 'action', 'action2', 'ulticocl_notice', 'ulticocl_n', 'ulticocl_a'), isset($_POST['_wp_http_referer']) ? wp_unslash($_POST['_wp_http_referer']) : (isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification -- Only used as a redirect target after wp_safe_redirect() validation.
     2049
     2050        if (in_array($action, self::COMMENT_ACTIONS, true) && $ids) {
     2051            $done = 0;
     2052            wp_defer_comment_counting(true);
     2053            foreach ($ids as $id) {
     2054                if ($this->comment_action($id, $action)) {
     2055                    $done++;
     2056                }
     2057            }
     2058            wp_defer_comment_counting(false);
     2059            $url = add_query_arg(array('ulticocl_notice' => 1, 'ulticocl_n' => $done, 'ulticocl_a' => $action), $url);
     2060        }
     2061
     2062        wp_safe_redirect($url ? $url : admin_url('tools.php?page=ultimate-comment-cleaner&tab=manage'));
    10942063        exit;
    10952064    }
    1096    
    1097     public function check_triggers() {
    1098         $triggers = get_option('ulticocl_triggers', array());
    1099        
    1100         foreach ($triggers as $id => $trigger) {
    1101             if (!$trigger['active']) continue;
    1102            
    1103             $last_run = isset($trigger['last_run']) ? strtotime($trigger['last_run']) : 0;
    1104             $schedule_interval = $this->get_schedule_interval($trigger['schedule']);
    1105            
    1106             if (time() - $last_run >= $schedule_interval) {
    1107                 $this->execute_trigger($id, $trigger);
    1108             }
    1109         }
    1110     }
    1111    
    1112     private function execute_trigger($trigger_id, $trigger) {
    1113         global $wpdb;
    1114        
    1115         $where = array("comment_type = 'comment'");
    1116        
    1117         if ($trigger['status'] === 'approved') {
    1118             $where[] = "comment_approved = '1'";
    1119         } elseif ($trigger['status'] === 'pending') {
    1120             $where[] = "comment_approved = '0'";
    1121         } elseif ($trigger['status'] === 'spam') {
    1122             $where[] = "comment_approved = 'spam'";
    1123         }
    1124        
    1125         if (!empty($trigger['older_than'])) {
    1126             $date = gmdate('Y-m-d H:i:s', strtotime('-' . intval($trigger['older_than']) . ' days'));
    1127             $where[] = $wpdb->prepare("comment_date < %s", $date);
    1128         }
    1129        
    1130         $where_clause = implode(' AND ', $where);
    1131        
    1132         // Get comment IDs before deletion
    1133         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    1134         $comment_ids = $wpdb->get_col("SELECT comment_ID FROM $wpdb->comments WHERE $where_clause");
    1135        
    1136         if (empty($comment_ids)) {
     2065
     2066    // ---------------------------------------------------------------------
     2067    // Scheduled runs
     2068    // ---------------------------------------------------------------------
     2069
     2070    /**
     2071     * Hourly cron: continue or start scans for rules that apply to existing comments.
     2072     */
     2073    public function run_scheduled_rules() {
     2074        $rules = $this->get_rules();
     2075        $state = get_option('ulticocl_rule_state', array());
     2076        $start = time();
     2077
     2078        foreach ($rules as $id => $rule) {
     2079            if (empty($rule['active']) || 'new' === $rule['scope'] || !ULTICOCL_Matcher::scope_is_safe($rule)) {
     2080                continue;
     2081            }
     2082            if (time() - $start >= self::CRON_BUDGET) {
     2083                break;
     2084            }
     2085            $st = wp_parse_args(isset($state[$id]) ? $state[$id] : array(), array('hits' => 0, 'cursor' => 0, 'last_run' => 0));
     2086            // A scan in progress carries on; otherwise wait for the schedule (5 minutes of slack for hourly cron drift).
     2087            if (0 === (int) $st['cursor'] && time() - (int) $st['last_run'] < $this->get_schedule_interval($rule['schedule']) - 5 * MINUTE_IN_SECONDS) {
     2088                continue;
     2089            }
     2090
     2091            $acted = 0;
     2092            do {
     2093                $result = $this->scan_rule($rule, (int) $st['cursor'], self::BATCH_SIZE, false, $id);
     2094                if (is_wp_error($result)) {
     2095                    break;
     2096                }
     2097                $st['cursor'] = $result['done'] ? 0 : $result['cursor'];
     2098                $acted += $result['acted'];
     2099            } while (!$result['done'] && time() - $start < self::CRON_BUDGET);
     2100
     2101            if (!is_wp_error($result) && $result['done']) {
     2102                $st['last_run'] = time();
     2103            }
     2104            $st['hits'] += $acted;
     2105            $state[$id] = $st;
     2106            $this->record_stat('scan', $acted, $id);
     2107
     2108            if ($acted > 0) {
     2109                /* translators: 1: rule name, 2: result sentence */
     2110                $this->log_activity(sprintf(__('Rule "%1$s": %2$s', 'ultimate-comment-cleaner'), $rule['name'], $this->action_message($rule['action'], $acted)));
     2111                $this->send_email_notification($acted, $rule['name'], $rule['action']);
     2112            }
     2113        }
     2114        update_option('ulticocl_rule_state', $state, false);
     2115    }
     2116
     2117    public function get_settings() {
     2118        return wp_parse_args(get_option('ulticocl_settings', array()), array(
     2119            'enable_logging' => 1,
     2120            'send_email_notifications' => 0,
     2121            'notification_email' => get_option('admin_email'),
     2122            'quarantine_days' => 14,
     2123            'allowlist' => '',
     2124        ));
     2125    }
     2126
     2127    private function send_email_notification($count, $source = 'manual', $action = 'delete') {
     2128        $settings = $this->get_settings();
     2129
     2130        if (empty($settings['send_email_notifications']) || $count < 1) {
    11372131            return;
    11382132        }
    1139        
    1140         $deleted = 0;
    1141         // Use WordPress function to properly delete comments
    1142         foreach ($comment_ids as $comment_id) {
    1143             if (wp_delete_comment($comment_id, true)) {
    1144                 $deleted++;
    1145             }
    1146         }
    1147        
    1148         // Update trigger last run
    1149         $triggers = get_option('ulticocl_triggers', array());
    1150         $triggers[$trigger_id]['last_run'] = current_time('mysql');
    1151         update_option('ulticocl_triggers', $triggers);
    1152        
    1153         // Clear caches
    1154         wp_cache_flush();
    1155         delete_transient('ulticocl_comments_count');
    1156        
    1157         // translators: %1$s: trigger name, %2$d: number of comments deleted
    1158         $this->log_activity(sprintf(__('Trigger "%1$s" deleted %2$d comments', 'ultimate-comment-cleaner'), $trigger['name'], $deleted));
    1159         $this->send_email_notification($deleted, $trigger['name']);
    1160     }
    1161    
    1162     private function send_email_notification($count, $source = 'manual') {
    1163         $settings = get_option('ulticocl_settings', array());
    1164        
    1165         // Only send if notifications are enabled
    1166         if (!isset($settings['send_email_notifications']) || !$settings['send_email_notifications']) {
    1167             return;
    1168         }
    1169        
    1170         // Get notification email address
    1171         $email = isset($settings['notification_email']) && !empty($settings['notification_email'])
    1172             ? $settings['notification_email']
    1173             : get_option('admin_email');
    1174        
     2133
     2134        $email = !empty($settings['notification_email']) ? $settings['notification_email'] : get_option('admin_email');
     2135
    11752136        if (empty($email) || !is_email($email)) {
    11762137            return;
    11772138        }
    1178        
    1179         // Prepare email
     2139
    11802140        // translators: %s: Site name.
    1181         $subject = sprintf(__('[%s] Comments Deleted', 'ultimate-comment-cleaner'), get_bloginfo('name'));
    1182        
    1183         // translators: %d: Number of comments deleted.
    1184         $message = sprintf(
    1185             // translators: %d: Number of comments deleted.
    1186             __('%d comment(s) have been deleted from your website.', 'ultimate-comment-cleaner'),
    1187             $count
     2141        $subject = sprintf(__('[%s] Comments Cleaned', 'ultimate-comment-cleaner'), get_bloginfo('name'));
     2142
     2143        $sentences = array(
     2144            /* translators: %d: number of comments */
     2145            'delete'     => _n('%d comment has been deleted from your website.', '%d comments have been deleted from your website.', $count, 'ultimate-comment-cleaner'),
     2146            /* translators: %d: number of comments */
     2147            'quarantine' => _n('%d comment has been moved to quarantine on your website.', '%d comments have been moved to quarantine on your website.', $count, 'ultimate-comment-cleaner'),
     2148            /* translators: %d: number of comments */
     2149            'spam'       => _n('%d comment has been marked as spam on your website.', '%d comments have been marked as spam on your website.', $count, 'ultimate-comment-cleaner'),
    11882150        );
    1189        
     2151        $message = sprintf(isset($sentences[$action]) ? $sentences[$action] : $sentences['delete'], $count);
     2152
    11902153        if ($source !== 'manual') {
    1191             // translators: %s: trigger name
    1192             $message .= "\n\n" . sprintf(__('Source: Auto Trigger "%s"', 'ultimate-comment-cleaner'), $source);
     2154            // translators: %s: rule name
     2155            $message .= "\n\n" . sprintf(__('Source: Smart Rule "%s"', 'ultimate-comment-cleaner'), $source);
    11932156        } else {
    1194             $message .= "\n\n" . __('Source: Manual deletion', 'ultimate-comment-cleaner');
    1195         }
    1196        
     2157            $message .= "\n\n" . __('Source: Manual clean-up', 'ultimate-comment-cleaner');
     2158        }
     2159
    11972160        // translators: %s: current time
    11982161        $message .= "\n\n" . sprintf(__('Time: %s', 'ultimate-comment-cleaner'), current_time('mysql'));
    11992162        $message .= "\n\n" . __('This is an automated notification from Ultimate Comment Cleaner plugin.', 'ultimate-comment-cleaner');
    1200        
    1201         // Send email
     2163
    12022164        wp_mail($email, $subject, $message);
    12032165    }
    1204    
     2166
    12052167    private function get_schedule_interval($schedule) {
    12062168        switch ($schedule) {
    … …  
    12082170            case 'daily': return DAY_IN_SECONDS;
    12092171            case 'weekly': return WEEK_IN_SECONDS;
    1210             default: return HOUR_IN_SECONDS;
    1211         }
    1212     }
    1213    
     2172            default: return DAY_IN_SECONDS;
     2173        }
     2174    }
     2175
    12142176    private function log_activity($message) {
    1215         $settings = get_option('ulticocl_settings', array());
    1216        
    1217         // Only log if logging is enabled
    1218         if (isset($settings['enable_logging']) && $settings['enable_logging']) {
     2177        $settings = $this->get_settings();
     2178
     2179        if (!empty($settings['enable_logging'])) {
    12192180            $activities = get_option('ulticocl_activity_log', array());
    12202181            $activities[] = array(
    … …  
    12222183                'message' => $message
    12232184            );
    1224             update_option('ulticocl_activity_log', array_slice($activities, -100)); // Keep last 100
    1225         }
    1226     }
    1227    
     2185            update_option('ulticocl_activity_log', array_slice($activities, -100), false); // Keep last 100
     2186        }
     2187    }
     2188
     2189    /**
     2190     * Save settings from $_POST. Callers must verify the nonce and capability first.
     2191     */
     2192    private function save_settings_from_post() {
     2193        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified by callers.
     2194        $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
     2195        $current = $this->get_settings();
     2196
     2197        // One email address or @domain per line.
     2198        $allowlist = array();
     2199        if (isset($_POST['allowlist'])) {
     2200            foreach (preg_split('/\r\n|\r|\n/', sanitize_textarea_field(wp_unslash($_POST['allowlist']))) as $line) {
     2201                $line = strtolower(trim($line));
     2202                if (is_email($line) || preg_match('/^@?[a-z0-9-]+(\.[a-z0-9-]+)+$/', $line)) {
     2203                    $allowlist[] = $line;
     2204                }
     2205            }
     2206        }
     2207
     2208        $settings = array(
     2209            'enable_logging' => empty($_POST['enable_logging']) ? 0 : 1,
     2210            'send_email_notifications' => empty($_POST['send_email_notifications']) ? 0 : 1,
     2211            'notification_email' => $notification_email ? $notification_email : get_option('admin_email'),
     2212            'quarantine_days' => isset($_POST['quarantine_days']) ? min(365, absint($_POST['quarantine_days'])) : (int) $current['quarantine_days'],
     2213            'allowlist' => isset($_POST['allowlist']) ? implode("\n", array_unique($allowlist)) : $current['allowlist'],
     2214        );
     2215        // phpcs:enable WordPress.Security.NonceVerification.Missing
     2216
     2217        update_option('ulticocl_settings', $settings);
     2218        $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
     2219    }
     2220
    12282221    public function handle_settings_save() {
    12292222        if (!isset($_POST['ulticocl_action']) || sanitize_text_field(wp_unslash($_POST['ulticocl_action'])) !== 'save_settings') {
    12302223            return;
    12312224        }
    1232        
     2225
    12332226        if (!current_user_can('manage_options')) {
    12342227            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner'));
    12352228        }
    1236        
     2229
    12372230        if (!isset($_POST['ulticocl_settings_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ulticocl_settings_nonce'])), 'ulticocl_save_settings')) {
    12382231            wp_die(esc_html__('Security check failed.', 'ultimate-comment-cleaner'));
    12392232        }
    1240        
    1241         $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
    1242         if (empty($notification_email)) {
    1243             $notification_email = get_option('admin_email');
    1244         }
    1245        
    1246         $settings = array(
    1247             'enable_logging' => isset($_POST['enable_logging']) ? 1 : 0,
    1248             'send_email_notifications' => isset($_POST['send_email_notifications']) ? 1 : 0,
    1249             'notification_email' => $notification_email
    1250         );
    1251        
    1252         update_option('ulticocl_settings', $settings);
    1253        
    1254         // Log settings update if logging is enabled (check before update)
    1255         $old_settings = get_option('rc_settings', array());
    1256         if (isset($old_settings['enable_logging']) && $old_settings['enable_logging']) {
    1257             $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
    1258         }
    1259        
    1260         // Redirect back to settings tab with success message
     2233
     2234        $this->save_settings_from_post();
     2235
    12612236        wp_safe_redirect(add_query_arg(array(
    12622237            'page' => 'ultimate-comment-cleaner',
    … …  
    12662241        exit;
    12672242    }
    1268    
     2243
    12692244    public function ajax_save_settings() {
    12702245        check_ajax_referer('ulticocl_nonce', 'nonce');
    1271        
     2246
    12722247        if (!current_user_can('manage_options')) {
    12732248            wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    12742249        }
    1275        
    1276         $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
    1277         if (empty($notification_email)) {
    1278             $notification_email = get_option('admin_email');
    1279         }
    1280        
    1281         $settings = array(
    1282             'enable_logging' => isset($_POST['enable_logging']) ? 1 : 0,
    1283             'send_email_notifications' => isset($_POST['send_email_notifications']) ? 1 : 0,
    1284             'notification_email' => $notification_email
    1285         );
    1286        
    1287         update_option('ulticocl_settings', $settings);
    1288        
    1289         // Log settings update if logging is enabled (check after update)
    1290         if ($settings['enable_logging']) {
    1291             $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
    1292         }
    1293        
     2250
     2251        $this->save_settings_from_post();
     2252
    12942253        wp_send_json_success(array('message' => __('Settings saved successfully.', 'ultimate-comment-cleaner')));
    12952254    }
    1296    
     2255
     2256    /**
     2257     * Upgrade data from earlier versions (runs once). Version 1.0 "Auto Triggers" become
     2258     * Smart Rules that delete exactly as before. A trigger that could have deleted every
     2259     * approved or pending comment is imported switched off.
     2260     */
     2261    public function maybe_upgrade() {
     2262        if (version_compare((string) get_option('ulticocl_db_version', '0'), '1.1.0', '>=')) {
     2263            return;
     2264        }
     2265
     2266        $triggers = get_option('ulticocl_triggers', array());
     2267        if (is_array($triggers) && $triggers && !get_option('ulticocl_rules')) {
     2268            $rules = array();
     2269            $state = array();
     2270            $disabled = 0;
     2271            foreach ($triggers as $old_id => $trigger) {
     2272                $status = isset($trigger['status']) && in_array($trigger['status'], ULTICOCL_Matcher::RULE_STATUSES, true) ? $trigger['status'] : 'approved';
     2273                $schedule = isset($trigger['schedule']) && in_array($trigger['schedule'], self::SCHEDULES, true) ? $trigger['schedule'] : 'daily';
     2274                $rule = array_merge(ULTICOCL_Matcher::defaults(), array(
     2275                    'name'       => !empty($trigger['name']) ? sanitize_text_field($trigger['name']) : __('Imported trigger', 'ultimate-comment-cleaner'),
     2276                    'active'     => !empty($trigger['active']),
     2277                    'scope'      => 'existing',
     2278                    'action'     => 'delete',
     2279                    'status'     => array($status),
     2280                    'older_than' => isset($trigger['older_than']) ? absint($trigger['older_than']) : 0,
     2281                    'schedule'   => $schedule,
     2282                    'created'    => !empty($trigger['created']) ? $trigger['created'] : current_time('mysql'),
     2283                ));
     2284                if ($rule['active'] && !ULTICOCL_Matcher::scope_is_safe($rule)) {
     2285                    $rule['active'] = false;
     2286                    $disabled++;
     2287                }
     2288                $id = '' === (string) $old_id ? uniqid('rule_') : sanitize_key($old_id);
     2289                $rules[$id] = $rule;
     2290                $state[$id] = array('hits' => 0, 'cursor' => 0, 'last_run' => !empty($trigger['last_run']) ? (int) get_gmt_from_date($trigger['last_run'], 'U') : 0);
     2291            }
     2292            update_option('ulticocl_rules', $rules, false);
     2293            update_option('ulticocl_rule_state', $state, false);
     2294            /* translators: %d: number of rules */
     2295            $this->log_activity(sprintf(_n('Imported %d Auto Trigger as a Smart Rule', 'Imported %d Auto Triggers as Smart Rules', count($rules), 'ultimate-comment-cleaner'), count($rules)));
     2296            if ($disabled) {
     2297                /* translators: %d: number of rules */
     2298                $this->log_activity(sprintf(_n('%d imported rule was switched off because it had no age limit. Review it in Smart Rules.', '%d imported rules were switched off because they had no age limit. Review them in Smart Rules.', $disabled, 'ultimate-comment-cleaner'), $disabled));
     2299            }
     2300        }
     2301        delete_option('ulticocl_triggers');
     2302        wp_clear_scheduled_hook('ulticocl_check_triggers');
     2303        update_option('ulticocl_db_version', '1.1.0');
     2304    }
     2305
    12972306    /**
    12982307     * Add settings link to plugin action links
    12992308     */
    13002309    public function add_plugin_action_links($links) {
    1301         $settings_link = '<a href="' . admin_url('tools.php?page=ultimate-comment-cleaner') . '">' . esc_html__('Settings', 'ultimate-comment-cleaner') . '</a>';
     2310        $settings_link = '<a href="' . esc_url(admin_url('tools.php?page=ultimate-comment-cleaner')) . '">' . esc_html__('Settings', 'ultimate-comment-cleaner') . '</a>';
    13022311        array_unshift($links, $settings_link);
    13032312        return $links;
    … …  
    13112320add_action('plugins_loaded', 'ulticocl_init');
    13122321
     2322// Stop the scheduled jobs when the plugin is deactivated.
     2323register_deactivation_hook(__FILE__, function () {
     2324    wp_clear_scheduled_hook('ulticocl_run_rules');
     2325    wp_clear_scheduled_hook('ulticocl_purge_quarantine');
     2326});
  • ultimate-comment-cleaner/trunk/assets/css/admin.css

    r3446581 r3752017  
    10431043}
    10441044
     1045/* ------------------------------------------------------------------ */
     1046/* Smart Rules, Quarantine and Manage Comments                         */
     1047/* ------------------------------------------------------------------ */
     1048
     1049.ulticocl-count {
     1050    display: inline-block;
     1051    min-width: 18px;
     1052    padding: 0 6px;
     1053    margin-left: 6px;
     1054    border-radius: 9px;
     1055    background: var(--remove-comments-warning);
     1056    color: #fff;
     1057    font-size: 11px;
     1058    line-height: 18px;
     1059    text-align: center;
     1060}
     1061
     1062.ulticocl-badge {
     1063    display: inline-block;
     1064    padding: 2px 8px;
     1065    border-radius: 3px;
     1066    background: var(--remove-comments-gray-200);
     1067    color: var(--remove-comments-gray-700);
     1068    font-size: 11px;
     1069    font-weight: 600;
     1070    line-height: 1.6;
     1071    text-transform: uppercase;
     1072    letter-spacing: 0.02em;
     1073}
     1074
     1075.ulticocl-badge-approved { background: #d1fae5; color: #065f46; }
     1076.ulticocl-badge-pending { background: #fef3c7; color: #92400e; }
     1077.ulticocl-badge-spam { background: #fee2e2; color: #991b1b; }
     1078.ulticocl-badge-quarantine,
     1079.ulticocl-badge-action-quarantine { background: #fef3c7; color: #92400e; }
     1080.ulticocl-badge-action-spam { background: #fee2e2; color: #991b1b; }
     1081.ulticocl-badge-action-delete { background: #1f2937; color: #fff; }
     1082
     1083.ulticocl-chip {
     1084    display: inline-block;
     1085    margin-left: 4px;
     1086    padding: 0 6px;
     1087    border: 1px solid var(--remove-comments-gray-300);
     1088    border-radius: 10px;
     1089    background: var(--remove-comments-gray-50);
     1090    color: var(--remove-comments-gray-600);
     1091    font-size: 11px;
     1092    line-height: 18px;
     1093}
     1094
     1095.ulticocl-callout {
     1096    margin: 0 0 24px;
     1097    padding: 20px 24px;
     1098    border-left: 4px solid var(--remove-comments-primary);
     1099    border-radius: 4px;
     1100    background: var(--remove-comments-gray-50);
     1101}
     1102
     1103.ulticocl-callout h2 { margin-top: 0; }
     1104
     1105/* Quick-start presets: one line that glides left and right with the pointer. */
     1106.ulticocl-presets {
     1107    display: flex;
     1108    align-items: center;
     1109    gap: 14px;
     1110    min-width: 0;
     1111    margin-bottom: 16px;
     1112}
     1113
     1114.ulticocl-presets-title {
     1115    flex: none;
     1116    color: var(--remove-comments-gray-700);
     1117    font-size: 13px;
     1118}
     1119
     1120.ulticocl-presets-viewport {
     1121    flex: 1;
     1122    min-width: 0;
     1123    padding: 5px 6px;
     1124    overflow-x: auto;
     1125    overflow-y: hidden;
     1126    scrollbar-width: none;
     1127    -ms-overflow-style: none;
     1128}
     1129
     1130.ulticocl-presets-viewport::-webkit-scrollbar { display: none; }
     1131
     1132/* Fade the edge that has more buttons behind it. */
     1133.ulticocl-presets-viewport.has-right {
     1134    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
     1135    mask-image: linear-gradient(to right, #000 calc(100% - 40px), transparent);
     1136}
     1137
     1138.ulticocl-presets-viewport.has-left {
     1139    -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 40px), transparent);
     1140    mask-image: linear-gradient(to left, #000 calc(100% - 40px), transparent);
     1141}
     1142
     1143.ulticocl-presets-viewport.has-left.has-right {
     1144    -webkit-mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
     1145    mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
     1146}
     1147
     1148.ulticocl-presets-track {
     1149    display: flex;
     1150    flex-wrap: nowrap;
     1151    gap: 8px;
     1152    width: max-content;
     1153}
     1154
     1155.ulticocl-preset {
     1156    display: inline-flex;
     1157    flex: none;
     1158    align-items: center;
     1159    gap: 8px;
     1160    height: 38px;
     1161    margin: 0;
     1162    padding: 0 14px 0 8px;
     1163    border: 1px solid var(--remove-comments-gray-300);
     1164    border-radius: 4px;
     1165    background: #fff;
     1166    color: var(--remove-comments-gray-700);
     1167    font-size: 13px;
     1168    font-weight: 500;
     1169    line-height: 1;
     1170    white-space: nowrap;
     1171    cursor: pointer;
     1172    box-shadow: 0 1px 2px rgba(17, 24, 39, 0.05);
     1173    transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
     1174}
     1175
     1176.ulticocl-preset .dashicons {
     1177    display: inline-flex;
     1178    align-items: center;
     1179    justify-content: center;
     1180    width: 24px;
     1181    height: 24px;
     1182    border-radius: 4px;
     1183    background: #eef2ff;
     1184    color: var(--remove-comments-primary);
     1185    font-size: 15px;
     1186    line-height: 1;
     1187    transition: background-color 0.15s ease, color 0.15s ease;
     1188}
     1189
     1190.ulticocl-preset:hover,
     1191.ulticocl-preset:focus-visible {
     1192    border-color: var(--remove-comments-primary);
     1193    background: #f7f7ff;
     1194    color: var(--remove-comments-primary-dark);
     1195    box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
     1196    outline: none;
     1197}
     1198
     1199.ulticocl-preset:hover .dashicons,
     1200.ulticocl-preset:focus-visible .dashicons {
     1201    background: var(--remove-comments-primary);
     1202    color: #fff;
     1203}
     1204
     1205.ulticocl-preset:active { transform: translateY(1px); }
     1206
     1207@media (max-width: 600px) {
     1208    .ulticocl-presets { flex-direction: column; align-items: flex-start; gap: 6px; }
     1209    .ulticocl-presets-viewport { width: 100%; }
     1210}
     1211
     1212.ulticocl-scan-result {
     1213    margin: 16px 0;
     1214    padding: 16px 20px;
     1215    border: 1px solid var(--remove-comments-gray-200);
     1216    border-left: 4px solid var(--remove-comments-info);
     1217    border-radius: 4px;
     1218    background: #fff;
     1219}
     1220
     1221.ulticocl-scan-result p:first-child { margin-top: 0; }
     1222.ulticocl-error { color: var(--remove-comments-danger); font-weight: 600; }
     1223.ulticocl-samples { margin: 8px 0 12px 18px; list-style: disc; }
     1224
     1225.ulticocl-progress {
     1226    height: 8px;
     1227    border-radius: 4px;
     1228    background: var(--remove-comments-gray-200);
     1229    overflow: hidden;
     1230}
     1231
     1232.ulticocl-progress > div {
     1233    height: 100%;
     1234    background: var(--remove-comments-primary);
     1235    transition: width 0.2s ease;
     1236}
     1237
     1238.ulticocl-progress-box {
     1239    position: fixed;
     1240    top: 50%;
     1241    left: 50%;
     1242    transform: translate(-50%, -50%);
     1243    z-index: 100001;
     1244    padding: 20px;
     1245    border: 2px solid var(--remove-comments-primary);
     1246    border-radius: 4px;
     1247    background: #fff;
     1248    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
     1249}
     1250
     1251.remove-comments-modal-content.ulticocl-wide { max-width: 760px; }
     1252
     1253.ulticocl-fieldset {
     1254    margin: 0 0 20px;
     1255    padding: 12px 16px 4px;
     1256    border: 1px solid var(--remove-comments-gray-200);
     1257    border-radius: 4px;
     1258}
     1259
     1260.ulticocl-fieldset legend {
     1261    padding: 0 6px;
     1262    font-weight: 600;
     1263    color: var(--remove-comments-gray-700);
     1264}
     1265
     1266.ulticocl-grid-2,
     1267.ulticocl-grid-3 {
     1268    display: grid;
     1269    gap: 0 16px;
     1270}
     1271
     1272.ulticocl-grid-2 { grid-template-columns: 1fr 1fr; }
     1273.ulticocl-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
     1274
     1275.remove-comments-modal-content textarea,
     1276.remove-comments-modal-content input[type="text"],
     1277.remove-comments-modal-content input[type="number"] {
     1278    width: 100%;
     1279    box-sizing: border-box;
     1280}
     1281
     1282.remove-comments-modal-content input.small-text { width: 70px; }
     1283.remove-comments-form-group input[type="number"].small-text { width: 90px; }
     1284
     1285.ulticocl-inline-checks {
     1286    display: flex;
     1287    flex-wrap: wrap;
     1288    align-items: center;
     1289    gap: 6px 16px;
     1290    margin-top: 8px;
     1291}
     1292
     1293.ulticocl-inline-checks > span {
     1294    color: var(--remove-comments-gray-600);
     1295    font-size: 13px;
     1296    font-weight: 600;
     1297}
     1298
     1299/* Small on/off switches in the brand colour (a real checkbox underneath). */
     1300.ulticocl-toggle {
     1301    display: inline-flex;
     1302    align-items: center;
     1303    gap: 8px;
     1304    margin: 0;
     1305    color: var(--remove-comments-gray-700);
     1306    font-size: 13px;
     1307    font-weight: 400;
     1308    line-height: 1.2;
     1309    cursor: pointer;
     1310    user-select: none;
     1311}
     1312
     1313.remove-comments-toggle-switch.ulticocl-mini { width: 32px; height: 18px; }
     1314.ulticocl-mini .remove-comments-toggle-slider { border-radius: 18px; }
     1315
     1316.ulticocl-mini .remove-comments-toggle-slider:before {
     1317    width: 12px;
     1318    height: 12px;
     1319    left: 3px;
     1320    bottom: 3px;
     1321    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
     1322}
     1323
     1324.ulticocl-mini input:checked + .remove-comments-toggle-slider:before { transform: translateX(14px); }
     1325.ulticocl-toggle:hover .remove-comments-toggle-slider { background-color: var(--remove-comments-gray-400); }
     1326.ulticocl-toggle:hover input:checked + .remove-comments-toggle-slider { background-color: var(--remove-comments-primary-dark); }
     1327
     1328/* A switch that sits in a row of fields lines up with the inputs beside it. */
     1329.ulticocl-field-toggle { display: flex; align-items: flex-end; }
     1330.ulticocl-field-toggle .ulticocl-toggle { height: var(--ulticocl-field, 40px); }
     1331/* Six tabs do not fit a phone: let the bar scroll sideways instead of overflowing the page. */
     1332@media (max-width: 782px) {
     1333    .remove-comments-tabs {
     1334        overflow-x: auto;
     1335        overflow-y: hidden;
     1336        scrollbar-width: thin;
     1337    }
     1338
     1339    .remove-comments-tab {
     1340        flex: none;
     1341        white-space: nowrap;
     1342    }
     1343}
     1344
     1345.ulticocl-filters {
     1346    display: flex;
     1347    flex-wrap: wrap;
     1348    align-items: center;
     1349    gap: 8px 14px;
     1350    margin-bottom: 12px;
     1351}
     1352
     1353.ulticocl-filters input[type="search"] { min-width: 280px; }
     1354.ulticocl-filters label { display: inline-flex; align-items: center; gap: 4px; }
     1355
     1356.remove-comments-comments-panel .subsubsub { float: none; margin: 0 0 8px; }
     1357.remove-comments-comments-panel .wp-list-table .column-cb { width: 2.2em; }
     1358.remove-comments-comments-panel .wp-list-table .column-author { width: 18%; }
     1359.remove-comments-comments-panel .wp-list-table .column-date { width: 130px; }
     1360
     1361@media (max-width: 768px) {
     1362    .ulticocl-grid-2,
     1363    .ulticocl-grid-3 { grid-template-columns: 1fr; }
     1364    .ulticocl-filters input[type="search"] { min-width: 0; width: 100%; }
     1365}
     1366
     1367/* One field height. WordPress gives selects extra height (53px next to 40px inputs), so every
     1368   input and select in the plugin shares one box and rows line up. */
     1369.remove-comments-wrap { --ulticocl-field: 40px; }
     1370
     1371.remove-comments-wrap .remove-comments-select,
     1372.remove-comments-wrap .remove-comments-form-group select,
     1373.remove-comments-wrap .remove-comments-form-group input[type="text"],
     1374.remove-comments-wrap .remove-comments-form-group input[type="email"],
     1375.remove-comments-wrap .remove-comments-form-group input[type="number"]:not(.small-text),
     1376.remove-comments-wrap input.remove-comments-form-input {
     1377    box-sizing: border-box;
     1378    height: var(--ulticocl-field);
     1379    min-height: 0;
     1380    margin: 0;
     1381    padding-top: 0;
     1382    padding-bottom: 0;
     1383    line-height: 1.3;
     1384}
     1385
     1386/* The inline percentage field sits inside a sentence, so it stays compact. */
     1387.remove-comments-wrap .remove-comments-form-group input[type="number"].small-text {
     1388    box-sizing: border-box;
     1389    width: 80px;
     1390    height: 34px;
     1391    padding: 0 10px;
     1392}
     1393
     1394/* ===================================================================== */
     1395/* Design pass: one set of tokens for every panel, matching the dashboard */
     1396/* Controls use a 4px radius, surfaces (cards, tables, dialogs) 12px.     */
     1397/* Spacing follows a 4/8px rhythm.                                        */
     1398/* ===================================================================== */
     1399
     1400.remove-comments-wrap {
     1401    --ulticocl-field: 40px;
     1402    --uc-radius: 4px;
     1403    --uc-radius-lg: 12px;
     1404    --uc-line: #e5e7eb;
     1405    --uc-line-soft: #f3f4f6;
     1406    --uc-ink: #1f2937;
     1407    --uc-ink-2: #4b5563;
     1408    --uc-muted: #6b7280;
     1409    --uc-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
     1410    --uc-shadow-lg: 0 16px 40px rgba(17, 24, 39, 0.18);
     1411    --uc-focus: 0 0 0 3px rgba(99, 102, 241, 0.2);
     1412}
     1413
     1414/* ---- Panel header ------------------------------------------------------- */
     1415
     1416.remove-comments-panel-header {
     1417    margin-bottom: 24px;
     1418    padding-bottom: 16px;
     1419    border-bottom: 1px solid var(--uc-line);
     1420}
     1421
     1422.remove-comments-panel-header h2 {
     1423    margin: 0 0 4px;
     1424    padding: 0;
     1425    color: var(--uc-ink);
     1426    font-size: 20px;
     1427    font-weight: 700;
     1428    letter-spacing: -0.01em;
     1429    line-height: 1.3;
     1430}
     1431
     1432.remove-comments-panel-header p {
     1433    max-width: 72ch;
     1434    margin: 0;
     1435    color: var(--uc-muted);
     1436    font-size: 13px;
     1437    line-height: 1.55;
     1438}
     1439
     1440.ulticocl-panel-head {
     1441    display: flex;
     1442    align-items: flex-start;
     1443    justify-content: space-between;
     1444    gap: 24px;
     1445}
     1446
     1447.ulticocl-panel-head-text { min-width: 0; }
     1448.ulticocl-panel-head-actions { flex: none; }
     1449
     1450/* Destructive action: quiet until hovered, then unmistakably red. */
     1451.ulticocl-danger-btn {
     1452    display: inline-flex;
     1453    align-items: center;
     1454    gap: 8px;
     1455    height: var(--ulticocl-field);
     1456    margin: 0;
     1457    padding: 0 16px;
     1458    border: 1px solid #fca5a5;
     1459    border-radius: var(--uc-radius);
     1460    background: #fff;
     1461    color: #b91c1c;
     1462    font-size: 13px;
     1463    font-weight: 600;
     1464    line-height: 1;
     1465    white-space: nowrap;
     1466    cursor: pointer;
     1467    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     1468}
     1469
     1470.ulticocl-danger-btn .dashicons { width: 16px; height: 16px; font-size: 16px; }
     1471
     1472.ulticocl-danger-btn:hover {
     1473    border-color: var(--remove-comments-danger);
     1474    background: var(--remove-comments-danger);
     1475    color: #fff;
     1476}
     1477
     1478.ulticocl-danger-btn:focus-visible {
     1479    outline: none;
     1480    box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
     1481}
     1482
     1483@media (max-width: 782px) {
     1484    .ulticocl-panel-head { flex-direction: column; gap: 16px; }
     1485}
     1486
     1487/* ---- Buttons ------------------------------------------------------------ */
     1488
     1489.remove-comments-wrap .button,
     1490.remove-comments-wrap .button-secondary {
     1491    display: inline-flex;
     1492    align-items: center;
     1493    justify-content: center;
     1494    gap: 6px;
     1495    box-sizing: border-box;
     1496    height: var(--ulticocl-field);
     1497    min-height: 0;
     1498    margin: 0;
     1499    padding: 0 16px;
     1500    border: 1px solid var(--remove-comments-gray-300);
     1501    border-radius: var(--uc-radius);
     1502    background: #fff;
     1503    color: var(--uc-ink);
     1504    font-size: 13px;
     1505    font-weight: 500;
     1506    line-height: 1;
     1507    box-shadow: none;
     1508    text-decoration: none;
     1509    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     1510}
     1511
     1512.remove-comments-wrap .button:hover,
     1513.remove-comments-wrap .button-secondary:hover {
     1514    border-color: var(--remove-comments-primary);
     1515    background: #f7f7ff;
     1516    color: var(--remove-comments-primary-dark);
     1517}
     1518
     1519.remove-comments-wrap .button:focus-visible,
     1520.remove-comments-wrap .button-secondary:focus-visible {
     1521    outline: none;
     1522    border-color: var(--remove-comments-primary);
     1523    box-shadow: var(--uc-focus);
     1524}
     1525
     1526.remove-comments-wrap .button.button-primary {
     1527    border-color: var(--remove-comments-primary);
     1528    background: var(--remove-comments-primary);
     1529    color: #fff;
     1530}
     1531
     1532.remove-comments-wrap .button.button-primary:hover {
     1533    border-color: var(--remove-comments-primary-dark);
     1534    background: var(--remove-comments-primary-dark);
     1535    color: #fff;
     1536}
     1537
     1538.remove-comments-wrap .button[disabled],
     1539.remove-comments-wrap .button.disabled { opacity: 0.5; cursor: not-allowed; }
     1540
     1541.remove-comments-wrap .button-small { height: 32px; padding: 0 12px; font-size: 12px; }
     1542.remove-comments-wrap .button-hero { height: 44px; padding: 0 22px; font-size: 14px; font-weight: 600; }
     1543
     1544.ulticocl-seg button { height: 38px; padding: 0 16px; }
     1545.ulticocl-seg-small button { height: 28px; padding: 0 12px; }
     1546
     1547/* ---- Fields ------------------------------------------------------------- */
     1548
     1549.remove-comments-wrap .remove-comments-form-group > label,
     1550.ulticocl-sublabel {
     1551    display: block;
     1552    margin: 0 0 8px;
     1553    color: var(--remove-comments-gray-700);
     1554    font-size: 13px;
     1555    font-weight: 600;
     1556    line-height: 1.4;
     1557}
     1558
     1559.remove-comments-wrap .remove-comments-select,
     1560.remove-comments-wrap .remove-comments-form-group select {
     1561    max-width: none; /* WordPress caps selects at 25rem, which left one select narrower than its neighbours */
     1562    padding-right: 34px;
     1563    padding-left: 12px;
     1564    border-color: var(--remove-comments-gray-300);
     1565    -webkit-appearance: none;
     1566    appearance: none;
     1567    background-color: #fff;
     1568    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 7.5L10 12l4.5-4.5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
     1569    background-repeat: no-repeat;
     1570    background-position: right 10px center;
     1571    background-size: 18px;
     1572}
     1573
     1574.remove-comments-wrap .remove-comments-select:focus,
     1575.remove-comments-wrap .remove-comments-form-group select:focus,
     1576.remove-comments-wrap .remove-comments-form-group input[type="text"]:focus,
     1577.remove-comments-wrap .remove-comments-form-group input[type="email"]:focus,
     1578.remove-comments-wrap .remove-comments-form-group input[type="number"]:focus,
     1579.remove-comments-wrap .remove-comments-form-group textarea:focus,
     1580.remove-comments-wrap input.remove-comments-form-input:focus {
     1581    outline: none;
     1582    border-color: var(--remove-comments-primary);
     1583    box-shadow: var(--uc-focus);
     1584}
     1585
     1586.remove-comments-wrap .remove-comments-form-group textarea,
     1587.remove-comments-wrap textarea.remove-comments-form-input {
     1588    box-sizing: border-box;
     1589    width: 100%;
     1590    padding: 10px 12px;
     1591    border: 1px solid var(--remove-comments-gray-300);
     1592    border-radius: var(--uc-radius);
     1593    font-size: 13px;
     1594    line-height: 1.6;
     1595}
     1596
     1597.remove-comments-wrap #ulticocl-rule-keywords {
     1598    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
     1599    font-size: 12.5px;
     1600}
     1601
     1602/* Helper text sits flush with the field above it, not indented like a toggle's. */
     1603.remove-comments-wrap .remove-comments-form-group .description,
     1604.ulticocl-fieldset > .description {
     1605    margin: 8px 0 0;
     1606    color: var(--uc-muted);
     1607    font-size: 12px;
     1608    font-style: normal;
     1609    line-height: 1.55;
     1610}
     1611
     1612/* The row above ends with a 20px group margin, so pull the text up to sit 8px below its fields. */
     1613.ulticocl-fieldset > .description { margin: -12px 0 0; }
     1614
     1615/* A sentence with a field in the middle: keep the text and the box on one centre line. */
     1616.remove-comments-wrap .remove-comments-form-group .description.ulticocl-inline-field {
     1617    display: flex;
     1618    flex-wrap: wrap;
     1619    align-items: center;
     1620    gap: 4px 8px;
     1621}
     1622
     1623/* ---- Toggle fields: switches laid out on an aligned grid ------------------ */
     1624
     1625.ulticocl-toggle-grid {
     1626    display: grid;
     1627    grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
     1628    /* 16px between columns, the same as the input grids, so column edges line up down the form. */
     1629    gap: 12px 16px;
     1630}
     1631
     1632.ulticocl-toggle-grid[data-cols="2"] { --cols: 2; }
     1633.ulticocl-toggle-grid[data-cols="3"] { --cols: 3; }
     1634.ulticocl-toggle-grid[data-cols="4"] { --cols: 4; }
     1635
     1636.ulticocl-toggle-label {
     1637    min-width: 0;
     1638    overflow: hidden;
     1639    text-overflow: ellipsis;
     1640    white-space: nowrap;
     1641}
     1642
     1643.ulticocl-toggle-grid .ulticocl-toggle,
     1644.ulticocl-field-toggle .ulticocl-toggle {
     1645    box-sizing: border-box;
     1646    width: 100%;
     1647    height: var(--ulticocl-field);
     1648    padding: 0 12px;
     1649    border: 1px solid var(--remove-comments-gray-300);
     1650    border-radius: var(--uc-radius);
     1651    background: #fff;
     1652    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     1653}
     1654
     1655.ulticocl-toggle-grid .ulticocl-toggle:hover,
     1656.ulticocl-field-toggle .ulticocl-toggle:hover { border-color: var(--remove-comments-gray-400); }
     1657
     1658.ulticocl-toggle-grid .ulticocl-toggle:has(input:checked),
     1659.ulticocl-field-toggle .ulticocl-toggle:has(input:checked) {
     1660    border-color: #c7d2fe;
     1661    background: #f5f6ff;
     1662    color: #3730a3;
     1663}
     1664
     1665.ulticocl-toggle-grid .ulticocl-toggle:has(input:focus-visible),
     1666.ulticocl-field-toggle .ulticocl-toggle:has(input:focus-visible) {
     1667    border-color: var(--remove-comments-primary);
     1668    box-shadow: var(--uc-focus);
     1669}
     1670
     1671/* The tile already shows focus, so the switch itself does not need its own ring. */
     1672.ulticocl-toggle input:focus + .remove-comments-toggle-slider { box-shadow: none; }
     1673
     1674@media (max-width: 600px) {
     1675    .ulticocl-toggle-grid[data-cols="3"],
     1676    .ulticocl-toggle-grid[data-cols="4"] { --cols: 2; }
     1677}
     1678
     1679/* ---- Rule dialog -------------------------------------------------------- */
     1680
     1681.remove-comments-modal { background: rgba(17, 24, 39, 0.55); }
     1682
     1683.remove-comments-modal-content {
     1684    border-radius: var(--uc-radius-lg);
     1685    box-shadow: var(--uc-shadow-lg);
     1686}
     1687
     1688.remove-comments-modal-content.ulticocl-wide { padding: 28px 28px 24px; }
     1689
     1690.remove-comments-modal-content > h2 {
     1691    margin: 0 0 24px;
     1692    padding-right: 40px;
     1693    color: var(--uc-ink);
     1694    font-size: 20px;
     1695    font-weight: 700;
     1696    letter-spacing: -0.01em;
     1697    line-height: 1.3;
     1698}
     1699
     1700.remove-comments-modal-content > .remove-comments-modal-close { top: 22px; right: 26px; }
     1701
     1702.ulticocl-fieldset {
     1703    margin: 0 0 20px;
     1704    padding: 12px 20px 20px; /* the legend sits on the top border, so less is needed above than below */
     1705    border: 1px solid var(--uc-line);
     1706    border-radius: var(--uc-radius-lg);
     1707    background: #fff;
     1708}
     1709
     1710.ulticocl-fieldset legend {
     1711    margin-left: -4px;
     1712    padding: 0 8px;
     1713    color: var(--uc-ink);
     1714    font-size: 13px;
     1715    font-weight: 700;
     1716}
     1717
     1718.ulticocl-grid-2, .ulticocl-grid-3 { gap: 0 16px; }
     1719
     1720.ulticocl-modal-footer-row,
     1721.remove-comments-modal-content .remove-comments-form-actions {
     1722    align-items: center;
     1723    gap: 12px;
     1724    margin-top: 24px;
     1725    padding-top: 20px;
     1726    border-top: 1px solid var(--uc-line);
     1727}
     1728
     1729.remove-comments-modal-content .remove-comments-form-actions .remove-comments-btn {
     1730    height: var(--ulticocl-field);
     1731    padding: 0 18px;
     1732    font-size: 13px;
     1733    font-weight: 600;
     1734}
     1735
     1736.remove-comments-modal-content .remove-comments-modal-close-btn {
     1737    width: var(--ulticocl-field);
     1738    height: var(--ulticocl-field);
     1739    margin-left: auto;
     1740    border-radius: var(--uc-radius);
     1741}
     1742
     1743/* ---- Filter bar --------------------------------------------------------- */
     1744
     1745.ulticocl-filters {
     1746    gap: 12px 20px;
     1747    margin: 0 0 16px;
     1748    padding: 12px 16px;
     1749    border: 1px solid var(--uc-line);
     1750    border-radius: var(--uc-radius-lg);
     1751    background: #fff;
     1752    box-shadow: var(--uc-shadow);
     1753}
     1754
     1755.ulticocl-filters input[type="search"],
     1756.ulticocl-filters input[type="date"] {
     1757    box-sizing: border-box;
     1758    height: var(--ulticocl-field);
     1759    margin: 0;
     1760    padding: 0 12px;
     1761    border: 1px solid var(--remove-comments-gray-300);
     1762    border-radius: var(--uc-radius);
     1763    background: #fff;
     1764    color: var(--uc-ink);
     1765    font-size: 13px;
     1766    line-height: 1.3;
     1767}
     1768
     1769.ulticocl-filters input[type="search"] { flex: 1 1 260px; min-width: 200px; }
     1770
     1771.ulticocl-filters input[type="search"]:focus,
     1772.ulticocl-filters input[type="date"]:focus {
     1773    outline: none;
     1774    border-color: var(--remove-comments-primary);
     1775    box-shadow: var(--uc-focus);
     1776}
     1777
     1778.ulticocl-filter-field {
     1779    display: inline-flex;
     1780    align-items: center;
     1781    gap: 8px;
     1782    margin: 0;
     1783    color: var(--uc-ink-2);
     1784    font-size: 13px;
     1785    font-weight: 500;
     1786}
     1787
     1788.ulticocl-reset {
     1789    color: var(--uc-muted);
     1790    font-size: 13px;
     1791    text-decoration: none;
     1792}
     1793
     1794.ulticocl-reset:hover { color: var(--remove-comments-primary); text-decoration: underline; }
     1795
     1796/* ---- Status views, bulk bar and pagination ------------------------------------ */
     1797
     1798.remove-comments-comments-panel .subsubsub {
     1799    display: flex;
     1800    flex-wrap: wrap;
     1801    gap: 6px;
     1802    float: none;
     1803    margin: 0 0 16px;
     1804    padding: 0;
     1805    font-size: 0; /* hides the "|" separators WordPress prints between views */
     1806}
     1807
     1808.remove-comments-comments-panel .subsubsub li { margin: 0; padding: 0; font-size: 0; }
     1809
     1810.remove-comments-comments-panel .subsubsub a {
     1811    display: inline-flex;
     1812    align-items: center;
     1813    gap: 6px;
     1814    height: 32px;
     1815    padding: 0 12px;
     1816    border: 1px solid transparent;
     1817    border-radius: var(--uc-radius);
     1818    color: var(--uc-ink-2);
     1819    font-size: 13px;
     1820    font-weight: 500;
     1821    line-height: 1;
     1822    text-decoration: none;
     1823    transition: background-color 0.15s ease, color 0.15s ease;
     1824}
     1825
     1826.remove-comments-comments-panel .subsubsub a:hover { background: var(--remove-comments-gray-100); color: var(--uc-ink); }
     1827
     1828.remove-comments-comments-panel .subsubsub a.current {
     1829    border-color: #e0e7ff;
     1830    background: #eef2ff;
     1831    color: #4338ca;
     1832    font-weight: 600;
     1833}
     1834
     1835.remove-comments-comments-panel .subsubsub .count { color: var(--uc-muted); font-weight: 400; }
     1836.remove-comments-comments-panel .subsubsub a.current .count { color: var(--remove-comments-primary); }
     1837
     1838.remove-comments-comments-panel .tablenav {
     1839    display: flex;
     1840    flex-wrap: wrap;
     1841    align-items: center;
     1842    gap: 12px;
     1843    height: auto;
     1844    margin: 0 0 12px;
     1845    padding: 0;
     1846}
     1847
     1848.remove-comments-comments-panel .tablenav.bottom { margin: 12px 0 0; }
     1849.remove-comments-comments-panel .tablenav .actions { display: inline-flex; align-items: center; gap: 8px; padding: 0; overflow: visible; }
     1850.remove-comments-comments-panel .tablenav .alignleft,
     1851.remove-comments-comments-panel .tablenav .alignright { float: none; }
     1852
     1853.remove-comments-comments-panel .tablenav .actions select {
     1854    box-sizing: border-box;
     1855    height: var(--ulticocl-field);
     1856    min-width: 170px;
     1857    margin: 0;
     1858    padding: 0 34px 0 12px;
     1859    border: 1px solid var(--remove-comments-gray-300);
     1860    border-radius: var(--uc-radius);
     1861    -webkit-appearance: none;
     1862    appearance: none;
     1863    background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 7.5L10 12l4.5-4.5' fill='none' stroke='%236b7280' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 18px;
     1864    color: var(--uc-ink);
     1865    font-size: 13px;
     1866    line-height: 1.3;
     1867}
     1868
     1869.remove-comments-comments-panel .tablenav .actions select:focus { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     1870
     1871.remove-comments-comments-panel .tablenav-pages {
     1872    display: inline-flex;
     1873    align-items: center;
     1874    gap: 10px;
     1875    margin: 0 0 0 auto;
     1876    color: var(--uc-muted);
     1877    font-size: 13px;
     1878}
     1879
     1880.remove-comments-comments-panel .tablenav-pages .pagination-links { display: inline-flex; align-items: center; gap: 6px; }
     1881.remove-comments-comments-panel .tablenav-pages .paging-input { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
     1882
     1883.remove-comments-comments-panel .tablenav-pages .button,
     1884.remove-comments-comments-panel .tablenav-pages .tablenav-pages-navspan {
     1885    display: inline-flex;
     1886    align-items: center;
     1887    justify-content: center;
     1888    box-sizing: border-box;
     1889    min-width: var(--ulticocl-field);
     1890    height: var(--ulticocl-field);
     1891    padding: 0 10px;
     1892    border: 1px solid var(--remove-comments-gray-300);
     1893    border-radius: var(--uc-radius);
     1894    background: #fff;
     1895    font-size: 16px;
     1896    line-height: 1;
     1897}
     1898
     1899.remove-comments-comments-panel .tablenav-pages .tablenav-pages-navspan { opacity: 0.45; border-style: dashed; }
     1900
     1901.remove-comments-comments-panel .tablenav-pages .current-page {
     1902    box-sizing: border-box;
     1903    width: 52px;
     1904    height: var(--ulticocl-field);
     1905    margin: 0;
     1906    padding: 0 6px;
     1907    border: 1px solid var(--remove-comments-gray-300);
     1908    border-radius: var(--uc-radius);
     1909    text-align: center;
     1910}
     1911
     1912.remove-comments-comments-panel .tablenav-pages .current-page:focus { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     1913
     1914/* ---- The comment table --------------------------------------------------- */
     1915
     1916.remove-comments-comments-panel .wp-list-table {
     1917    border: 1px solid var(--uc-line);
     1918    border-radius: var(--uc-radius-lg);
     1919    border-collapse: separate;
     1920    border-spacing: 0;
     1921    overflow: hidden;
     1922    background: #fff;
     1923    box-shadow: var(--uc-shadow);
     1924}
     1925
     1926.remove-comments-comments-panel .wp-list-table thead th,
     1927.remove-comments-comments-panel .wp-list-table thead td,
     1928.remove-comments-comments-panel .wp-list-table tfoot th,
     1929.remove-comments-comments-panel .wp-list-table tfoot td {
     1930    padding: 12px 16px;
     1931    border: 0;
     1932    border-bottom: 1px solid var(--uc-line);
     1933    background: #f9fafb;
     1934    color: var(--uc-muted);
     1935    font-size: 12px;
     1936    font-weight: 600;
     1937    letter-spacing: 0.04em;
     1938    line-height: 1.4;
     1939    text-transform: uppercase;
     1940}
     1941
     1942.remove-comments-comments-panel .wp-list-table tfoot th,
     1943.remove-comments-comments-panel .wp-list-table tfoot td { border-top: 1px solid var(--uc-line); border-bottom: 0; }
     1944
     1945.remove-comments-comments-panel .wp-list-table tbody th,
     1946.remove-comments-comments-panel .wp-list-table tbody td {
     1947    padding: 16px;
     1948    border-bottom: 1px solid var(--uc-line-soft);
     1949    background: transparent;
     1950    color: var(--uc-ink-2);
     1951    font-size: 13px;
     1952    line-height: 1.55;
     1953    vertical-align: top;
     1954}
     1955
     1956.remove-comments-comments-panel .wp-list-table tbody tr:last-child th,
     1957.remove-comments-comments-panel .wp-list-table tbody tr:last-child td { border-bottom: 0; }
     1958
     1959/* No zebra stripes: the hairlines carry the structure. */
     1960.remove-comments-comments-panel .wp-list-table.striped > tbody > tr,
     1961.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:nth-child(odd) { background: #fff; }
     1962
     1963.remove-comments-comments-panel .wp-list-table tbody tr { transition: background-color 0.12s ease; }
     1964.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:hover { background: #fafbff; }
     1965.remove-comments-comments-panel .wp-list-table.striped > tbody > tr:has(.check-column input:checked) { background: #f5f6ff; }
     1966
     1967.remove-comments-comments-panel .wp-list-table .check-column {
     1968    width: 44px;
     1969    padding: 16px 0 0 16px;
     1970    vertical-align: top;
     1971}
     1972
     1973.remove-comments-comments-panel .wp-list-table thead .check-column,
     1974.remove-comments-comments-panel .wp-list-table tfoot .check-column { padding: 12px 0 12px 16px; vertical-align: middle; }
     1975
     1976/* The comment is what people read, so it gets the room; the rest stay compact. */
     1977.remove-comments-comments-panel .wp-list-table .column-author { width: 17%; }
     1978.remove-comments-comments-panel .wp-list-table .column-comment { width: 40%; }
     1979.remove-comments-comments-panel .wp-list-table .column-post,
     1980.remove-comments-comments-panel .wp-list-table .column-caught { width: 17%; }
     1981.remove-comments-comments-panel .wp-list-table .column-purge,
     1982.remove-comments-comments-panel .wp-list-table .column-date { width: 120px; }
     1983
     1984.remove-comments-comments-panel .wp-list-table .ulticocl-date { display: block; color: var(--uc-ink-2); white-space: nowrap; }
     1985.remove-comments-comments-panel .wp-list-table .ulticocl-time { display: block; margin-top: 2px; color: var(--uc-muted); font-size: 12px; }
     1986
     1987/* Author cell: name, then quiet detail lines with an even 2px between them. */
     1988.remove-comments-comments-panel .wp-list-table .ulticocl-author-name { margin: 0; }
     1989.remove-comments-comments-panel .wp-list-table .ulticocl-author-line { display: block; margin-top: 2px; color: var(--uc-muted); font-size: 12.5px; overflow-wrap: anywhere; }
     1990.remove-comments-comments-panel .wp-list-table code.ulticocl-author-line { display: inline-block; margin-top: 8px; }
     1991
     1992/* Comment cell: status, text, signals, actions, each separated by the same step. */
     1993.remove-comments-comments-panel .wp-list-table .ulticocl-comment-status { margin-bottom: 8px; }
     1994.remove-comments-comments-panel .wp-list-table .ulticocl-comment-text { color: var(--uc-ink); overflow-wrap: anywhere; }
     1995.remove-comments-comments-panel .wp-list-table .ulticocl-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
     1996.remove-comments-comments-panel .wp-list-table .ulticocl-chip { margin: 0; white-space: nowrap; }
     1997
     1998.remove-comments-comments-panel .wp-list-table .column-author strong { display: block; margin-bottom: 2px; color: var(--uc-ink); font-size: 13.5px; }
     1999.remove-comments-comments-panel .wp-list-table .column-author code { padding: 1px 6px; border-radius: var(--uc-radius); background: var(--remove-comments-gray-100); color: var(--uc-ink-2); font-size: 12px; }
     2000.remove-comments-comments-panel .wp-list-table .column-author a,
     2001.remove-comments-comments-panel .wp-list-table .column-post a { color: var(--remove-comments-primary); text-decoration: none; }
     2002.remove-comments-comments-panel .wp-list-table .column-author a:hover,
     2003.remove-comments-comments-panel .wp-list-table .column-post a:hover { color: var(--remove-comments-primary-dark); text-decoration: underline; }
     2004
     2005.remove-comments-comments-panel .wp-list-table .column-comment { color: var(--uc-ink); }
     2006.remove-comments-comments-panel .wp-list-table .column-caught strong { color: var(--uc-ink); }
     2007
     2008.remove-comments-comments-panel .wp-list-table .row-actions { margin-top: 6px; padding: 0; color: var(--remove-comments-gray-300); font-size: 12px; }
     2009.remove-comments-comments-panel .wp-list-table .row-actions a { color: var(--remove-comments-primary); font-weight: 500; text-decoration: none; }
     2010.remove-comments-comments-panel .wp-list-table .row-actions a:hover { text-decoration: underline; }
     2011.remove-comments-comments-panel .wp-list-table .row-actions .delete a,
     2012.remove-comments-comments-panel .wp-list-table .row-actions .submitdelete { color: #b91c1c; }
     2013
     2014.remove-comments-comments-panel .wp-list-table tr.no-items td { padding: 48px 16px; color: var(--uc-muted); font-size: 14px; text-align: center; }
     2015
     2016/* Brand checkboxes for the table, so the select boxes match the switches elsewhere. */
     2017.remove-comments-comments-panel .wp-list-table input[type="checkbox"] {
     2018    -webkit-appearance: none;
     2019    appearance: none;
     2020    position: relative;
     2021    flex: none;
     2022    width: 16px;
     2023    height: 16px;
     2024    margin: 0;
     2025    border: 1px solid var(--remove-comments-gray-300);
     2026    border-radius: 4px;
     2027    background: #fff;
     2028    box-shadow: none;
     2029    vertical-align: middle;
     2030    cursor: pointer;
     2031    transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
     2032}
     2033
     2034.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:hover { border-color: var(--remove-comments-primary); }
     2035
     2036.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:checked {
     2037    border-color: var(--remove-comments-primary);
     2038    background: var(--remove-comments-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8.5l2.7 2.7L12 5.5' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat;
     2039}
     2040
     2041.remove-comments-comments-panel .wp-list-table input[type="checkbox"]::before { content: none !important; display: none !important; }
     2042.remove-comments-comments-panel .wp-list-table input[type="checkbox"]:focus { outline: none; box-shadow: var(--uc-focus); }
     2043
     2044.remove-comments-comments-panel .notice.inline { margin: 0 0 16px; border-radius: var(--uc-radius); }
     2045
     2046/* ---- Smart Rules page ------------------------------------------------------ */
     2047
     2048.ulticocl-rules-toolbar {
     2049    display: flex;
     2050    align-items: center;
     2051    gap: 16px;
     2052    margin: 0 0 20px;
     2053}
     2054
     2055.ulticocl-rules-toolbar .ulticocl-presets { flex: 1; min-width: 0; margin: 0; }
     2056
     2057/* The one primary action on the page. */
     2058.ulticocl-add-btn {
     2059    display: inline-flex;
     2060    flex: none;
     2061    align-items: center;
     2062    gap: 8px;
     2063    height: var(--ulticocl-field);
     2064    margin: 0;
     2065    padding: 0 18px;
     2066    border: 1px solid var(--remove-comments-primary);
     2067    border-radius: var(--uc-radius);
     2068    background: var(--remove-comments-primary);
     2069    color: #fff;
     2070    font-size: 13px;
     2071    font-weight: 600;
     2072    line-height: 1;
     2073    white-space: nowrap;
     2074    cursor: pointer;
     2075    box-shadow: 0 1px 2px rgba(99, 102, 241, 0.35);
     2076    transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
     2077}
     2078
     2079.ulticocl-add-btn .dashicons { width: 16px; height: 16px; font-size: 16px; }
     2080
     2081.ulticocl-add-btn:hover { border-color: var(--remove-comments-primary-dark); background: var(--remove-comments-primary-dark); }
     2082.ulticocl-add-btn:focus-visible { outline: none; box-shadow: var(--uc-focus); }
     2083
     2084@media (max-width: 782px) {
     2085    .ulticocl-rules-toolbar { flex-direction: column; align-items: stretch; }
     2086    .ulticocl-add-btn { justify-content: center; }
     2087}
     2088
     2089.remove-comments-triggers-list { display: flex; flex-direction: column; gap: 16px; margin: 0; }
     2090
     2091.ulticocl-rule {
     2092    padding: 20px 24px;
     2093    border: 1px solid var(--uc-line);
     2094    border-radius: var(--uc-radius-lg);
     2095    background: #fff;
     2096    box-shadow: var(--uc-shadow);
     2097    transition: border-color 0.15s ease, box-shadow 0.15s ease;
     2098}
     2099
     2100.ulticocl-rule:hover { border-color: var(--remove-comments-gray-300); box-shadow: 0 6px 16px rgba(17, 24, 39, 0.06); }
     2101
     2102.ulticocl-rule-head {
     2103    display: flex;
     2104    align-items: center;
     2105    justify-content: space-between;
     2106    gap: 16px;
     2107    margin-bottom: 16px;
     2108}
     2109
     2110.ulticocl-rule-head h3 { margin: 0; padding: 0; color: var(--uc-ink); font-size: 16px; font-weight: 600; line-height: 1.3; }
     2111.ulticocl-rule-badges { display: flex; flex: none; align-items: center; gap: 8px; }
     2112
     2113.ulticocl-rule-meta {
     2114    display: grid;
     2115    grid-template-columns: repeat(3, minmax(0, 1fr));
     2116    gap: 16px 24px;
     2117    margin: 0 0 20px;
     2118    padding: 0;
     2119}
     2120
     2121.ulticocl-rule-meta > div { min-width: 0; }
     2122.ulticocl-rule-meta > .is-wide { grid-column: 1 / -1; }
     2123
     2124.ulticocl-rule-meta dt {
     2125    margin: 0 0 4px;
     2126    color: var(--uc-muted);
     2127    font-size: 11px;
     2128    font-weight: 600;
     2129    letter-spacing: 0.06em;
     2130    text-transform: uppercase;
     2131}
     2132
     2133.ulticocl-rule-meta dd { margin: 0; color: var(--uc-ink); font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
     2134
     2135.ulticocl-rule-actions {
     2136    display: flex;
     2137    flex-wrap: wrap;
     2138    align-items: center;
     2139    gap: 8px;
     2140    padding-top: 16px;
     2141    border-top: 1px solid var(--uc-line-soft);
     2142}
     2143
     2144.ulticocl-rbtn {
     2145    display: inline-flex;
     2146    align-items: center;
     2147    gap: 6px;
     2148    height: 36px;
     2149    margin: 0;
     2150    padding: 0 14px 0 12px;
     2151    border: 1px solid var(--remove-comments-gray-300);
     2152    border-radius: var(--uc-radius);
     2153    background: #fff;
     2154    color: var(--uc-ink);
     2155    font-size: 13px;
     2156    font-weight: 500;
     2157    line-height: 1;
     2158    white-space: nowrap;
     2159    cursor: pointer;
     2160    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
     2161}
     2162
     2163.ulticocl-rbtn .dashicons { width: 16px; height: 16px; font-size: 16px; color: var(--uc-muted); transition: color 0.15s ease; }
     2164
     2165.ulticocl-rbtn:hover { border-color: var(--remove-comments-primary); background: #f7f7ff; color: var(--remove-comments-primary-dark); }
     2166.ulticocl-rbtn:hover .dashicons { color: var(--remove-comments-primary); }
     2167.ulticocl-rbtn:focus-visible { outline: none; border-color: var(--remove-comments-primary); box-shadow: var(--uc-focus); }
     2168
     2169.ulticocl-rbtn.is-primary { border-color: var(--remove-comments-primary); background: var(--remove-comments-primary); color: #fff; }
     2170.ulticocl-rbtn.is-primary .dashicons { color: #fff; }
     2171.ulticocl-rbtn.is-primary:hover { border-color: var(--remove-comments-primary-dark); background: var(--remove-comments-primary-dark); color: #fff; }
     2172
     2173/* Delete sits apart at the far end and stays quiet until hovered. */
     2174.ulticocl-rbtn.is-danger { margin-left: auto; border-color: transparent; background: transparent; color: #b91c1c; }
     2175.ulticocl-rbtn.is-danger .dashicons { color: #b91c1c; }
     2176.ulticocl-rbtn.is-danger:hover { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
     2177.ulticocl-rbtn.is-danger:focus-visible { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25); }
     2178
     2179.ulticocl-badge { display: inline-flex; align-items: center; gap: 6px; }
     2180.ulticocl-badge-on { background: #d1fae5; color: #065f46; }
     2181.ulticocl-badge-off { background: var(--remove-comments-gray-100); color: var(--uc-ink-2); }
     2182.ulticocl-badge-on::before,
     2183.ulticocl-badge-off::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
     2184
     2185.remove-comments-empty-state {
     2186    padding: 40px 24px;
     2187    border: 1px dashed var(--remove-comments-gray-300);
     2188    border-radius: var(--uc-radius-lg);
     2189    background: var(--remove-comments-gray-50);
     2190    color: var(--uc-muted);
     2191    text-align: center;
     2192}
     2193
     2194.remove-comments-empty-state p { margin: 0; font-size: 14px; }
     2195
     2196@media (max-width: 782px) {
     2197    .ulticocl-rule-meta { grid-template-columns: 1fr; }
     2198    .ulticocl-rule-head { align-items: flex-start; flex-direction: column; gap: 8px; }
     2199}
     2200
     2201/* ---- Bulk Clean and Settings: same cards, switches and footer as the rest ------------- */
     2202
     2203/* Every on/off switch in the plugin is the small brand switch. */
     2204.remove-comments-toggle-switch { width: 32px; height: 18px; }
     2205.remove-comments-toggle-slider { border-radius: 18px; }
     2206
     2207.remove-comments-toggle-slider:before {
     2208    width: 12px;
     2209    height: 12px;
     2210    left: 3px;
     2211    bottom: 3px;
     2212    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
     2213}
     2214
     2215.remove-comments-toggle-switch input:checked + .remove-comments-toggle-slider:before { transform: translateX(14px); }
     2216
     2217.remove-comments-toggle-label {
     2218    color: var(--uc-ink);
     2219    font-size: 13px;
     2220    font-weight: 500;
     2221    line-height: 1.3;
     2222}
     2223
     2224/* Sections become cards, like the dashboard's. */
     2225.remove-comments-form-section {
     2226    margin: 0 0 16px;
     2227    padding: 20px 24px;
     2228    border: 1px solid var(--uc-line);
     2229    border-radius: var(--uc-radius-lg);
     2230    background: #fff;
     2231    box-shadow: var(--uc-shadow);
     2232}
     2233
     2234.remove-comments-form-section h3 {
     2235    margin: 0 0 16px;
     2236    padding: 0;
     2237    border: 0;
     2238    color: var(--uc-ink);
     2239    font-size: 15px;
     2240    font-weight: 600;
     2241    line-height: 1.3;
     2242}
     2243
     2244.remove-comments-form-section > :last-child,
     2245.remove-comments-form-section > .remove-comments-form-group:last-child { margin-bottom: 0; }
     2246.remove-comments-form-section .ulticocl-grid-2 > .remove-comments-form-group { margin-bottom: 0; }
     2247.remove-comments-form-section .ulticocl-row-after-toggles { margin-top: 16px; }
     2248
     2249/* Switch groups become the same aligned tiles as in the rule dialog. */
     2250.remove-comments-form-section .remove-comments-checkbox-group {
     2251    display: grid;
     2252    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
     2253    gap: 12px 16px;
     2254    margin: 0;
     2255}
     2256
     2257.remove-comments-form-section .remove-comments-checkbox-group label {
     2258    display: flex;
     2259    align-items: center;
     2260    gap: 10px;
     2261    box-sizing: border-box;
     2262    height: var(--ulticocl-field);
     2263    margin: 0;
     2264    padding: 0 12px;
     2265    border: 1px solid var(--remove-comments-gray-300);
     2266    border-radius: var(--uc-radius);
     2267    background: #fff;
     2268    cursor: pointer;
     2269    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     2270}
     2271
     2272.remove-comments-form-section .remove-comments-checkbox-group label:hover { border-color: var(--remove-comments-gray-400); background: #fff; }
     2273
     2274.remove-comments-form-section .remove-comments-checkbox-group label:has(input:checked) {
     2275    border-color: #c7d2fe;
     2276    background: #f5f6ff;
     2277}
     2278
     2279.remove-comments-form-section .remove-comments-checkbox-group label:has(input:checked) .remove-comments-toggle-label { color: #3730a3; }
     2280
     2281.remove-comments-form-section .remove-comments-checkbox-group label:has(input:focus-visible) {
     2282    border-color: var(--remove-comments-primary);
     2283    box-shadow: var(--uc-focus);
     2284}
     2285
     2286.remove-comments-checkbox-group .remove-comments-toggle-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     2287
     2288/* Date range: two fields on the same 16px grid as everything else. */
     2289.remove-comments-date-range { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
     2290
     2291.remove-comments-date-input label {
     2292    display: block;
     2293    margin: 0 0 8px;
     2294    color: var(--remove-comments-gray-700);
     2295    font-size: 13px;
     2296    font-weight: 600;
     2297}
     2298
     2299.remove-comments-wrap .remove-comments-date-input input[type="date"] {
     2300    box-sizing: border-box;
     2301    width: 100%;
     2302    height: var(--ulticocl-field);
     2303    margin: 0;
     2304    padding: 0 12px;
     2305    border: 1px solid var(--remove-comments-gray-300);
     2306    border-radius: var(--uc-radius);
     2307    background: #fff;
     2308    color: var(--uc-ink);
     2309    font-size: 13px;
     2310    line-height: 1.3;
     2311}
     2312
     2313.remove-comments-wrap .remove-comments-date-input input[type="date"]:focus {
     2314    outline: none;
     2315    border-color: var(--remove-comments-primary);
     2316    box-shadow: var(--uc-focus);
     2317}
     2318
     2319/* Settings: a switch, its label, and helper text lined up under the label. */
     2320.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) { gap: 12px; }
     2321
     2322.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) + .description {
     2323    margin: 4px 0 0 44px; /* 32px switch + 12px gap, so the text sits under its label */
     2324}
     2325
     2326.remove-comments-wrap #ulticocl_quarantine_days {
     2327    box-sizing: border-box;
     2328    width: 110px;
     2329    height: var(--ulticocl-field);
     2330    padding: 0 12px;
     2331}
     2332
     2333/* ---- Form footer: secondary actions left, the one primary action right ------------------- */
     2334
     2335.remove-comments-form-actions {
     2336    display: flex;
     2337    flex-wrap: wrap;
     2338    align-items: center;
     2339    gap: 12px;
     2340    margin-top: 8px;
     2341    padding-top: 20px;
     2342    border-top: 1px solid var(--uc-line);
     2343}
     2344
     2345.remove-comments-form-actions .remove-comments-btn {
     2346    height: var(--ulticocl-field);
     2347    padding: 0 18px;
     2348    font-size: 13px;
     2349    font-weight: 600;
     2350}
     2351
     2352.ulticocl-form-footer .ulticocl-rbtn { height: var(--ulticocl-field); padding: 0 16px 0 14px; }
     2353.ulticocl-form-footer .ulticocl-apply { margin-left: auto; }
     2354
     2355/* The Apply button turns red when the chosen action cannot be undone. */
     2356.ulticocl-apply[data-action="delete"] {
     2357    border-color: var(--remove-comments-danger);
     2358    background: var(--remove-comments-danger);
     2359    box-shadow: 0 1px 2px rgba(239, 68, 68, 0.35);
     2360}
     2361
     2362.ulticocl-apply[data-action="delete"]:hover { border-color: #dc2626; background: #dc2626; }
     2363.ulticocl-apply[data-action="delete"]:focus-visible { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25); }
     2364
     2365.remove-comments-preview-result {
     2366    margin-top: 16px;
     2367    padding: 12px 16px;
     2368    border: 1px solid #bfdbfe;
     2369    border-left: 4px solid var(--remove-comments-info);
     2370    border-radius: var(--uc-radius);
     2371    background: #f5f9ff;
     2372    color: var(--uc-ink);
     2373    font-size: 13px;
     2374}
     2375
     2376@media (max-width: 782px) {
     2377    .remove-comments-date-range { grid-template-columns: 1fr; gap: 16px; }
     2378    .ulticocl-form-footer .ulticocl-apply { margin-left: 0; width: 100%; justify-content: center; }
     2379}
     2380
     2381/* ---- Card rhythm: one 16px gap, whatever is inside (also holds when a field is hidden) ---- */
     2382
     2383.remove-comments-form-section { display: flex; flex-direction: column; gap: 16px; }
     2384.remove-comments-form-section > * { margin: 0; }
     2385.remove-comments-form-section > .remove-comments-form-group { margin: 0; }
     2386.remove-comments-form-section .ulticocl-row-after-toggles { margin-top: 0; }
     2387.remove-comments-form-section h3 { margin: 0; }
     2388
     2389.remove-comments-panel-header p { max-width: 84ch; }
     2390
     2391/* Settings switch rows: the helper text sits right under its label. */
     2392.remove-comments-wrap .remove-comments-form-group > label:has(.remove-comments-toggle-switch) { margin-bottom: 0; }
     2393
     2394/* Label -> control is 8px everywhere: the date fields were adding a flex gap on top of the label margin. */
     2395.remove-comments-date-input { gap: 0; }
     2396
     2397/* Helper text hugs the select it explains instead of floating a full row below it. */
     2398.remove-comments-form-section > .description { margin-top: -8px; }
     2399
     2400/* ---- Phones: one column of tiles, full-width actions, snugger cards ------------------------ */
     2401
     2402@media (max-width: 600px) {
     2403    .remove-comments-form-section { padding: 16px; }
     2404    .remove-comments-form-section .ulticocl-toggle-grid[data-cols] { --cols: 1; }
     2405    .remove-comments-form-section .ulticocl-toggle-label { white-space: normal; line-height: 1.2; }
     2406    .remove-comments-form-section .ulticocl-grid-2 { grid-template-columns: 1fr; }
     2407
     2408    .ulticocl-form-footer { flex-direction: column; align-items: stretch; }
     2409    .ulticocl-form-footer > button { width: 100%; justify-content: center; }
     2410}
     2411
     2412/* ---- Rule dialog: the on/off switch is a status card, not a footnote ----------------------- */
     2413
     2414.remove-comments-toggle-switch.ulticocl-large { flex: none; width: 44px; height: 24px; }
     2415.ulticocl-large .remove-comments-toggle-slider { border-radius: 24px; }
     2416
     2417.ulticocl-large .remove-comments-toggle-slider:before {
     2418    width: 18px;
     2419    height: 18px;
     2420    left: 3px;
     2421    bottom: 3px;
     2422}
     2423
     2424.ulticocl-large input:checked + .remove-comments-toggle-slider:before { transform: translateX(20px); }
     2425
     2426.remove-comments-modal .ulticocl-status-card {
     2427    display: flex;
     2428    align-items: center;
     2429    gap: 16px;
     2430    box-sizing: border-box;
     2431    margin: 0 0 20px;
     2432    padding: 14px 16px;
     2433    border: 1px solid var(--remove-comments-gray-300);
     2434    border-radius: var(--uc-radius);
     2435    background: var(--remove-comments-gray-50);
     2436    cursor: pointer;
     2437    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
     2438}
     2439
     2440.remove-comments-modal .ulticocl-status-card:hover { border-color: var(--remove-comments-gray-400); }
     2441
     2442.remove-comments-modal .ulticocl-status-card:has(input:checked) {
     2443    border-color: #c7d2fe;
     2444    background: #f5f6ff;
     2445}
     2446
     2447.remove-comments-modal .ulticocl-status-card:has(input:focus-visible) {
     2448    border-color: var(--remove-comments-primary);
     2449    box-shadow: var(--uc-focus);
     2450}
     2451
     2452.ulticocl-status-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
     2453
     2454.ulticocl-status-title { color: var(--uc-ink); font-size: 14px; font-weight: 600; line-height: 1.3; }
     2455.ulticocl-status-card:has(input:checked) .ulticocl-status-title { color: #3730a3; }
     2456
     2457.ulticocl-status-help { color: var(--uc-muted); font-size: 12.5px; line-height: 1.5; }
  • ultimate-comment-cleaner/trunk/assets/js/admin.js

    r3446581 r3752017  
    11jQuery(document).ready(function($) {
    22    'use strict';
    3    
     3
     4    const { __, _n, sprintf } = wp.i18n;
     5    const td = 'ultimate-comment-cleaner';
     6
    47    // Helper function to show native alert
    58    function showAlert(message, type) {
    69        if (type === 'error') {
    7             alert('Error: ' + message);
    8         } else if (type === 'success') {
    9             alert('Success: ' + message);
     10            alert(sprintf(__('Error: %s', td), message));
    1011        } else {
    1112            alert(message);
    1213        }
    1314    }
    14    
    15     // Helper function to show native confirm
    16     function showConfirm(message, callback) {
    17         if (confirm(message)) {
    18             if (callback) callback();
    19         }
    20     }
    21    
    22     // Delete Comments Form
     15
     16    function errorMessage(response) {
     17        return (response && response.data && response.data.message) || __('An error occurred. Please try again.', td);
     18    }
     19
     20    function post(data) {
     21        return $.post(ulticoclData.ajaxurl, $.extend({ nonce: ulticoclData.nonce }, data));
     22    }
     23
     24    function isOn(value) {
     25        return value === true || value === 1 || value === '1';
     26    }
     27
     28    // ---------------------------------------------------------------------
     29    // Bulk Clean: count first, confirm with the real number, then work in batches
     30    // ---------------------------------------------------------------------
     31
     32    function confirmText(action, count) {
     33        const n = count.toLocaleString();
     34        if (action === 'delete') {
     35            /* translators: %s: number of comments */
     36            return sprintf(_n('Permanently delete %s comment? This cannot be undone.', 'Permanently delete %s comments? This cannot be undone.', count, td), n);
     37        }
     38        if (action === 'spam') {
     39            /* translators: %s: number of comments */
     40            return sprintf(_n('Mark %s comment as spam?', 'Mark %s comments as spam?', count, td), n);
     41        }
     42        /* translators: %s: number of comments */
     43        return sprintf(_n('Move %s comment to quarantine? You can restore it from the Quarantine tab.', 'Move %s comments to quarantine? You can restore them from the Quarantine tab.', count, td), n);
     44    }
     45
     46    function runBulk(filters) {
     47        post($.extend({ action: 'ulticocl_preview_count' }, filters)).done(function(response) {
     48            if (!response.success) {
     49                showAlert(errorMessage(response), 'error');
     50                return;
     51            }
     52            const count = response.data.count;
     53            if (count === 0) {
     54                showAlert(__('No comments match your criteria.', td));
     55                return;
     56            }
     57            if (confirm(confirmText(filters.clean_action, count))) {
     58                processBulk(filters, count);
     59            }
     60        }).fail(function() {
     61            showAlert(__('An error occurred. Please try again.', td), 'error');
     62        });
     63    }
     64
     65    function processBulk(filters, expected) {
     66        const progress = $('<div>').addClass('ulticocl-progress-box').attr('role', 'status');
     67        $('body').append(progress);
     68
     69        function step(processedSoFar) {
     70            progress.text(sprintf(
     71                /* translators: 1: comments processed so far, 2: total comments */
     72                __('Working... %1$s of %2$s', td),
     73                processedSoFar.toLocaleString(),
     74                expected.toLocaleString()
     75            ));
     76            post($.extend({ action: 'ulticocl_bulk_process', processed_so_far: processedSoFar }, filters)).done(function(response) {
     77                if (!response.success) {
     78                    progress.remove();
     79                    showAlert(errorMessage(response), 'error');
     80                    return;
     81                }
     82                if (!response.data.done) {
     83                    step(response.data.total);
     84                    return;
     85                }
     86                progress.remove();
     87                let message = response.data.message;
     88                if (response.data.remaining > 0) {
     89                    message += '\n' + sprintf(
     90                        /* translators: %s: number of comments */
     91                        __('%s matching comments could not be changed.', td),
     92                        response.data.remaining.toLocaleString()
     93                    );
     94                }
     95                showAlert(message);
     96                location.reload();
     97            }).fail(function() {
     98                progress.remove();
     99                showAlert(__('An error occurred while processing comments.', td), 'error');
     100            });
     101        }
     102
     103        step(0);
     104    }
     105
    23106    $('#remove-comments-delete-form').on('submit', function(e) {
    24107        e.preventDefault();
    25        
    26         const formData = $(this).serialize();
    27         const selectedStatus = $('input[name="status[]"]:checked').length;
    28        
    29         if (selectedStatus === 0) {
    30             showAlert('Please select at least one status to delete.', 'error');
     108
     109        if ($('input[name="status[]"]:checked').length === 0) {
     110            showAlert(__('Please select at least one status.', td), 'error');
    31111            return;
    32112        }
    33        
    34         showConfirm('Are you sure? This will permanently delete the selected comments. This action cannot be undone!', function() {
    35             deleteComments(formData);
    36         });
    37     });
    38    
     113        runBulk(getFormData());
     114    });
     115
     116    // Quarantine tab: delete everything quarantined
     117    $('#ulticocl-empty-quarantine').on('click', function() {
     118        runBulk({ status: ['quarantine'], clean_action: 'delete' });
     119    });
     120
    39121    // Preview Count
    40122    $('#remove-comments-preview-count').on('click', function() {
    41         const formData = $('#remove-comments-delete-form').serialize() + '&action=ulticocl_preview_count';
    42        
    43         $.ajax({
    44             url: ulticoclData.ajaxurl,
    45             type: 'POST',
    46             data: {
    47                 action: 'ulticocl_preview_count',
    48                 nonce: ulticoclData.nonce,
    49                 ...getFormData()
    50             },
    51             beforeSend: function() {
    52                 $('#remove-comments-preview-count').prop('disabled', true).text('Counting...');
    53             },
    54             success: function(response) {
    55                 if (response.success) {
    56                     $('#remove-comments-preview-result').html(
    57                         `Found <strong>${response.data.count}</strong> comments matching your criteria.`
    58                     ).show();
    59                 } else {
    60                     showAlert(response.data.message || 'An error occurred.', 'error');
    61                 }
    62             },
    63             complete: function() {
    64                 $('#remove-comments-preview-count').prop('disabled', false).text('Preview Count');
    65             }
    66         });
    67     });
    68    
     123        const button = $(this);
     124        const label = button.find('.ulticocl-btn-label');
     125        const originalText = label.text();
     126        button.prop('disabled', true);
     127        label.text(__('Counting...', td));
     128
     129        post($.extend({ action: 'ulticocl_preview_count' }, getFormData())).done(function(response) {
     130            if (response.success) {
     131                const count = response.data.count;
     132                $('#remove-comments-preview-result').text(sprintf(
     133                    /* translators: %s: number of comments */
     134                    _n('Found %s comment matching your criteria.', 'Found %s comments matching your criteria.', count, td),
     135                    count.toLocaleString()
     136                )).show();
     137            } else {
     138                showAlert(errorMessage(response), 'error');
     139            }
     140        }).always(function() {
     141            button.prop('disabled', false);
     142            label.text(originalText);
     143        });
     144    });
     145
     146    // Bulk Clean: describe what the chosen action does, and let the button's colour follow the consequence.
     147    (function() {
     148        const select = $('#remove-comments-clean-action');
     149        if (!select.length) {
     150            return;
     151        }
     152        const help = {
     153            quarantine: __('Hidden from your site. You can restore them from the Quarantine tab.', td),
     154            spam: __('Moved to the spam folder.', td),
     155            'delete': __('Removed permanently. This cannot be undone.', td)
     156        };
     157        const sync = function() {
     158            const action = select.val();
     159            $('#ulticocl-action-help').text(help[action] || '');
     160            $('#remove-comments-delete-btn').attr('data-action', action);
     161        };
     162        select.on('change', sync);
     163        sync();
     164    })();
     165
    69166    // Export Comments
    70167    $('#remove-comments-export-btn, #remove-comments-export-all').on('click', function() {
    71168        const formData = getFormData();
     169        delete formData.clean_action;
    72170        formData.action = 'ulticocl_export_comments';
    73171        formData.nonce = ulticoclData.nonce;
    74        
     172        // The dashboard button has no filter form: export everything.
     173        if (!formData.status) {
     174            formData.status = ['all'];
     175        }
     176
    75177        // Create form and submit
    76178        const form = $('<form>', {
    … …  
    79181            target: '_blank'
    80182        });
    81        
     183
    82184        Object.keys(formData).forEach(key => {
    83             form.append($('<input>', {
    84                 type: 'hidden',
    85                 name: key,
    86                 value: formData[key]
    87             }));
    88         });
    89        
     185            // Arrays must be sent as key[] so PHP receives them as arrays.
     186            [].concat(formData[key]).forEach(value => {
     187                form.append($('<input>', {
     188                    type: 'hidden',
     189                    name: Array.isArray(formData[key]) ? key + '[]' : key,
     190                    value: value
     191                }));
     192            });
     193        });
     194
    90195        $('body').append(form);
    91196        form.submit();
    92197        form.remove();
    93198    });
    94    
    95     // Add Trigger
    96     $('#remove-comments-add-trigger').on('click', function() {
    97         $('#remove-comments-trigger-form')[0].reset();
    98         $('#remove-comments-trigger-id').val('');
    99         $('#remove-comments-trigger-modal').show();
    100     });
    101    
    102     // Edit Trigger
    103     $(document).on('click', '.remove-comments-edit-trigger', function() {
    104         const triggerId = $(this).data('id');
    105        
    106         $.ajax({
    107             url: ulticoclData.ajaxurl,
    108             type: 'POST',
    109             data: {
    110                 action: 'ulticocl_get_trigger',
    111                 nonce: ulticoclData.nonce,
    112                 trigger_id: triggerId
    113             },
    114             success: function(response) {
    115                 if (response.success) {
    116                     const trigger = response.data;
    117                     $('#remove-comments-trigger-id').val(triggerId);
    118                     $('#remove-comments-trigger-name').val(trigger.name);
    119                     $('#remove-comments-trigger-status').val(trigger.status);
    120                     $('#remove-comments-trigger-older').val(trigger.older_than);
    121                     $('#remove-comments-trigger-schedule').val(trigger.schedule);
    122                     $('#remove-comments-trigger-active').prop('checked', trigger.active);
    123                     $('#remove-comments-trigger-modal').show();
    124                 }
    125             }
    126         });
    127     });
    128    
    129     // Delete Trigger
    130     $(document).on('click', '.remove-comments-delete-trigger', function() {
    131         const triggerId = $(this).data('id');
    132        
    133         showConfirm('Are you sure you want to delete this trigger?', function() {
    134             $.ajax({
    135                 url: ulticoclData.ajaxurl,
    136                 type: 'POST',
    137                 data: {
    138                     action: 'ulticocl_delete_trigger',
    139                     nonce: ulticoclData.nonce,
    140                     trigger_id: triggerId
    141                 },
    142                 success: function(response) {
    143                     if (response.success) {
    144                         showAlert('Trigger has been deleted.', 'success');
    145                         location.reload();
    146                     } else {
    147                         showAlert(response.data.message || 'An error occurred.', 'error');
    148                     }
    149                 }
    150             });
    151         });
    152     });
    153    
    154     // Save Trigger
    155     $('#remove-comments-trigger-form').on('submit', function(e) {
    156         e.preventDefault();
    157        
    158         const formData = {
    159             action: 'ulticocl_save_trigger',
    160             nonce: ulticoclData.nonce,
    161             trigger_id: $('#remove-comments-trigger-id').val(),
    162             name: $('#remove-comments-trigger-name').val(),
    163             status: $('#remove-comments-trigger-status').val(),
    164             older_than: $('#remove-comments-trigger-older').val(),
    165             schedule: $('#remove-comments-trigger-schedule').val(),
    166             active: $('#remove-comments-trigger-active').is(':checked') ? 1 : 0
    167         };
    168        
    169         $.ajax({
    170             url: ulticoclData.ajaxurl,
    171             type: 'POST',
    172             data: formData,
    173             success: function(response) {
    174                 if (response.success) {
    175                     showAlert('Trigger has been saved successfully.', 'success');
    176                     setTimeout(function() {
    177                         location.reload();
    178                     }, 1000);
    179                 } else {
    180                     showAlert(response.data.message || 'An error occurred.', 'error');
    181                 }
    182             }
    183         });
    184     });
    185    
    186     // Close Modal
    187     $('.remove-comments-modal-close').on('click', function() {
    188         $('#remove-comments-trigger-modal').hide();
    189     });
    190    
    191     // Close modal on outside click
    192     $(window).on('click', function(e) {
    193         if ($(e.target).hasClass('rc-modal')) {
    194             $('.remove-comments-modal').hide();
    195         }
    196     });
    197    
    198     // Helper Functions
    199     function deleteComments(formData) {
    200         const data = {
    201             action: 'ulticocl_delete_comments',
    202             nonce: ulticoclData.nonce,
    203             ...getFormData()
    204         };
    205        
    206         // Show loading message
    207         const loadingMsg = $('<div>').text('Deleting comments... Please wait.').css({
    208             position: 'fixed',
    209             top: '50%',
    210             left: '50%',
    211             transform: 'translate(-50%, -50%)',
    212             background: '#fff',
    213             padding: '20px',
    214             border: '2px solid #6366f1',
    215             borderRadius: '4px',
    216             zIndex: 9999,
    217             boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
    218         });
    219         $('body').append(loadingMsg);
    220        
    221         $.ajax({
    222             url: ulticoclData.ajaxurl,
    223             type: 'POST',
    224             data: data,
    225             success: function(response) {
    226                 loadingMsg.remove();
    227                 if (response.success) {
    228                     showAlert(response.data.message, 'success');
    229                     setTimeout(function() {
    230                         location.reload();
    231                     }, 1000);
    232                 } else {
    233                     showAlert(response.data.message || 'An error occurred.', 'error');
    234                 }
    235             },
    236             error: function() {
    237                 loadingMsg.remove();
    238                 showAlert('An error occurred while deleting comments.', 'error');
    239             }
    240         });
    241     }
    242    
     199
    243200    function getFormData() {
    244201        const data = {};
    245        
     202
    246203        // Get status checkboxes
    247204        const statuses = [];
    … …  
    252209            data.status = statuses;
    253210        }
    254        
     211
    255212        // Get date range
    256213        const dateFrom = $('#remove-comments-date-from').val();
    … …  
    258215        if (dateFrom) data.date_from = dateFrom;
    259216        if (dateTo) data.date_to = dateTo;
    260        
     217
    261218        // Get post types
    262219        const postTypes = [];
    … …  
    267224            data.post_type = postTypes;
    268225        }
    269        
     226
    270227        // Get author
    271228        const author = $('#remove-comments-author-filter').val();
    … …  
    273230            data.author = author;
    274231        }
    275        
     232
     233        // Search text
     234        const search = $('#remove-comments-search').val();
     235        if (search && search.trim() !== '') {
     236            data.search = search.trim();
     237        }
     238
    276239        // Additional filters
    277240        if ($('input[name="has_url"]').is(':checked')) {
    … …  
    284247            data.short_comments = 1;
    285248        }
    286        
     249
    287250        const minWords = $('input[name="min_words"]').val();
    288251        const maxWords = $('input[name="max_words"]').val();
    289252        if (minWords) data.min_words = minWords;
    290253        if (maxWords) data.max_words = maxWords;
    291        
     254
     255        data.clean_action = $('#remove-comments-clean-action').val() || 'quarantine';
     256
    292257        return data;
    293258    }
    294    
    295     // Auto-refresh stats every 30 seconds on dashboard
    296     if ($('.remove-comments-dashboard').length) {
    297         setInterval(function() {
    298             $.ajax({
    299                 url: ulticoclData.ajaxurl,
    300                 type: 'POST',
    301                 data: {
    302                     action: 'ulticocl_get_stats',
    303                     nonce: ulticoclData.nonce
    304                 },
    305                 success: function(response) {
    306                     if (response.success) {
    307                         updateStats(response.data);
    308                     }
    309                 }
     259
     260    // ---------------------------------------------------------------------
     261    // Smart Rules
     262    // ---------------------------------------------------------------------
     263
     264    const $modal = $('#ulticocl-rule-modal');
     265    const $form = $('#ulticocl-rule-form');
     266    const $result = $('#ulticocl-scan-result');
     267
     268    function setChecks(name, values) {
     269        values = [].concat(values || []);
     270        $form.find('input[name="' + name + '[]"]').each(function() {
     271            $(this).prop('checked', values.indexOf($(this).val()) !== -1);
     272        });
     273    }
     274
     275    function checked(name) {
     276        return $form.find('input[name="' + name + '[]"]:checked').map(function() {
     277            return this.value;
     278        }).get();
     279    }
     280
     281    function toggleScope() {
     282        $('.ulticocl-existing-only').toggle($('#ulticocl-rule-scope').val() !== 'new');
     283    }
     284
     285    // The status card reads "active" or "paused" to match the switch.
     286    function syncStatusCard() {
     287        const on = $('#ulticocl-rule-active').is(':checked');
     288        $('.ulticocl-status-title, .ulticocl-status-help').each(function() {
     289            $(this).text($(this).attr(on ? 'data-on' : 'data-off'));
     290        });
     291    }
     292
     293    function fillRuleForm(rule, title) {
     294        rule = $.extend({}, ulticoclData.ruleDefaults, rule);
     295        $('#ulticocl-rule-title').text(title);
     296        $('#ulticocl-rule-id').val(rule.id || '');
     297        $('#ulticocl-rule-name').val(rule.name);
     298        $('#ulticocl-rule-scope').val(rule.scope);
     299        $('#ulticocl-rule-action').val(rule.action);
     300        $('#ulticocl-rule-mode').val(rule.mode);
     301        $('#ulticocl-rule-schedule').val(rule.schedule);
     302        $('#ulticocl-rule-keywords').val([].concat(rule.keywords || []).join('\n'));
     303        setChecks('fields', rule.fields);
     304        setChecks('scripts', rule.scripts);
     305        setChecks('status', rule.status);
     306        $('#ulticocl-rule-pct').val(rule.script_min_pct);
     307        $('#ulticocl-rule-links').val(rule.min_links);
     308        $('#ulticocl-rule-words').val(rule.max_words);
     309        $('#ulticocl-rule-older').val(rule.older_than);
     310        $('#ulticocl-rule-has-url').prop('checked', isOn(rule.has_url));
     311        $('#ulticocl-rule-active').prop('checked', isOn(rule.active));
     312        syncStatusCard();
     313        toggleScope();
     314        $modal.show();
     315        $('#ulticocl-rule-name').trigger('focus');
     316    }
     317
     318    function rulePayload() {
     319        return {
     320            rule_id: $('#ulticocl-rule-id').val(),
     321            name: $('#ulticocl-rule-name').val(),
     322            scope: $('#ulticocl-rule-scope').val(),
     323            rule_action: $('#ulticocl-rule-action').val(),
     324            mode: $('#ulticocl-rule-mode').val(),
     325            schedule: $('#ulticocl-rule-schedule').val(),
     326            keywords: $('#ulticocl-rule-keywords').val(),
     327            fields: checked('fields'),
     328            scripts: checked('scripts'),
     329            status: checked('status'),
     330            script_min_pct: $('#ulticocl-rule-pct').val(),
     331            min_links: $('#ulticocl-rule-links').val(),
     332            max_words: $('#ulticocl-rule-words').val(),
     333            older_than: $('#ulticocl-rule-older').val(),
     334            has_url: $('#ulticocl-rule-has-url').is(':checked') ? 1 : 0,
     335            active: $('#ulticocl-rule-active').is(':checked') ? 1 : 0
     336        };
     337    }
     338
     339    $('#ulticocl-rule-scope').on('change', toggleScope);
     340    $('#ulticocl-rule-active').on('change', syncStatusCard);
     341
     342    $('#ulticocl-add-rule').on('click', function() {
     343        fillRuleForm({}, __('New Smart Rule', td));
     344    });
     345
     346    // Quick-start presets sit in one row that glides left and right with the pointer.
     347    (function() {
     348        const viewport = document.getElementById('ulticocl-presets');
     349        if (!viewport) {
     350            return;
     351        }
     352        const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
     353        let target = 0;
     354        let frame = 0;
     355        const range = function() {
     356            return viewport.scrollWidth - viewport.clientWidth;
     357        };
     358        // Fade the side that still has buttons behind it.
     359        const edges = function() {
     360            viewport.classList.toggle('has-left', viewport.scrollLeft > 2);
     361            viewport.classList.toggle('has-right', viewport.scrollLeft < range() - 2);
     362        };
     363        const glide = function() {
     364            const diff = target - viewport.scrollLeft;
     365            if (Math.abs(diff) < 0.5) {
     366                viewport.scrollLeft = target;
     367                frame = 0;
     368                return;
     369            }
     370            viewport.scrollLeft += diff * 0.14;
     371            frame = requestAnimationFrame(glide);
     372        };
     373        viewport.addEventListener('pointermove', function(event) {
     374            if (event.pointerType === 'touch' || range() <= 0) {
     375                return; // touch scrolls natively
     376            }
     377            const box = viewport.getBoundingClientRect();
     378            // The outer 12% on each side is the dead zone that reaches the first and last button.
     379            const ratio = Math.min(1, Math.max(0, ((event.clientX - box.left) / box.width - 0.12) / 0.76));
     380            target = ratio * range();
     381            if (reduceMotion) {
     382                viewport.scrollLeft = target;
     383            } else if (!frame) {
     384                frame = requestAnimationFrame(glide);
     385            }
     386        });
     387        viewport.addEventListener('scroll', edges, { passive: true });
     388        window.addEventListener('resize', edges);
     389        edges();
     390    })();
     391
     392    // Quick start: open a ready-made rule for review before saving
     393    $('.ulticocl-preset').on('click', function() {
     394        fillRuleForm(ulticoclData.presets[$(this).data('preset')], __('New Smart Rule', td));
     395    });
     396
     397    $(document).on('click', '.ulticocl-edit-rule', function() {
     398        const id = $(this).data('id');
     399        post({ action: 'ulticocl_get_rule', rule_id: id }).done(function(response) {
     400            if (response.success) {
     401                fillRuleForm($.extend({ id: id }, response.data), __('Edit Smart Rule', td));
     402            } else {
     403                showAlert(errorMessage(response), 'error');
     404            }
     405        });
     406    });
     407
     408    $(document).on('click', '.ulticocl-delete-rule', function() {
     409        const id = $(this).data('id');
     410        if (!confirm(__('Delete this rule? Comments it already caught stay where they are.', td))) {
     411            return;
     412        }
     413        post({ action: 'ulticocl_delete_rule', rule_id: id }).done(function(response) {
     414            if (response.success) {
     415                location.reload();
     416            } else {
     417                showAlert(errorMessage(response), 'error');
     418            }
     419        });
     420    });
     421
     422    $form.on('submit', function(e) {
     423        e.preventDefault();
     424        post($.extend({ action: 'ulticocl_save_rule' }, rulePayload())).done(function(response) {
     425            if (response.success) {
     426                location.reload();
     427            } else {
     428                showAlert(errorMessage(response), 'error');
     429            }
     430        }).fail(function() {
     431            showAlert(__('An error occurred. Please try again.', td), 'error');
     432        });
     433    });
     434
     435    // Close Modal
     436    $('.remove-comments-modal-close').on('click', function() {
     437        $modal.hide();
     438    });
     439
     440    // Close modal on outside click
     441    $(window).on('click', function(e) {
     442        if ($(e.target).is('.remove-comments-modal')) {
     443            $('.remove-comments-modal').hide();
     444        }
     445    });
     446
     447    // Deep links from the dashboard: ?preset=cyrillic or ?keywords=a,b,c open the dialog prefilled
     448    if ($modal.length) {
     449        const params = new URLSearchParams(window.location.search);
     450        const preset = params.get('preset');
     451        const keywords = params.get('keywords');
     452        if (preset && ulticoclData.presets[preset]) {
     453            fillRuleForm(ulticoclData.presets[preset], __('New Smart Rule', td));
     454        } else if (keywords) {
     455            fillRuleForm({
     456                name: __('Spam words', td),
     457                keywords: keywords.split(',').map(function(word) { return word.trim(); }).filter(Boolean).slice(0, 50)
     458            }, __('New Smart Rule', td));
     459        }
     460    }
     461
     462    function doneText(action, n) {
     463        const count = n.toLocaleString();
     464        if (action === 'delete') {
     465            /* translators: %s: number of comments */
     466            return sprintf(_n('Deleted %s comment.', 'Deleted %s comments.', n, td), count);
     467        }
     468        if (action === 'spam') {
     469            /* translators: %s: number of comments */
     470            return sprintf(_n('Marked %s comment as spam.', 'Marked %s comments as spam.', n, td), count);
     471        }
     472        /* translators: %s: number of comments */
     473        return sprintf(_n('Moved %s comment to quarantine.', 'Moved %s comments to quarantine.', n, td), count);
     474    }
     475
     476    // Scan existing comments with a rule in batches, showing progress. dry = test only.
     477    function scanRule(payload, dry, ruleName, action) {
     478        const totals = { scanned: 0, matched: 0, acted: 0, total: 0, samples: [] };
     479        $result.show().empty().append($('<p>').text(__('Starting...', td)));
     480        $result[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
     481
     482        function progress() {
     483            const pct = totals.total ? Math.min(100, Math.round(100 * totals.scanned / totals.total)) : 0;
     484            $result.empty().append(
     485                $('<p>').text(sprintf(
     486                    /* translators: 1: comments checked so far, 2: total comments to check, 3: comments matched so far */
     487                    __('Checking... %1$s of %2$s comments, %3$s matched so far', td),
     488                    totals.scanned.toLocaleString(), totals.total.toLocaleString(), totals.matched.toLocaleString()
     489                )),
     490                $('<div>').addClass('ulticocl-progress').append($('<div>').css('width', pct + '%'))
     491            );
     492        }
     493
     494        function finish() {
     495            $result.empty();
     496            if (dry) {
     497                $result.append($('<p>').append($('<strong>').text(sprintf(
     498                    /* translators: 1: comments matched, 2: comments checked, 3: rule name */
     499                    __('Test finished: %1$s of %2$s existing comments match "%3$s". Nothing was changed.', td),
     500                    totals.matched.toLocaleString(), totals.scanned.toLocaleString(), ruleName
     501                ))));
     502                if (totals.scanned >= 10 && totals.matched / totals.scanned >= 0.5) {
     503                    $result.append($('<p>').addClass('ulticocl-error').text(
     504                        __('This rule matches a very large share of your comments. Check its keywords before running it.', td)
     505                    ));
     506                }
     507                if (totals.samples.length) {
     508                    const list = $('<ul>').addClass('ulticocl-samples');
     509                    totals.samples.forEach(function(s) {
     510                        list.append($('<li>').append(
     511                            $('<strong>').text(s.author || '—'),
     512                            document.createTextNode(': ' + s.excerpt + ' '),
     513                            $('<em>').text('(' + s.reason + ')')
     514                        ));
     515                    });
     516                    $result.append(list);
     517                }
     518                if (totals.matched > 0) {
     519                    $result.append($('<p>').append($('<a>', {
     520                        href: ulticoclData.reviewUrl,
     521                        'class': 'button button-primary',
     522                        text: __('Review these comments', td)
     523                    })));
     524                }
     525            } else {
     526                $result.append($('<p>').append($('<strong>').text(doneText(action, totals.acted))));
     527                $result.append($('<p>').append($('<a>', { href: window.location.href, text: __('Refresh the list', td) })));
     528            }
     529        }
     530
     531        function step(cursor) {
     532            post($.extend({
     533                action: 'ulticocl_scan_rule',
     534                dry: dry ? 1 : 0,
     535                cursor: cursor,
     536                acted_so_far: totals.acted
     537            }, payload)).done(function(response) {
     538                if (!response.success) {
     539                    $result.empty().append($('<p>').addClass('ulticocl-error').text(errorMessage(response)));
     540                    return;
     541                }
     542                const d = response.data;
     543                totals.scanned += d.scanned;
     544                totals.matched += d.matched;
     545                totals.acted += d.acted;
     546                totals.samples = totals.samples.concat(d.samples).slice(0, 5);
     547                if (d.total !== undefined) {
     548                    totals.total = d.total;
     549                }
     550                if (d.done) {
     551                    finish();
     552                } else {
     553                    progress();
     554                    step(d.cursor);
     555                }
     556            }).fail(function() {
     557                $result.empty().append($('<p>').addClass('ulticocl-error').text(__('An error occurred. Please try again.', td)));
    310558            });
    311         }, 30000);
    312     }
    313    
    314     function updateStats(stats) {
    315         // Update stat cards
    316         $('.remove-comments-stat-card').each(function() {
    317             const label = $(this).find('.remove-comments-stat-label').text().trim();
    318             let value = 0;
    319            
    320             if (label.includes('Total')) value = stats.total;
    321             else if (label.includes('Approved')) value = stats.approved;
    322             else if (label.includes('Pending')) value = stats.pending;
    323             else if (label.includes('Spam')) value = stats.spam;
    324             else if (label.includes('Trash')) value = stats.trash;
    325             else if (label.includes('Triggers')) value = stats.triggers;
    326            
    327             $(this).find('.remove-comments-stat-value').text(value.toLocaleString());
    328         });
    329     }
    330    
     559        }
     560
     561        step(0);
     562    }
     563
     564    // Test the rule currently in the form, without saving it
     565    $('#ulticocl-test-form').on('click', function() {
     566        const payload = rulePayload();
     567        $modal.hide();
     568        scanRule(payload, true, payload.name);
     569    });
     570
     571    // Test or run a saved rule from the list
     572    $(document).on('click', '.ulticocl-scan-saved', function() {
     573        const button = $(this);
     574        const dry = String(button.data('dry')) === '1';
     575        const name = button.closest('.ulticocl-rule').find('h3').text();
     576        const action = button.data('action');
     577        if (!dry) {
     578            const messages = {
     579                quarantine: __('Run this rule now? Matching comments will be moved to quarantine.', td),
     580                spam: __('Run this rule now? Matching comments will be marked as spam.', td),
     581                'delete': __('Run this rule now? Matching comments will be permanently deleted.', td)
     582            };
     583            if (!confirm(messages[action] || messages.quarantine)) {
     584                return;
     585            }
     586        }
     587        scanRule({ rule_id: button.data('id') }, dry, name, action);
     588    });
     589
     590    // ---------------------------------------------------------------------
     591    // Comment lists: confirm permanent deletes
     592    // ---------------------------------------------------------------------
     593
     594    $(document).on('click', '.remove-comments-comments-panel a.submitdelete', function(e) {
     595        if (!confirm(__('Permanently delete this comment? This cannot be undone.', td))) {
     596            e.preventDefault();
     597        }
     598    });
     599
     600    $('.remove-comments-comments-panel form[method="post"]').on('submit', function(e) {
     601        const form = $(this);
     602        const selected = form.find('input[name="ids[]"]:checked').length;
     603        const action = [form.find('select[name="action"]').val(), form.find('select[name="action2"]').val()].filter(function(v) {
     604            return v && v !== '-1';
     605        })[0];
     606        if (action === 'delete' && selected > 0 && !confirm(sprintf(
     607            /* translators: %s: number of comments */
     608            _n('Permanently delete %s comment? This cannot be undone.', 'Permanently delete %s comments? This cannot be undone.', selected, td),
     609            selected.toLocaleString()
     610        ))) {
     611            e.preventDefault();
     612        }
     613    });
     614
     615    // ---------------------------------------------------------------------
     616    // Dashboard, filters and settings
     617    // ---------------------------------------------------------------------
     618
    331619    // Checkbox group handling
    332620    $('input[name="status[]"]').on('change', function() {
     621        if (!$(this).closest('#remove-comments-delete-form').length) {
     622            return;
     623        }
    333624        if ($(this).val() === 'all' && $(this).is(':checked')) {
    334625            $('input[name="status[]"]').not(this).prop('checked', false);
    … …  
    337628        }
    338629    });
    339    
     630
    340631    $('input[name="post_type[]"]').on('change', function() {
    341632        if ($(this).val() === 'all' && $(this).is(':checked')) {
    … …  
    345636        }
    346637    });
    347    
     638
    348639    // Toggle email field visibility based on notification checkbox
    349640    $('input[name="send_email_notifications"]').on('change', function() {
    … …  
    354645        }
    355646    });
    356    
     647
    357648    // Settings Form - Use AJAX for better UX
    358649    $('#remove-comments-settings-form').on('submit', function(e) {
    359650        e.preventDefault();
    360        
     651
    361652        const notificationEmail = $('#rc_notification_email').val();
    362653        if ($('input[name="send_email_notifications"]').is(':checked') && (!notificationEmail || !notificationEmail.trim())) {
    363             showAlert('Please enter an email address for notifications.', 'error');
     654            showAlert(__('Please enter an email address for notifications.', td), 'error');
    364655            return;
    365656        }
    366        
    367         const formData = {
     657
     658        const submitButton = $(this).find('button[type="submit"]');
     659        const originalText = submitButton.text();
     660        submitButton.prop('disabled', true).text(__('Saving...', td));
     661
     662        post({
    368663            action: 'ulticocl_save_settings',
    369             nonce: ulticoclData.nonce,
    370664            enable_logging: $('input[name="enable_logging"]').is(':checked') ? 1 : 0,
    371665            send_email_notifications: $('input[name="send_email_notifications"]').is(':checked') ? 1 : 0,
    372             notification_email: notificationEmail
    373         };
    374        
    375         const submitButton = $(this).find('button[type="submit"]');
    376         const originalText = submitButton.text();
    377        
    378         $.ajax({
    379             url: ulticoclData.ajaxurl,
    380             type: 'POST',
    381             data: formData,
    382             beforeSend: function() {
    383                 submitButton.prop('disabled', true).text('Saving...');
    384             },
    385             success: function(response) {
    386                 if (response.success) {
    387                     showAlert(response.data.message || 'Settings have been saved successfully.', 'success');
    388                     // Reload to show updated settings and notice
    389                     setTimeout(function() {
    390                         const url = new URL(window.location.href);
    391                         url.searchParams.set('tab', 'settings');
    392                         url.searchParams.set('settings-updated', 'true');
    393                         window.location.href = url.toString();
    394                     }, 1000);
    395                 } else {
    396                     showAlert(response.data.message || 'An error occurred while saving settings.', 'error');
    397                 }
    398             },
    399             error: function() {
    400                 showAlert('An error occurred while saving settings.', 'error');
    401             },
    402             complete: function() {
     666            notification_email: notificationEmail,
     667            quarantine_days: $('#ulticocl_quarantine_days').val(),
     668            allowlist: $('#ulticocl_allowlist').val()
     669        }).done(function(response) {
     670            if (response.success) {
     671                // Reload to show updated settings and notice
     672                const url = new URL(window.location.href);
     673                url.searchParams.set('tab', 'settings');
     674                url.searchParams.set('settings-updated', 'true');
     675                window.location.href = url.toString();
     676            } else {
     677                showAlert(errorMessage(response), 'error');
    403678                submitButton.prop('disabled', false).text(originalText);
    404679            }
     680        }).fail(function() {
     681            showAlert(__('An error occurred while saving settings.', td), 'error');
     682            submitButton.prop('disabled', false).text(originalText);
    405683        });
    406684    });
  • ultimate-comment-cleaner/trunk/readme.txt

    r3446581 r3752017  
    11=== Ultimate Comment Cleaner ===
    22Contributors: wpunicorn
    3 Tags: delete comments, bulk delete comment, remove comment,stop spam, clean comment
    4 Requires at least: 5.0
    5 Tested up to: 6.9
    6 Stable tag: 1.0.0
     3Tags: spam, delete comments, comment cleaner, quarantine, bulk delete
     4Requires at least: 5.5
     5Tested up to: 7.1
     6Requires PHP: 7.4
     7Stable tag: 1.1.0
    78License: GPLv2 or later
    89License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1011Author URI: https://profiles.wordpress.org/wpunicorn/
    1112
    12 Bulk delete WordPress comments with advanced filters, triggers, and a modern dashboard. Delete by status, date, author, and more.
     13Stop comment spam with smart rules: keywords, Cyrillic/Chinese text, links. Quarantine, review and bulk-clean comments. Restore any mistake.
    1314
    1415== Description ==
    1516
    16 **Ultimate Comment Cleaner** is a comprehensive WordPress comment management plugin that allows you to efficiently delete comments in bulk with advanced filtering options. Whether you need to clean up spam, remove old comments, or manage comments by specific criteria, this plugin provides all the tools you need.
     17**Ultimate Comment Cleaner** catches comment spam by *what it says*, keeps what it catches in a quarantine you can restore from, and gives you a proper screen to search, filter and clean up every comment on your site.
    1718
    18 = Key Features =
     19Getting hit by Russian gambling spam, Chinese link farms or "buy followers" offers? Pick a ready-made rule, test it against your existing comments to see exactly what it would catch, then switch it on.
    1920
    20 * **Creative Dashboard** - Beautiful, modern dashboard with real-time statistics and visual cards
    21 * **Bulk Delete** - Delete comments by status, date range, post type, author, and more
    22 * **Advanced Filters** - Filter by URL presence, word count, and custom criteria
    23 * **Auto Triggers** - Set up automatic deletion rules that run on schedule
    24 * **Export Functionality** - Export comments to CSV before deletion
    25 * **Preview Count** - See how many comments match your criteria before deleting
    26 * **Activity Log** - Track all deletion activities
    27 * **Real-time Stats** - Live statistics that update automatically
    28 * **WordPress Standards** - Follows WordPress coding standards and best practices
     21= Smart Rules =
    2922
    30 = Dashboard Features =
     23Rules check **new comments as they arrive** and can clean up **existing comments on a schedule**. A rule can look for:
    3124
    32 * Visual stat cards showing comment counts by status
    33 * Total comments, approved, pending, spam, and trash counts
    34 * Active triggers counter
    35 * Recent activity log
    36 * Quick action buttons
     25* **Keywords and phrases** - whole word (`casino`), word start (`casino*`), anywhere (`*casino*`) or a regular expression. English, Russian and any other language.
     26* **Writing system** - flag comments written mostly in Cyrillic, Chinese, Japanese, Korean, Arabic, Thai, Hebrew, Hindi or Greek.
     27* **Links** - comments with N or more links, or a website filled in.
     28* **Short comments** - fewer than N words. Combine with "ALL" for rules such as "short comment with a link".
     29* **Where to look** - comment text, name, email or website.
     30* **Status and age** - for existing comments, such as "pending comments older than 30 days".
    3731
    38 = Bulk Delete Options =
     32Ready-made rules to start from: Russian/Cyrillic text, Gambling spam (English and Russian), Chinese/Japanese/Korean text, Link stuffing, Short comment with a link, Pharmacy spam, Crypto and get-rich-quick, SEO and promotion spam, Adult content.
    3933
    40 * Filter by status (All, Approved, Pending, Spam, Trash)
    41 * Date range filtering
    42 * Post type filtering
    43 * Author filtering
    44 * Comments with/without URLs
    45 * Short comments (less than 10 words)
    46 * Custom word count range (min/max words)
    47 * Preview count before deletion
    48 * Export to CSV
     34What a rule does when it matches is up to you: **move to quarantine** (recommended), **mark as spam** or **delete permanently**.
    4935
    50 = Auto Triggers =
     36= Test before you trust it =
    5137
    52 * Create automatic deletion rules
    53 * Schedule: hourly, daily, or weekly
    54 * Filter by status and age (older than X days)
    55 * Enable/disable individually
    56 * View execution history
     38Every rule has a **Test on existing comments** button. It shows how many comments would match, a sample of them and why, and changes nothing. One click opens the matches in the comment list, where you can approve, spam, quarantine or delete them individually or in bulk.
     39
     40= Quarantine =
     41
     42Caught comments are hidden from your site but not lost. The Quarantine screen shows which rule caught each comment and why, lets you **restore** or delete them, and removes them automatically after the number of days you choose. A comment you restore is never caught by your rules again.
     43
     44= Dashboard =
     45
     46See what is happening at a glance. Stat tiles with trends, comments per day by status, how many comments your rules stopped, when spam arrives, and which writing systems, websites and words show up in spam. A rule performance table shows how many comments each rule caught and how often you had to restore them, and plain-language suggestions point out what needs attention. Switch between the last 7, 30 and 90 days. Every chart has a table view, and nothing is loaded from outside your site.
     47
     48= Manage Comments =
     49
     50A full comment list inside the plugin: search by text, name, email, website or IP, filter by links, website and date, see spam signals under each comment (links, writing system), and approve, unapprove, mark as spam, quarantine, edit or delete in bulk.
     51
     52= Bulk Clean =
     53
     54Delete, mark as spam or quarantine comments in bulk with filters for status, date range, post type, author, search text, links and word count. Preview the count first. Large clean-ups run in batches with a progress counter, so they do not time out. Export matching comments to CSV before you delete.
     55
     56= Built to be safe =
     57
     58* Quarantine is the default action, and everything in it can be restored.
     59* Rules that could match every comment are refused.
     60* Comments by moderators and administrators are never touched. Add trusted email addresses or domains to the allowlist.
     61* Only real comments, pingbacks and trackbacks are managed. WooCommerce reviews and order notes are left alone.
     62* Confirmations show the exact number of comments before anything permanent happens.
    5763
    5864== Installation ==
    … …  
    6470== Frequently Asked Questions ==
    6571
    66 = How do I delete all spam comments? =
     72= How do I stop Russian gambling spam? =
    6773
    68 Go to **Tools → Ultimate Comment Cleaner → Bulk Delete**, select "Spam" in the status filter, and click "Delete Comments".
     74Go to **Tools → Ultimate Comment Cleaner → Smart Rules** and click **Gambling spam** (keywords in English and Russian). Click **Test on existing comments** to see what it would catch, then **Save Rule**. For comments written in Russian generally, also add **Russian / Cyrillic text**.
    6975
    70 = Can I preview how many comments will be deleted? =
     76= What happens to a comment a rule catches? =
    7177
    72 Yes! Use the "Preview Count" button to see how many comments match your criteria before deleting.
     78By default it is moved to quarantine: hidden from your site and from the normal comment screen, but kept so you can restore it. Choose "Mark as spam" or "Delete permanently" per rule if you prefer.
    7379
    74 = How do I set up automatic spam cleanup? =
     80= Can a rule catch good comments by mistake? =
    7581
    76 Go to **Tools → Ultimate Comment Cleaner → Auto Triggers**, click "Add New Trigger", set the status to "Spam", choose a schedule, and enable it.
     82It can, which is why rules default to quarantine and have a test button. Restore a good comment from the Quarantine tab and it is never caught by your rules again. You can also add trusted email addresses or domains to the allowlist in Settings.
     83
     84= Does it check new comments or only old ones? =
     85
     86Both. Each rule can apply to new comments as they arrive, to existing comments on an hourly, daily or weekly schedule, or to both.
     87
     88= How long do quarantined comments stay? =
     89
     9014 days by default. Change it in **Settings**, or set 0 to keep them until you delete them.
     91
     92= Does it work with Akismet? =
     93
     94Yes. Comments Akismet already marks as spam are left to Akismet. Your rules add extra checks on top, such as keywords and writing systems.
     95
     96= Where did my quarantined comments go if I deactivate the plugin? =
     97
     98Quarantined comments stay hidden while the plugin is deactivated. Reactivate it to see them again. If you delete the plugin, they are handed back to WordPress as spam.
     99
     100= I used Auto Triggers in version 1.0. What happened to them? =
     101
     102They are imported as Smart Rules that delete exactly as before. A trigger that had no age limit (and so could have deleted every approved or pending comment) is imported switched off. Review it under Smart Rules.
     103
     104= Can I preview how many comments will be affected? =
     105
     106Yes. Bulk Clean has a "Preview Count" button, rules have a "Test on existing comments" button, and every confirmation shows the exact number.
    77107
    78108= Can I export comments before deleting them? =
    79109
    80 Yes! Use the "Export Selected" or "Export All Comments" buttons to download comments as a CSV file.
     110Yes. Use "Export Selected" or "Export All Comments" to download comments as a CSV file.
    81111
    82112= What permissions are required? =
    83113
    84114You need the `manage_options` capability (typically Administrator role) to use this plugin.
    85 
    86 = Why are some comments left behind after deletion? =
    87 
    88 This has been fixed in version 1.0.0. The plugin now uses WordPress's `wp_delete_comment()` function which properly cleans up all comment data including meta and post counts.
    89115
    90116== Screenshots ==
    … …  
    96122
    97123== Changelog ==
     124
     125= 1.1.0 =
     126* New: Smart Rules - catch spam by keywords, writing system (Cyrillic, Chinese, Japanese, Korean and more), links, website and comment length, on new comments and on existing ones
     127* New: nine ready-made rules, including Russian/Cyrillic text and Gambling spam in English and Russian
     128* New: test any rule on your existing comments before using it, then review the matches in the comment list
     129* New: Quarantine - caught comments are hidden but restorable, and are deleted automatically after the number of days you set
     130* New: Manage Comments - search, filter, edit and bulk-manage all comments inside the plugin
     131* New: redesigned dashboard with trend charts, spam insights, rule accuracy and suggestions for the last 7, 30 or 90 days
     132* New: Bulk Clean can quarantine, mark as spam or delete, and can search by text
     133* New: allowlist of trusted email addresses and domains that rules never touch
     134* New: confirmations for permanent deletes in the comment lists
     135* Change: "Auto Triggers" are now Smart Rules; existing triggers are imported automatically
     136* Fix: Creating a new auto trigger no longer overwrites the previous one
     137* Fix: "Export Selected" now exports only the comments matching your filters, including word-count filters
     138* Fix: Activity logging and email notifications can now be turned off
     139* Fix: Scheduled rules no longer drift on sites with a non-UTC timezone
     140* Fix: Clicking outside the rule dialog now closes it
     141* Safety: Invalid or missing filters now match nothing instead of all comments
     142* Safety: Rules that would match every comment are refused
     143* Safety: Exported CSV cells are protected against spreadsheet formula injection
     144* Improvement: Bulk actions run in batches with a progress counter, so large clean-ups no longer time out
     145* Improvement: Confirmation shows the exact number of comments affected
     146* Improvement: Faster deletion (deferred comment counting, no site-wide cache flush)
     147* Improvement: Large exports are streamed in chunks to save memory
     148* Improvement: All interface messages are now translatable
     149* Improvement: Scheduled events are removed on deactivation; plugin data is removed on uninstall
     150* Requires WordPress 5.5 and PHP 7.4 or newer
    98151
    99152= 1.0.0 =
    … …  
    111164== Upgrade Notice ==
    112165
     166= 1.1.0 =
     167Big update: Smart Rules catch spam by keywords, language and links, with a restorable quarantine and a full comment manager. Fixes new triggers overwriting each other. Existing triggers are imported as rules.
     168
    113169= 1.0.0 =
    114170Initial release of Ultimate Comment Cleaner. Install to start managing your comments efficiently!
  • ultimate-comment-cleaner/trunk/ultimate-comment-cleaner.php

    r3446581 r3752017  
    33 * Plugin Name: Ultimate Comment Cleaner
    44 * Plugin URI: https://wordpress.org/plugins/ultimate-comment-cleaner/
    5  * Description: A powerful WordPress plugin to bulk delete comments with advanced filters, triggers, and a beautiful creative dashboard. Delete comments by status, date range, author, post type, and more.
    6  * Version: 1.0.0
     5 * Description: Stop comment spam with smart rules (keywords, writing system, links), a restorable quarantine, a full comment manager and bulk clean-up tools.
     6 * Version: 1.1.0
    77 * Author: wpunicorn
    88 * Author URI: https://profiles.wordpress.org/wpunicorn/
    … …  
    1010 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1111 * Text Domain: ultimate-comment-cleaner
    12  * Requires at least: 5.0
    13  * Tested up to: 6.9
     12 * Requires at least: 5.5
     13 * Requires PHP: 7.4
     14 * Tested up to: 7.1
    1415 */
    1516
    … …  
    2526 */
    2627if (!defined('ULTICOCL_PLUGIN_VERSION')) {
    27     define('ULTICOCL_PLUGIN_VERSION', '1.0.0');
     28    define('ULTICOCL_PLUGIN_VERSION', '1.1.0');
    2829}
    2930if (!defined('ULTICOCL_PLUGIN_DIR')) {
    … …  
    3738}
    3839
     40require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-matcher.php';
     41require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-insights.php';
     42
    3943class ULTICOCL_Plugin {
    40    
     44
     45    /**
     46     * Comments handled per request / per cron batch. Keeps each request well under host time limits.
     47     */
     48    const BATCH_SIZE = 500;
     49
     50    /**
     51     * Seconds one cron run may spend scanning rules before it carries on next time.
     52     */
     53    const CRON_BUDGET = 20;
     54
     55    /**
     56     * comment_approved value for quarantined comments. Core ignores unknown statuses,
     57     * so quarantined comments stay out of the site, the comment screen and trash handling.
     58     */
     59    const QUARANTINE = 'ulticocl_quarantine';
     60
     61    /**
     62     * Allowed status filter values mapped to comment_approved column values.
     63     */
     64    const STATUS_MAP = array(
     65        'approved'   => '1',
     66        'pending'    => '0',
     67        'spam'       => 'spam',
     68        'trash'      => 'trash',
     69        'quarantine' => self::QUARANTINE,
     70    );
     71
     72    /**
     73     * Comment types this plugin manages. Other types (WooCommerce notes, reviews...) are never touched.
     74     */
     75    const TYPES = array('comment', 'pingback', 'trackback');
     76
     77    const SCHEDULES = array('hourly', 'daily', 'weekly');
     78
     79    const COMMENT_ACTIONS = array('approve', 'unapprove', 'spam', 'quarantine', 'restore', 'delete');
     80
    4181    private static $instance = null;
    42    
     82
     83    /** @var array|null Rule that flagged the comment currently being inserted: array(id, rule, reason). */
     84    private $pending = null;
     85
     86    /** @var int[] New comments to delete once WordPress has finished inserting them. */
     87    private $deferred_deletes = array();
     88
    4389    public static function get_instance() {
    4490        if (null === self::$instance) {
    … …  
    4793        return self::$instance;
    4894    }
    49    
     95
    5096    private function __construct() {
    5197        $this->init_hooks();
    5298    }
    53    
     99
    54100    private function init_hooks() {
    55101        add_action('admin_menu', array($this, 'add_admin_menu'));
    56102        add_action('admin_enqueue_scripts', array($this, 'enqueue_scripts'));
    57         add_action('wp_ajax_ulticocl_delete_comments', array($this, 'ajax_delete_comments'));
    58         add_action('wp_ajax_ulticocl_get_stats', array($this, 'ajax_get_stats'));
     103        add_action('wp_ajax_ulticocl_bulk_process', array($this, 'ajax_bulk_process'));
     104        add_action('wp_ajax_ulticocl_dashboard', array($this, 'ajax_dashboard'));
    59105        add_action('wp_ajax_ulticocl_export_comments', array($this, 'ajax_export_comments'));
    60         add_action('wp_ajax_ulticocl_save_trigger', array($this, 'ajax_save_trigger'));
    61         add_action('wp_ajax_ulticocl_delete_trigger', array($this, 'ajax_delete_trigger'));
    62         add_action('wp_ajax_ulticocl_get_trigger', array($this, 'ajax_get_trigger'));
     106        add_action('wp_ajax_ulticocl_save_rule', array($this, 'ajax_save_rule'));
     107        add_action('wp_ajax_ulticocl_delete_rule', array($this, 'ajax_delete_rule'));
     108        add_action('wp_ajax_ulticocl_get_rule', array($this, 'ajax_get_rule'));
     109        add_action('wp_ajax_ulticocl_scan_rule', array($this, 'ajax_scan_rule'));
    63110        add_action('wp_ajax_ulticocl_preview_count', array($this, 'ajax_preview_count'));
    64111        add_action('wp_ajax_ulticocl_save_settings', array($this, 'ajax_save_settings'));
    65        
    66         // Handle settings form submission (non-AJAX fallback)
     112
     113        // Settings form (non-AJAX fallback) and row/bulk actions on the comment lists
    67114        add_action('admin_init', array($this, 'handle_settings_save'));
    68        
    69         // Scheduled triggers
    70         add_action('ulticocl_check_triggers', array($this, 'check_triggers'));
    71         if (!wp_next_scheduled('ulticocl_check_triggers')) {
    72             wp_schedule_event(time(), 'hourly', 'ulticocl_check_triggers');
    73         }
    74        
     115        add_action('admin_init', array($this, 'handle_comment_actions'));
     116
     117        // Screen new comments as they arrive
     118        add_filter('pre_comment_approved', array($this, 'screen_new_comment'), 20, 2);
     119        add_action('comment_post', array($this, 'after_new_comment'), 5);
     120
     121        add_action('init', array($this, 'maybe_upgrade'), 1);
     122
     123        // Scheduled rule runs and quarantine clean-up
     124        add_action('ulticocl_run_rules', array($this, 'run_scheduled_rules'));
     125        add_action('ulticocl_purge_quarantine', array($this, 'purge_quarantine'));
     126        if (!wp_next_scheduled('ulticocl_run_rules')) {
     127            wp_schedule_event(time(), 'hourly', 'ulticocl_run_rules');
     128        }
     129        if (!wp_next_scheduled('ulticocl_purge_quarantine')) {
     130            wp_schedule_event(time(), 'daily', 'ulticocl_purge_quarantine');
     131        }
     132
    75133        // Add settings link to plugins page
    76134        add_filter('plugin_action_links_' . ULTICOCL_PLUGIN_BASENAME, array($this, 'add_plugin_action_links'));
    … …  
    103161       
    104162        // Enqueue scripts
    105         wp_enqueue_script('jquery');
    106163        wp_enqueue_script(
    107164            'ulticocl-admin-script',
    108165            ULTICOCL_PLUGIN_URL . 'assets/js/admin.js',
    109             array('jquery'),
     166            array('jquery', 'wp-i18n'),
    110167            ULTICOCL_PLUGIN_VERSION,
    111168            true
    112169        );
    113        
    114         // Note: SweetAlert2 removed - external CDN resources not allowed
    115         // Using WordPress native confirmations via JavaScript confirm() instead
    116        
    117         // Localize script
     170        wp_set_script_translations('ulticocl-admin-script', 'ultimate-comment-cleaner');
     171
     172        $presets = array();
     173        foreach (ULTICOCL_Matcher::presets() as $key => $preset) {
     174            $presets[$key] = $preset['rule'];
     175        }
    118176        wp_localize_script('ulticocl-admin-script', 'ulticoclData', array(
    119177            'ajaxurl' => admin_url('admin-ajax.php'),
    120178            'nonce' => wp_create_nonce('ulticocl_nonce'),
    121             'strings' => array(
    122                 'confirm_delete' => __('Are you sure you want to delete these comments? This action cannot be undone.', 'ultimate-comment-cleaner'),
    123                 'deleting' => __('Deleting comments...', 'ultimate-comment-cleaner'),
    124                 'success' => __('Comments deleted successfully!', 'ultimate-comment-cleaner'),
    125                 'error' => __('An error occurred. Please try again.', 'ultimate-comment-cleaner'),
    126             )
     179            'presets' => $presets,
     180            'ruleDefaults' => ULTICOCL_Matcher::defaults(),
     181            'reviewUrl' => add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => 'manage', 'review' => 1), admin_url('tools.php')),
    127182        ));
     183
     184        // The charts only exist on the dashboard, so only load them there.
     185        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter is for navigation only.
     186        $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard';
     187        if ('dashboard' === $tab) {
     188            wp_enqueue_style('ulticocl-dashboard-style', ULTICOCL_PLUGIN_URL . 'assets/css/dashboard.css', array('ulticocl-admin-style'), ULTICOCL_PLUGIN_VERSION);
     189            wp_enqueue_script('ulticocl-dashboard-script', ULTICOCL_PLUGIN_URL . 'assets/js/dashboard.js', array('ulticocl-admin-script', 'wp-i18n'), ULTICOCL_PLUGIN_VERSION, true);
     190            wp_set_script_translations('ulticocl-dashboard-script', 'ultimate-comment-cleaner');
     191            // First paint uses this data; the script refreshes it over AJAX. Hex-escaped so it is safe inside a script tag.
     192            wp_add_inline_script(
     193                'ulticocl-dashboard-script',
     194                'window.ulticoclDash = ' . wp_json_encode(ULTICOCL_Insights::collect($this->dashboard_days(), $this->get_comment_stats()), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';',
     195                'before'
     196            );
     197        }
    128198    }
    129199   
    … …  
    134204       
    135205        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab parameter is for navigation only, not form submission
    136         $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'dashboard';
    137        
     206        $current_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard';
     207        if ('triggers' === $current_tab) {
     208            $current_tab = 'rules'; // Old bookmarks from version 1.0.
     209        }
     210        if (!in_array($current_tab, array('dashboard', 'manage', 'quarantine', 'rules', 'delete', 'settings'), true)) {
     211            $current_tab = 'dashboard';
     212        }
     213        $quarantined = (int) get_comments(array('count' => true, 'status' => self::QUARANTINE));
     214        $tabs = array(
     215            'dashboard'  => array('dashicons-dashboard', __('Dashboard', 'ultimate-comment-cleaner')),
     216            'manage'     => array('dashicons-admin-comments', __('Manage Comments', 'ultimate-comment-cleaner')),
     217            'quarantine' => array('dashicons-shield', __('Quarantine', 'ultimate-comment-cleaner')),
     218            'rules'      => array('dashicons-filter', __('Smart Rules', 'ultimate-comment-cleaner')),
     219            'delete'     => array('dashicons-trash', __('Bulk Clean', 'ultimate-comment-cleaner')),
     220            'settings'   => array('dashicons-admin-settings', __('Settings', 'ultimate-comment-cleaner')),
     221        );
     222
    138223        ?>
    139224        <div class="wrap remove-comments-wrap">
    … …  
    142227                    <?php echo esc_html__('Ultimate Comment Cleaner', 'ultimate-comment-cleaner'); ?>
    143228                </h1>
    144                 <p class="remove-comments-subtitle"><?php echo esc_html__('Advanced comment management with powerful bulk deletion tools', 'ultimate-comment-cleaner'); ?></p>
     229                <p class="remove-comments-subtitle"><?php echo esc_html__('Smart rules, quarantine and bulk tools to keep your comments clean', 'ultimate-comment-cleaner'); ?></p>
    145230            </div>
    146            
     231
    147232            <nav class="remove-comments-tabs">
    148                 <a href="?page=ultimate-comment-cleaner&tab=dashboard" class="remove-comments-tab <?php echo $current_tab === 'dashboard' ? 'active' : ''; ?>">
    149                     <span class="dashicons dashicons-dashboard"></span>
    150                     <?php echo esc_html__('Dashboard', 'ultimate-comment-cleaner'); ?>
    151                 </a>
    152                 <a href="?page=ultimate-comment-cleaner&tab=delete" class="remove-comments-tab <?php echo $current_tab === 'delete' ? 'active' : ''; ?>">
    153                     <span class="dashicons dashicons-trash"></span>
    154                     <?php echo esc_html__('Bulk Delete', 'ultimate-comment-cleaner'); ?>
    155                 </a>
    156                 <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-tab <?php echo $current_tab === 'triggers' ? 'active' : ''; ?>">
    157                     <span class="dashicons dashicons-clock"></span>
    158                     <?php echo esc_html__('Auto Triggers', 'ultimate-comment-cleaner'); ?>
    159                 </a>
    160                 <a href="?page=ultimate-comment-cleaner&tab=settings" class="remove-comments-tab <?php echo $current_tab === 'settings' ? 'active' : ''; ?>">
    161                     <span class="dashicons dashicons-admin-settings"></span>
    162                     <?php echo esc_html__('Settings', 'ultimate-comment-cleaner'); ?>
    163                 </a>
     233                <?php foreach ($tabs as $slug => $tab) : ?>
     234                    <a href="<?php echo esc_url(add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => $slug), admin_url('tools.php'))); ?>" class="remove-comments-tab <?php echo $current_tab === $slug ? 'active' : ''; ?>">
     235                        <span class="dashicons <?php echo esc_attr($tab[0]); ?>"></span>
     236                        <?php echo esc_html($tab[1]); ?>
     237                        <?php if ('quarantine' === $slug && $quarantined > 0) : ?>
     238                            <span class="ulticocl-count"><?php echo esc_html(number_format_i18n($quarantined)); ?></span>
     239                        <?php endif; ?>
     240                    </a>
     241                <?php endforeach; ?>
    164242            </nav>
    165            
     243
    166244            <div class="remove-comments-content">
    167245                <?php
     246                $this->render_notice();
    168247                switch ($current_tab) {
    169248                    case 'dashboard':
    170249                        $this->render_dashboard_tab();
    171250                        break;
     251                    case 'manage':
     252                    case 'quarantine':
     253                        $this->render_comments_tab($current_tab);
     254                        break;
     255                    case 'rules':
     256                        $this->render_rules_tab();
     257                        break;
    172258                    case 'delete':
    173259                        $this->render_delete_tab();
    174                         break;
    175                     case 'triggers':
    176                         $this->render_triggers_tab();
    177260                        break;
    178261                    case 'settings':
    … …  
    187270   
    188271    private function render_dashboard_tab() {
    189         $stats = $this->get_comment_stats();
    190272        ?>
    191         <div class="remove-comments-dashboard">
    192             <div class="remove-comments-stats-grid">
    193                 <div class="remove-comments-stat-card remove-comments-stat-primary">
    194                     <span class="dashicons dashicons-admin-comments"></span>
    195                     <div class="remove-comments-stat-content">
    196                         <div class="remove-comments-stat-label"><?php esc_html_e('Total Comments', 'ultimate-comment-cleaner'); ?></div>
    197                         <div class="remove-comments-stat-value"><?php echo number_format($stats['total']); ?></div>
    198                     </div>
     273        <div id="ulticocl-dash" class="ulticocl-dash">
     274            <div class="ulticocl-filterbar">
     275                <div class="ulticocl-seg" id="ulticocl-range" role="group" aria-label="<?php esc_attr_e('Date range', 'ultimate-comment-cleaner'); ?>">
     276                    <?php foreach (ULTICOCL_Insights::RANGES as $range) : ?>
     277                        <button type="button" data-days="<?php echo (int) $range; ?>" aria-pressed="false">
     278                            <?php
     279                            /* translators: %d: number of days */
     280                            echo esc_html(sprintf(__('Last %d days', 'ultimate-comment-cleaner'), $range));
     281                            ?>
     282                        </button>
     283                    <?php endforeach; ?>
    199284                </div>
    200                
    201                 <div class="remove-comments-stat-card remove-comments-stat-success">
    202                     <span class="dashicons dashicons-yes-alt"></span>
    203                     <div class="remove-comments-stat-content">
    204                         <div class="remove-comments-stat-label"><?php esc_html_e('Approved', 'ultimate-comment-cleaner'); ?></div>
    205                         <div class="remove-comments-stat-value"><?php echo number_format($stats['approved']); ?></div>
    206                     </div>
    207                 </div>
    208                
    209                 <div class="remove-comments-stat-card remove-comments-stat-warning">
    210                     <span class="dashicons dashicons-clock"></span>
    211                     <div class="remove-comments-stat-content">
    212                         <div class="remove-comments-stat-label"><?php esc_html_e('Pending', 'ultimate-comment-cleaner'); ?></div>
    213                         <div class="remove-comments-stat-value"><?php echo number_format($stats['pending']); ?></div>
    214                     </div>
    215                 </div>
    216                
    217                 <div class="remove-comments-stat-card remove-comments-stat-danger">
    218                     <span class="dashicons dashicons-warning"></span>
    219                     <div class="remove-comments-stat-content">
    220                         <div class="remove-comments-stat-label"><?php esc_html_e('Spam', 'ultimate-comment-cleaner'); ?></div>
    221                         <div class="remove-comments-stat-value"><?php echo number_format($stats['spam']); ?></div>
    222                     </div>
    223                 </div>
    224                
    225                 <div class="remove-comments-stat-card remove-comments-stat-gray">
    226                     <span class="dashicons dashicons-trash"></span>
    227                     <div class="remove-comments-stat-content">
    228                         <div class="remove-comments-stat-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></div>
    229                         <div class="remove-comments-stat-value"><?php echo number_format($stats['trash']); ?></div>
    230                     </div>
    231                 </div>
    232                
    233                 <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-stat-card remove-comments-stat-info remove-comments-stat-clickable">
    234                     <span class="dashicons dashicons-admin-settings"></span>
    235                     <div class="remove-comments-stat-content">
    236                         <div class="remove-comments-stat-label"><?php esc_html_e('Triggers', 'ultimate-comment-cleaner'); ?></div>
    237                         <div class="remove-comments-stat-value"><?php echo number_format($stats['triggers']); ?></div>
    238                     </div>
    239                     <span class="dashicons dashicons-arrow-right-alt remove-comments-stat-arrow"></span>
    240                 </a>
     285                <span class="ulticocl-updated" id="ulticocl-updated" aria-live="polite"></span>
     286                <button type="button" class="ulticocl-linkbtn" id="ulticocl-refresh">
     287                    <span class="dashicons dashicons-update" aria-hidden="true"></span>
     288                    <?php esc_html_e('Refresh', 'ultimate-comment-cleaner'); ?>
     289                </button>
     290                <span class="ulticocl-filterbar-actions">
     291                    <a class="button" href="?page=ultimate-comment-cleaner&tab=manage"><?php esc_html_e('Manage comments', 'ultimate-comment-cleaner'); ?></a>
     292                    <a class="button" href="?page=ultimate-comment-cleaner&tab=rules"><?php esc_html_e('Smart Rules', 'ultimate-comment-cleaner'); ?></a>
     293                    <button type="button" class="button" id="remove-comments-export-all"><?php esc_html_e('Export all', 'ultimate-comment-cleaner'); ?></button>
     294                </span>
    241295            </div>
    242            
    243             <div class="remove-comments-quick-actions">
    244                 <h2><?php esc_html_e('Quick Actions', 'ultimate-comment-cleaner'); ?></h2>
    245                 <div class="remove-comments-action-buttons">
    246                     <a href="?page=ultimate-comment-cleaner&tab=delete" class="remove-comments-action-btn remove-comments-btn-danger">
    247                         <span class="dashicons dashicons-trash"></span>
    248                         <?php esc_html_e('Delete Comments', 'ultimate-comment-cleaner'); ?>
    249                     </a>
    250                     <a href="?page=ultimate-comment-cleaner&tab=triggers" class="remove-comments-action-btn remove-comments-btn-primary">
    251                         <span class="dashicons dashicons-admin-settings"></span>
    252                         <?php esc_html_e('Manage Triggers', 'ultimate-comment-cleaner'); ?>
    253                     </a>
    254                     <button type="button" class="remove-comments-action-btn remove-comments-btn-success" id="remove-comments-export-all">
    255                         <span class="dashicons dashicons-download"></span>
    256                         <?php esc_html_e('Export All Comments', 'ultimate-comment-cleaner'); ?>
    257                     </button>
    258                 </div>
     296
     297            <section class="ulticocl-hero" id="ulticocl-hero" aria-labelledby="ulticocl-hero-title"></section>
     298            <div class="ulticocl-tiles" id="ulticocl-tiles"></div>
     299
     300            <h2 class="ulticocl-h"><?php esc_html_e('Trends', 'ultimate-comment-cleaner'); ?></h2>
     301            <div class="ulticocl-grid">
     302                <?php
     303                $this->dash_card('comments', 8, __('Comments over time', 'ultimate-comment-cleaner'), __('New comments per day, by status', 'ultimate-comment-cleaner'), true);
     304                $this->dash_card('insights', 4, __('Insights', 'ultimate-comment-cleaner'), __('What needs your attention', 'ultimate-comment-cleaner'));
     305                $this->dash_card('status', 4, __('Where comments stand', 'ultimate-comment-cleaner'), __('All comments right now', 'ultimate-comment-cleaner'));
     306                $this->dash_card('caught', 8, __('Caught by Smart Rules', 'ultimate-comment-cleaner'), __('Comments stopped per day', 'ultimate-comment-cleaner'), true);
     307                ?>
    259308            </div>
    260            
    261             <div class="remove-comments-recent-activity">
    262                 <h2><?php esc_html_e('Recent Activity', 'ultimate-comment-cleaner'); ?></h2>
    263                 <div class="remove-comments-activity-list">
    264                     <?php $this->render_recent_activity(); ?>
    265                 </div>
     309
     310            <h2 class="ulticocl-h"><?php esc_html_e('Spam intelligence', 'ultimate-comment-cleaner'); ?></h2>
     311            <div class="ulticocl-grid">
     312                <?php
     313                $this->dash_card('hours', 8, __('When spam arrives', 'ultimate-comment-cleaner'), __('Comments by hour of day. Spam in red, everything else in gray.', 'ultimate-comment-cleaner'), true);
     314                $this->dash_card('scripts', 4, __('What spam is written in', 'ultimate-comment-cleaner'), __('Writing system of recent spam', 'ultimate-comment-cleaner'));
     315                $this->dash_card('sources', 4, __('Where spam comes from', 'ultimate-comment-cleaner'), __('Most common email domains and websites', 'ultimate-comment-cleaner'));
     316                $this->dash_card('posts', 4, __('Most targeted posts', 'ultimate-comment-cleaner'), __('Spam received per post', 'ultimate-comment-cleaner'));
     317                $this->dash_card('words', 4, __('Words that stand out', 'ultimate-comment-cleaner'), __('Common in spam and not covered by a rule', 'ultimate-comment-cleaner'));
     318                ?>
     319            </div>
     320
     321            <h2 class="ulticocl-h"><?php esc_html_e('Automation and activity', 'ultimate-comment-cleaner'); ?></h2>
     322            <div class="ulticocl-grid">
     323                <?php
     324                $this->dash_card('rules', 8, __('Rule performance', 'ultimate-comment-cleaner'), __('Comments caught in this period, and how often you restored them', 'ultimate-comment-cleaner'));
     325                $this->dash_card('automation', 4, __('Automation', 'ultimate-comment-cleaner'), __('What is running on its own', 'ultimate-comment-cleaner'));
     326                $this->dash_card('latest', 6, __('Latest caught', 'ultimate-comment-cleaner'), __('Newest comments in quarantine', 'ultimate-comment-cleaner'));
     327                $this->dash_card('activity', 6, __('Recent activity', 'ultimate-comment-cleaner'), __('Clean-ups, rule runs and settings changes', 'ultimate-comment-cleaner'));
     328                ?>
    266329            </div>
    267330        </div>
    268331        <?php
    269332    }
    270    
     333
     334    /**
     335     * A small on/off switch in the brand colour. It is a real checkbox, so it submits and
     336     * is read exactly like one; only the look differs.
     337     */
     338    private function mini_toggle($name, $value, $label, $id = '', $checked = false, $title = '') {
     339        ?>
     340        <label class="ulticocl-toggle"<?php echo '' !== $title ? ' title="' . esc_attr($title) . '"' : ''; ?>>
     341            <span class="remove-comments-toggle-switch ulticocl-mini">
     342                <input type="checkbox" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>"<?php echo '' !== $id ? ' id="' . esc_attr($id) . '"' : ''; ?> <?php checked($checked); ?>>
     343                <span class="remove-comments-toggle-slider"></span>
     344            </span>
     345            <span class="ulticocl-toggle-label"><?php echo esc_html($label); ?></span>
     346        </label>
     347        <?php
     348    }
     349
     350    /**
     351     * One dashboard card. The dashboard script fills its body from the insights data.
     352     */
     353    private function dash_card($slot, $span, $title, $subtitle = '', $table_toggle = false) {
     354        ?>
     355        <section class="ulticocl-card ulticocl-span-<?php echo (int) $span; ?>" data-card="<?php echo esc_attr($slot); ?>">
     356            <header class="ulticocl-card-head">
     357                <div>
     358                    <h3><?php echo esc_html($title); ?></h3>
     359                    <?php if ('' !== $subtitle) : ?>
     360                        <p class="ulticocl-sub"><?php echo esc_html($subtitle); ?></p>
     361                    <?php endif; ?>
     362                </div>
     363                <?php if ($table_toggle) : ?>
     364                    <div class="ulticocl-seg ulticocl-seg-small" role="group" aria-label="<?php esc_attr_e('View', 'ultimate-comment-cleaner'); ?>" data-toggle="<?php echo esc_attr($slot); ?>">
     365                        <button type="button" data-view="chart" aria-pressed="true"><?php esc_html_e('Chart', 'ultimate-comment-cleaner'); ?></button>
     366                        <button type="button" data-view="table" aria-pressed="false"><?php esc_html_e('Table', 'ultimate-comment-cleaner'); ?></button>
     367                    </div>
     368                <?php endif; ?>
     369            </header>
     370            <div class="ulticocl-card-body" data-slot="<?php echo esc_attr($slot); ?>"></div>
     371        </section>
     372        <?php
     373    }
     374
     375    /**
     376     * Date range the dashboard opens with: the last one this user picked.
     377     */
     378    private function dashboard_days() {
     379        $days = (int) get_user_meta(get_current_user_id(), 'ulticocl_dash_days', true);
     380        return in_array($days, ULTICOCL_Insights::RANGES, true) ? $days : 30;
     381    }
     382
    271383    private function render_delete_tab() {
    272384        $post_types = get_post_types(array('public' => true), 'objects');
    … …  
    275387        <div class="remove-comments-delete-panel">
    276388            <div class="remove-comments-panel-header">
    277                 <h2><?php esc_html_e('Bulk Delete Comments', 'ultimate-comment-cleaner'); ?></h2>
    278                 <p><?php esc_html_e('Select filters to find and delete comments matching your criteria', 'ultimate-comment-cleaner'); ?></p>
     389                <h2><?php esc_html_e('Bulk Clean Comments', 'ultimate-comment-cleaner'); ?></h2>
     390                <p><?php esc_html_e('Select filters, then quarantine, mark as spam or delete everything that matches. Quarantined comments can be restored.', 'ultimate-comment-cleaner'); ?></p>
    279391            </div>
    280392           
    … …  
    318430                            <span class="remove-comments-toggle-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></span>
    319431                        </label>
     432                        <label>
     433                            <div class="remove-comments-toggle-switch">
     434                                <input type="checkbox" name="status[]" value="quarantine">
     435                                <span class="remove-comments-toggle-slider"></span>
     436                            </div>
     437                            <span class="remove-comments-toggle-label"><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></span>
     438                        </label>
    320439                    </div>
    321440                </div>
    322                
     441
     442                <div class="remove-comments-form-section">
     443                    <h3><?php esc_html_e('Search Text', 'ultimate-comment-cleaner'); ?></h3>
     444                    <input type="search" name="search" id="remove-comments-search" class="remove-comments-form-input" placeholder="<?php esc_attr_e('Word or phrase in the comment, name, email or website', 'ultimate-comment-cleaner'); ?>">
     445                </div>
     446
    323447                <div class="remove-comments-form-section">
    324448                    <h3><?php esc_html_e('Filter by Date Range', 'ultimate-comment-cleaner'); ?></h3>
    … …  
    377501                <div class="remove-comments-form-section">
    378502                    <h3><?php esc_html_e('Additional Filters', 'ultimate-comment-cleaner'); ?></h3>
    379                     <div class="remove-comments-advanced-filters">
    380                         <label>
    381                             <div class="remove-comments-toggle-switch">
    382                                 <input type="checkbox" name="has_url">
    383                                 <span class="remove-comments-toggle-slider"></span>
    384                             </div>
    385                             <span class="remove-comments-toggle-label"><?php esc_html_e('Comments with URL only', 'ultimate-comment-cleaner'); ?></span>
    386                         </label>
    387                         <label>
    388                             <div class="remove-comments-toggle-switch">
    389                                 <input type="checkbox" name="no_url">
    390                                 <span class="remove-comments-toggle-slider"></span>
    391                             </div>
    392                             <span class="remove-comments-toggle-label"><?php esc_html_e('Comments without URL', 'ultimate-comment-cleaner'); ?></span>
    393                         </label>
    394                         <label>
    395                             <div class="remove-comments-toggle-switch">
    396                                 <input type="checkbox" name="short_comments">
    397                                 <span class="remove-comments-toggle-slider"></span>
    398                             </div>
    399                             <span class="remove-comments-toggle-label"><?php esc_html_e('Short comments (less than 10 words)', 'ultimate-comment-cleaner'); ?></span>
    400                         </label>
    401                         <label>
    402                             <input type="number" name="min_words" placeholder="<?php esc_html_e('Minimum words', 'ultimate-comment-cleaner'); ?>" min="0">
    403                         </label>
    404                         <label>
    405                             <input type="number" name="max_words" placeholder="<?php esc_html_e('Maximum words', 'ultimate-comment-cleaner'); ?>" min="0">
    406                         </label>
     503                    <div class="ulticocl-toggle-grid" data-cols="3">
     504                        <?php
     505                        $this->mini_toggle('has_url', '1', __('Comments with URL only', 'ultimate-comment-cleaner'));
     506                        $this->mini_toggle('no_url', '1', __('Comments without URL', 'ultimate-comment-cleaner'));
     507                        $this->mini_toggle('short_comments', '1', __('Short comments (under 10 words)', 'ultimate-comment-cleaner'));
     508                        ?>
     509                    </div>
     510                    <div class="ulticocl-grid-2 ulticocl-row-after-toggles">
     511                        <div class="remove-comments-form-group">
     512                            <label for="ulticocl-min-words"><?php esc_html_e('Minimum words', 'ultimate-comment-cleaner'); ?></label>
     513                            <input type="number" name="min_words" id="ulticocl-min-words" min="0" placeholder="0">
     514                        </div>
     515                        <div class="remove-comments-form-group">
     516                            <label for="ulticocl-max-words"><?php esc_html_e('Maximum words', 'ultimate-comment-cleaner'); ?></label>
     517                            <input type="number" name="max_words" id="ulticocl-max-words" min="0" placeholder="0">
     518                        </div>
    407519                    </div>
    408520                </div>
    409                
    410                 <div class="remove-comments-form-actions">
    411                     <button type="button" id="remove-comments-preview-count" class="remove-comments-btn remove-comments-btn-secondary">
    412                         <?php esc_html_e('Preview Count', 'ultimate-comment-cleaner'); ?>
     521
     522                <div class="remove-comments-form-section">
     523                    <h3><?php esc_html_e('What to do with matching comments', 'ultimate-comment-cleaner'); ?></h3>
     524                    <select name="clean_action" id="remove-comments-clean-action" class="remove-comments-select">
     525                        <option value="quarantine"><?php esc_html_e('Move to quarantine (can be restored)', 'ultimate-comment-cleaner'); ?></option>
     526                        <option value="spam"><?php esc_html_e('Mark as spam', 'ultimate-comment-cleaner'); ?></option>
     527                        <option value="delete"><?php esc_html_e('Delete permanently', 'ultimate-comment-cleaner'); ?></option>
     528                    </select>
     529                    <p class="description" id="ulticocl-action-help"></p>
     530                </div>
     531
     532                <div class="remove-comments-form-actions ulticocl-form-footer">
     533                    <button type="button" id="remove-comments-preview-count" class="ulticocl-rbtn">
     534                        <span class="dashicons dashicons-visibility" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Preview Count', 'ultimate-comment-cleaner'); ?></span>
    413535                    </button>
    414                     <button type="submit" id="remove-comments-delete-btn" class="remove-comments-btn remove-comments-btn-danger">
    415                         <span class="dashicons dashicons-trash"></span>
    416                         <?php esc_html_e('Delete Comments', 'ultimate-comment-cleaner'); ?>
     536                    <button type="button" id="remove-comments-export-btn" class="ulticocl-rbtn">
     537                        <span class="dashicons dashicons-download" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Export Selected', 'ultimate-comment-cleaner'); ?></span>
    417538                    </button>
    418                     <button type="button" id="remove-comments-export-btn" class="remove-comments-btn remove-comments-btn-success">
    419                         <span class="dashicons dashicons-download"></span>
    420                         <?php esc_html_e('Export Selected', 'ultimate-comment-cleaner'); ?>
     539                    <button type="submit" id="remove-comments-delete-btn" class="ulticocl-add-btn ulticocl-apply" data-action="quarantine">
     540                        <span class="dashicons dashicons-yes" aria-hidden="true"></span><span class="ulticocl-btn-label"><?php esc_html_e('Apply to Matching Comments', 'ultimate-comment-cleaner'); ?></span>
    421541                    </button>
    422542                </div>
    423                
     543
    424544                <div id="remove-comments-preview-result" class="remove-comments-preview-result" style="display:none;"></div>
    425545            </form>
    … …  
    428548    }
    429549   
    430     private function render_triggers_tab() {
    431         $triggers = get_option('ulticocl_triggers', array());
     550    private function render_rules_tab() {
     551        $rules = $this->get_rules();
     552        $state = get_option('ulticocl_rule_state', array());
     553        $scripts = ULTICOCL_Matcher::scripts();
     554        $actions = $this->action_labels();
     555        $scopes = array(
     556            'new'      => __('New comments', 'ultimate-comment-cleaner'),
     557            'existing' => __('Existing comments', 'ultimate-comment-cleaner'),
     558            'both'     => __('New and existing comments', 'ultimate-comment-cleaner'),
     559        );
    432560        ?>
    433561        <div class="remove-comments-triggers-panel">
    434562            <div class="remove-comments-panel-header">
    435                 <h2><?php esc_html_e('Auto Delete Triggers', 'ultimate-comment-cleaner'); ?></h2>
    436                 <p><?php esc_html_e('Set up automatic triggers to find and delete comments based on conditions', 'ultimate-comment-cleaner'); ?></p>
     563                <h2><?php esc_html_e('Smart Rules', 'ultimate-comment-cleaner'); ?></h2>
     564                <p><?php esc_html_e('Catch spam by what it says. Rules check new comments as they arrive and can clean up existing ones on a schedule. Quarantined comments can always be restored.', 'ultimate-comment-cleaner'); ?></p>
    437565            </div>
    438            
    439             <button type="button" id="remove-comments-add-trigger" class="remove-comments-btn remove-comments-btn-primary">
    440                 <span class="dashicons dashicons-plus"></span>
    441                 <?php esc_html_e('Add New Trigger', 'ultimate-comment-cleaner'); ?>
    442             </button>
    443            
    444             <div id="remove-comments-triggers-list" class="remove-comments-triggers-list">
    445                 <?php if (empty($triggers)): ?>
     566
     567            <div class="ulticocl-rules-toolbar">
     568                <div class="ulticocl-presets">
     569                    <strong class="ulticocl-presets-title"><?php esc_html_e('Quick start', 'ultimate-comment-cleaner'); ?></strong>
     570                    <div class="ulticocl-presets-viewport" id="ulticocl-presets">
     571                        <div class="ulticocl-presets-track">
     572                            <?php foreach (ULTICOCL_Matcher::presets() as $key => $preset) : ?>
     573                                <button type="button" class="ulticocl-preset" data-preset="<?php echo esc_attr($key); ?>" title="<?php echo esc_attr($preset['description']); ?>">
     574                                    <span class="dashicons dashicons-<?php echo esc_attr($preset['icon']); ?>" aria-hidden="true"></span>
     575                                    <span><?php echo esc_html($preset['label']); ?></span>
     576                                </button>
     577                            <?php endforeach; ?>
     578                        </div>
     579                    </div>
     580                </div>
     581                <button type="button" id="ulticocl-add-rule" class="ulticocl-add-btn">
     582                    <span class="dashicons dashicons-plus" aria-hidden="true"></span>
     583                    <?php esc_html_e('Add New Rule', 'ultimate-comment-cleaner'); ?>
     584                </button>
     585            </div>
     586
     587            <div id="ulticocl-scan-result" class="ulticocl-scan-result" style="display:none;" role="status" aria-live="polite"></div>
     588
     589            <div id="ulticocl-rules-list" class="remove-comments-triggers-list">
     590                <?php if (empty($rules)) : ?>
    446591                    <div class="remove-comments-empty-state">
    447                         <p><?php esc_html_e('No triggers configured. Click "Add New Trigger" to create one.', 'ultimate-comment-cleaner'); ?></p>
     592                        <p><?php esc_html_e('No rules yet. Pick a Quick start above or click "Add New Rule".', 'ultimate-comment-cleaner'); ?></p>
    448593                    </div>
    449                 <?php else: ?>
    450                     <?php foreach ($triggers as $id => $trigger): ?>
    451                         <div class="remove-comments-trigger-item" data-trigger-id="<?php echo esc_attr($id); ?>">
    452                             <div class="remove-comments-trigger-header">
    453                                 <h3><?php echo esc_html($trigger['name']); ?></h3>
    454                                 <div class="remove-comments-trigger-status">
    455                                     <span class="remove-comments-status-badge <?php echo $trigger['active'] ? 'active' : 'inactive'; ?>">
    456                                         <?php echo $trigger['active'] ? esc_html__('Active', 'ultimate-comment-cleaner') : esc_html__('Inactive', 'ultimate-comment-cleaner'); ?>
     594                <?php else : ?>
     595                    <?php foreach ($rules as $id => $rule) : ?>
     596                        <?php
     597                        $st = isset($state[$id]) ? $state[$id] : array();
     598                        $last = !empty($st['last_run']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $st['last_run']) : __('Not yet', 'ultimate-comment-cleaner');
     599                        ?>
     600                        <article class="ulticocl-rule" data-rule-id="<?php echo esc_attr($id); ?>">
     601                            <header class="ulticocl-rule-head">
     602                                <h3><?php echo esc_html($rule['name']); ?></h3>
     603                                <div class="ulticocl-rule-badges">
     604                                    <span class="ulticocl-badge ulticocl-badge-action-<?php echo esc_attr($rule['action']); ?>"><?php echo esc_html($actions[$rule['action']]); ?></span>
     605                                    <span class="ulticocl-badge <?php echo $rule['active'] ? 'ulticocl-badge-on' : 'ulticocl-badge-off'; ?>">
     606                                        <?php echo $rule['active'] ? esc_html__('Active', 'ultimate-comment-cleaner') : esc_html__('Inactive', 'ultimate-comment-cleaner'); ?>
    457607                                    </span>
    458608                                </div>
    459                             </div>
    460                             <div class="remove-comments-trigger-body">
    461                                 <p><strong><?php esc_html_e('Conditions:', 'ultimate-comment-cleaner'); ?></strong> <?php echo esc_html($this->format_trigger_conditions($trigger)); ?></p>
    462                                 <p><strong><?php esc_html_e('Schedule:', 'ultimate-comment-cleaner'); ?></strong> <?php echo esc_html($trigger['schedule']); ?></p>
    463                                 <p><strong><?php esc_html_e('Last Run:', 'ultimate-comment-cleaner'); ?></strong> <?php echo isset($trigger['last_run']) ? esc_html($trigger['last_run']) : esc_html__('Never', 'ultimate-comment-cleaner'); ?></p>
    464                             </div>
    465                             <div class="remove-comments-trigger-actions">
    466                                 <button type="button" class="remove-comments-btn-small remove-comments-btn-primary remove-comments-edit-trigger" data-id="<?php echo esc_attr($id); ?>">
    467                                     <?php esc_html_e('Edit', 'ultimate-comment-cleaner'); ?>
     609                            </header>
     610                            <dl class="ulticocl-rule-meta">
     611                                <div class="is-wide">
     612                                    <dt><?php esc_html_e('Checks', 'ultimate-comment-cleaner'); ?></dt>
     613                                    <dd><?php echo esc_html($this->rule_summary($rule)); ?></dd>
     614                                </div>
     615                                <div>
     616                                    <dt><?php esc_html_e('Applies to', 'ultimate-comment-cleaner'); ?></dt>
     617                                    <dd><?php echo esc_html($scopes[$rule['scope']]); ?></dd>
     618                                </div>
     619                                <div>
     620                                    <dt><?php esc_html_e('Caught so far', 'ultimate-comment-cleaner'); ?></dt>
     621                                    <dd><?php echo esc_html(number_format_i18n(isset($st['hits']) ? (int) $st['hits'] : 0)); ?></dd>
     622                                </div>
     623                                <div>
     624                                    <dt><?php esc_html_e('Last full scan', 'ultimate-comment-cleaner'); ?></dt>
     625                                    <dd><?php echo 'new' !== $rule['scope'] ? esc_html($last) : '&mdash;'; ?></dd>
     626                                </div>
     627                            </dl>
     628                            <footer class="ulticocl-rule-actions">
     629                                <button type="button" class="ulticocl-rbtn is-primary ulticocl-edit-rule" data-id="<?php echo esc_attr($id); ?>">
     630                                    <span class="dashicons dashicons-edit" aria-hidden="true"></span><?php esc_html_e('Edit', 'ultimate-comment-cleaner'); ?>
    468631                                </button>
    469                                 <button type="button" class="remove-comments-btn-small remove-comments-btn-danger remove-comments-delete-trigger" data-id="<?php echo esc_attr($id); ?>">
    470                                     <?php esc_html_e('Delete', 'ultimate-comment-cleaner'); ?>
     632                                <button type="button" class="ulticocl-rbtn ulticocl-scan-saved" data-id="<?php echo esc_attr($id); ?>" data-dry="1">
     633                                    <span class="dashicons dashicons-visibility" aria-hidden="true"></span><?php esc_html_e('Test on existing', 'ultimate-comment-cleaner'); ?>
    471634                                </button>
    472                             </div>
    473                         </div>
     635                                <button type="button" class="ulticocl-rbtn ulticocl-scan-saved" data-id="<?php echo esc_attr($id); ?>" data-dry="0" data-action="<?php echo esc_attr($rule['action']); ?>">
     636                                    <span class="dashicons dashicons-controls-play" aria-hidden="true"></span><?php esc_html_e('Run now', 'ultimate-comment-cleaner'); ?>
     637                                </button>
     638                                <button type="button" class="ulticocl-rbtn is-danger ulticocl-delete-rule" data-id="<?php echo esc_attr($id); ?>">
     639                                    <span class="dashicons dashicons-trash" aria-hidden="true"></span><?php esc_html_e('Delete', 'ultimate-comment-cleaner'); ?>
     640                                </button>
     641                            </footer>
     642                        </article>
    474643                    <?php endforeach; ?>
    475644                <?php endif; ?>
    476645            </div>
    477            
    478             <!-- Trigger Modal -->
    479             <div id="remove-comments-trigger-modal" class="remove-comments-modal" style="display:none;">
    480                 <div class="remove-comments-modal-content">
     646
     647            <!-- Rule Modal -->
     648            <div id="ulticocl-rule-modal" class="remove-comments-modal" style="display:none;">
     649                <div class="remove-comments-modal-content ulticocl-wide" role="dialog" aria-modal="true" aria-labelledby="ulticocl-rule-title">
    481650                    <span class="remove-comments-modal-close">&times;</span>
    482                     <h2><?php esc_html_e('Create/Edit Trigger', 'ultimate-comment-cleaner'); ?></h2>
    483                     <form id="remove-comments-trigger-form">
    484                         <input type="hidden" name="trigger_id" id="remove-comments-trigger-id">
     651                    <h2 id="ulticocl-rule-title"><?php esc_html_e('Smart Rule', 'ultimate-comment-cleaner'); ?></h2>
     652                    <form id="ulticocl-rule-form">
     653                        <input type="hidden" name="rule_id" id="ulticocl-rule-id">
     654
    485655                        <div class="remove-comments-form-group">
    486                             <label><?php esc_html_e('Trigger Name', 'ultimate-comment-cleaner'); ?></label>
    487                             <input type="text" name="name" id="remove-comments-trigger-name" required>
     656                            <label for="ulticocl-rule-name"><?php esc_html_e('Rule name', 'ultimate-comment-cleaner'); ?></label>
     657                            <input type="text" name="name" id="ulticocl-rule-name" required>
    488658                        </div>
    489                         <div class="remove-comments-form-group">
    490                             <label><?php esc_html_e('Status', 'ultimate-comment-cleaner'); ?></label>
    491                             <select name="status" id="remove-comments-trigger-status" class="remove-comments-select">
    492                                 <option value="approved"><?php esc_html_e('Approved', 'ultimate-comment-cleaner'); ?></option>
    493                                 <option value="pending"><?php esc_html_e('Pending', 'ultimate-comment-cleaner'); ?></option>
    494                                 <option value="spam"><?php esc_html_e('Spam', 'ultimate-comment-cleaner'); ?></option>
    495                             </select>
     659
     660                        <label class="ulticocl-status-card" for="ulticocl-rule-active">
     661                            <span class="remove-comments-toggle-switch ulticocl-large">
     662                                <input type="checkbox" name="active" value="1" id="ulticocl-rule-active" checked>
     663                                <span class="remove-comments-toggle-slider"></span>
     664                            </span>
     665                            <span class="ulticocl-status-text">
     666                                <strong class="ulticocl-status-title"
     667                                    data-on="<?php echo esc_attr__('Rule is active', 'ultimate-comment-cleaner'); ?>"
     668                                    data-off="<?php echo esc_attr__('Rule is paused', 'ultimate-comment-cleaner'); ?>"><?php esc_html_e('Rule is active', 'ultimate-comment-cleaner'); ?></strong>
     669                                <span class="ulticocl-status-help"
     670                                    data-on="<?php echo esc_attr__('It checks new comments as they arrive and runs its scheduled scans.', 'ultimate-comment-cleaner'); ?>"
     671                                    data-off="<?php echo esc_attr__('Nothing is checked or moved until you turn it back on. Your settings are kept.', 'ultimate-comment-cleaner'); ?>"><?php esc_html_e('It checks new comments as they arrive and runs its scheduled scans.', 'ultimate-comment-cleaner'); ?></span>
     672                            </span>
     673                        </label>
     674
     675                        <div class="ulticocl-grid-2">
     676                            <div class="remove-comments-form-group">
     677                                <label for="ulticocl-rule-scope"><?php esc_html_e('Check', 'ultimate-comment-cleaner'); ?></label>
     678                                <select name="scope" id="ulticocl-rule-scope" class="remove-comments-select">
     679                                    <?php foreach ($scopes as $value => $label) : ?>
     680                                        <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
     681                                    <?php endforeach; ?>
     682                                </select>
     683                            </div>
     684                            <div class="remove-comments-form-group">
     685                                <label for="ulticocl-rule-action"><?php esc_html_e('When it matches', 'ultimate-comment-cleaner'); ?></label>
     686                                <select name="rule_action" id="ulticocl-rule-action" class="remove-comments-select">
     687                                    <?php foreach ($actions as $value => $label) : ?>
     688                                        <option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
     689                                    <?php endforeach; ?>
     690                                </select>
     691                            </div>
    496692                        </div>
    497                         <div class="remove-comments-form-group">
    498                             <label><?php esc_html_e('Older Than (days)', 'ultimate-comment-cleaner'); ?></label>
    499                             <input type="number" name="older_than" id="remove-comments-trigger-older" min="0">
    500                         </div>
    501                         <div class="remove-comments-form-group">
    502                             <label><?php esc_html_e('Schedule', 'ultimate-comment-cleaner'); ?></label>
    503                             <select name="schedule" id="remove-comments-trigger-schedule">
    504                                 <option value="hourly"><?php esc_html_e('Hourly', 'ultimate-comment-cleaner'); ?></option>
    505                                 <option value="daily"><?php esc_html_e('Daily', 'ultimate-comment-cleaner'); ?></option>
    506                                 <option value="weekly"><?php esc_html_e('Weekly', 'ultimate-comment-cleaner'); ?></option>
    507                             </select>
    508                         </div>
    509                         <div class="remove-comments-form-group">
    510                             <label>
    511                                 <div class="remove-comments-toggle-switch">
    512                                     <input type="checkbox" name="active" id="remove-comments-trigger-active" checked>
    513                                     <span class="remove-comments-toggle-slider"></span>
     693
     694                        <fieldset class="ulticocl-fieldset">
     695                            <legend><?php esc_html_e('What to look for', 'ultimate-comment-cleaner'); ?></legend>
     696
     697                            <div class="remove-comments-form-group">
     698                                <label for="ulticocl-rule-mode"><?php esc_html_e('Flag a comment when', 'ultimate-comment-cleaner'); ?></label>
     699                                <select name="mode" id="ulticocl-rule-mode" class="remove-comments-select">
     700                                    <option value="any"><?php esc_html_e('ANY of the checks below match', 'ultimate-comment-cleaner'); ?></option>
     701                                    <option value="all"><?php esc_html_e('ALL of the checks below match', 'ultimate-comment-cleaner'); ?></option>
     702                                </select>
     703                            </div>
     704
     705                            <div class="remove-comments-form-group">
     706                                <label for="ulticocl-rule-keywords"><?php esc_html_e('Keywords', 'ultimate-comment-cleaner'); ?></label>
     707                                <textarea name="keywords" id="ulticocl-rule-keywords" rows="6" spellcheck="false"></textarea>
     708                                <p class="description"><?php esc_html_e('One per line. "casino" matches the whole word only, "casino*" also matches casinos and casino88, "*casino*" matches anywhere, and /regular expression/ is supported. Not case sensitive.', 'ultimate-comment-cleaner'); ?></p>
     709                            </div>
     710
     711                            <div class="remove-comments-form-group">
     712                                <span class="ulticocl-sublabel"><?php esc_html_e('Search in', 'ultimate-comment-cleaner'); ?></span>
     713                                <div class="ulticocl-toggle-grid" data-cols="4">
     714                                    <?php
     715                                    $fields = array(
     716                                        'content' => __('Comment text', 'ultimate-comment-cleaner'),
     717                                        'author'  => __('Name', 'ultimate-comment-cleaner'),
     718                                        'email'   => __('Email', 'ultimate-comment-cleaner'),
     719                                        'url'     => __('Website', 'ultimate-comment-cleaner'),
     720                                    );
     721                                    foreach ($fields as $value => $label) {
     722                                        $this->mini_toggle('fields[]', $value, $label);
     723                                    }
     724                                    ?>
    514725                                </div>
    515                                 <span class="remove-comments-toggle-label"><?php esc_html_e('Active', 'ultimate-comment-cleaner'); ?></span>
    516                             </label>
    517                         </div>
     726                            </div>
     727
     728                            <div class="remove-comments-form-group">
     729                                <span class="ulticocl-sublabel"><?php esc_html_e('Writing system', 'ultimate-comment-cleaner'); ?></span>
     730                                <div class="ulticocl-toggle-grid" data-cols="3">
     731                                    <?php
     732                                    foreach ($scripts as $value => $script) {
     733                                        // Short name on the switch, the full name on hover.
     734                                        $this->mini_toggle('scripts[]', $value, ULTICOCL_Matcher::short_label($value), '', false, $script[1]);
     735                                    }
     736                                    ?>
     737                                </div>
     738                                <p class="description ulticocl-inline-field">
     739                                    <?php esc_html_e('Flag comments written mostly in the selected alphabets. At least', 'ultimate-comment-cleaner'); ?>
     740                                    <input type="number" name="script_min_pct" id="ulticocl-rule-pct" min="1" max="100" class="small-text">
     741                                    <?php esc_html_e('% of the letters.', 'ultimate-comment-cleaner'); ?>
     742                                </p>
     743                            </div>
     744
     745                            <div class="ulticocl-grid-3">
     746                                <div class="remove-comments-form-group">
     747                                    <label for="ulticocl-rule-links"><?php esc_html_e('At least N links', 'ultimate-comment-cleaner'); ?></label>
     748                                    <input type="number" name="min_links" id="ulticocl-rule-links" min="0">
     749                                </div>
     750                                <div class="remove-comments-form-group">
     751                                    <label for="ulticocl-rule-words"><?php esc_html_e('Shorter than N words', 'ultimate-comment-cleaner'); ?></label>
     752                                    <input type="number" name="max_words" id="ulticocl-rule-words" min="0">
     753                                </div>
     754                                <div class="remove-comments-form-group ulticocl-field-toggle">
     755                                    <?php $this->mini_toggle('has_url', '1', __('Has a website', 'ultimate-comment-cleaner'), 'ulticocl-rule-has-url'); ?>
     756                                </div>
     757                            </div>
     758                            <p class="description"><?php esc_html_e('Use 0 to ignore links and length. Combine with "ALL" for rules like "short comment with a link".', 'ultimate-comment-cleaner'); ?></p>
     759                        </fieldset>
     760
     761                        <fieldset class="ulticocl-fieldset ulticocl-existing-only">
     762                            <legend><?php esc_html_e('Existing comments', 'ultimate-comment-cleaner'); ?></legend>
     763
     764                            <div class="remove-comments-form-group">
     765                                <span class="ulticocl-sublabel"><?php esc_html_e('Status', 'ultimate-comment-cleaner'); ?></span>
     766                                <div class="ulticocl-toggle-grid" data-cols="3">
     767                                    <?php $this->mini_toggle('status[]', 'approved', __('Approved', 'ultimate-comment-cleaner')); ?>
     768                                    <?php $this->mini_toggle('status[]', 'pending', __('Pending', 'ultimate-comment-cleaner')); ?>
     769                                    <?php $this->mini_toggle('status[]', 'spam', __('Spam', 'ultimate-comment-cleaner')); ?>
     770                                </div>
     771                            </div>
     772
     773                            <div class="ulticocl-grid-2">
     774                                <div class="remove-comments-form-group">
     775                                    <label for="ulticocl-rule-older"><?php esc_html_e('Only older than (days)', 'ultimate-comment-cleaner'); ?></label>
     776                                    <input type="number" name="older_than" id="ulticocl-rule-older" min="0">
     777                                </div>
     778                                <div class="remove-comments-form-group">
     779                                    <label for="ulticocl-rule-schedule"><?php esc_html_e('Scan', 'ultimate-comment-cleaner'); ?></label>
     780                                    <select name="schedule" id="ulticocl-rule-schedule" class="remove-comments-select">
     781                                        <option value="hourly"><?php esc_html_e('Hourly', 'ultimate-comment-cleaner'); ?></option>
     782                                        <option value="daily"><?php esc_html_e('Daily', 'ultimate-comment-cleaner'); ?></option>
     783                                        <option value="weekly"><?php esc_html_e('Weekly', 'ultimate-comment-cleaner'); ?></option>
     784                                    </select>
     785                                </div>
     786                            </div>
     787                            <p class="description"><?php esc_html_e('Without any checks above, a rule needs an age of at least 1 day, or must target spam only.', 'ultimate-comment-cleaner'); ?></p>
     788                        </fieldset>
     789
    518790                        <div class="remove-comments-form-actions">
    519                             <button type="submit" class="remove-comments-btn remove-comments-btn-primary"><?php esc_html_e('Save Trigger', 'ultimate-comment-cleaner'); ?></button>
     791                            <button type="submit" class="remove-comments-btn remove-comments-btn-primary"><?php esc_html_e('Save Rule', 'ultimate-comment-cleaner'); ?></button>
     792                            <button type="button" id="ulticocl-test-form" class="remove-comments-btn remove-comments-btn-secondary"><?php esc_html_e('Test on existing comments', 'ultimate-comment-cleaner'); ?></button>
    520793                            <button type="button" class="remove-comments-modal-close-btn remove-comments-modal-close" title="<?php esc_attr_e('Close', 'ultimate-comment-cleaner'); ?>">
    521794                                <span class="dashicons dashicons-no-alt"></span>
    … …  
    528801        <?php
    529802    }
    530    
     803
     804    /**
     805     * Manage Comments and Quarantine tabs.
     806     */
     807    private function render_comments_tab($mode) {
     808        require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
     809        require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-comments-table.php';
     810        $table = new ULTICOCL_Comments_Table($mode);
     811        $table->prepare_items();
     812        $days = (int) $this->get_settings()['quarantine_days'];
     813        ?>
     814        <div class="remove-comments-comments-panel">
     815            <div class="remove-comments-panel-header ulticocl-panel-head">
     816                <div class="ulticocl-panel-head-text">
     817                <?php if ('quarantine' === $mode) : ?>
     818                    <h2><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></h2>
     819                    <p>
     820                        <?php
     821                        echo esc_html(
     822                            $days > 0
     823                                /* translators: %d: number of days */
     824                                ? sprintf(_n('Comments caught by your rules wait here for %d day, then are deleted. Restore the ones that are not spam.', 'Comments caught by your rules wait here for %d days, then are deleted. Restore the ones that are not spam.', $days, 'ultimate-comment-cleaner'), $days)
     825                                : __('Comments caught by your rules wait here until you restore or delete them. Automatic deletion is off (see Settings).', 'ultimate-comment-cleaner')
     826                        );
     827                        echo ' ' . esc_html__('A restored comment is never caught by your rules again.', 'ultimate-comment-cleaner');
     828                        ?>
     829                    </p>
     830                <?php else : ?>
     831                    <h2><?php esc_html_e('Manage Comments', 'ultimate-comment-cleaner'); ?></h2>
     832                    <p><?php esc_html_e('Search, filter and clean up comments in bulk. Spam signals such as links and writing system are shown under each comment.', 'ultimate-comment-cleaner'); ?></p>
     833                <?php endif; ?>
     834                </div>
     835                <?php if ('quarantine' === $mode) : ?>
     836                    <div class="ulticocl-panel-head-actions">
     837                        <button type="button" id="ulticocl-empty-quarantine" class="ulticocl-danger-btn">
     838                            <span class="dashicons dashicons-trash" aria-hidden="true"></span>
     839                            <?php esc_html_e('Delete all quarantined comments', 'ultimate-comment-cleaner'); ?>
     840                        </button>
     841                    </div>
     842                <?php endif; ?>
     843            </div>
     844
     845            <?php if ($table->is_review()) : ?>
     846                <?php
     847                $preview = get_transient('ulticocl_preview_' . get_current_user_id());
     848                $name = is_array($preview) && !empty($preview['name']) ? $preview['name'] : '';
     849                ?>
     850                <div class="notice notice-info inline">
     851                    <p>
     852                        <?php
     853                        /* translators: %s: rule name */
     854                        echo esc_html(sprintf(__('Showing comments that the rule "%s" would catch. Select the ones to act on, or review them first.', 'ultimate-comment-cleaner'), $name));
     855                        ?>
     856                        <a href="<?php echo esc_url(add_query_arg(array('page' => 'ultimate-comment-cleaner', 'tab' => 'manage'), admin_url('tools.php'))); ?>"><?php esc_html_e('Show all comments', 'ultimate-comment-cleaner'); ?></a>
     857                    </p>
     858                </div>
     859            <?php endif; ?>
     860
     861            <?php $table->render_filters(); ?>
     862            <?php $table->views(); ?>
     863            <form method="post">
     864                <?php $table->display(); ?>
     865            </form>
     866        </div>
     867        <?php
     868    }
     869
     870    private function render_notice() {
     871        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Display-only result message.
     872        if (empty($_GET['ulticocl_notice'])) {
     873            return;
     874        }
     875        $n = isset($_GET['ulticocl_n']) ? absint($_GET['ulticocl_n']) : 0;
     876        $action = isset($_GET['ulticocl_a']) ? sanitize_key(wp_unslash($_GET['ulticocl_a'])) : '';
     877        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     878        $messages = array(
     879            /* translators: %d: number of comments */
     880            'approve'    => _n('%d comment approved.', '%d comments approved.', $n, 'ultimate-comment-cleaner'),
     881            /* translators: %d: number of comments */
     882            'unapprove'  => _n('%d comment unapproved.', '%d comments unapproved.', $n, 'ultimate-comment-cleaner'),
     883            /* translators: %d: number of comments */
     884            'spam'       => _n('%d comment marked as spam.', '%d comments marked as spam.', $n, 'ultimate-comment-cleaner'),
     885            /* translators: %d: number of comments */
     886            'quarantine' => _n('%d comment moved to quarantine.', '%d comments moved to quarantine.', $n, 'ultimate-comment-cleaner'),
     887            /* translators: %d: number of comments */
     888            'restore'    => _n('%d comment restored.', '%d comments restored.', $n, 'ultimate-comment-cleaner'),
     889            /* translators: %d: number of comments */
     890            'delete'     => _n('%d comment deleted.', '%d comments deleted.', $n, 'ultimate-comment-cleaner'),
     891        );
     892        if (isset($messages[$action])) {
     893            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(sprintf($messages[$action], $n)) . '</p></div>';
     894        }
     895    }
     896
    531897    private function render_settings_tab() {
    532         $settings = get_option('ulticocl_settings', array());
     898        $settings = $this->get_settings();
    533899        ?>
    534900        <div class="remove-comments-settings-panel">
    535901            <div class="remove-comments-panel-header">
    536902                <h2><?php esc_html_e('Plugin Settings', 'ultimate-comment-cleaner'); ?></h2>
     903                <p><?php esc_html_e('Choose what gets logged, who is told about it, and how long quarantined comments are kept.', 'ultimate-comment-cleaner'); ?></p>
    537904            </div>
    538905           
    … …  
    588955                    </div>
    589956                </div>
     957
     958                <div class="remove-comments-form-section">
     959                    <h3><?php esc_html_e('Quarantine', 'ultimate-comment-cleaner'); ?></h3>
     960                    <div class="remove-comments-form-group">
     961                        <label for="ulticocl_quarantine_days"><?php esc_html_e('Delete quarantined comments after (days)', 'ultimate-comment-cleaner'); ?></label>
     962                        <input type="number" name="quarantine_days" id="ulticocl_quarantine_days" min="0" max="365" value="<?php echo esc_attr((int) $settings['quarantine_days']); ?>" class="small-text">
     963                        <p class="description"><?php esc_html_e('Quarantined comments are hidden from your site and can be restored until then. Use 0 to keep them until you delete them yourself.', 'ultimate-comment-cleaner'); ?></p>
     964                    </div>
     965                    <div class="remove-comments-form-group">
     966                        <label for="ulticocl_allowlist"><?php esc_html_e('Never touch comments from', 'ultimate-comment-cleaner'); ?></label>
     967                        <textarea name="allowlist" id="ulticocl_allowlist" rows="4" class="remove-comments-form-input" placeholder="friend@example.com&#10;@mycompany.com"><?php echo esc_textarea($settings['allowlist']); ?></textarea>
     968                        <p class="description"><?php esc_html_e('One email address or @domain per line. Rules skip these commenters. Comments by users who can moderate comments are always skipped.', 'ultimate-comment-cleaner'); ?></p>
     969                    </div>
     970                </div>
    590971               
    591972                <div class="remove-comments-form-actions">
    … …  
    599980    private function get_comment_stats() {
    600981        global $wpdb;
    601        
    602         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct queries needed for comment counts, caching not practical for real-time stats
    603         $stats = array(
    604             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for total comments count
    605             'total' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_type = 'comment'"),
    606             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for approved comments count
    607             'approved' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type = 'comment'"),
    608             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for pending comments count
    609             'pending' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = '0' AND comment_type = 'comment'"),
    610             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for spam comments count
    611             'spam' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = 'spam' AND comment_type = 'comment'"),
    612             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query for trash comments count
    613             'trash' => (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE comment_approved = 'trash' AND comment_type = 'comment'"),
    614             'triggers' => count(get_option('ulticocl_triggers', array()))
     982
     983        $types = $this->types_sql();
     984        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- One grouped count; $types is prepared in types_sql().
     985        $rows = $wpdb->get_results("SELECT comment_approved AS status, COUNT(*) AS n FROM $wpdb->comments WHERE $types GROUP BY comment_approved", OBJECT_K);
     986        $rows = is_array($rows) ? $rows : array();
     987        $count = function ($status) use ($rows) {
     988            return isset($rows[$status]) ? (int) $rows[$status]->n : 0;
     989        };
     990
     991        return array(
     992            'total'      => (int) array_sum(wp_list_pluck($rows, 'n')),
     993            'approved'   => $count('1'),
     994            'pending'    => $count('0'),
     995            'spam'       => $count('spam'),
     996            'trash'      => $count('trash'),
     997            'quarantine' => $count(self::QUARANTINE),
     998            'rules'      => count($this->get_rules()),
     999            'caught'     => (int) array_sum(wp_list_pluck((array) get_option('ulticocl_rule_state', array()), 'hits')),
    6151000        );
    616        
    617         return $stats;
    618     }
    619    
     1001    }
     1002
     1003    /**
     1004     * Prepared "comment_type IN (...)" fragment for the types this plugin manages.
     1005     */
     1006    private function types_sql() {
     1007        global $wpdb;
     1008        return $wpdb->prepare('comment_type IN (' . implode(',', array_fill(0, count(self::TYPES), '%s')) . ')', self::TYPES);
     1009    }
     1010
    6201011    private function get_comment_authors() {
    6211012        global $wpdb;
    622         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for author list, caching not practical
    623         return $wpdb->get_results("SELECT DISTINCT comment_author, comment_author_email FROM $wpdb->comments WHERE comment_type = 'comment' AND comment_author_email != '' ORDER BY comment_author ASC LIMIT 200");
    624     }
    625    
     1013        $types = $this->types_sql();
     1014        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct query needed for author list, caching not practical; $types is prepared in types_sql().
     1015        return $wpdb->get_results("SELECT DISTINCT comment_author, comment_author_email FROM $wpdb->comments WHERE $types AND comment_author_email != '' ORDER BY comment_author ASC LIMIT 200");
     1016    }
     1017
    6261018    private function render_recent_activity() {
    6271019        $activities = get_option('ulticocl_activity_log', array());
    … …  
    6301022            return;
    6311023        }
    632        
     1024
    6331025        $activities = array_slice(array_reverse($activities), 0, 10);
    6341026        foreach ($activities as $activity) {
    … …  
    6391031        }
    6401032    }
    641    
    642     private function format_trigger_conditions($trigger) {
    643         $conditions = array();
    644         if (isset($trigger['status'])) {
    645             $conditions[] = esc_html__('Status:', 'ultimate-comment-cleaner') . ' ' . esc_html($trigger['status']);
    646         }
    647         if (isset($trigger['older_than'])) {
    648             $conditions[] = esc_html__('Older than:', 'ultimate-comment-cleaner') . ' ' . intval($trigger['older_than']) . ' ' . esc_html__('days', 'ultimate-comment-cleaner');
    649         }
    650         return implode(', ', $conditions);
    651     }
    652    
    653     public function ajax_delete_comments() {
     1033
     1034    private function action_labels() {
     1035        return array(
     1036            'quarantine' => __('Quarantine', 'ultimate-comment-cleaner'),
     1037            'spam'       => __('Mark as spam', 'ultimate-comment-cleaner'),
     1038            'delete'     => __('Delete permanently', 'ultimate-comment-cleaner'),
     1039        );
     1040    }
     1041
     1042    private function action_message($action, $n) {
     1043        $messages = array(
     1044            /* translators: %d: number of comments */
     1045            'delete'     => _n('Deleted %d comment.', 'Deleted %d comments.', $n, 'ultimate-comment-cleaner'),
     1046            /* translators: %d: number of comments */
     1047            'quarantine' => _n('Moved %d comment to quarantine.', 'Moved %d comments to quarantine.', $n, 'ultimate-comment-cleaner'),
     1048            /* translators: %d: number of comments */
     1049            'spam'       => _n('Marked %d comment as spam.', 'Marked %d comments as spam.', $n, 'ultimate-comment-cleaner'),
     1050        );
     1051        return sprintf($messages[$action], $n);
     1052    }
     1053
     1054    /**
     1055     * One-line description of what a rule checks, for the rules list.
     1056     */
     1057    private function rule_summary($rule) {
     1058        $parts = array();
     1059        if (!empty($rule['keywords'])) {
     1060            $shown = array_slice($rule['keywords'], 0, 3);
     1061            $more = count($rule['keywords']) - count($shown);
     1062            /* translators: 1: sample keywords, 2: number of additional keywords */
     1063            $parts[] = $more > 0 ? sprintf(__('keywords %1$s (+%2$d more)', 'ultimate-comment-cleaner'), implode(', ', $shown), $more) : sprintf(__('keywords %s', 'ultimate-comment-cleaner'), implode(', ', $shown));
     1064        }
     1065        if (!empty($rule['scripts'])) {
     1066            $scripts = ULTICOCL_Matcher::scripts();
     1067            $labels = array();
     1068            foreach ($rule['scripts'] as $key) {
     1069                if (isset($scripts[$key])) {
     1070                    $labels[] = ULTICOCL_Matcher::short_label($key);
     1071                }
     1072            }
     1073            /* translators: 1: writing systems, 2: percentage */
     1074            $parts[] = sprintf(__('%1$s text (%2$d%%+)', 'ultimate-comment-cleaner'), implode('/', $labels), (int) $rule['script_min_pct']);
     1075        }
     1076        if (!empty($rule['min_links'])) {
     1077            /* translators: %d: number of links */
     1078            $parts[] = sprintf(_n('%d+ link', '%d+ links', (int) $rule['min_links'], 'ultimate-comment-cleaner'), (int) $rule['min_links']);
     1079        }
     1080        if (!empty($rule['has_url'])) {
     1081            $parts[] = __('has a website', 'ultimate-comment-cleaner');
     1082        }
     1083        if (!empty($rule['max_words'])) {
     1084            /* translators: %d: number of words */
     1085            $parts[] = sprintf(__('under %d words', 'ultimate-comment-cleaner'), (int) $rule['max_words']);
     1086        }
     1087        $glue = 'all' === $rule['mode'] ? ' ' . __('AND', 'ultimate-comment-cleaner') . ' ' : ' ' . __('OR', 'ultimate-comment-cleaner') . ' ';
     1088        $text = implode($glue, $parts);
     1089
     1090        if ('new' !== $rule['scope']) {
     1091            $scope = array();
     1092            $scope[] = sprintf(
     1093                /* translators: %s: comment statuses */
     1094                __('status: %s', 'ultimate-comment-cleaner'),
     1095                implode(', ', (array) $rule['status'])
     1096            );
     1097            if (!empty($rule['older_than'])) {
     1098                /* translators: %d: number of days */
     1099                $scope[] = sprintf(_n('older than %d day', 'older than %d days', (int) $rule['older_than'], 'ultimate-comment-cleaner'), (int) $rule['older_than']);
     1100            }
     1101            $text .= ('' !== $text ? ' (' : '') . implode(', ', $scope) . ('' !== $text ? ')' : '');
     1102        }
     1103        return $text;
     1104    }
     1105
     1106    // phpcs:disable WordPress.Security.NonceVerification.Missing -- The AJAX handlers below verify the nonce in verify_ajax().
     1107
     1108    private function verify_ajax() {
    6541109        check_ajax_referer('ulticocl_nonce', 'nonce');
    655        
     1110
    6561111        if (!current_user_can('manage_options')) {
    6571112            wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    6581113        }
    659        
     1114    }
     1115
     1116    /**
     1117     * Process one batch of matching comments. The admin JS calls this repeatedly
     1118     * until `done` is true, so large clean-ups never hit request time limits.
     1119     */
     1120    public function ajax_bulk_process() {
     1121        $this->verify_ajax();
     1122
    6601123        $filters = $this->extract_filter_params();
    661         $deleted = $this->delete_comments_by_filters($filters);
    662        
    663         // translators: %d: number of comments deleted
    664         $this->log_activity(sprintf(esc_html__('Deleted %d comments', 'ultimate-comment-cleaner'), $deleted));
    665         $this->send_email_notification($deleted, 'manual');
    666        
     1124        $action = $filters['clean_action'];
     1125        $processed = $this->process_by_filters($filters);
     1126        $remaining = $this->count_comments_by_filters($filters);
     1127        $total = (isset($_POST['processed_so_far']) ? absint($_POST['processed_so_far']) : 0) + $processed;
     1128        // Nothing processed this round means we are finished, or blocked (e.g. by another plugin): stop either way.
     1129        $done = 0 === $processed || 0 === $remaining;
     1130
     1131        if ($done && $total > 0) {
     1132            $this->log_activity($this->action_message($action, $total));
     1133            $this->send_email_notification($total, 'manual', $action);
     1134        }
     1135
    6671136        wp_send_json_success(array(
    668             // translators: %d: number of comments deleted
    669             'message' => sprintf(esc_html__('Successfully deleted %d comments.', 'ultimate-comment-cleaner'), $deleted),
    670             'count' => $deleted
     1137            'processed' => $processed,
     1138            'total' => $total,
     1139            'remaining' => $remaining,
     1140            'done' => $done,
     1141            'message' => $this->action_message($action, $total),
    6711142        ));
    6721143    }
    673    
    674     public function ajax_get_stats() {
    675         check_ajax_referer('ulticocl_nonce', 'nonce');
    676         wp_send_json_success($this->get_comment_stats());
    677     }
    678    
     1144
     1145    /**
     1146     * Dashboard data for a date range. The range is remembered per user.
     1147     */
     1148    public function ajax_dashboard() {
     1149        $this->verify_ajax();
     1150
     1151        $days = isset($_POST['days']) ? absint($_POST['days']) : 30;
     1152        if (!in_array($days, ULTICOCL_Insights::RANGES, true)) {
     1153            $days = 30;
     1154        }
     1155        update_user_meta(get_current_user_id(), 'ulticocl_dash_days', $days);
     1156        wp_send_json_success(ULTICOCL_Insights::collect($days, $this->get_comment_stats()));
     1157    }
     1158
    6791159    public function ajax_export_comments() {
    680         check_ajax_referer('ulticocl_nonce', 'nonce');
    681        
    682         if (!current_user_can('manage_options')) {
    683             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    684         }
    685        
     1160        $this->verify_ajax();
     1161
    6861162        $filters = $this->extract_filter_params();
     1163        unset($filters['clean_action']); // Export everything that matches, including quarantined comments.
    6871164        $this->export_comments($filters);
    6881165    }
    689    
    690     public function ajax_save_trigger() {
    691         check_ajax_referer('ulticocl_nonce', 'nonce');
    692        
    693         if (!current_user_can('manage_options')) {
    694             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    695         }
    696        
    697         $triggers = get_option('ulticocl_triggers', array());
    698         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : uniqid('trigger_');
    699        
    700         // Preserve existing trigger data if editing
    701         $existing_trigger = isset($triggers[$trigger_id]) ? $triggers[$trigger_id] : array();
    702        
    703         $triggers[$trigger_id] = array(
    704             'name' => isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '',
    705             'status' => isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'approved',
    706             'older_than' => isset($_POST['older_than']) ? intval($_POST['older_than']) : 0,
    707             'schedule' => isset($_POST['schedule']) ? sanitize_text_field(wp_unslash($_POST['schedule'])) : 'hourly',
    708             'active' => isset($_POST['active']) && intval($_POST['active']) === 1,
    709             'created' => isset($existing_trigger['created']) ? $existing_trigger['created'] : current_time('mysql'),
    710             'last_run' => isset($existing_trigger['last_run']) ? $existing_trigger['last_run'] : null
    711         );
    712        
    713         update_option('ulticocl_triggers', $triggers);
    714         wp_send_json_success(array('message' => __('Trigger saved successfully.', 'ultimate-comment-cleaner')));
    715     }
    716    
    717     public function ajax_delete_trigger() {
    718         check_ajax_referer('ulticocl_nonce', 'nonce');
    719        
    720         if (!current_user_can('manage_options')) {
    721             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    722         }
    723        
    724         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : '';
    725         $triggers = get_option('ulticocl_triggers', array());
    726        
    727         if (isset($triggers[$trigger_id])) {
    728             unset($triggers[$trigger_id]);
    729             update_option('ulticocl_triggers', $triggers);
    730             wp_send_json_success(array('message' => __('Trigger deleted successfully.', 'ultimate-comment-cleaner')));
    731         }
    732        
    733         wp_send_json_error(array('message' => __('Trigger not found.', 'ultimate-comment-cleaner')));
    734     }
    735    
    736     public function ajax_get_trigger() {
    737         check_ajax_referer('ulticocl_nonce', 'nonce');
    738        
    739         if (!current_user_can('manage_options')) {
    740             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    741         }
    742        
    743         $trigger_id = isset($_POST['trigger_id']) ? sanitize_text_field(wp_unslash($_POST['trigger_id'])) : '';
    744         $triggers = get_option('ulticocl_triggers', array());
    745        
    746         if (isset($triggers[$trigger_id])) {
    747             wp_send_json_success($triggers[$trigger_id]);
    748         }
    749        
    750         wp_send_json_error(array('message' => __('Trigger not found.', 'ultimate-comment-cleaner')));
    751     }
    752    
     1166
    7531167    public function ajax_preview_count() {
    754         check_ajax_referer('ulticocl_nonce', 'nonce');
    755        
    756         if (!current_user_can('manage_options')) {
    757             wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    758         }
    759        
    760         $filters = $this->extract_filter_params();
    761         $count = $this->count_comments_by_filters($filters);
    762        
    763         wp_send_json_success(array('count' => $count));
    764     }
    765    
     1168        $this->verify_ajax();
     1169
     1170        wp_send_json_success(array('count' => $this->count_comments_by_filters($this->extract_filter_params())));
     1171    }
     1172
     1173    public function ajax_save_rule() {
     1174        $this->verify_ajax();
     1175
     1176        $rule = $this->rule_from_post();
     1177        if (is_wp_error($rule)) {
     1178            wp_send_json_error(array('message' => $rule->get_error_message()));
     1179        }
     1180
     1181        $rules = $this->get_rules();
     1182        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1183        // Only reuse an ID that already exists; anything else is a new rule.
     1184        if ('' === $id || !isset($rules[$id])) {
     1185            $id = uniqid('rule_');
     1186        }
     1187        $rule['created'] = !empty($rules[$id]['created']) ? $rules[$id]['created'] : current_time('mysql');
     1188        $rules[$id] = $rule;
     1189        update_option('ulticocl_rules', $rules, false);
     1190
     1191        // An edited rule is scanned from the start again.
     1192        $state = get_option('ulticocl_rule_state', array());
     1193        $state[$id] = array_merge(array('hits' => 0, 'last_run' => 0), isset($state[$id]) ? $state[$id] : array(), array('cursor' => 0));
     1194        update_option('ulticocl_rule_state', $state, false);
     1195
     1196        wp_send_json_success(array('message' => __('Rule saved.', 'ultimate-comment-cleaner'), 'id' => $id));
     1197    }
     1198
     1199    public function ajax_delete_rule() {
     1200        $this->verify_ajax();
     1201
     1202        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1203        $rules = $this->get_rules();
     1204        if (!isset($rules[$id])) {
     1205            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1206        }
     1207        unset($rules[$id]);
     1208        update_option('ulticocl_rules', $rules, false);
     1209        $state = get_option('ulticocl_rule_state', array());
     1210        unset($state[$id]);
     1211        update_option('ulticocl_rule_state', $state, false);
     1212        wp_send_json_success(array('message' => __('Rule deleted.', 'ultimate-comment-cleaner')));
     1213    }
     1214
     1215    public function ajax_get_rule() {
     1216        $this->verify_ajax();
     1217
     1218        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1219        $rules = $this->get_rules();
     1220        if (!isset($rules[$id])) {
     1221            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1222        }
     1223        wp_send_json_success($rules[$id]);
     1224    }
     1225
     1226    /**
     1227     * Scan one chunk of existing comments with a rule. With `dry` set nothing is changed:
     1228     * the matches are counted and sampled, and their IDs are kept for "Review matches".
     1229     */
     1230    public function ajax_scan_rule() {
     1231        $this->verify_ajax();
     1232
     1233        $dry = !empty($_POST['dry']);
     1234        $id = isset($_POST['rule_id']) ? sanitize_key(wp_unslash($_POST['rule_id'])) : '';
     1235        $rules = $this->get_rules();
     1236
     1237        if (!$dry) {
     1238            if (!isset($rules[$id])) {
     1239                wp_send_json_error(array('message' => __('Save the rule before running it.', 'ultimate-comment-cleaner')));
     1240            }
     1241            $rule = $rules[$id];
     1242        } elseif (isset($_POST['name'])) {
     1243            $rule = $this->rule_from_post();
     1244            if (is_wp_error($rule)) {
     1245                wp_send_json_error(array('message' => $rule->get_error_message()));
     1246            }
     1247        } elseif (isset($rules[$id])) {
     1248            $rule = $rules[$id];
     1249        } else {
     1250            wp_send_json_error(array('message' => __('Rule not found.', 'ultimate-comment-cleaner')));
     1251        }
     1252
     1253        $cursor = isset($_POST['cursor']) ? absint($_POST['cursor']) : 0;
     1254        $result = $this->scan_rule($rule, $cursor, self::BATCH_SIZE, $dry, $id);
     1255        if (is_wp_error($result)) {
     1256            wp_send_json_error(array('message' => $result->get_error_message()));
     1257        }
     1258
     1259        if (0 === $cursor) {
     1260            $result['total'] = $this->count_scope($rule);
     1261        }
     1262
     1263        if ($dry) {
     1264            $key = 'ulticocl_preview_' . get_current_user_id();
     1265            $preview = get_transient($key);
     1266            if (0 === $cursor || !is_array($preview)) {
     1267                $preview = array('name' => $rule['name'], 'ids' => array());
     1268            }
     1269            $preview['ids'] = array_slice(array_merge($preview['ids'], $result['ids']), 0, 1000);
     1270            set_transient($key, $preview, HOUR_IN_SECONDS);
     1271        } else {
     1272            $this->add_hits($id, $result['acted']);
     1273            $this->record_stat('scan', $result['acted'], $id);
     1274            $acted = (isset($_POST['acted_so_far']) ? absint($_POST['acted_so_far']) : 0) + $result['acted'];
     1275            if ($result['done'] && $acted > 0) {
     1276                /* translators: 1: rule name, 2: result sentence */
     1277                $this->log_activity(sprintf(__('Rule "%1$s" run manually: %2$s', 'ultimate-comment-cleaner'), $rule['name'], $this->action_message($rule['action'], $acted)));
     1278                $this->send_email_notification($acted, $rule['name'], $rule['action']);
     1279            }
     1280        }
     1281        unset($result['ids']);
     1282
     1283        wp_send_json_success($result);
     1284    }
     1285
    7661286    /**
    7671287     * Extract and sanitize filter parameters from $_POST
    … …  
    7721292    private function extract_filter_params() {
    7731293        $filters = array();
    774        
     1294
    7751295        // Status filter (array)
    7761296        if (isset($_POST['status']) && is_array($_POST['status'])) {
    777             $filters['status'] = array_map('sanitize_text_field', wp_unslash($_POST['status']));
    778         }
    779        
     1297            $filters['status'] = array_map('sanitize_key', wp_unslash($_POST['status']));
     1298        }
     1299
    7801300        // Date range
    781         if (isset($_POST['date_from']) && !empty($_POST['date_from'])) {
     1301        if (!empty($_POST['date_from'])) {
    7821302            $filters['date_from'] = sanitize_text_field(wp_unslash($_POST['date_from']));
    7831303        }
    784         if (isset($_POST['date_to']) && !empty($_POST['date_to'])) {
     1304        if (!empty($_POST['date_to'])) {
    7851305            $filters['date_to'] = sanitize_text_field(wp_unslash($_POST['date_to']));
    7861306        }
    787        
     1307
    7881308        // Author filter
    789         if (isset($_POST['author']) && !empty($_POST['author'])) {
     1309        if (!empty($_POST['author'])) {
    7901310            $filters['author'] = sanitize_text_field(wp_unslash($_POST['author']));
    7911311        }
    792        
     1312
     1313        // Search text
     1314        if (!empty($_POST['search'])) {
     1315            $filters['search'] = sanitize_text_field(wp_unslash($_POST['search']));
     1316        }
     1317
    7931318        // Post type filter (array)
    7941319        if (isset($_POST['post_type']) && is_array($_POST['post_type'])) {
    7951320            $filters['post_type'] = array_map('sanitize_key', wp_unslash($_POST['post_type']));
    7961321        }
    797        
     1322
    7981323        // Additional filters (boolean flags)
    799         if (isset($_POST['has_url']) && !empty($_POST['has_url'])) {
    800             $filters['has_url'] = 1;
    801         }
    802         if (isset($_POST['no_url']) && !empty($_POST['no_url'])) {
    803             $filters['no_url'] = 1;
    804         }
    805         if (isset($_POST['short_comments']) && !empty($_POST['short_comments'])) {
    806             $filters['short_comments'] = 1;
    807         }
    808        
     1324        $filters['has_url'] = !empty($_POST['has_url']);
     1325        $filters['no_url'] = !empty($_POST['no_url']);
     1326        $filters['short_comments'] = !empty($_POST['short_comments']);
     1327
    8091328        // Word count filters
    810         if (isset($_POST['min_words']) && !empty($_POST['min_words'])) {
    811             $filters['min_words'] = intval($_POST['min_words']);
    812         }
    813         if (isset($_POST['max_words']) && !empty($_POST['max_words'])) {
    814             $filters['max_words'] = intval($_POST['max_words']);
    815         }
    816        
     1329        $filters['min_words'] = isset($_POST['min_words']) ? absint($_POST['min_words']) : 0;
     1330        $filters['max_words'] = isset($_POST['max_words']) ? absint($_POST['max_words']) : 0;
     1331
     1332        // What to do with the matches; anything unknown falls back to the safe, restorable option.
     1333        $action = isset($_POST['clean_action']) ? sanitize_key(wp_unslash($_POST['clean_action'])) : '';
     1334        $filters['clean_action'] = in_array($action, ULTICOCL_Matcher::ACTIONS, true) ? $action : 'quarantine';
     1335
    8171336        return $filters;
    8181337    }
    819    
     1338
     1339    /**
     1340     * Build a rule from the rule form in $_POST.
     1341     *
     1342     * @return array|WP_Error Canonical rule, or an error describing what to fix.
     1343     */
     1344    private function rule_from_post() {
     1345        $p = wp_unslash($_POST); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Every field is sanitized below.
     1346        $text = function ($key) use ($p) {
     1347            return isset($p[$key]) && is_string($p[$key]) ? sanitize_text_field($p[$key]) : '';
     1348        };
     1349        $keys = function ($key) use ($p) {
     1350            return isset($p[$key]) && is_array($p[$key]) ? array_map('sanitize_key', $p[$key]) : array();
     1351        };
     1352        $int = function ($key, $max) use ($p) {
     1353            return isset($p[$key]) ? min($max, absint($p[$key])) : 0;
     1354        };
     1355
     1356        $rule = ULTICOCL_Matcher::defaults();
     1357        $rule['name'] = $text('name');
     1358        $rule['active'] = !empty($p['active']);
     1359        $rule['mode'] = 'all' === $text('mode') ? 'all' : 'any';
     1360        $scope = $text('scope');
     1361        $rule['scope'] = in_array($scope, ULTICOCL_Matcher::SCOPES, true) ? $scope : 'both';
     1362        $action = $text('rule_action');
     1363        $rule['action'] = in_array($action, ULTICOCL_Matcher::ACTIONS, true) ? $action : 'quarantine';
     1364        $schedule = $text('schedule');
     1365        $rule['schedule'] = in_array($schedule, self::SCHEDULES, true) ? $schedule : 'daily';
     1366
     1367        $raw = isset($p['keywords']) && is_string($p['keywords']) ? sanitize_textarea_field($p['keywords']) : '';
     1368        // Not /\R/: without the u flag it treats byte 0x85 as a newline and would cut Cyrillic letters in half.
     1369        $lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $raw)), 'strlen');
     1370        $rule['keywords'] = array_slice(array_values(array_unique($lines)), 0, 500);
     1371        $invalid = array();
     1372        foreach ($rule['keywords'] as $keyword) {
     1373            if (null === ULTICOCL_Matcher::keyword_regex($keyword)) {
     1374                $invalid[] = $keyword;
     1375            }
     1376        }
     1377
     1378        $fields = array_values(array_intersect($keys('fields'), ULTICOCL_Matcher::FIELDS));
     1379        $rule['fields'] = $fields ? $fields : array('content', 'author', 'url');
     1380        $rule['scripts'] = array_values(array_intersect($keys('scripts'), array_keys(ULTICOCL_Matcher::scripts())));
     1381        $rule['script_min_pct'] = max(1, $int('script_min_pct', 100));
     1382        $rule['min_links'] = $int('min_links', 100);
     1383        $rule['has_url'] = !empty($p['has_url']);
     1384        $rule['max_words'] = $int('max_words', 1000);
     1385        $status = array_values(array_intersect($keys('status'), ULTICOCL_Matcher::RULE_STATUSES));
     1386        $rule['status'] = $status ? $status : ULTICOCL_Matcher::RULE_STATUSES;
     1387        $rule['older_than'] = $int('older_than', 3650);
     1388
     1389        if ('' === $rule['name']) {
     1390            return new WP_Error('name', __('Please enter a rule name.', 'ultimate-comment-cleaner'));
     1391        }
     1392        if ($invalid) {
     1393            /* translators: %s: invalid keyword lines */
     1394            return new WP_Error('keywords', sprintf(__('These keywords are not valid: %s', 'ultimate-comment-cleaner'), implode(', ', array_slice($invalid, 0, 3))));
     1395        }
     1396        if (!ULTICOCL_Matcher::has_signals($rule)) {
     1397            if ('new' === $rule['scope']) {
     1398                return new WP_Error('signals', __('Rules for new comments need at least one check: keywords, a writing system, links, a website or short comments.', 'ultimate-comment-cleaner'));
     1399            }
     1400            $rule['scope'] = 'existing'; // Nothing to look for in a new comment.
     1401        }
     1402        if (!ULTICOCL_Matcher::scope_is_safe($rule)) {
     1403            return new WP_Error('unsafe', __('This rule would match every comment. Add a check, set "Only older than" to at least 1 day, or select Spam only.', 'ultimate-comment-cleaner'));
     1404        }
     1405
     1406        return $rule;
     1407    }
     1408
     1409    // phpcs:enable WordPress.Security.NonceVerification.Missing
     1410
     1411    /**
     1412     * Build the SQL WHERE clause shared by preview count, bulk clean and export.
     1413     *
     1414     * Fails closed: a filter that is present but invalid matches nothing, so a bad
     1415     * request can never widen a bulk action to every comment.
     1416     *
     1417     * @param array $filters Sanitized filters.
     1418     * @return string|false WHERE clause, or false when nothing can match.
     1419     */
     1420    private function build_where($filters) {
     1421        global $wpdb;
     1422
     1423        $where = array($this->types_sql());
     1424
     1425        // Status is required; 'all' means no status restriction.
     1426        if (empty($filters['status'])) {
     1427            return false;
     1428        }
     1429        if (!in_array('all', $filters['status'], true)) {
     1430            $statuses = array_values(array_intersect_key(self::STATUS_MAP, array_flip($filters['status'])));
     1431            if (empty($statuses)) {
     1432                return false;
     1433            }
     1434            $where[] = $wpdb->prepare('comment_approved IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')', $statuses);
     1435        }
     1436
     1437        // Skip comments the action cannot change, so each batch makes progress.
     1438        if (isset($filters['clean_action'])) {
     1439            if ('quarantine' === $filters['clean_action']) {
     1440                $where[] = $wpdb->prepare('comment_approved != %s', self::QUARANTINE);
     1441            } elseif ('spam' === $filters['clean_action']) {
     1442                $where[] = $wpdb->prepare('comment_approved NOT IN (%s, %s)', 'spam', self::QUARANTINE);
     1443            }
     1444        }
     1445
     1446        // Date range (site local time, as entered by the admin)
     1447        if (!empty($filters['date_from'])) {
     1448            if (!$this->is_valid_date($filters['date_from'])) {
     1449                return false;
     1450            }
     1451            $where[] = $wpdb->prepare('comment_date >= %s', $filters['date_from'] . ' 00:00:00');
     1452        }
     1453        if (!empty($filters['date_to'])) {
     1454            if (!$this->is_valid_date($filters['date_to'])) {
     1455                return false;
     1456            }
     1457            $where[] = $wpdb->prepare('comment_date <= %s', $filters['date_to'] . ' 23:59:59');
     1458        }
     1459
     1460        // Author filter
     1461        if (!empty($filters['author']) && 'all' !== $filters['author']) {
     1462            if (!is_email($filters['author'])) {
     1463                return false;
     1464            }
     1465            $where[] = $wpdb->prepare('comment_author_email = %s', $filters['author']);
     1466        }
     1467
     1468        // Search text
     1469        if (!empty($filters['search'])) {
     1470            $like = '%' . $wpdb->esc_like($filters['search']) . '%';
     1471            $where[] = $wpdb->prepare('(comment_content LIKE %s OR comment_author LIKE %s OR comment_author_email LIKE %s OR comment_author_url LIKE %s)', $like, $like, $like, $like);
     1472        }
     1473
     1474        // Post type filter
     1475        if (!empty($filters['post_type']) && !in_array('all', $filters['post_type'], true)) {
     1476            $where[] = $wpdb->prepare(
     1477                "comment_post_ID IN (SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($filters['post_type']), '%s')) . '))',
     1478                $filters['post_type']
     1479            );
     1480        }
     1481
     1482        // URL filters
     1483        if (!empty($filters['has_url'])) {
     1484            $where[] = "comment_author_url != ''";
     1485        }
     1486        if (!empty($filters['no_url'])) {
     1487            $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
     1488        }
     1489
     1490        // ponytail: word count = spaces + 1; newlines and double spaces skew it. Good enough for spam triage.
     1491        $words = "LENGTH(TRIM(comment_content)) - LENGTH(REPLACE(TRIM(comment_content), ' ', '')) + 1";
     1492        if (!empty($filters['short_comments'])) {
     1493            $where[] = "$words < 10";
     1494        }
     1495        if (!empty($filters['min_words'])) {
     1496            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $words is a fixed SQL expression
     1497            $where[] = $wpdb->prepare("$words >= %d", $filters['min_words']);
     1498        }
     1499        if (!empty($filters['max_words'])) {
     1500            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $words is a fixed SQL expression
     1501            $where[] = $wpdb->prepare("$words <= %d", $filters['max_words']);
     1502        }
     1503
     1504        return implode(' AND ', $where);
     1505    }
     1506
     1507    private function is_valid_date($value) {
     1508        $date = DateTime::createFromFormat('Y-m-d', $value);
     1509        return $date && $date->format('Y-m-d') === $value;
     1510    }
     1511
    8201512    private function count_comments_by_filters($filters) {
    8211513        global $wpdb;
    822        
    823         $where = array("comment_type = 'comment'");
    824        
    825         // Status filter
    826         if (isset($filters['status']) && is_array($filters['status'])) {
    827             $statuses = array();
    828             foreach ($filters['status'] as $status) {
    829                 if ($status === 'all') continue;
    830                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    831             }
    832             if (!empty($statuses)) {
    833                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    834             }
    835         }
    836        
    837         // Date range
    838         if (!empty($filters['date_from'])) {
    839             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    840         }
    841         if (!empty($filters['date_to'])) {
    842             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    843         }
    844        
    845         // Author filter
    846         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    847             $author_email = sanitize_email($filters['author']);
    848             if (is_email($author_email)) {
    849                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    850             }
    851         }
    852        
    853         // Post type filter
    854         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    855             // Sanitize post types
    856             $post_types = array_map('sanitize_key', $filters['post_type']);
    857             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed, caching not practical
    858             $post_ids = $wpdb->get_col($wpdb->prepare(
    859                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($post_types), '%s')) . ")",
    860                 ...$post_types
    861             ));
    862             if (!empty($post_ids)) {
    863                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    864             } else {
    865                 return 0; // No posts found, so no comments
    866             }
    867         }
    868        
    869         // Additional filters
    870         if (!empty($filters['has_url'])) {
    871             $where[] = "comment_author_url != ''";
    872         }
    873         if (!empty($filters['no_url'])) {
    874             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    875         }
    876        
    877         // Word count filters - sanitize inputs
    878         if (!empty($filters['short_comments'])) {
    879             $where[] = "LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 < 10";
    880         }
    881         if (!empty($filters['min_words'])) {
    882             $min_words = intval($filters['min_words']);
    883             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 >= %d", $min_words);
    884         }
    885         if (!empty($filters['max_words'])) {
    886             $max_words = intval($filters['max_words']);
    887             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 <= %d", $max_words);
    888         }
    889        
    890         $where_clause = implode(' AND ', $where);
    891        
    892         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    893         return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where_clause");
    894     }
    895    
    896     private function delete_comments_by_filters($filters) {
     1514
     1515        $where = $this->build_where($filters);
     1516        if (false === $where) {
     1517            return 0;
     1518        }
     1519
     1520        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1521        return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where");
     1522    }
     1523
     1524    /**
     1525     * Apply the chosen action to up to one batch of matching comments.
     1526     *
     1527     * @return int Number of comments changed.
     1528     */
     1529    private function process_by_filters($filters) {
    8971530        global $wpdb;
    898        
    899         $where = array("comment_type = 'comment'");
    900        
    901         // Status filter
    902         if (isset($filters['status']) && is_array($filters['status'])) {
    903             $statuses = array();
    904             foreach ($filters['status'] as $status) {
    905                 if ($status === 'all') continue;
    906                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    907             }
    908             if (!empty($statuses)) {
    909                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    910             }
    911         }
    912        
    913         // Date range
    914         if (!empty($filters['date_from'])) {
    915             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    916         }
    917         if (!empty($filters['date_to'])) {
    918             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    919         }
    920        
    921         // Author filter
    922         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    923             $author_email = sanitize_email($filters['author']);
    924             if (is_email($author_email)) {
    925                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    926             }
    927         }
    928        
    929         // Post type filter
    930         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    931             // Sanitize post types
    932             $post_types = array_map('sanitize_key', $filters['post_type']);
    933             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed, caching not practical
    934             $post_ids = $wpdb->get_col($wpdb->prepare(
    935                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($post_types), '%s')) . ")",
    936                 ...$post_types
    937             ));
    938             if (!empty($post_ids)) {
    939                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    940             } else {
    941                 return 0; // No posts found, so no comments
    942             }
    943         }
    944        
    945         // Additional filters
    946         if (!empty($filters['has_url'])) {
    947             $where[] = "comment_author_url != ''";
    948         }
    949         if (!empty($filters['no_url'])) {
    950             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    951         }
    952        
    953         // Word count filters - sanitize inputs
    954         if (!empty($filters['short_comments'])) {
    955             $where[] = "LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 < 10";
    956         }
    957         if (!empty($filters['min_words'])) {
    958             $min_words = intval($filters['min_words']);
    959             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 >= %d", $min_words);
    960         }
    961         if (!empty($filters['max_words'])) {
    962             $max_words = intval($filters['max_words']);
    963             $where[] = $wpdb->prepare("LENGTH(comment_content) - LENGTH(REPLACE(comment_content, ' ', '')) + 1 <= %d", $max_words);
    964         }
    965        
    966         $where_clause = implode(' AND ', $where);
    967        
    968         // Get comment IDs and post IDs BEFORE deletion (WordPress standard way)
    969         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    970         $comments = $wpdb->get_results("SELECT comment_ID, comment_post_ID FROM $wpdb->comments WHERE $where_clause");
    971        
    972         if (empty($comments)) {
     1531
     1532        $where = $this->build_where($filters);
     1533        if (false === $where) {
    9731534            return 0;
    9741535        }
    975        
    976         $count = count($comments);
    977         $comment_ids = array();
    978         $post_ids = array();
    979        
    980         // Collect comment IDs and unique post IDs
    981         foreach ($comments as $comment) {
    982             $comment_ids[] = (int) $comment->comment_ID;
    983             $post_ids[(int) $comment->comment_post_ID] = true;
    984         }
    985        
    986         $post_ids = array_keys($post_ids);
    987        
    988         // Use WordPress function to properly delete comments and clean up
     1536
     1537        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1538        $comment_ids = $wpdb->get_col($wpdb->prepare("SELECT comment_ID FROM $wpdb->comments WHERE $where ORDER BY comment_ID LIMIT %d", self::BATCH_SIZE));
     1539
     1540        // Recount each affected post once at the end instead of after every comment.
     1541        wp_defer_comment_counting(true);
     1542        $changed = 0;
    9891543        foreach ($comment_ids as $comment_id) {
    990             wp_delete_comment($comment_id, true); // true = force delete (skip trash)
    991         }
    992        
    993         // Clean up post comment counts
    994         if (!empty($post_ids)) {
    995             foreach ($post_ids as $post_id) {
    996                 clean_post_cache($post_id);
    997                 wp_update_comment_count_now($post_id);
    998             }
    999         }
    1000        
    1001         // Clear caches
    1002         wp_cache_flush();
    1003         delete_transient('ulticocl_comments_count');
    1004        
    1005         return (int) $count;
    1006     }
    1007    
     1544            if ($this->comment_action((int) $comment_id, $filters['clean_action'])) {
     1545                $changed++;
     1546            }
     1547        }
     1548        wp_defer_comment_counting(false);
     1549
     1550        return $changed;
     1551    }
     1552
    10081553    private function export_comments($filters) {
    10091554        global $wpdb;
    1010        
    1011         $where = array("comment_type = 'comment'");
    1012        
    1013         // Status filter
    1014         if (isset($filters['status']) && is_array($filters['status'])) {
    1015             $statuses = array();
    1016             foreach ($filters['status'] as $status) {
    1017                 if ($status === 'all') continue;
    1018                 $statuses[] = $wpdb->prepare("comment_approved = %s", $status === 'approved' ? '1' : ($status === 'pending' ? '0' : $status));
    1019             }
    1020             if (!empty($statuses)) {
    1021                 $where[] = '(' . implode(' OR ', $statuses) . ')';
    1022             }
    1023         }
    1024        
    1025         // Date range
    1026         if (!empty($filters['date_from'])) {
    1027             $where[] = $wpdb->prepare("comment_date >= %s", $filters['date_from'] . ' 00:00:00');
    1028         }
    1029         if (!empty($filters['date_to'])) {
    1030             $where[] = $wpdb->prepare("comment_date <= %s", $filters['date_to'] . ' 23:59:59');
    1031         }
    1032        
    1033         // Author filter
    1034         if (isset($filters['author']) && !empty($filters['author']) && $filters['author'] !== 'all') {
    1035             $author_email = sanitize_email($filters['author']);
    1036             if (is_email($author_email)) {
    1037                 $where[] = $wpdb->prepare("comment_author_email = %s", $author_email);
    1038             }
    1039         }
    1040        
    1041         // Post type filter
    1042         $comments = array();
    1043         if (isset($filters['post_type']) && is_array($filters['post_type']) && !in_array('all', $filters['post_type'])) {
    1044             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for post type filtering, caching not practical
    1045             $post_ids = $wpdb->get_col($wpdb->prepare(
    1046                 "SELECT ID FROM $wpdb->posts WHERE post_type IN (" . implode(',', array_fill(0, count($filters['post_type']), '%s')) . ")",
    1047                 ...$filters['post_type']
     1555
     1556        $where = $this->build_where($filters);
     1557
     1558        nocache_headers();
     1559        header('Content-Type: text/csv; charset=utf-8');
     1560        header('Content-Disposition: attachment; filename="comments-export-' . gmdate('Y-m-d') . '.csv"');
     1561
     1562        $output = fopen('php://output', 'w');
     1563        fputcsv($output, array('ID', 'Post ID', 'Author', 'Email', 'URL', 'Content', 'Date', 'Status'), ',', '"', '');
     1564
     1565        // Stream in chunks so large exports don't exhaust memory.
     1566        $last_id = 0;
     1567        while (false !== $where) {
     1568            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in build_where()
     1569            $comments = $wpdb->get_results($wpdb->prepare("SELECT comment_ID, comment_post_ID, comment_author, comment_author_email, comment_author_url, comment_content, comment_date, comment_approved FROM $wpdb->comments WHERE $where AND comment_ID > %d ORDER BY comment_ID LIMIT %d", $last_id, self::BATCH_SIZE));
     1570            if (empty($comments)) {
     1571                break;
     1572            }
     1573            foreach ($comments as $comment) {
     1574                fputcsv($output, array_map(array($this, 'csv_safe'), array_values((array) $comment)), ',', '"', '');
     1575                $last_id = $comment->comment_ID;
     1576            }
     1577        }
     1578
     1579        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output is a special stream, not a file
     1580        fclose($output);
     1581        exit;
     1582    }
     1583
     1584    /**
     1585     * Prefix cells that a spreadsheet would run as a formula (CSV injection).
     1586     */
     1587    private function csv_safe($value) {
     1588        return preg_match('/^[=+\-@\t\r]/', (string) $value) ? "'" . $value : $value;
     1589    }
     1590
     1591    // ---------------------------------------------------------------------
     1592    // Smart rules
     1593    // ---------------------------------------------------------------------
     1594
     1595    private function get_rules() {
     1596        $rules = get_option('ulticocl_rules', array());
     1597        if (!is_array($rules)) {
     1598            return array();
     1599        }
     1600        foreach ($rules as $id => $rule) {
     1601            $rules[$id] = wp_parse_args($rule, ULTICOCL_Matcher::defaults());
     1602        }
     1603        return $rules;
     1604    }
     1605
     1606    /**
     1607     * Add to a per-rule counter ('hits' = comments caught, 'restored' = caught by mistake).
     1608     */
     1609    private function add_hits($rule_id, $n, $field = 'hits') {
     1610        if ($n < 1 || '' === $rule_id) {
     1611            return;
     1612        }
     1613        $state = get_option('ulticocl_rule_state', array());
     1614        $current = isset($state[$rule_id]) ? $state[$rule_id] : array();
     1615        $state[$rule_id] = array_merge(array('hits' => 0, 'cursor' => 0, 'last_run' => 0), $current);
     1616        $state[$rule_id][$field] = (isset($state[$rule_id][$field]) ? (int) $state[$rule_id][$field] : 0) + (int) $n;
     1617        update_option('ulticocl_rule_state', $state, false);
     1618    }
     1619
     1620    /**
     1621     * Daily counters behind the dashboard charts, kept for 120 days.
     1622     *
     1623     * @param string $type    'new' (caught on arrival), 'scan' (caught among existing comments) or 'restored'.
     1624     * @param int    $n       How many.
     1625     * @param string $rule_id Rule responsible, if any.
     1626     */
     1627    private function record_stat($type, $n, $rule_id = '') {
     1628        if ($n < 1) {
     1629            return;
     1630        }
     1631        $stats = get_option('ulticocl_stats', array());
     1632        $stats = is_array($stats) ? $stats : array();
     1633        $day = current_time('Y-m-d');
     1634        $row = isset($stats['days'][$day]) ? $stats['days'][$day] : array();
     1635        $row[$type] = (isset($row[$type]) ? (int) $row[$type] : 0) + (int) $n;
     1636        if ('' !== $rule_id && 'restored' !== $type) {
     1637            $row['r'][$rule_id] = (isset($row['r'][$rule_id]) ? (int) $row['r'][$rule_id] : 0) + (int) $n;
     1638        }
     1639        $stats['days'][$day] = $row;
     1640        if (count($stats['days']) > 120) {
     1641            ksort($stats['days']);
     1642            $stats['days'] = array_slice($stats['days'], -120, null, true);
     1643        }
     1644        update_option('ulticocl_stats', $stats, false);
     1645    }
     1646
     1647    /**
     1648     * WHERE fragment limiting a rule scan to the comments it may touch: managed types,
     1649     * the chosen statuses (never trash or quarantine) and the minimum age.
     1650     */
     1651    private function rule_scope_where($rule) {
     1652        global $wpdb;
     1653
     1654        $statuses = array();
     1655        foreach ((array) $rule['status'] as $status) {
     1656            if (in_array($status, ULTICOCL_Matcher::RULE_STATUSES, true)) {
     1657                $statuses[] = self::STATUS_MAP[$status];
     1658            }
     1659        }
     1660        if (!$statuses) {
     1661            $statuses = array('1', '0', 'spam');
     1662        }
     1663        $where = $this->types_sql() . ' AND ' . $wpdb->prepare('comment_approved IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')', $statuses);
     1664        if (!empty($rule['older_than'])) {
     1665            $where .= $wpdb->prepare(' AND comment_date_gmt < %s', gmdate('Y-m-d H:i:s', time() - absint($rule['older_than']) * DAY_IN_SECONDS));
     1666        }
     1667        // Comments restored from quarantine are marked as fine and never caught again.
     1668        $where .= " AND NOT EXISTS (SELECT 1 FROM $wpdb->commentmeta ok WHERE ok.comment_id = $wpdb->comments.comment_ID AND ok.meta_key = '_ulticocl_ok')";
     1669        return $where;
     1670    }
     1671
     1672    private function count_scope($rule) {
     1673        global $wpdb;
     1674        $where = $this->rule_scope_where($rule);
     1675        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in rule_scope_where()
     1676        return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments WHERE $where");
     1677    }
     1678
     1679    /**
     1680     * Check one chunk of existing comments against a rule and, unless $dry, apply its action.
     1681     *
     1682     * ponytail: comments are matched in PHP (regex, Unicode scripts) in chunks of BATCH_SIZE by ID.
     1683     * A full pass over ~100k comments takes a few cron runs; add a SQL pre-filter if that is too slow.
     1684     *
     1685     * @return array|WP_Error scanned, matched, acted, cursor, done, samples, ids
     1686     */
     1687    private function scan_rule($rule, $cursor, $limit, $dry, $rule_id = '') {
     1688        global $wpdb;
     1689
     1690        $rule = wp_parse_args($rule, ULTICOCL_Matcher::defaults());
     1691        if (!ULTICOCL_Matcher::scope_is_safe($rule)) {
     1692            return new WP_Error('unsafe', __('This rule has no checks and no minimum age, so it was skipped to protect your comments. Edit the rule to continue.', 'ultimate-comment-cleaner'));
     1693        }
     1694
     1695        $where = $this->rule_scope_where($rule);
     1696        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is assembled from prepared fragments in rule_scope_where()
     1697        $rows = $wpdb->get_results($wpdb->prepare("SELECT comment_ID, comment_post_ID, comment_author, comment_author_email, comment_author_url, comment_content, comment_approved, user_id FROM $wpdb->comments WHERE comment_ID > %d AND $where ORDER BY comment_ID LIMIT %d", $cursor, $limit));
     1698
     1699        $result = array('scanned' => count($rows), 'matched' => 0, 'acted' => 0, 'cursor' => $cursor, 'done' => count($rows) < $limit, 'samples' => array(), 'ids' => array());
     1700
     1701        if (!$dry) {
     1702            wp_defer_comment_counting(true);
     1703        }
     1704        foreach ($rows as $row) {
     1705            $result['cursor'] = (int) $row->comment_ID;
     1706            if ($this->is_protected($row)) {
     1707                continue;
     1708            }
     1709            $reason = ULTICOCL_Matcher::evaluate($rule, $row);
     1710            if (false === $reason) {
     1711                continue;
     1712            }
     1713            if ('' === $reason) {
     1714                $reason = __('Matched the age and status of the rule', 'ultimate-comment-cleaner');
     1715            }
     1716            $result['matched']++;
     1717            if (count($result['samples']) < 5) {
     1718                $result['samples'][] = array(
     1719                    'author'  => $row->comment_author,
     1720                    'excerpt' => wp_trim_words(wp_strip_all_tags($row->comment_content), 18),
     1721                    'reason'  => $reason,
     1722                );
     1723            }
     1724            if ($dry) {
     1725                $result['ids'][] = (int) $row->comment_ID;
     1726            } elseif ($this->apply_rule_action($row, $rule, $reason, null, $rule_id)) {
     1727                $result['acted']++;
     1728            }
     1729        }
     1730        if (!$dry) {
     1731            wp_defer_comment_counting(false);
     1732        }
     1733
     1734        return $result;
     1735    }
     1736
     1737    private function apply_rule_action($comment, $rule, $reason, $previous_status = null, $rule_id = '') {
     1738        switch ($rule['action']) {
     1739            case 'delete':
     1740                return (bool) wp_delete_comment((int) $comment->comment_ID, true);
     1741            case 'spam':
     1742                return 'spam' === (string) $comment->comment_approved ? false : (bool) wp_spam_comment((int) $comment->comment_ID);
     1743            default:
     1744                return $this->quarantine($comment, $rule['name'], $reason, $previous_status, $rule_id);
     1745        }
     1746    }
     1747
     1748    /**
     1749     * Commenters rules must never touch: moderators and the allowlist from Settings.
     1750     *
     1751     * @param object|array $comment Comment row or comment data.
     1752     */
     1753    private function is_protected($comment) {
     1754        static $moderators = array();
     1755        $c = (array) $comment;
     1756
     1757        $user_id = isset($c['user_id']) ? (int) $c['user_id'] : 0;
     1758        if ($user_id) {
     1759            if (!isset($moderators[$user_id])) {
     1760                $moderators[$user_id] = user_can($user_id, 'moderate_comments');
     1761            }
     1762            if ($moderators[$user_id]) {
     1763                return true;
     1764            }
     1765        }
     1766
     1767        $email = isset($c['comment_author_email']) ? strtolower(trim($c['comment_author_email'])) : '';
     1768        if ('' === $email) {
     1769            return false;
     1770        }
     1771        foreach (preg_split('/\r\n|\r|\n/', strtolower($this->get_settings()['allowlist']), -1, PREG_SPLIT_NO_EMPTY) as $entry) {
     1772            $entry = trim($entry);
     1773            if ('' === $entry) {
     1774                continue;
     1775            }
     1776            if ($entry === $email || ('@' === $entry[0] && substr($email, -strlen($entry)) === $entry)) {
     1777                return true;
     1778            }
     1779        }
     1780        return false;
     1781    }
     1782
     1783    // ---------------------------------------------------------------------
     1784    // New comments
     1785    // ---------------------------------------------------------------------
     1786
     1787    /**
     1788     * Filter on pre_comment_approved: hold back a new comment that a rule flags.
     1789     * It is saved as spam first, which keeps notification emails quiet, then
     1790     * after_new_comment() applies the rule's real action.
     1791     */
     1792    public function screen_new_comment($approved, $commentdata) {
     1793        $this->pending = null;
     1794
     1795        if (is_wp_error($approved) || 'spam' === $approved || !is_array($commentdata)) {
     1796            return $approved;
     1797        }
     1798        $type = isset($commentdata['comment_type']) ? $commentdata['comment_type'] : '';
     1799        if ('' !== $type && !in_array($type, self::TYPES, true)) {
     1800            return $approved;
     1801        }
     1802        if ($this->is_protected($commentdata)) {
     1803            return $approved;
     1804        }
     1805
     1806        foreach ($this->get_rules() as $id => $rule) {
     1807            if (empty($rule['active']) || 'existing' === $rule['scope'] || !ULTICOCL_Matcher::has_signals($rule)) {
     1808                continue;
     1809            }
     1810            $reason = ULTICOCL_Matcher::evaluate($rule, $commentdata);
     1811            if (false !== $reason) {
     1812                $this->pending = array($id, $rule, $reason);
     1813                return 'spam';
     1814            }
     1815        }
     1816        return $approved;
     1817    }
     1818
     1819    /**
     1820     * Action comment_post: finish handling a comment that screen_new_comment() flagged.
     1821     */
     1822    public function after_new_comment($comment_id) {
     1823        if (null === $this->pending) {
     1824            return;
     1825        }
     1826        list($id, $rule, $reason) = $this->pending;
     1827        $this->pending = null;
     1828
     1829        $comment = get_comment((int) $comment_id);
     1830        if (!$comment) {
     1831            return;
     1832        }
     1833        // Deleting now would pull the comment out from under core and other plugins that are
     1834        // still handling it. It is already saved as spam, so it stays hidden until shutdown.
     1835        if ('delete' === $rule['action']) {
     1836            if (!$this->deferred_deletes) {
     1837                add_action('shutdown', array($this, 'delete_deferred_comments'));
     1838            }
     1839            $this->deferred_deletes[] = (int) $comment_id;
     1840            $this->add_hits($id, 1);
     1841            $this->record_stat('new', 1, $id);
     1842            return;
     1843        }
     1844        // A new comment would have waited for moderation, so that is where a restore puts it back.
     1845        if ($this->apply_rule_action($comment, $rule, $reason, '0', $id) || 'spam' === $rule['action']) {
     1846            $this->add_hits($id, 1);
     1847            $this->record_stat('new', 1, $id);
     1848        }
     1849    }
     1850
     1851    // ---------------------------------------------------------------------
     1852    // Quarantine
     1853    // ---------------------------------------------------------------------
     1854
     1855    /**
     1856     * Action shutdown: delete new comments that a "delete" rule flagged.
     1857     */
     1858    public function delete_deferred_comments() {
     1859        foreach ($this->deferred_deletes as $comment_id) {
     1860            wp_delete_comment($comment_id, true);
     1861        }
     1862        $this->deferred_deletes = array();
     1863    }
     1864
     1865    private function status_name($status) {
     1866        $names = array('1' => 'approved', '0' => 'unapproved');
     1867        return isset($names[$status]) ? $names[$status] : $status;
     1868    }
     1869
     1870    /**
     1871     * Change comment_approved to any value, including our own quarantine status that
     1872     * the core status functions refuse. Fires the usual transition hooks.
     1873     */
     1874    private function set_status($comment, $new) {
     1875        global $wpdb;
     1876
     1877        $comment = get_comment($comment);
     1878        if (!$comment) {
     1879            return false;
     1880        }
     1881        $old = (string) $comment->comment_approved;
     1882        if ($old === $new) {
     1883            return true;
     1884        }
     1885        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core cannot set a custom comment status.
     1886        if (false === $wpdb->update($wpdb->comments, array('comment_approved' => $new), array('comment_ID' => (int) $comment->comment_ID))) {
     1887            return false;
     1888        }
     1889        clean_comment_cache((int) $comment->comment_ID);
     1890        wp_update_comment_count((int) $comment->comment_post_ID);
     1891        wp_transition_comment_status($this->status_name($new), $this->status_name($old), get_comment((int) $comment->comment_ID));
     1892        return true;
     1893    }
     1894
     1895    /**
     1896     * Hide a comment in quarantine, remembering why and what it was before.
     1897     */
     1898    private function quarantine($comment, $source, $reason, $previous_status = null, $rule_id = '') {
     1899        $comment = get_comment($comment);
     1900        if (!$comment || self::QUARANTINE === (string) $comment->comment_approved) {
     1901            return false;
     1902        }
     1903        $previous = null === $previous_status ? (string) $comment->comment_approved : $previous_status;
     1904        if (!$this->set_status($comment, self::QUARANTINE)) {
     1905            return false;
     1906        }
     1907        update_comment_meta((int) $comment->comment_ID, '_ulticocl_q_time', time());
     1908        update_comment_meta((int) $comment->comment_ID, '_ulticocl_q', array('prev' => $previous, 'source' => $source, 'reason' => $reason, 'rule' => $rule_id));
     1909        return true;
     1910    }
     1911
     1912    private function restore($comment) {
     1913        $comment = get_comment($comment);
     1914        if (!$comment || self::QUARANTINE !== (string) $comment->comment_approved) {
     1915            return false;
     1916        }
     1917        $info = get_comment_meta((int) $comment->comment_ID, '_ulticocl_q', true);
     1918        $previous = is_array($info) && isset($info['prev']) ? (string) $info['prev'] : '0';
     1919        if (!in_array($previous, array('1', '0', 'spam'), true)) {
     1920            $previous = '0';
     1921        }
     1922        if (!$this->set_status($comment, $previous)) {
     1923            return false;
     1924        }
     1925        $this->clear_quarantine_meta((int) $comment->comment_ID);
     1926        // The admin just said this one is fine, so scheduled rules must not catch it again.
     1927        update_comment_meta((int) $comment->comment_ID, '_ulticocl_ok', 1);
     1928        // A restore means the rule caught a good comment: count it against the rule's accuracy.
     1929        if (is_array($info) && !empty($info['rule'])) {
     1930            $this->add_hits($info['rule'], 1, 'restored');
     1931        }
     1932        $this->record_stat('restored', 1);
     1933        return true;
     1934    }
     1935
     1936    private function clear_quarantine_meta($comment_id) {
     1937        delete_comment_meta($comment_id, '_ulticocl_q');
     1938        delete_comment_meta($comment_id, '_ulticocl_q_time');
     1939    }
     1940
     1941    /**
     1942     * Delete quarantined comments older than the retention set in Settings (daily cron).
     1943     */
     1944    public function purge_quarantine() {
     1945        $days = (int) $this->get_settings()['quarantine_days'];
     1946        if ($days < 1) {
     1947            return;
     1948        }
     1949        $cutoff = time() - $days * DAY_IN_SECONDS;
     1950        $start = time();
     1951        $purged = 0;
     1952        do {
     1953            $ids = get_comments(array(
     1954                'status'     => self::QUARANTINE,
     1955                'fields'     => 'ids',
     1956                'number'     => self::BATCH_SIZE,
     1957                'orderby'    => 'comment_ID',
     1958                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Small, daily, batched query on the quarantine only.
     1959                'meta_query' => array(array('key' => '_ulticocl_q_time', 'value' => $cutoff, 'compare' => '<', 'type' => 'NUMERIC')),
    10481960            ));
    1049             if (!empty($post_ids)) {
    1050                 $where[] = "comment_post_ID IN (" . implode(',', array_map('intval', $post_ids)) . ")";
    1051             } else {
    1052                 // No posts found, so no comments - will export empty array
    1053             }
    1054         }
    1055        
    1056         // Additional filters
    1057         if (!empty($filters['has_url'])) {
    1058             $where[] = "comment_author_url != ''";
    1059         }
    1060         if (!empty($filters['no_url'])) {
    1061             $where[] = "(comment_author_url = '' OR comment_author_url IS NULL)";
    1062         }
    1063        
    1064         // Only query if we have valid where conditions
    1065         if (!empty($where)) {
    1066             $where_clause = implode(' AND ', $where);
    1067             // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    1068             $comments = $wpdb->get_results("SELECT * FROM $wpdb->comments WHERE $where_clause");
    1069         }
    1070        
    1071         header('Content-Type: text/csv');
    1072         header('Content-Disposition: attachment; filename="comments-export-' . gmdate('Y-m-d') . '.csv"');
    1073        
    1074         $output = fopen('php://output', 'w');
    1075         fputcsv($output, array('ID', 'Post ID', 'Author', 'Email', 'URL', 'Content', 'Date', 'Status'));
    1076        
    1077         foreach ($comments as $comment) {
    1078             fputcsv($output, array(
    1079                 $comment->comment_ID,
    1080                 $comment->comment_post_ID,
    1081                 $comment->comment_author,
    1082                 $comment->comment_author_email,
    1083                 $comment->comment_author_url,
    1084                 $comment->comment_content,
    1085                 $comment->comment_date,
    1086                 $comment->comment_approved
    1087             ));
    1088         }
    1089        
    1090         if (is_resource($output)) {
    1091             // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output is a special stream, not a file
    1092             fclose($output);
    1093         }
     1961            wp_defer_comment_counting(true);
     1962            foreach ($ids as $id) {
     1963                if (wp_delete_comment((int) $id, true)) {
     1964                    $purged++;
     1965                }
     1966            }
     1967            wp_defer_comment_counting(false);
     1968        } while (count($ids) === self::BATCH_SIZE && time() - $start < self::CRON_BUDGET);
     1969
     1970        if ($purged > 0) {
     1971            /* translators: 1: number of comments, 2: number of days */
     1972            $this->log_activity(sprintf(_n('Deleted %1$d quarantined comment older than %2$d days', 'Deleted %1$d quarantined comments older than %2$d days', $purged, 'ultimate-comment-cleaner'), $purged, $days));
     1973        }
     1974    }
     1975
     1976    // ---------------------------------------------------------------------
     1977    // Comment list actions (row links and bulk actions)
     1978    // ---------------------------------------------------------------------
     1979
     1980    /**
     1981     * Apply one action to one comment. Only managed comment types can be touched.
     1982     */
     1983    private function comment_action($id, $action) {
     1984        $comment = get_comment((int) $id);
     1985        if (!$comment || !in_array($comment->comment_type, array_merge(self::TYPES, array('')), true)) {
     1986            return false;
     1987        }
     1988        $id = (int) $comment->comment_ID;
     1989        $quarantined = self::QUARANTINE === (string) $comment->comment_approved;
     1990
     1991        switch ($action) {
     1992            case 'approve':
     1993                $ok = $quarantined ? $this->set_status($comment, '1') : wp_set_comment_status($id, 'approve');
     1994                break;
     1995            case 'unapprove':
     1996                $ok = $quarantined ? $this->set_status($comment, '0') : wp_set_comment_status($id, 'hold');
     1997                break;
     1998            case 'spam':
     1999                $ok = $quarantined ? $this->set_status($comment, 'spam') : wp_spam_comment($id);
     2000                break;
     2001            case 'quarantine':
     2002                return $this->quarantine($comment, __('Moved manually', 'ultimate-comment-cleaner'), '');
     2003            case 'restore':
     2004                return $this->restore($comment);
     2005            case 'delete':
     2006                return (bool) wp_delete_comment($id, true);
     2007            default:
     2008                return false;
     2009        }
     2010        if ($ok && $quarantined) {
     2011            $this->clear_quarantine_meta($id);
     2012        }
     2013        return (bool) $ok;
     2014    }
     2015
     2016    /**
     2017     * admin_init: handle a row link or a bulk action from the comment lists.
     2018     */
     2019    public function handle_comment_actions() {
     2020        // phpcs:disable WordPress.Security.NonceVerification -- Both paths below call check_admin_referer().
     2021        if (!isset($_GET['page']) || 'ultimate-comment-cleaner' !== $_GET['page']) {
     2022            return;
     2023        }
     2024
     2025        $ids = array();
     2026        $action = '';
     2027        if (!empty($_GET['ulticocl_do']) && isset($_GET['c'])) {
     2028            $action = sanitize_key(wp_unslash($_GET['ulticocl_do']));
     2029            $ids = array(absint($_GET['c']));
     2030            check_admin_referer('ulticocl_row_' . $action . '_' . $ids[0]);
     2031        } elseif (isset($_SERVER['REQUEST_METHOD']) && 'POST' === $_SERVER['REQUEST_METHOD'] && isset($_POST['ids']) && is_array($_POST['ids'])) {
     2032            foreach (array('action', 'action2') as $key) {
     2033                if (empty($action) && !empty($_POST[$key]) && '-1' !== $_POST[$key]) {
     2034                    $action = sanitize_key(wp_unslash($_POST[$key]));
     2035                }
     2036            }
     2037            check_admin_referer('bulk-ulticocl_comments');
     2038            $ids = array_filter(array_map('absint', wp_unslash($_POST['ids'])));
     2039        } else {
     2040            return;
     2041        }
     2042        // phpcs:enable WordPress.Security.NonceVerification
     2043
     2044        if (!current_user_can('manage_options')) {
     2045            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner'));
     2046        }
     2047
     2048        $url = remove_query_arg(array('ulticocl_do', 'c', '_wpnonce', '_wp_http_referer', 'action', 'action2', 'ulticocl_notice', 'ulticocl_n', 'ulticocl_a'), isset($_POST['_wp_http_referer']) ? wp_unslash($_POST['_wp_http_referer']) : (isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification -- Only used as a redirect target after wp_safe_redirect() validation.
     2049
     2050        if (in_array($action, self::COMMENT_ACTIONS, true) && $ids) {
     2051            $done = 0;
     2052            wp_defer_comment_counting(true);
     2053            foreach ($ids as $id) {
     2054                if ($this->comment_action($id, $action)) {
     2055                    $done++;
     2056                }
     2057            }
     2058            wp_defer_comment_counting(false);
     2059            $url = add_query_arg(array('ulticocl_notice' => 1, 'ulticocl_n' => $done, 'ulticocl_a' => $action), $url);
     2060        }
     2061
     2062        wp_safe_redirect($url ? $url : admin_url('tools.php?page=ultimate-comment-cleaner&tab=manage'));
    10942063        exit;
    10952064    }
    1096    
    1097     public function check_triggers() {
    1098         $triggers = get_option('ulticocl_triggers', array());
    1099        
    1100         foreach ($triggers as $id => $trigger) {
    1101             if (!$trigger['active']) continue;
    1102            
    1103             $last_run = isset($trigger['last_run']) ? strtotime($trigger['last_run']) : 0;
    1104             $schedule_interval = $this->get_schedule_interval($trigger['schedule']);
    1105            
    1106             if (time() - $last_run >= $schedule_interval) {
    1107                 $this->execute_trigger($id, $trigger);
    1108             }
    1109         }
    1110     }
    1111    
    1112     private function execute_trigger($trigger_id, $trigger) {
    1113         global $wpdb;
    1114        
    1115         $where = array("comment_type = 'comment'");
    1116        
    1117         if ($trigger['status'] === 'approved') {
    1118             $where[] = "comment_approved = '1'";
    1119         } elseif ($trigger['status'] === 'pending') {
    1120             $where[] = "comment_approved = '0'";
    1121         } elseif ($trigger['status'] === 'spam') {
    1122             $where[] = "comment_approved = 'spam'";
    1123         }
    1124        
    1125         if (!empty($trigger['older_than'])) {
    1126             $date = gmdate('Y-m-d H:i:s', strtotime('-' . intval($trigger['older_than']) . ' days'));
    1127             $where[] = $wpdb->prepare("comment_date < %s", $date);
    1128         }
    1129        
    1130         $where_clause = implode(' AND ', $where);
    1131        
    1132         // Get comment IDs before deletion
    1133         // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_clause is built from validated/sanitized inputs
    1134         $comment_ids = $wpdb->get_col("SELECT comment_ID FROM $wpdb->comments WHERE $where_clause");
    1135        
    1136         if (empty($comment_ids)) {
     2065
     2066    // ---------------------------------------------------------------------
     2067    // Scheduled runs
     2068    // ---------------------------------------------------------------------
     2069
     2070    /**
     2071     * Hourly cron: continue or start scans for rules that apply to existing comments.
     2072     */
     2073    public function run_scheduled_rules() {
     2074        $rules = $this->get_rules();
     2075        $state = get_option('ulticocl_rule_state', array());
     2076        $start = time();
     2077
     2078        foreach ($rules as $id => $rule) {
     2079            if (empty($rule['active']) || 'new' === $rule['scope'] || !ULTICOCL_Matcher::scope_is_safe($rule)) {
     2080                continue;
     2081            }
     2082            if (time() - $start >= self::CRON_BUDGET) {
     2083                break;
     2084            }
     2085            $st = wp_parse_args(isset($state[$id]) ? $state[$id] : array(), array('hits' => 0, 'cursor' => 0, 'last_run' => 0));
     2086            // A scan in progress carries on; otherwise wait for the schedule (5 minutes of slack for hourly cron drift).
     2087            if (0 === (int) $st['cursor'] && time() - (int) $st['last_run'] < $this->get_schedule_interval($rule['schedule']) - 5 * MINUTE_IN_SECONDS) {
     2088                continue;
     2089            }
     2090
     2091            $acted = 0;
     2092            do {
     2093                $result = $this->scan_rule($rule, (int) $st['cursor'], self::BATCH_SIZE, false, $id);
     2094                if (is_wp_error($result)) {
     2095                    break;
     2096                }
     2097                $st['cursor'] = $result['done'] ? 0 : $result['cursor'];
     2098                $acted += $result['acted'];
     2099            } while (!$result['done'] && time() - $start < self::CRON_BUDGET);
     2100
     2101            if (!is_wp_error($result) && $result['done']) {
     2102                $st['last_run'] = time();
     2103            }
     2104            $st['hits'] += $acted;
     2105            $state[$id] = $st;
     2106            $this->record_stat('scan', $acted, $id);
     2107
     2108            if ($acted > 0) {
     2109                /* translators: 1: rule name, 2: result sentence */
     2110                $this->log_activity(sprintf(__('Rule "%1$s": %2$s', 'ultimate-comment-cleaner'), $rule['name'], $this->action_message($rule['action'], $acted)));
     2111                $this->send_email_notification($acted, $rule['name'], $rule['action']);
     2112            }
     2113        }
     2114        update_option('ulticocl_rule_state', $state, false);
     2115    }
     2116
     2117    public function get_settings() {
     2118        return wp_parse_args(get_option('ulticocl_settings', array()), array(
     2119            'enable_logging' => 1,
     2120            'send_email_notifications' => 0,
     2121            'notification_email' => get_option('admin_email'),
     2122            'quarantine_days' => 14,
     2123            'allowlist' => '',
     2124        ));
     2125    }
     2126
     2127    private function send_email_notification($count, $source = 'manual', $action = 'delete') {
     2128        $settings = $this->get_settings();
     2129
     2130        if (empty($settings['send_email_notifications']) || $count < 1) {
    11372131            return;
    11382132        }
    1139        
    1140         $deleted = 0;
    1141         // Use WordPress function to properly delete comments
    1142         foreach ($comment_ids as $comment_id) {
    1143             if (wp_delete_comment($comment_id, true)) {
    1144                 $deleted++;
    1145             }
    1146         }
    1147        
    1148         // Update trigger last run
    1149         $triggers = get_option('ulticocl_triggers', array());
    1150         $triggers[$trigger_id]['last_run'] = current_time('mysql');
    1151         update_option('ulticocl_triggers', $triggers);
    1152        
    1153         // Clear caches
    1154         wp_cache_flush();
    1155         delete_transient('ulticocl_comments_count');
    1156        
    1157         // translators: %1$s: trigger name, %2$d: number of comments deleted
    1158         $this->log_activity(sprintf(__('Trigger "%1$s" deleted %2$d comments', 'ultimate-comment-cleaner'), $trigger['name'], $deleted));
    1159         $this->send_email_notification($deleted, $trigger['name']);
    1160     }
    1161    
    1162     private function send_email_notification($count, $source = 'manual') {
    1163         $settings = get_option('ulticocl_settings', array());
    1164        
    1165         // Only send if notifications are enabled
    1166         if (!isset($settings['send_email_notifications']) || !$settings['send_email_notifications']) {
    1167             return;
    1168         }
    1169        
    1170         // Get notification email address
    1171         $email = isset($settings['notification_email']) && !empty($settings['notification_email'])
    1172             ? $settings['notification_email']
    1173             : get_option('admin_email');
    1174        
     2133
     2134        $email = !empty($settings['notification_email']) ? $settings['notification_email'] : get_option('admin_email');
     2135
    11752136        if (empty($email) || !is_email($email)) {
    11762137            return;
    11772138        }
    1178        
    1179         // Prepare email
     2139
    11802140        // translators: %s: Site name.
    1181         $subject = sprintf(__('[%s] Comments Deleted', 'ultimate-comment-cleaner'), get_bloginfo('name'));
    1182        
    1183         // translators: %d: Number of comments deleted.
    1184         $message = sprintf(
    1185             // translators: %d: Number of comments deleted.
    1186             __('%d comment(s) have been deleted from your website.', 'ultimate-comment-cleaner'),
    1187             $count
     2141        $subject = sprintf(__('[%s] Comments Cleaned', 'ultimate-comment-cleaner'), get_bloginfo('name'));
     2142
     2143        $sentences = array(
     2144            /* translators: %d: number of comments */
     2145            'delete'     => _n('%d comment has been deleted from your website.', '%d comments have been deleted from your website.', $count, 'ultimate-comment-cleaner'),
     2146            /* translators: %d: number of comments */
     2147            'quarantine' => _n('%d comment has been moved to quarantine on your website.', '%d comments have been moved to quarantine on your website.', $count, 'ultimate-comment-cleaner'),
     2148            /* translators: %d: number of comments */
     2149            'spam'       => _n('%d comment has been marked as spam on your website.', '%d comments have been marked as spam on your website.', $count, 'ultimate-comment-cleaner'),
    11882150        );
    1189        
     2151        $message = sprintf(isset($sentences[$action]) ? $sentences[$action] : $sentences['delete'], $count);
     2152
    11902153        if ($source !== 'manual') {
    1191             // translators: %s: trigger name
    1192             $message .= "\n\n" . sprintf(__('Source: Auto Trigger "%s"', 'ultimate-comment-cleaner'), $source);
     2154            // translators: %s: rule name
     2155            $message .= "\n\n" . sprintf(__('Source: Smart Rule "%s"', 'ultimate-comment-cleaner'), $source);
    11932156        } else {
    1194             $message .= "\n\n" . __('Source: Manual deletion', 'ultimate-comment-cleaner');
    1195         }
    1196        
     2157            $message .= "\n\n" . __('Source: Manual clean-up', 'ultimate-comment-cleaner');
     2158        }
     2159
    11972160        // translators: %s: current time
    11982161        $message .= "\n\n" . sprintf(__('Time: %s', 'ultimate-comment-cleaner'), current_time('mysql'));
    11992162        $message .= "\n\n" . __('This is an automated notification from Ultimate Comment Cleaner plugin.', 'ultimate-comment-cleaner');
    1200        
    1201         // Send email
     2163
    12022164        wp_mail($email, $subject, $message);
    12032165    }
    1204    
     2166
    12052167    private function get_schedule_interval($schedule) {
    12062168        switch ($schedule) {
    … …  
    12082170            case 'daily': return DAY_IN_SECONDS;
    12092171            case 'weekly': return WEEK_IN_SECONDS;
    1210             default: return HOUR_IN_SECONDS;
    1211         }
    1212     }
    1213    
     2172            default: return DAY_IN_SECONDS;
     2173        }
     2174    }
     2175
    12142176    private function log_activity($message) {
    1215         $settings = get_option('ulticocl_settings', array());
    1216        
    1217         // Only log if logging is enabled
    1218         if (isset($settings['enable_logging']) && $settings['enable_logging']) {
     2177        $settings = $this->get_settings();
     2178
     2179        if (!empty($settings['enable_logging'])) {
    12192180            $activities = get_option('ulticocl_activity_log', array());
    12202181            $activities[] = array(
    … …  
    12222183                'message' => $message
    12232184            );
    1224             update_option('ulticocl_activity_log', array_slice($activities, -100)); // Keep last 100
    1225         }
    1226     }
    1227    
     2185            update_option('ulticocl_activity_log', array_slice($activities, -100), false); // Keep last 100
     2186        }
     2187    }
     2188
     2189    /**
     2190     * Save settings from $_POST. Callers must verify the nonce and capability first.
     2191     */
     2192    private function save_settings_from_post() {
     2193        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified by callers.
     2194        $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
     2195        $current = $this->get_settings();
     2196
     2197        // One email address or @domain per line.
     2198        $allowlist = array();
     2199        if (isset($_POST['allowlist'])) {
     2200            foreach (preg_split('/\r\n|\r|\n/', sanitize_textarea_field(wp_unslash($_POST['allowlist']))) as $line) {
     2201                $line = strtolower(trim($line));
     2202                if (is_email($line) || preg_match('/^@?[a-z0-9-]+(\.[a-z0-9-]+)+$/', $line)) {
     2203                    $allowlist[] = $line;
     2204                }
     2205            }
     2206        }
     2207
     2208        $settings = array(
     2209            'enable_logging' => empty($_POST['enable_logging']) ? 0 : 1,
     2210            'send_email_notifications' => empty($_POST['send_email_notifications']) ? 0 : 1,
     2211            'notification_email' => $notification_email ? $notification_email : get_option('admin_email'),
     2212            'quarantine_days' => isset($_POST['quarantine_days']) ? min(365, absint($_POST['quarantine_days'])) : (int) $current['quarantine_days'],
     2213            'allowlist' => isset($_POST['allowlist']) ? implode("\n", array_unique($allowlist)) : $current['allowlist'],
     2214        );
     2215        // phpcs:enable WordPress.Security.NonceVerification.Missing
     2216
     2217        update_option('ulticocl_settings', $settings);
     2218        $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
     2219    }
     2220
    12282221    public function handle_settings_save() {
    12292222        if (!isset($_POST['ulticocl_action']) || sanitize_text_field(wp_unslash($_POST['ulticocl_action'])) !== 'save_settings') {
    12302223            return;
    12312224        }
    1232        
     2225
    12332226        if (!current_user_can('manage_options')) {
    12342227            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner'));
    12352228        }
    1236        
     2229
    12372230        if (!isset($_POST['ulticocl_settings_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ulticocl_settings_nonce'])), 'ulticocl_save_settings')) {
    12382231            wp_die(esc_html__('Security check failed.', 'ultimate-comment-cleaner'));
    12392232        }
    1240        
    1241         $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
    1242         if (empty($notification_email)) {
    1243             $notification_email = get_option('admin_email');
    1244         }
    1245        
    1246         $settings = array(
    1247             'enable_logging' => isset($_POST['enable_logging']) ? 1 : 0,
    1248             'send_email_notifications' => isset($_POST['send_email_notifications']) ? 1 : 0,
    1249             'notification_email' => $notification_email
    1250         );
    1251        
    1252         update_option('ulticocl_settings', $settings);
    1253        
    1254         // Log settings update if logging is enabled (check before update)
    1255         $old_settings = get_option('rc_settings', array());
    1256         if (isset($old_settings['enable_logging']) && $old_settings['enable_logging']) {
    1257             $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
    1258         }
    1259        
    1260         // Redirect back to settings tab with success message
     2233
     2234        $this->save_settings_from_post();
     2235
    12612236        wp_safe_redirect(add_query_arg(array(
    12622237            'page' => 'ultimate-comment-cleaner',
    … …  
    12662241        exit;
    12672242    }
    1268    
     2243
    12692244    public function ajax_save_settings() {
    12702245        check_ajax_referer('ulticocl_nonce', 'nonce');
    1271        
     2246
    12722247        if (!current_user_can('manage_options')) {
    12732248            wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner')));
    12742249        }
    1275        
    1276         $notification_email = isset($_POST['notification_email']) ? sanitize_email(wp_unslash($_POST['notification_email'])) : '';
    1277         if (empty($notification_email)) {
    1278             $notification_email = get_option('admin_email');
    1279         }
    1280        
    1281         $settings = array(
    1282             'enable_logging' => isset($_POST['enable_logging']) ? 1 : 0,
    1283             'send_email_notifications' => isset($_POST['send_email_notifications']) ? 1 : 0,
    1284             'notification_email' => $notification_email
    1285         );
    1286        
    1287         update_option('ulticocl_settings', $settings);
    1288        
    1289         // Log settings update if logging is enabled (check after update)
    1290         if ($settings['enable_logging']) {
    1291             $this->log_activity(__('Settings updated', 'ultimate-comment-cleaner'));
    1292         }
    1293        
     2250
     2251        $this->save_settings_from_post();
     2252
    12942253        wp_send_json_success(array('message' => __('Settings saved successfully.', 'ultimate-comment-cleaner')));
    12952254    }
    1296    
     2255
     2256    /**
     2257     * Upgrade data from earlier versions (runs once). Version 1.0 "Auto Triggers" become
     2258     * Smart Rules that delete exactly as before. A trigger that could have deleted every
     2259     * approved or pending comment is imported switched off.
     2260     */
     2261    public function maybe_upgrade() {
     2262        if (version_compare((string) get_option('ulticocl_db_version', '0'), '1.1.0', '>=')) {
     2263            return;
     2264        }
     2265
     2266        $triggers = get_option('ulticocl_triggers', array());
     2267        if (is_array($triggers) && $triggers && !get_option('ulticocl_rules')) {
     2268            $rules = array();
     2269            $state = array();
     2270            $disabled = 0;
     2271            foreach ($triggers as $old_id => $trigger) {
     2272                $status = isset($trigger['status']) && in_array($trigger['status'], ULTICOCL_Matcher::RULE_STATUSES, true) ? $trigger['status'] : 'approved';
     2273                $schedule = isset($trigger['schedule']) && in_array($trigger['schedule'], self::SCHEDULES, true) ? $trigger['schedule'] : 'daily';
     2274                $rule = array_merge(ULTICOCL_Matcher::defaults(), array(
     2275                    'name'       => !empty($trigger['name']) ? sanitize_text_field($trigger['name']) : __('Imported trigger', 'ultimate-comment-cleaner'),
     2276                    'active'     => !empty($trigger['active']),
     2277                    'scope'      => 'existing',
     2278                    'action'     => 'delete',
     2279                    'status'     => array($status),
     2280                    'older_than' => isset($trigger['older_than']) ? absint($trigger['older_than']) : 0,
     2281                    'schedule'   => $schedule,
     2282                    'created'    => !empty($trigger['created']) ? $trigger['created'] : current_time('mysql'),
     2283                ));
     2284                if ($rule['active'] && !ULTICOCL_Matcher::scope_is_safe($rule)) {
     2285                    $rule['active'] = false;
     2286                    $disabled++;
     2287                }
     2288                $id = '' === (string) $old_id ? uniqid('rule_') : sanitize_key($old_id);
     2289                $rules[$id] = $rule;
     2290                $state[$id] = array('hits' => 0, 'cursor' => 0, 'last_run' => !empty($trigger['last_run']) ? (int) get_gmt_from_date($trigger['last_run'], 'U') : 0);
     2291            }
     2292            update_option('ulticocl_rules', $rules, false);
     2293            update_option('ulticocl_rule_state', $state, false);
     2294            /* translators: %d: number of rules */
     2295            $this->log_activity(sprintf(_n('Imported %d Auto Trigger as a Smart Rule', 'Imported %d Auto Triggers as Smart Rules', count($rules), 'ultimate-comment-cleaner'), count($rules)));
     2296            if ($disabled) {
     2297                /* translators: %d: number of rules */
     2298                $this->log_activity(sprintf(_n('%d imported rule was switched off because it had no age limit. Review it in Smart Rules.', '%d imported rules were switched off because they had no age limit. Review them in Smart Rules.', $disabled, 'ultimate-comment-cleaner'), $disabled));
     2299            }
     2300        }
     2301        delete_option('ulticocl_triggers');
     2302        wp_clear_scheduled_hook('ulticocl_check_triggers');
     2303        update_option('ulticocl_db_version', '1.1.0');
     2304    }
     2305
    12972306    /**
    12982307     * Add settings link to plugin action links
    12992308     */
    13002309    public function add_plugin_action_links($links) {
    1301         $settings_link = '<a href="' . admin_url('tools.php?page=ultimate-comment-cleaner') . '">' . esc_html__('Settings', 'ultimate-comment-cleaner') . '</a>';
     2310        $settings_link = '<a href="' . esc_url(admin_url('tools.php?page=ultimate-comment-cleaner')) . '">' . esc_html__('Settings', 'ultimate-comment-cleaner') . '</a>';
    13022311        array_unshift($links, $settings_link);
    13032312        return $links;
    … …  
    13112320add_action('plugins_loaded', 'ulticocl_init');
    13122321
     2322// Stop the scheduled jobs when the plugin is deactivated.
     2323register_deactivation_hook(__FILE__, function () {
     2324    wp_clear_scheduled_hook('ulticocl_run_rules');
     2325    wp_clear_scheduled_hook('ulticocl_purge_quarantine');
     2326});
Note: See TracChangeset for help on using the changeset viewer.