Changeset 3752017
- Timestamp:
- 10/17/2026 02:30:43 PM (3 weeks ago)
- Location:
- ultimate-comment-cleaner
- Files:
-
- 14 added
- 2 deleted
- 8 edited
- 1 copied
-
tags/1.1.0 (copied) (copied from ultimate-comment-cleaner/trunk )
-
tags/1.1.0/assets/css/admin.css (modified) (1 diff)
-
tags/1.1.0/assets/css/dashboard.css (added)
-
tags/1.1.0/assets/js/admin.js (modified) (10 diffs)
-
tags/1.1.0/assets/js/dashboard.js (added)
-
tags/1.1.0/includes (added)
-
tags/1.1.0/includes/class-ulticocl-comments-table.php (added)
-
tags/1.1.0/includes/class-ulticocl-insights.php (added)
-
tags/1.1.0/includes/class-ulticocl-matcher.php (added)
-
tags/1.1.0/languages (deleted)
-
tags/1.1.0/readme.txt (modified) (5 diffs)
-
tags/1.1.0/ultimate-comment-cleaner.php (modified) (23 diffs)
-
tags/1.1.0/uninstall.php (added)
-
trunk/assets/css/admin.css (modified) (1 diff)
-
trunk/assets/css/dashboard.css (added)
-
trunk/assets/js/admin.js (modified) (10 diffs)
-
trunk/assets/js/dashboard.js (added)
-
trunk/includes (added)
-
trunk/includes/class-ulticocl-comments-table.php (added)
-
trunk/includes/class-ulticocl-insights.php (added)
-
trunk/includes/class-ulticocl-matcher.php (added)
-
trunk/languages (deleted)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/ultimate-comment-cleaner.php (modified) (23 diffs)
-
trunk/uninstall.php (added)
Legend:
- Unmodified
- Added
- Removed
-
ultimate-comment-cleaner/tags/1.1.0/assets/css/admin.css
r3446581 r3752017 1043 1043 } 1044 1044 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 1 1 jQuery(document).ready(function($) { 2 2 'use strict'; 3 3 4 const { __, _n, sprintf } = wp.i18n; 5 const td = 'ultimate-comment-cleaner'; 6 4 7 // Helper function to show native alert 5 8 function showAlert(message, type) { 6 9 if (type === 'error') { 7 alert('Error: ' + message); 8 } else if (type === 'success') { 9 alert('Success: ' + message); 10 alert(sprintf(__('Error: %s', td), message)); 10 11 } else { 11 12 alert(message); 12 13 } 13 14 } 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 23 106 $('#remove-comments-delete-form').on('submit', function(e) { 24 107 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'); 31 111 return; 32 112 } 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 39 121 // Preview Count 40 122 $('#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 69 166 // Export Comments 70 167 $('#remove-comments-export-btn, #remove-comments-export-all').on('click', function() { 71 168 const formData = getFormData(); 169 delete formData.clean_action; 72 170 formData.action = 'ulticocl_export_comments'; 73 171 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 75 177 // Create form and submit 76 178 const form = $('<form>', { … … 79 181 target: '_blank' 80 182 }); 81 183 82 184 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 90 195 $('body').append(form); 91 196 form.submit(); 92 197 form.remove(); 93 198 }); 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 243 200 function getFormData() { 244 201 const data = {}; 245 202 246 203 // Get status checkboxes 247 204 const statuses = []; … … 252 209 data.status = statuses; 253 210 } 254 211 255 212 // Get date range 256 213 const dateFrom = $('#remove-comments-date-from').val(); … … 258 215 if (dateFrom) data.date_from = dateFrom; 259 216 if (dateTo) data.date_to = dateTo; 260 217 261 218 // Get post types 262 219 const postTypes = []; … … 267 224 data.post_type = postTypes; 268 225 } 269 226 270 227 // Get author 271 228 const author = $('#remove-comments-author-filter').val(); … … 273 230 data.author = author; 274 231 } 275 232 233 // Search text 234 const search = $('#remove-comments-search').val(); 235 if (search && search.trim() !== '') { 236 data.search = search.trim(); 237 } 238 276 239 // Additional filters 277 240 if ($('input[name="has_url"]').is(':checked')) { … … 284 247 data.short_comments = 1; 285 248 } 286 249 287 250 const minWords = $('input[name="min_words"]').val(); 288 251 const maxWords = $('input[name="max_words"]').val(); 289 252 if (minWords) data.min_words = minWords; 290 253 if (maxWords) data.max_words = maxWords; 291 254 255 data.clean_action = $('#remove-comments-clean-action').val() || 'quarantine'; 256 292 257 return data; 293 258 } 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))); 310 558 }); 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 331 619 // Checkbox group handling 332 620 $('input[name="status[]"]').on('change', function() { 621 if (!$(this).closest('#remove-comments-delete-form').length) { 622 return; 623 } 333 624 if ($(this).val() === 'all' && $(this).is(':checked')) { 334 625 $('input[name="status[]"]').not(this).prop('checked', false); … … 337 628 } 338 629 }); 339 630 340 631 $('input[name="post_type[]"]').on('change', function() { 341 632 if ($(this).val() === 'all' && $(this).is(':checked')) { … … 345 636 } 346 637 }); 347 638 348 639 // Toggle email field visibility based on notification checkbox 349 640 $('input[name="send_email_notifications"]').on('change', function() { … … 354 645 } 355 646 }); 356 647 357 648 // Settings Form - Use AJAX for better UX 358 649 $('#remove-comments-settings-form').on('submit', function(e) { 359 650 e.preventDefault(); 360 651 361 652 const notificationEmail = $('#rc_notification_email').val(); 362 653 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'); 364 655 return; 365 656 } 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({ 368 663 action: 'ulticocl_save_settings', 369 nonce: ulticoclData.nonce,370 664 enable_logging: $('input[name="enable_logging"]').is(':checked') ? 1 : 0, 371 665 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'); 403 678 submitButton.prop('disabled', false).text(originalText); 404 679 } 680 }).fail(function() { 681 showAlert(__('An error occurred while saving settings.', td), 'error'); 682 submitButton.prop('disabled', false).text(originalText); 405 683 }); 406 684 }); -
ultimate-comment-cleaner/tags/1.1.0/readme.txt
r3446581 r3752017 1 1 === Ultimate Comment Cleaner === 2 2 Contributors: 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 3 Tags: spam, delete comments, comment cleaner, quarantine, bulk delete 4 Requires at least: 5.5 5 Tested up to: 7.1 6 Requires PHP: 7.4 7 Stable tag: 1.1.0 7 8 License: GPLv2 or later 8 9 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 10 11 Author URI: https://profiles.wordpress.org/wpunicorn/ 11 12 12 Bulk delete WordPress comments with advanced filters, triggers, and a modern dashboard. Delete by status, date, author, and more.13 Stop comment spam with smart rules: keywords, Cyrillic/Chinese text, links. Quarantine, review and bulk-clean comments. Restore any mistake. 13 14 14 15 == Description == 15 16 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. 17 18 18 = Key Features = 19 Getting 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. 19 20 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 = 29 22 30 = Dashboard Features = 23 Rules check **new comments as they arrive** and can clean up **existing comments on a schedule**. A rule can look for: 31 24 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". 37 31 38 = Bulk Delete Options = 32 Ready-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. 39 33 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 34 What a rule does when it matches is up to you: **move to quarantine** (recommended), **mark as spam** or **delete permanently**. 49 35 50 = Auto Triggers=36 = Test before you trust it = 51 37 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 38 Every 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 42 Caught 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 46 See 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 50 A 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 54 Delete, 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. 57 63 58 64 == Installation == … … 64 70 == Frequently Asked Questions == 65 71 66 = How do I delete all spam comments? =72 = How do I stop Russian gambling spam? = 67 73 68 Go to **Tools → Ultimate Comment Cleaner → Bulk Delete**, select "Spam" in the status filter, and click "Delete Comments".74 Go 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**. 69 75 70 = Can I preview how many comments will be deleted? =76 = What happens to a comment a rule catches? = 71 77 72 Yes! Use the "Preview Count" button to see how many comments match your criteria before deleting.78 By 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. 73 79 74 = How do I set up automatic spam cleanup? =80 = Can a rule catch good comments by mistake? = 75 81 76 Go to **Tools → Ultimate Comment Cleaner → Auto Triggers**, click "Add New Trigger", set the status to "Spam", choose a schedule, and enable it. 82 It 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 86 Both. 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 90 14 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 94 Yes. 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 98 Quarantined 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 102 They 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 106 Yes. Bulk Clean has a "Preview Count" button, rules have a "Test on existing comments" button, and every confirmation shows the exact number. 77 107 78 108 = Can I export comments before deleting them? = 79 109 80 Yes ! Use the "Export Selected" or "Export All Comments" buttonsto download comments as a CSV file.110 Yes. Use "Export Selected" or "Export All Comments" to download comments as a CSV file. 81 111 82 112 = What permissions are required? = 83 113 84 114 You 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.89 115 90 116 == Screenshots == … … 96 122 97 123 == 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 98 151 99 152 = 1.0.0 = … … 111 164 == Upgrade Notice == 112 165 166 = 1.1.0 = 167 Big 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 113 169 = 1.0.0 = 114 170 Initial 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 3 3 * Plugin Name: Ultimate Comment Cleaner 4 4 * 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.05 * 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 7 7 * Author: wpunicorn 8 8 * Author URI: https://profiles.wordpress.org/wpunicorn/ … … 10 10 * License URI: https://www.gnu.org/licenses/gpl-2.0.html 11 11 * 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 14 15 */ 15 16 … … 25 26 */ 26 27 if (!defined('ULTICOCL_PLUGIN_VERSION')) { 27 define('ULTICOCL_PLUGIN_VERSION', '1. 0.0');28 define('ULTICOCL_PLUGIN_VERSION', '1.1.0'); 28 29 } 29 30 if (!defined('ULTICOCL_PLUGIN_DIR')) { … … 37 38 } 38 39 40 require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-matcher.php'; 41 require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-insights.php'; 42 39 43 class 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 41 81 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 43 89 public static function get_instance() { 44 90 if (null === self::$instance) { … … 47 93 return self::$instance; 48 94 } 49 95 50 96 private function __construct() { 51 97 $this->init_hooks(); 52 98 } 53 99 54 100 private function init_hooks() { 55 101 add_action('admin_menu', array($this, 'add_admin_menu')); 56 102 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')); 59 105 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')); 63 110 add_action('wp_ajax_ulticocl_preview_count', array($this, 'ajax_preview_count')); 64 111 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 67 114 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 75 133 // Add settings link to plugins page 76 134 add_filter('plugin_action_links_' . ULTICOCL_PLUGIN_BASENAME, array($this, 'add_plugin_action_links')); … … 103 161 104 162 // Enqueue scripts 105 wp_enqueue_script('jquery');106 163 wp_enqueue_script( 107 164 'ulticocl-admin-script', 108 165 ULTICOCL_PLUGIN_URL . 'assets/js/admin.js', 109 array('jquery' ),166 array('jquery', 'wp-i18n'), 110 167 ULTICOCL_PLUGIN_VERSION, 111 168 true 112 169 ); 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 } 118 176 wp_localize_script('ulticocl-admin-script', 'ulticoclData', array( 119 177 'ajaxurl' => admin_url('admin-ajax.php'), 120 178 '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')), 127 182 )); 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 } 128 198 } 129 199 … … 134 204 135 205 // 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 138 223 ?> 139 224 <div class="wrap remove-comments-wrap"> … … 142 227 <?php echo esc_html__('Ultimate Comment Cleaner', 'ultimate-comment-cleaner'); ?> 143 228 </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> 145 230 </div> 146 231 147 232 <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; ?> 164 242 </nav> 165 243 166 244 <div class="remove-comments-content"> 167 245 <?php 246 $this->render_notice(); 168 247 switch ($current_tab) { 169 248 case 'dashboard': 170 249 $this->render_dashboard_tab(); 171 250 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; 172 258 case 'delete': 173 259 $this->render_delete_tab(); 174 break;175 case 'triggers':176 $this->render_triggers_tab();177 260 break; 178 261 case 'settings': … … 187 270 188 271 private function render_dashboard_tab() { 189 $stats = $this->get_comment_stats();190 272 ?> 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; ?> 199 284 </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> 241 295 </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 ?> 259 308 </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 ?> 266 329 </div> 267 330 </div> 268 331 <?php 269 332 } 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 271 383 private function render_delete_tab() { 272 384 $post_types = get_post_types(array('public' => true), 'objects'); … … 275 387 <div class="remove-comments-delete-panel"> 276 388 <div class="remove-comments-panel-header"> 277 <h2><?php esc_html_e('Bulk DeleteComments', '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> 279 391 </div> 280 392 … … 318 430 <span class="remove-comments-toggle-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></span> 319 431 </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> 320 439 </div> 321 440 </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 323 447 <div class="remove-comments-form-section"> 324 448 <h3><?php esc_html_e('Filter by Date Range', 'ultimate-comment-cleaner'); ?></h3> … … 377 501 <div class="remove-comments-form-section"> 378 502 <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> 407 519 </div> 408 520 </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> 413 535 </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> 417 538 </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> 421 541 </button> 422 542 </div> 423 543 424 544 <div id="remove-comments-preview-result" class="remove-comments-preview-result" style="display:none;"></div> 425 545 </form> … … 428 548 } 429 549 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 ); 432 560 ?> 433 561 <div class="remove-comments-triggers-panel"> 434 562 <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> 437 565 </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)) : ?> 446 591 <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> 448 593 </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'); ?> 457 607 </span> 458 608 </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) : '—'; ?></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'); ?> 468 631 </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'); ?> 471 634 </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> 474 643 <?php endforeach; ?> 475 644 <?php endif; ?> 476 645 </div> 477 478 <!-- TriggerModal -->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"> 481 650 <span class="remove-comments-modal-close">×</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 485 655 <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> 488 658 </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> 496 692 </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 ?> 514 725 </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 518 790 <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> 520 793 <button type="button" class="remove-comments-modal-close-btn remove-comments-modal-close" title="<?php esc_attr_e('Close', 'ultimate-comment-cleaner'); ?>"> 521 794 <span class="dashicons dashicons-no-alt"></span> … … 528 801 <?php 529 802 } 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 531 897 private function render_settings_tab() { 532 $settings = get_option('ulticocl_settings', array());898 $settings = $this->get_settings(); 533 899 ?> 534 900 <div class="remove-comments-settings-panel"> 535 901 <div class="remove-comments-panel-header"> 536 902 <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> 537 904 </div> 538 905 … … 588 955 </div> 589 956 </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 @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> 590 971 591 972 <div class="remove-comments-form-actions"> … … 599 980 private function get_comment_stats() { 600 981 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')), 615 1000 ); 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 620 1011 private function get_comment_authors() { 621 1012 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 626 1018 private function render_recent_activity() { 627 1019 $activities = get_option('ulticocl_activity_log', array()); … … 630 1022 return; 631 1023 } 632 1024 633 1025 $activities = array_slice(array_reverse($activities), 0, 10); 634 1026 foreach ($activities as $activity) { … … 639 1031 } 640 1032 } 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() { 654 1109 check_ajax_referer('ulticocl_nonce', 'nonce'); 655 1110 656 1111 if (!current_user_can('manage_options')) { 657 1112 wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner'))); 658 1113 } 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 660 1123 $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 667 1136 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), 671 1142 )); 672 1143 } 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 679 1159 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 686 1162 $filters = $this->extract_filter_params(); 1163 unset($filters['clean_action']); // Export everything that matches, including quarantined comments. 687 1164 $this->export_comments($filters); 688 1165 } 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 753 1167 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 766 1286 /** 767 1287 * Extract and sanitize filter parameters from $_POST … … 772 1292 private function extract_filter_params() { 773 1293 $filters = array(); 774 1294 775 1295 // Status filter (array) 776 1296 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 780 1300 // Date range 781 if ( isset($_POST['date_from']) &&!empty($_POST['date_from'])) {1301 if (!empty($_POST['date_from'])) { 782 1302 $filters['date_from'] = sanitize_text_field(wp_unslash($_POST['date_from'])); 783 1303 } 784 if ( isset($_POST['date_to']) &&!empty($_POST['date_to'])) {1304 if (!empty($_POST['date_to'])) { 785 1305 $filters['date_to'] = sanitize_text_field(wp_unslash($_POST['date_to'])); 786 1306 } 787 1307 788 1308 // Author filter 789 if ( isset($_POST['author']) &&!empty($_POST['author'])) {1309 if (!empty($_POST['author'])) { 790 1310 $filters['author'] = sanitize_text_field(wp_unslash($_POST['author'])); 791 1311 } 792 1312 1313 // Search text 1314 if (!empty($_POST['search'])) { 1315 $filters['search'] = sanitize_text_field(wp_unslash($_POST['search'])); 1316 } 1317 793 1318 // Post type filter (array) 794 1319 if (isset($_POST['post_type']) && is_array($_POST['post_type'])) { 795 1320 $filters['post_type'] = array_map('sanitize_key', wp_unslash($_POST['post_type'])); 796 1321 } 797 1322 798 1323 // 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 809 1328 // 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 817 1336 return $filters; 818 1337 } 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 820 1512 private function count_comments_by_filters($filters) { 821 1513 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) { 897 1530 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) { 973 1534 return 0; 974 1535 } 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; 989 1543 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 1008 1553 private function export_comments($filters) { 1009 1554 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')), 1048 1960 )); 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')); 1094 2063 exit; 1095 2064 } 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) { 1137 2131 return; 1138 2132 } 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 1175 2136 if (empty($email) || !is_email($email)) { 1176 2137 return; 1177 2138 } 1178 1179 // Prepare email 2139 1180 2140 // 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'), 1188 2150 ); 1189 2151 $message = sprintf(isset($sentences[$action]) ? $sentences[$action] : $sentences['delete'], $count); 2152 1190 2153 if ($source !== 'manual') { 1191 // translators: %s: triggername1192 $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); 1193 2156 } 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 1197 2160 // translators: %s: current time 1198 2161 $message .= "\n\n" . sprintf(__('Time: %s', 'ultimate-comment-cleaner'), current_time('mysql')); 1199 2162 $message .= "\n\n" . __('This is an automated notification from Ultimate Comment Cleaner plugin.', 'ultimate-comment-cleaner'); 1200 1201 // Send email 2163 1202 2164 wp_mail($email, $subject, $message); 1203 2165 } 1204 2166 1205 2167 private function get_schedule_interval($schedule) { 1206 2168 switch ($schedule) { … … 1208 2170 case 'daily': return DAY_IN_SECONDS; 1209 2171 case 'weekly': return WEEK_IN_SECONDS; 1210 default: return HOUR_IN_SECONDS;1211 } 1212 } 1213 2172 default: return DAY_IN_SECONDS; 2173 } 2174 } 2175 1214 2176 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'])) { 1219 2180 $activities = get_option('ulticocl_activity_log', array()); 1220 2181 $activities[] = array( … … 1222 2183 'message' => $message 1223 2184 ); 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 1228 2221 public function handle_settings_save() { 1229 2222 if (!isset($_POST['ulticocl_action']) || sanitize_text_field(wp_unslash($_POST['ulticocl_action'])) !== 'save_settings') { 1230 2223 return; 1231 2224 } 1232 2225 1233 2226 if (!current_user_can('manage_options')) { 1234 2227 wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner')); 1235 2228 } 1236 2229 1237 2230 if (!isset($_POST['ulticocl_settings_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ulticocl_settings_nonce'])), 'ulticocl_save_settings')) { 1238 2231 wp_die(esc_html__('Security check failed.', 'ultimate-comment-cleaner')); 1239 2232 } 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 1261 2236 wp_safe_redirect(add_query_arg(array( 1262 2237 'page' => 'ultimate-comment-cleaner', … … 1266 2241 exit; 1267 2242 } 1268 2243 1269 2244 public function ajax_save_settings() { 1270 2245 check_ajax_referer('ulticocl_nonce', 'nonce'); 1271 2246 1272 2247 if (!current_user_can('manage_options')) { 1273 2248 wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner'))); 1274 2249 } 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 1294 2253 wp_send_json_success(array('message' => __('Settings saved successfully.', 'ultimate-comment-cleaner'))); 1295 2254 } 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 1297 2306 /** 1298 2307 * Add settings link to plugin action links 1299 2308 */ 1300 2309 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>'; 1302 2311 array_unshift($links, $settings_link); 1303 2312 return $links; … … 1311 2320 add_action('plugins_loaded', 'ulticocl_init'); 1312 2321 2322 // Stop the scheduled jobs when the plugin is deactivated. 2323 register_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 1043 1043 } 1044 1044 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 1 1 jQuery(document).ready(function($) { 2 2 'use strict'; 3 3 4 const { __, _n, sprintf } = wp.i18n; 5 const td = 'ultimate-comment-cleaner'; 6 4 7 // Helper function to show native alert 5 8 function showAlert(message, type) { 6 9 if (type === 'error') { 7 alert('Error: ' + message); 8 } else if (type === 'success') { 9 alert('Success: ' + message); 10 alert(sprintf(__('Error: %s', td), message)); 10 11 } else { 11 12 alert(message); 12 13 } 13 14 } 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 23 106 $('#remove-comments-delete-form').on('submit', function(e) { 24 107 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'); 31 111 return; 32 112 } 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 39 121 // Preview Count 40 122 $('#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 69 166 // Export Comments 70 167 $('#remove-comments-export-btn, #remove-comments-export-all').on('click', function() { 71 168 const formData = getFormData(); 169 delete formData.clean_action; 72 170 formData.action = 'ulticocl_export_comments'; 73 171 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 75 177 // Create form and submit 76 178 const form = $('<form>', { … … 79 181 target: '_blank' 80 182 }); 81 183 82 184 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 90 195 $('body').append(form); 91 196 form.submit(); 92 197 form.remove(); 93 198 }); 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 243 200 function getFormData() { 244 201 const data = {}; 245 202 246 203 // Get status checkboxes 247 204 const statuses = []; … … 252 209 data.status = statuses; 253 210 } 254 211 255 212 // Get date range 256 213 const dateFrom = $('#remove-comments-date-from').val(); … … 258 215 if (dateFrom) data.date_from = dateFrom; 259 216 if (dateTo) data.date_to = dateTo; 260 217 261 218 // Get post types 262 219 const postTypes = []; … … 267 224 data.post_type = postTypes; 268 225 } 269 226 270 227 // Get author 271 228 const author = $('#remove-comments-author-filter').val(); … … 273 230 data.author = author; 274 231 } 275 232 233 // Search text 234 const search = $('#remove-comments-search').val(); 235 if (search && search.trim() !== '') { 236 data.search = search.trim(); 237 } 238 276 239 // Additional filters 277 240 if ($('input[name="has_url"]').is(':checked')) { … … 284 247 data.short_comments = 1; 285 248 } 286 249 287 250 const minWords = $('input[name="min_words"]').val(); 288 251 const maxWords = $('input[name="max_words"]').val(); 289 252 if (minWords) data.min_words = minWords; 290 253 if (maxWords) data.max_words = maxWords; 291 254 255 data.clean_action = $('#remove-comments-clean-action').val() || 'quarantine'; 256 292 257 return data; 293 258 } 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))); 310 558 }); 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 331 619 // Checkbox group handling 332 620 $('input[name="status[]"]').on('change', function() { 621 if (!$(this).closest('#remove-comments-delete-form').length) { 622 return; 623 } 333 624 if ($(this).val() === 'all' && $(this).is(':checked')) { 334 625 $('input[name="status[]"]').not(this).prop('checked', false); … … 337 628 } 338 629 }); 339 630 340 631 $('input[name="post_type[]"]').on('change', function() { 341 632 if ($(this).val() === 'all' && $(this).is(':checked')) { … … 345 636 } 346 637 }); 347 638 348 639 // Toggle email field visibility based on notification checkbox 349 640 $('input[name="send_email_notifications"]').on('change', function() { … … 354 645 } 355 646 }); 356 647 357 648 // Settings Form - Use AJAX for better UX 358 649 $('#remove-comments-settings-form').on('submit', function(e) { 359 650 e.preventDefault(); 360 651 361 652 const notificationEmail = $('#rc_notification_email').val(); 362 653 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'); 364 655 return; 365 656 } 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({ 368 663 action: 'ulticocl_save_settings', 369 nonce: ulticoclData.nonce,370 664 enable_logging: $('input[name="enable_logging"]').is(':checked') ? 1 : 0, 371 665 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'); 403 678 submitButton.prop('disabled', false).text(originalText); 404 679 } 680 }).fail(function() { 681 showAlert(__('An error occurred while saving settings.', td), 'error'); 682 submitButton.prop('disabled', false).text(originalText); 405 683 }); 406 684 }); -
ultimate-comment-cleaner/trunk/readme.txt
r3446581 r3752017 1 1 === Ultimate Comment Cleaner === 2 2 Contributors: 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 3 Tags: spam, delete comments, comment cleaner, quarantine, bulk delete 4 Requires at least: 5.5 5 Tested up to: 7.1 6 Requires PHP: 7.4 7 Stable tag: 1.1.0 7 8 License: GPLv2 or later 8 9 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 10 11 Author URI: https://profiles.wordpress.org/wpunicorn/ 11 12 12 Bulk delete WordPress comments with advanced filters, triggers, and a modern dashboard. Delete by status, date, author, and more.13 Stop comment spam with smart rules: keywords, Cyrillic/Chinese text, links. Quarantine, review and bulk-clean comments. Restore any mistake. 13 14 14 15 == Description == 15 16 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. 17 18 18 = Key Features = 19 Getting 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. 19 20 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 = 29 22 30 = Dashboard Features = 23 Rules check **new comments as they arrive** and can clean up **existing comments on a schedule**. A rule can look for: 31 24 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". 37 31 38 = Bulk Delete Options = 32 Ready-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. 39 33 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 34 What a rule does when it matches is up to you: **move to quarantine** (recommended), **mark as spam** or **delete permanently**. 49 35 50 = Auto Triggers=36 = Test before you trust it = 51 37 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 38 Every 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 42 Caught 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 46 See 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 50 A 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 54 Delete, 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. 57 63 58 64 == Installation == … … 64 70 == Frequently Asked Questions == 65 71 66 = How do I delete all spam comments? =72 = How do I stop Russian gambling spam? = 67 73 68 Go to **Tools → Ultimate Comment Cleaner → Bulk Delete**, select "Spam" in the status filter, and click "Delete Comments".74 Go 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**. 69 75 70 = Can I preview how many comments will be deleted? =76 = What happens to a comment a rule catches? = 71 77 72 Yes! Use the "Preview Count" button to see how many comments match your criteria before deleting.78 By 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. 73 79 74 = How do I set up automatic spam cleanup? =80 = Can a rule catch good comments by mistake? = 75 81 76 Go to **Tools → Ultimate Comment Cleaner → Auto Triggers**, click "Add New Trigger", set the status to "Spam", choose a schedule, and enable it. 82 It 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 86 Both. 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 90 14 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 94 Yes. 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 98 Quarantined 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 102 They 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 106 Yes. Bulk Clean has a "Preview Count" button, rules have a "Test on existing comments" button, and every confirmation shows the exact number. 77 107 78 108 = Can I export comments before deleting them? = 79 109 80 Yes ! Use the "Export Selected" or "Export All Comments" buttonsto download comments as a CSV file.110 Yes. Use "Export Selected" or "Export All Comments" to download comments as a CSV file. 81 111 82 112 = What permissions are required? = 83 113 84 114 You 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.89 115 90 116 == Screenshots == … … 96 122 97 123 == 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 98 151 99 152 = 1.0.0 = … … 111 164 == Upgrade Notice == 112 165 166 = 1.1.0 = 167 Big 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 113 169 = 1.0.0 = 114 170 Initial release of Ultimate Comment Cleaner. Install to start managing your comments efficiently! -
ultimate-comment-cleaner/trunk/ultimate-comment-cleaner.php
r3446581 r3752017 3 3 * Plugin Name: Ultimate Comment Cleaner 4 4 * 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.05 * 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 7 7 * Author: wpunicorn 8 8 * Author URI: https://profiles.wordpress.org/wpunicorn/ … … 10 10 * License URI: https://www.gnu.org/licenses/gpl-2.0.html 11 11 * 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 14 15 */ 15 16 … … 25 26 */ 26 27 if (!defined('ULTICOCL_PLUGIN_VERSION')) { 27 define('ULTICOCL_PLUGIN_VERSION', '1. 0.0');28 define('ULTICOCL_PLUGIN_VERSION', '1.1.0'); 28 29 } 29 30 if (!defined('ULTICOCL_PLUGIN_DIR')) { … … 37 38 } 38 39 40 require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-matcher.php'; 41 require_once ULTICOCL_PLUGIN_DIR . 'includes/class-ulticocl-insights.php'; 42 39 43 class 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 41 81 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 43 89 public static function get_instance() { 44 90 if (null === self::$instance) { … … 47 93 return self::$instance; 48 94 } 49 95 50 96 private function __construct() { 51 97 $this->init_hooks(); 52 98 } 53 99 54 100 private function init_hooks() { 55 101 add_action('admin_menu', array($this, 'add_admin_menu')); 56 102 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')); 59 105 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')); 63 110 add_action('wp_ajax_ulticocl_preview_count', array($this, 'ajax_preview_count')); 64 111 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 67 114 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 75 133 // Add settings link to plugins page 76 134 add_filter('plugin_action_links_' . ULTICOCL_PLUGIN_BASENAME, array($this, 'add_plugin_action_links')); … … 103 161 104 162 // Enqueue scripts 105 wp_enqueue_script('jquery');106 163 wp_enqueue_script( 107 164 'ulticocl-admin-script', 108 165 ULTICOCL_PLUGIN_URL . 'assets/js/admin.js', 109 array('jquery' ),166 array('jquery', 'wp-i18n'), 110 167 ULTICOCL_PLUGIN_VERSION, 111 168 true 112 169 ); 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 } 118 176 wp_localize_script('ulticocl-admin-script', 'ulticoclData', array( 119 177 'ajaxurl' => admin_url('admin-ajax.php'), 120 178 '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')), 127 182 )); 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 } 128 198 } 129 199 … … 134 204 135 205 // 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 138 223 ?> 139 224 <div class="wrap remove-comments-wrap"> … … 142 227 <?php echo esc_html__('Ultimate Comment Cleaner', 'ultimate-comment-cleaner'); ?> 143 228 </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> 145 230 </div> 146 231 147 232 <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; ?> 164 242 </nav> 165 243 166 244 <div class="remove-comments-content"> 167 245 <?php 246 $this->render_notice(); 168 247 switch ($current_tab) { 169 248 case 'dashboard': 170 249 $this->render_dashboard_tab(); 171 250 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; 172 258 case 'delete': 173 259 $this->render_delete_tab(); 174 break;175 case 'triggers':176 $this->render_triggers_tab();177 260 break; 178 261 case 'settings': … … 187 270 188 271 private function render_dashboard_tab() { 189 $stats = $this->get_comment_stats();190 272 ?> 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; ?> 199 284 </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> 241 295 </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 ?> 259 308 </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 ?> 266 329 </div> 267 330 </div> 268 331 <?php 269 332 } 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 271 383 private function render_delete_tab() { 272 384 $post_types = get_post_types(array('public' => true), 'objects'); … … 275 387 <div class="remove-comments-delete-panel"> 276 388 <div class="remove-comments-panel-header"> 277 <h2><?php esc_html_e('Bulk DeleteComments', '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> 279 391 </div> 280 392 … … 318 430 <span class="remove-comments-toggle-label"><?php esc_html_e('Trash', 'ultimate-comment-cleaner'); ?></span> 319 431 </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> 320 439 </div> 321 440 </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 323 447 <div class="remove-comments-form-section"> 324 448 <h3><?php esc_html_e('Filter by Date Range', 'ultimate-comment-cleaner'); ?></h3> … … 377 501 <div class="remove-comments-form-section"> 378 502 <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> 407 519 </div> 408 520 </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> 413 535 </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> 417 538 </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> 421 541 </button> 422 542 </div> 423 543 424 544 <div id="remove-comments-preview-result" class="remove-comments-preview-result" style="display:none;"></div> 425 545 </form> … … 428 548 } 429 549 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 ); 432 560 ?> 433 561 <div class="remove-comments-triggers-panel"> 434 562 <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> 437 565 </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)) : ?> 446 591 <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> 448 593 </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'); ?> 457 607 </span> 458 608 </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) : '—'; ?></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'); ?> 468 631 </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'); ?> 471 634 </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> 474 643 <?php endforeach; ?> 475 644 <?php endif; ?> 476 645 </div> 477 478 <!-- TriggerModal -->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"> 481 650 <span class="remove-comments-modal-close">×</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 485 655 <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> 488 658 </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> 496 692 </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 ?> 514 725 </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 518 790 <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> 520 793 <button type="button" class="remove-comments-modal-close-btn remove-comments-modal-close" title="<?php esc_attr_e('Close', 'ultimate-comment-cleaner'); ?>"> 521 794 <span class="dashicons dashicons-no-alt"></span> … … 528 801 <?php 529 802 } 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 531 897 private function render_settings_tab() { 532 $settings = get_option('ulticocl_settings', array());898 $settings = $this->get_settings(); 533 899 ?> 534 900 <div class="remove-comments-settings-panel"> 535 901 <div class="remove-comments-panel-header"> 536 902 <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> 537 904 </div> 538 905 … … 588 955 </div> 589 956 </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 @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> 590 971 591 972 <div class="remove-comments-form-actions"> … … 599 980 private function get_comment_stats() { 600 981 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')), 615 1000 ); 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 620 1011 private function get_comment_authors() { 621 1012 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 626 1018 private function render_recent_activity() { 627 1019 $activities = get_option('ulticocl_activity_log', array()); … … 630 1022 return; 631 1023 } 632 1024 633 1025 $activities = array_slice(array_reverse($activities), 0, 10); 634 1026 foreach ($activities as $activity) { … … 639 1031 } 640 1032 } 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() { 654 1109 check_ajax_referer('ulticocl_nonce', 'nonce'); 655 1110 656 1111 if (!current_user_can('manage_options')) { 657 1112 wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner'))); 658 1113 } 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 660 1123 $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 667 1136 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), 671 1142 )); 672 1143 } 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 679 1159 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 686 1162 $filters = $this->extract_filter_params(); 1163 unset($filters['clean_action']); // Export everything that matches, including quarantined comments. 687 1164 $this->export_comments($filters); 688 1165 } 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 753 1167 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 766 1286 /** 767 1287 * Extract and sanitize filter parameters from $_POST … … 772 1292 private function extract_filter_params() { 773 1293 $filters = array(); 774 1294 775 1295 // Status filter (array) 776 1296 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 780 1300 // Date range 781 if ( isset($_POST['date_from']) &&!empty($_POST['date_from'])) {1301 if (!empty($_POST['date_from'])) { 782 1302 $filters['date_from'] = sanitize_text_field(wp_unslash($_POST['date_from'])); 783 1303 } 784 if ( isset($_POST['date_to']) &&!empty($_POST['date_to'])) {1304 if (!empty($_POST['date_to'])) { 785 1305 $filters['date_to'] = sanitize_text_field(wp_unslash($_POST['date_to'])); 786 1306 } 787 1307 788 1308 // Author filter 789 if ( isset($_POST['author']) &&!empty($_POST['author'])) {1309 if (!empty($_POST['author'])) { 790 1310 $filters['author'] = sanitize_text_field(wp_unslash($_POST['author'])); 791 1311 } 792 1312 1313 // Search text 1314 if (!empty($_POST['search'])) { 1315 $filters['search'] = sanitize_text_field(wp_unslash($_POST['search'])); 1316 } 1317 793 1318 // Post type filter (array) 794 1319 if (isset($_POST['post_type']) && is_array($_POST['post_type'])) { 795 1320 $filters['post_type'] = array_map('sanitize_key', wp_unslash($_POST['post_type'])); 796 1321 } 797 1322 798 1323 // 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 809 1328 // 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 817 1336 return $filters; 818 1337 } 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 820 1512 private function count_comments_by_filters($filters) { 821 1513 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) { 897 1530 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) { 973 1534 return 0; 974 1535 } 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; 989 1543 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 1008 1553 private function export_comments($filters) { 1009 1554 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')), 1048 1960 )); 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')); 1094 2063 exit; 1095 2064 } 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) { 1137 2131 return; 1138 2132 } 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 1175 2136 if (empty($email) || !is_email($email)) { 1176 2137 return; 1177 2138 } 1178 1179 // Prepare email 2139 1180 2140 // 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'), 1188 2150 ); 1189 2151 $message = sprintf(isset($sentences[$action]) ? $sentences[$action] : $sentences['delete'], $count); 2152 1190 2153 if ($source !== 'manual') { 1191 // translators: %s: triggername1192 $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); 1193 2156 } 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 1197 2160 // translators: %s: current time 1198 2161 $message .= "\n\n" . sprintf(__('Time: %s', 'ultimate-comment-cleaner'), current_time('mysql')); 1199 2162 $message .= "\n\n" . __('This is an automated notification from Ultimate Comment Cleaner plugin.', 'ultimate-comment-cleaner'); 1200 1201 // Send email 2163 1202 2164 wp_mail($email, $subject, $message); 1203 2165 } 1204 2166 1205 2167 private function get_schedule_interval($schedule) { 1206 2168 switch ($schedule) { … … 1208 2170 case 'daily': return DAY_IN_SECONDS; 1209 2171 case 'weekly': return WEEK_IN_SECONDS; 1210 default: return HOUR_IN_SECONDS;1211 } 1212 } 1213 2172 default: return DAY_IN_SECONDS; 2173 } 2174 } 2175 1214 2176 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'])) { 1219 2180 $activities = get_option('ulticocl_activity_log', array()); 1220 2181 $activities[] = array( … … 1222 2183 'message' => $message 1223 2184 ); 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 1228 2221 public function handle_settings_save() { 1229 2222 if (!isset($_POST['ulticocl_action']) || sanitize_text_field(wp_unslash($_POST['ulticocl_action'])) !== 'save_settings') { 1230 2223 return; 1231 2224 } 1232 2225 1233 2226 if (!current_user_can('manage_options')) { 1234 2227 wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'ultimate-comment-cleaner')); 1235 2228 } 1236 2229 1237 2230 if (!isset($_POST['ulticocl_settings_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ulticocl_settings_nonce'])), 'ulticocl_save_settings')) { 1238 2231 wp_die(esc_html__('Security check failed.', 'ultimate-comment-cleaner')); 1239 2232 } 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 1261 2236 wp_safe_redirect(add_query_arg(array( 1262 2237 'page' => 'ultimate-comment-cleaner', … … 1266 2241 exit; 1267 2242 } 1268 2243 1269 2244 public function ajax_save_settings() { 1270 2245 check_ajax_referer('ulticocl_nonce', 'nonce'); 1271 2246 1272 2247 if (!current_user_can('manage_options')) { 1273 2248 wp_send_json_error(array('message' => __('Insufficient permissions.', 'ultimate-comment-cleaner'))); 1274 2249 } 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 1294 2253 wp_send_json_success(array('message' => __('Settings saved successfully.', 'ultimate-comment-cleaner'))); 1295 2254 } 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 1297 2306 /** 1298 2307 * Add settings link to plugin action links 1299 2308 */ 1300 2309 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>'; 1302 2311 array_unshift($links, $settings_link); 1303 2312 return $links; … … 1311 2320 add_action('plugins_loaded', 'ulticocl_init'); 1312 2321 2322 // Stop the scheduled jobs when the plugin is deactivated. 2323 register_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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)