All Pages.
add_filter('page_row_actions', [$this, 'page_duplicate_action'], 10, 2);
add_action('admin_post_ekmt_duplicate_page', [$this, 'handle_duplicate_page']);
add_action('admin_notices', [$this, 'duplicate_page_notice']);
}
public function admin_menu() {
add_menu_page(
'Ahmad Developer Toolkit',
'Ahmad Toolkit',
'upload_files',
'ahmad-developer-toolkit',
[$this, 'render'],
'dashicons-format-image',
58
);
}
public function assets($hook) {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if ($hook === 'toplevel_page_ahmad-developer-toolkit') {
wp_enqueue_style('ekmt-admin', plugin_dir_url(__FILE__) . 'assets/admin.css', [], self::VERSION);
wp_enqueue_script('ekmt-admin', plugin_dir_url(__FILE__) . 'assets/admin.js', ['jquery'], self::VERSION, true);
wp_enqueue_script('ekmt-seo-audit', plugin_dir_url(__FILE__) . 'assets/seo-audit.js', ['jquery', 'ekmt-admin'], self::VERSION, true);
wp_localize_script('ekmt-admin', 'EKMT', [
'ajax' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(self::NONCE),
'batch' => 15,
'home' => home_url('/'),
]);
}
$is_page_admin = $screen && ($screen->post_type === 'page' || $screen->id === 'edit-page');
if ($is_page_admin && in_array($hook, ['edit.php', 'post.php', 'post-new.php'], true)) {
wp_enqueue_style('ekmt-notes', plugin_dir_url(__FILE__) . 'assets/notes.css', [], self::VERSION);
wp_enqueue_script('ekmt-notes', plugin_dir_url(__FILE__) . 'assets/notes.js', ['jquery'], self::VERSION, true);
wp_localize_script('ekmt-notes', 'EKMT_NOTES', [
'ajax' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(self::NONCE),
]);
}
}
public function render() {
if (!current_user_can('upload_files')) return;
?>
AHMAD • DEVELOPER WORKSPACE
Media tools, audits, forms, tracking, and site utilities in one place.
Built for practical WordPress development and SEO work.
Renamer Clean filenames
WebP Modern format
Compressor Smaller files
Oversized Find heavy images
ALT Audit Missing alt text
SEO Audit Deep SEO diagnosis
Instant Indexing Manual IndexNow submit
Phone Scan Wrong numbers
Email Scan Wrong emails
Form Health Forms & bookings
Toolkit Forms Ready lead forms
Site Widgets Contact, popup + trust
Lead Tracking Calls + buttons + forms
Developer Login URL + page tools
Maintenance 1-click live page
Cleaner Background scan
site_widgets_settings(); $seo_default_phone = trim((string) ($widget_phone['phone'] ?? '')); ?>
SEO MONITORING SEO Scan History Every completed audit is saved locally in WordPress so you can see whether SEO health is improving or getting worse over time.
Refresh Clear History
Run an SEO audit to start building history.
DETAILED PAGE REPORT SEO Issues by Page Each issue includes the detected problem, impact, and recommended fix. Higher-priority pages appear first.
Copy SEO Report
indexnow_key(); $ix_key_url = $this->indexnow_key_location(); ?>
URL INDEXING TRACKER Request & Index Status Tracks the exact request time, IndexNow response, first confirmed indexing time, and elapsed time. In free/no-API mode, IndexNow does not report the exact moment a search engine indexes a URL, so the plugin never invents that status.
Enter a URL above and click View Status .
PAGE-BY-PAGE TRACKING IndexNow Requests Each row is a page you intentionally submitted. After verifying the page in a search engine, click Confirm Indexed ; the toolkit records the confirmation time and calculates how long it took from your IndexNow request.
Refresh Clear History
No IndexNow requests yet. Go to Pages or Posts and click IndexNow on the exact URL you want.
tracking_totals(); $track_settings = $this->tracking_settings(); $total_leads = array_sum($track); ?>
login_settings(); $login_slug = $login_settings['slug']; ?>
maintenance_mode(); $maint_settings = $this->maintenance_settings(); $maint_samples = $this->maintenance_samples(); ?>
Custom title This headline appears on the live page.
Custom description Add your own message for visitors.
Icon Choose a simple icon for the page.
maintenance_icon_choices() as $icon_key => $icon_label) : ?>
>
Quick preview
maintenance_icon_symbol($maint_settings['icon'])); ?>
Enable Maintenance Page
Enable Hosting Notice
Disable Live Page
Live status
maintenance_mode_label($maint_mode)); ?>
maintenance_mode_help($maint_mode)); ?>
10 READY SAMPLES Maintenance & hosting notice templates Click a sample to instantly load its title, description, icon, and page type.
maintenance_icon_symbol($sample['icon'])); ?>
Protected cleanup flow Nothing is permanently deleted during scanning. High-confidence candidates move to quarantine immediately when you choose them, so you can check the live site and restore anything you want.
Scan Media Library
Select All Found
Quarantine Selected
View Quarantine
RECOVERY AREA Quarantine Test your important pages here before permanently removing anything.
Refresh
admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('ekmt_front_forms'),
]);
$widgets = $this->site_widgets_settings();
if (!empty($widgets['floating_enabled']) || !empty($widgets['announcement_enabled']) || !empty($widgets['popup_enabled']) || !empty($widgets['trust_enabled'])) {
wp_enqueue_style('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.css', [], self::VERSION);
wp_enqueue_script('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.js', [], self::VERSION, true);
}
$tracking = $this->tracking_settings();
if (!empty($tracking['enabled'])) {
wp_enqueue_script('ekmt-conversion-tracking', plugin_dir_url(__FILE__) . 'assets/tracking.js', [], self::VERSION, true);
wp_localize_script('ekmt-conversion-tracking', 'EKMT_TRACK', [
'ajax' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('ekmt_track_event'),
]);
}
}
public function register_lead_entry_type() {
register_post_type(self::LEAD_POST_TYPE, [
'labels' => ['name' => 'Toolkit Form Entries', 'singular_name' => 'Toolkit Form Entry'],
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_rest' => false,
'supports' => ['title'],
'capability_type' => 'post',
'map_meta_cap' => true,
]);
}
private function forms_recipient() {
return sanitize_email((string) get_option(self::FORMS_RECIPIENT_OPTION, ''));
}
private function tracking_settings() {
$saved = get_option(self::TRACKING_SETTINGS_OPTION, []);
return wp_parse_args(is_array($saved) ? $saved : [], ['enabled' => 1]);
}
private function tracking_totals() {
$defaults = ['calls'=>0, 'forms'=>0, 'whatsapp'=>0, 'sms'=>0, 'email'=>0, 'directions'=>0];
$saved = get_option(self::TRACKING_OPTION, []);
$saved = wp_parse_args(is_array($saved) ? $saved : [], $defaults);
foreach ($defaults as $key => $zero) $saved[$key] = max(0, absint($saved[$key] ?? 0));
return $saved;
}
private function increment_tracking($event) {
$allowed = ['calls','forms','whatsapp','sms','email','directions'];
if (!in_array($event, $allowed, true)) return false;
$settings = $this->tracking_settings();
if (empty($settings['enabled'])) return false;
$totals = $this->tracking_totals();
$totals[$event] = absint($totals[$event]) + 1;
update_option(self::TRACKING_OPTION, $totals, false);
return true;
}
/**
* MetForm fires this hook after the submitted entry has been stored.
* Arguments are optional because MetForm versions have varied in what they
* pass to the action. We only need the successful event itself.
*/
public function track_metform_submission($form_id = 0, $form_data = [], $handler = null) {
$this->increment_tracking('forms');
}
private function seo_string_length($value) {
$value = (string) $value;
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
}
private function seo_word_count($value) {
$text = html_entity_decode(wp_strip_all_tags((string) $value), ENT_QUOTES, 'UTF-8');
if (preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\'\x{2019}-]*/u', $text, $m)) return count($m[0]);
return 0;
}
private function seo_normalize_url($url) {
$parts = wp_parse_url((string) $url);
if (!$parts || empty($parts['host'])) return untrailingslashit((string) $url);
$scheme = strtolower((string) ($parts['scheme'] ?? 'https'));
$host = strtolower((string) $parts['host']);
$port = isset($parts['port']) ? ':' . absint($parts['port']) : '';
$path = isset($parts['path']) ? '/' . ltrim((string) $parts['path'], '/') : '/';
$path = $path === '/' ? '/' : untrailingslashit($path);
return $scheme . '://' . $host . $port . $path;
}
private function seo_detect_plugin() {
if (defined('RANK_MATH_VERSION') || class_exists('RankMath\\Helper')) return 'Rank Math';
if (defined('WPSEO_VERSION') || class_exists('WPSEO_Options')) return 'Yoast SEO';
if (defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO')) return 'All in One SEO';
if (defined('SEOPRESS_VERSION')) return 'SEOPress';
return 'Not detected';
}
private function seo_auditable_post_types() {
$types = get_post_types(['public' => true], 'names');
unset($types['attachment']);
return array_values(array_filter($types, function($type) {
return !in_array($type, ['nav_menu_item', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation'], true);
}));
}
private function seo_is_generic_text($text) {
$text = strtolower(trim(wp_strip_all_tags((string) $text)));
$text = preg_replace('/\s+/', ' ', $text);
if ($text === '') return true;
$site_name = strtolower(trim((string) get_bloginfo('name')));
if ($site_name !== '' && trim($text, " -|–—:") === $site_name) return true;
if (strpos($text, 'just another wordpress site') !== false) return true;
if (preg_match('/^welcome(?:\s+to\b|[!.]?$)/i', $text)) return true;
$generic = ['home', 'welcome', 'welcome!', 'untitled', 'sample page', 'page', 'homepage', 'new page'];
return in_array($text, $generic, true);
}
private function seo_is_utility_page($page_id) {
$slug = strtolower((string) get_post_field('post_name', $page_id));
$title = strtolower((string) get_the_title($page_id));
$haystack = $slug . ' ' . $title;
foreach (['contact', 'privacy', 'terms', 'thank', 'cart', 'checkout', 'account', 'login', 'cookie', 'accessibility'] as $word) {
if (strpos($haystack, $word) !== false) return true;
}
return false;
}
private function seo_site_checks() {
$seo_plugin = $this->seo_detect_plugin();
$https = is_ssl() || stripos(home_url('/'), 'https://') === 0;
$pretty = trim((string) get_option('permalink_structure')) !== '';
$visible = (string) get_option('blog_public', '1') === '1';
$site_title = trim((string) get_option('blogname', ''));
$tagline = trim((string) get_option('blogdescription', ''));
$default_tagline = strtolower($tagline) === 'just another wordpress site';
$sitemap = false;
if ($seo_plugin !== 'Not detected') {
$sitemap = true;
} elseif (function_exists('wp_sitemaps_get_server')) {
try {
$server = wp_sitemaps_get_server();
$sitemap = $server && method_exists($server, 'sitemaps_enabled') ? (bool) $server->sitemaps_enabled() : true;
} catch (Throwable $e) {
$sitemap = false;
}
}
$robots_status = 'warning';
$robots_detail = 'robots.txt could not be verified.';
$robots_response = wp_remote_get(home_url('/robots.txt'), ['timeout' => 4, 'redirection' => 2, 'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION]);
if (!is_wp_error($robots_response)) {
$robots_code = (int) wp_remote_retrieve_response_code($robots_response);
$robots_body = (string) wp_remote_retrieve_body($robots_response);
if ($robots_code >= 200 && $robots_code < 300) {
if (preg_match('/^\s*Disallow\s*:\s*\/\s*$/mi', $robots_body)) {
$robots_status = 'critical';
$robots_detail = 'robots.txt contains a sitewide “Disallow: /” rule.';
} else {
$robots_status = 'pass';
$robots_detail = 'robots.txt is reachable and no exact sitewide Disallow: / rule was detected.';
}
} else {
$robots_detail = 'robots.txt returned HTTP ' . $robots_code . '.';
}
}
$sitemap_reachable = false;
$sitemap_url = '';
if ($seo_plugin === 'All in One SEO') $sitemap_candidates = [home_url('/sitemap.xml'), home_url('/sitemap_index.xml')];
elseif ($seo_plugin !== 'Not detected') $sitemap_candidates = [home_url('/sitemap_index.xml'), home_url('/wp-sitemap.xml')];
else $sitemap_candidates = [home_url('/wp-sitemap.xml'), home_url('/sitemap_index.xml')];
foreach ($sitemap_candidates as $candidate) {
$resp = wp_remote_get($candidate, ['timeout' => 4, 'redirection' => 2, 'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION]);
if (is_wp_error($resp)) continue;
$code = (int) wp_remote_retrieve_response_code($resp);
$body = strtolower(substr((string) wp_remote_retrieve_body($resp), 0, 12000));
if ($code >= 200 && $code < 300 && (strpos($body, ' $visible ? 'pass' : 'critical', 'label' => 'Search engine visibility', 'detail' => $visible ? 'Search engines are allowed to index the site.' : 'WordPress is set to discourage search engines.', 'score_weight' => 40],
['status' => $https ? 'pass' : 'warning', 'label' => 'HTTPS', 'detail' => $https ? 'The website is using HTTPS.' : 'The website URL is not using HTTPS.', 'score_weight' => 20],
['status' => $pretty ? 'pass' : 'warning', 'label' => 'Permalinks', 'detail' => $pretty ? 'Pretty permalinks are enabled.' : 'Plain permalink structure is enabled.', 'score_weight' => 10],
['status' => $sitemap ? 'pass' : 'warning', 'label' => 'XML sitemap provider', 'detail' => $sitemap ? 'A WordPress/SEO sitemap provider is available.' : 'No sitemap provider was detected locally.', 'score_weight' => 8],
['status' => $sitemap_reachable ? 'pass' : 'warning', 'label' => 'Sitemap reachability', 'detail' => $sitemap_reachable ? 'A public XML sitemap responded successfully: ' . $sitemap_url : 'Common sitemap URLs did not return a verifiable XML sitemap.', 'score_weight' => 10],
['status' => $robots_status, 'label' => 'robots.txt', 'detail' => $robots_detail, 'score_weight' => 8],
['status' => $site_title !== '' ? 'pass' : 'warning', 'label' => 'Site title', 'detail' => $site_title !== '' ? 'A WordPress site title is configured.' : 'The WordPress site title is empty.', 'score_weight' => 5],
['status' => !$default_tagline ? 'pass' : 'warning', 'label' => 'Default WordPress tagline', 'detail' => $default_tagline ? 'The default “Just another WordPress site” tagline is still configured.' : ($tagline !== '' ? 'The default WordPress tagline is not in use.' : 'No default WordPress tagline is being used.'), 'score_weight' => 10],
['status' => $seo_plugin === 'Not detected' ? 'info' : 'pass', 'label' => 'SEO plugin', 'detail' => $seo_plugin === 'Not detected' ? 'No major SEO plugin was detected. An SEO plugin is not required if equivalent output is handled another way.' : $seo_plugin . ' detected.', 'score_weight' => 0],
];
}
private function seo_content_shingles($text, $limit = 90) {
$text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES, 'UTF-8');
$text = strtolower(preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text));
$words = preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($words) || count($words) < 12) return [];
$stop = array_flip(['the','and','for','with','that','this','from','your','you','our','are','was','were','have','has','had','will','can','not','but','all','any','its','into','about','more','their','they','them','then','than','when','where','what','who','why','how','a','an','of','to','in','on','at','by','or','as','is','it','be','we','i']);
$clean = [];
foreach ($words as $w) {
if (isset($stop[$w]) || strlen($w) < 3) continue;
$clean[] = $w;
}
if (count($clean) < 9) return [];
$hashes = [];
$max = min(count($clean) - 2, 450);
for ($i = 0; $i < $max; $i++) {
$shingle = $clean[$i] . ' ' . $clean[$i+1] . ' ' . $clean[$i+2];
$hashes[substr(md5($shingle), 0, 10)] = true;
}
$keys = array_keys($hashes);
sort($keys, SORT_STRING);
if (count($keys) > $limit) {
$step = max(1, (int) floor(count($keys) / $limit));
$sample = [];
for ($i = 0; $i < count($keys) && count($sample) < $limit; $i += $step) $sample[] = $keys[$i];
$keys = $sample;
}
return array_values($keys);
}
private function seo_history_items() {
$history = get_option(self::SEO_HISTORY_OPTION, []);
return is_array($history) ? array_values($history) : [];
}
public function ajax_get_seo_history() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
wp_send_json_success(['items' => $this->seo_history_items()]);
}
public function ajax_clear_seo_history() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
delete_option(self::SEO_HISTORY_OPTION);
wp_send_json_success(['message' => 'SEO scan history cleared.']);
}
private function seo_clean_history_payload($payload) {
$summary = isset($payload['summary']) && is_array($payload['summary']) ? $payload['summary'] : [];
$categories = isset($payload['categories']) && is_array($payload['categories']) ? $payload['categories'] : [];
$issues = isset($payload['issues']) && is_array($payload['issues']) ? $payload['issues'] : [];
$pages = isset($payload['pages']) && is_array($payload['pages']) ? $payload['pages'] : [];
$clean_categories = [];
foreach (array_slice($categories, 0, 20, true) as $name => $score) {
$clean_categories[sanitize_text_field((string) $name)] = max(0, min(100, absint($score)));
}
$clean_issues = [];
foreach (array_slice($issues, 0, 80, true) as $code => $item) {
if (!is_array($item)) continue;
$key = sanitize_key((string) $code);
if ($key === '') continue;
$clean_issues[$key] = [
'label' => sanitize_text_field((string) ($item['label'] ?? $key)),
'severity' => in_array(($item['severity'] ?? ''), ['critical','warning','info'], true) ? $item['severity'] : 'warning',
'count' => max(0, absint($item['count'] ?? 0)),
];
}
$clean_pages = [];
foreach (array_slice($pages, 0, 250, true) as $url => $item) {
if (!is_array($item)) continue;
$safe_url = esc_url_raw((string) $url);
if ($safe_url === '') continue;
$clean_pages[$safe_url] = [
'title' => sanitize_text_field((string) ($item['title'] ?? '')),
'score' => max(0, min(100, absint($item['score'] ?? 0))),
'critical' => max(0, absint($item['critical'] ?? 0)),
'warnings' => max(0, absint($item['warnings'] ?? 0)),
];
}
return [
'id' => sanitize_key((string) ($payload['id'] ?? uniqid('seo_', false))),
'timestamp' => time(),
'date' => wp_date('M j, Y g:i a'),
'summary' => [
'score' => max(0, min(100, absint($summary['score'] ?? 0))),
'improvement' => max(0, min(100, absint($summary['improvement'] ?? 0))),
'pages' => max(0, absint($summary['pages'] ?? 0)),
'critical' => max(0, absint($summary['critical'] ?? 0)),
'warnings' => max(0, absint($summary['warnings'] ?? 0)),
'avgPage' => max(0, min(100, absint($summary['avgPage'] ?? 0))),
'technical' => max(0, min(100, absint($summary['technical'] ?? 0))),
'label' => sanitize_text_field((string) ($summary['label'] ?? '')),
],
'categories' => $clean_categories,
'issues' => $clean_issues,
'pages' => $clean_pages,
];
}
public function ajax_save_seo_history() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and every supported field is sanitized in seo_clean_history_payload().
$raw = wp_unslash((string) ($_POST['payload'] ?? ''));
if ($raw === '' || strlen($raw) > 800000) wp_send_json_error(['message' => 'SEO history payload is missing or too large.'], 400);
$payload = json_decode($raw, true);
if (!is_array($payload)) wp_send_json_error(['message' => 'SEO history payload is invalid.'], 400);
$item = $this->seo_clean_history_payload($payload);
$history = $this->seo_history_items();
$previous = !empty($history) ? end($history) : null;
$history[] = $item;
if (count($history) > 20) $history = array_slice($history, -20);
update_option(self::SEO_HISTORY_OPTION, array_values($history), false);
wp_send_json_success(['item' => $item, 'previous' => $previous, 'items' => array_values($history)]);
}
private function seo_fetch_page_html($page_id) {
$url = get_permalink($page_id);
if (!$url) return new WP_Error('no_url', 'Could not determine the public page URL.');
$response = wp_remote_get($url, [
'timeout' => 15,
'redirection' => 4,
'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION . '; ' . home_url('/'),
'headers' => ['Cache-Control' => 'no-cache'],
]);
if (is_wp_error($response)) return $response;
return [
'url' => $url,
'status' => (int) wp_remote_retrieve_response_code($response),
'html' => (string) wp_remote_retrieve_body($response),
];
}
private function seo_xpath_first_attr($xpath, $query, $attr = '') {
$nodes = $xpath->query($query);
if (!$nodes || !$nodes->length) return '';
$node = $nodes->item(0);
if ($attr !== '' && $node instanceof DOMElement) return trim((string) $node->getAttribute($attr));
return trim((string) $node->textContent);
}
private function seo_audit_page($page_id, $official_phone = '') {
$title_wp = get_the_title($page_id);
$edit_url = get_edit_post_link($page_id, 'raw') ?: '';
$fetched = $this->seo_fetch_page_html($page_id);
if (is_wp_error($fetched)) {
return [
'id' => (int) $page_id, 'page_title' => $title_wp, 'url' => get_permalink($page_id) ?: '', 'edit_url' => $edit_url,
'score' => 0, 'base_score' => 0, 'score_breakdown' => [], 'critical' => 1, 'warnings' => 0, 'passed' => 0, 'checks' => 1,
'title' => '', 'meta' => '', 'h1' => '', 'issues' => [[
'severity' => 'critical', 'code' => 'fetch_error', 'label' => 'Page could not be fetched', 'detail' => $fetched->get_error_message(),
]],
'stats' => ['images'=>0,'missing_alt'=>0,'words'=>0,'internal_links'=>0,'external_links'=>0,'wrong_phones'=>0],
'wrong_numbers' => [],
];
}
$html = $fetched['html'];
$url = $fetched['url'];
$status = absint($fetched['status']);
$issues = [];
$checks = 0;
$passed = 0;
$add_check = function($ok, $severity, $code, $label, $detail = '') use (&$issues, &$checks, &$passed) {
$checks++;
if ($ok) { $passed++; return; }
$issues[] = ['severity'=>$severity, 'code'=>$code, 'label'=>$label, 'detail'=>$detail];
};
$seo_title = '';
$meta_desc = '';
$canonical = '';
$robots = '';
$lang = '';
$viewport = '';
$h1s = [];
$h2_count = 0;
$heading_levels = [];
$empty_headings = 0;
$heading_skips = 0;
$image_total = 0;
$missing_alt = [];
$empty_alt = 0;
$missing_dimensions = 0;
$generic_image_names = 0;
$internal_links = 0;
$external_links = 0;
$empty_links = 0;
$generic_anchor_links = 0;
$nofollow_internal = 0;
$schema_count = 0;
$invalid_schema = 0;
$schema_types = [];
$og_title = '';
$og_image = '';
$twitter_card = '';
$body_text = '';
if (class_exists('DOMDocument')) {
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . $html, LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if ($loaded) {
$xpath = new DOMXPath($doc);
$seo_title = $this->seo_xpath_first_attr($xpath, '//title');
$meta_desc = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="description"]', 'content');
$canonical = $this->seo_xpath_first_attr($xpath, '//link[contains(concat(" ", normalize-space(translate(@rel,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")), " "), " canonical ")]', 'href');
$robots = strtolower($this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="robots"]', 'content'));
$viewport = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="viewport"]', 'content');
$lang = $this->seo_xpath_first_attr($xpath, '//html', 'lang');
$og_title = $this->seo_xpath_first_attr($xpath, '//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:title"]', 'content');
$og_image = $this->seo_xpath_first_attr($xpath, '//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:image"]', 'content');
$twitter_card = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="twitter:card"]', 'content');
$h1nodes = $xpath->query('//h1');
if ($h1nodes) foreach ($h1nodes as $node) { $txt = trim(preg_replace('/\s+/', ' ', (string) $node->textContent)); if ($txt !== '') $h1s[] = $txt; }
$h2nodes = $xpath->query('//h2');
$h2_count = $h2nodes ? (int) $h2nodes->length : 0;
$heading_nodes = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($heading_nodes) {
$previous_level = 0;
foreach ($heading_nodes as $heading_node) {
$level = (int) substr(strtolower((string) $heading_node->nodeName), 1);
$txt = trim(preg_replace('/\s+/', ' ', (string) $heading_node->textContent));
if ($txt === '') $empty_headings++;
if ($previous_level > 0 && $level > ($previous_level + 1)) $heading_skips++;
$heading_levels[] = $level;
$previous_level = $level;
}
}
$imgs = $xpath->query('//img');
if ($imgs) {
foreach ($imgs as $img) {
$image_total++;
if (!($img instanceof DOMElement)) continue;
$role = strtolower(trim((string) $img->getAttribute('role')));
$hidden = strtolower(trim((string) $img->getAttribute('aria-hidden')));
$src = trim((string) $img->getAttribute('src'));
$filename = $src ? basename((string) wp_parse_url($src, PHP_URL_PATH)) : '';
if ($filename !== '' && preg_match('/^(?:img|image|dsc|pxl|photo|screenshot|untitled)[-_ ]?\d+/i', pathinfo($filename, PATHINFO_FILENAME))) $generic_image_names++;
if (!$img->hasAttribute('width') || !$img->hasAttribute('height')) $missing_dimensions++;
if ($role === 'presentation' || $hidden === 'true') continue;
$has_alt = $img->hasAttribute('alt');
$alt_value = $has_alt ? trim((string) $img->getAttribute('alt')) : '';
if ($has_alt && $alt_value === '') $empty_alt++;
if (!$has_alt && count($missing_alt) < 8) {
$missing_alt[] = $filename !== '' ? $filename : 'Image without src';
} elseif (!$has_alt) {
$missing_alt[] = '';
}
}
}
$home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
$links = $xpath->query('//a[@href and not(ancestor::header) and not(ancestor::footer) and not(ancestor::nav)]');
if ($links) {
foreach ($links as $link) {
if (!($link instanceof DOMElement)) continue;
$href = trim(html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8'));
if ($href === '' || $href === '#' || stripos($href, 'javascript:void') === 0) { $empty_links++; continue; }
if (preg_match('~^(tel:|mailto:|sms:|javascript:)~i', $href) || strpos($href, '#') === 0) continue;
$host = strtolower((string) wp_parse_url($href, PHP_URL_HOST));
$is_internal = ($host === '' || $host === $home_host);
if ($is_internal) $internal_links++; else $external_links++;
$anchor_text = strtolower(trim(preg_replace('/\s+/', ' ', (string) $link->textContent)));
if (in_array($anchor_text, ['click here','read more','learn more','more','here','this link'], true)) $generic_anchor_links++;
$rel = strtolower(trim((string) $link->getAttribute('rel')));
if ($is_internal && preg_match('/(?:^|\s)nofollow(?:\s|$)/', $rel)) $nofollow_internal++;
}
}
$schemas = $xpath->query('//script[contains(translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"application/ld+json")]');
$schema_count = $schemas ? (int) $schemas->length : 0;
if ($schemas) {
foreach ($schemas as $schema_node) {
$json = trim((string) $schema_node->textContent);
if ($json === '') continue;
$decoded = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) { $invalid_schema++; continue; }
$stack = [$decoded];
while ($stack) {
$current = array_pop($stack);
if (!is_array($current)) continue;
if (isset($current['@type'])) {
$types = is_array($current['@type']) ? $current['@type'] : [$current['@type']];
foreach ($types as $type) if (is_string($type) && $type !== '') $schema_types[$type] = true;
}
if (isset($current['@graph']) && is_array($current['@graph'])) foreach ($current['@graph'] as $graph_item) $stack[] = $graph_item;
}
}
}
$texts = $xpath->query('//body//text()[normalize-space() != "" and not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript) and not(ancestor::svg) and not(ancestor::header) and not(ancestor::footer) and not(ancestor::nav)]');
$chunks = [];
if ($texts) foreach ($texts as $text_node) $chunks[] = (string) $text_node->nodeValue;
$body_text = implode(' ', $chunks);
}
} else {
if (preg_match('~]*>(.*?) ~is', $html, $m)) $seo_title = trim(wp_strip_all_tags($m[1]));
if (preg_match('~ ]+name=["\']description["\'][^>]+content=["\']([^"\']*)["\']~i', $html, $m)) $meta_desc = trim($m[1]);
if (preg_match('~ ]+rel=["\'][^"\']*canonical[^"\']*["\'][^>]+href=["\']([^"\']+)["\']~i', $html, $m)) $canonical = trim($m[1]);
if (preg_match_all('~]*>(.*?) ~is', $html, $m)) foreach ($m[1] as $h) $h1s[] = trim(wp_strip_all_tags($h));
$h2_count = preg_match_all('~]*>~i', $html, $imgs);
if ($imgs && !empty($imgs[0])) foreach ($imgs[0] as $tag) if (!preg_match('~\balt\s*=\s*["\'][^"\']+["\']~i', $tag)) $missing_alt[] = 'Image missing ALT';
$schema_count = preg_match_all('~application/ld\+json~i', $html, $m);
$body_text = wp_strip_all_tags($html);
}
$word_count = $this->seo_word_count($body_text);
$title_len = $this->seo_string_length($seo_title);
$meta_len = $this->seo_string_length($meta_desc);
$h1_count = count($h1s);
$missing_alt_count = count($missing_alt);
$is_utility = $this->seo_is_utility_page($page_id);
$generic_title = $this->seo_is_generic_text(preg_replace('/\s*[\|\-–—:]\s*' . preg_quote((string) get_bloginfo('name'), '/') . '\s*$/iu', '', $seo_title));
$generic_h1 = $h1_count > 0 ? $this->seo_is_generic_text($h1s[0]) : false;
$placeholder_content = (bool) preg_match('/\b(lorem ipsum|dummy text|sample content|sample page|placeholder text|coming soon)\b/i', $body_text);
$content_shingles = $this->seo_content_shingles($body_text);
$content_hash = $word_count >= 80 ? md5(strtolower(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($body_text))))) : '';
$url_path = (string) wp_parse_url($url, PHP_URL_PATH);
$url_length = strlen($url_path);
$url_has_underscore = strpos($url_path, '_') !== false;
$title_tokens = preg_split('/[^\p{L}\p{N}]+/u', strtolower(wp_strip_all_tags($seo_title)), -1, PREG_SPLIT_NO_EMPTY);
$h1_tokens = preg_split('/[^\p{L}\p{N}]+/u', strtolower(wp_strip_all_tags($h1s[0] ?? '')), -1, PREG_SPLIT_NO_EMPTY);
$stop_words = ['the','and','for','with','from','this','that','your','our','you','are','of','to','in','on','at','by','or','a','an'];
$title_tokens = array_values(array_diff(array_unique(array_filter((array) $title_tokens, fn($v) => strlen($v) >= 3)), $stop_words));
$h1_tokens = array_values(array_diff(array_unique(array_filter((array) $h1_tokens, fn($v) => strlen($v) >= 3)), $stop_words));
$title_h1_overlap = count(array_intersect($title_tokens, $h1_tokens));
$wrong_numbers = [];
if ($official_phone !== '' && $this->canonical_phone_digits($official_phone) !== '') {
$regions = $this->phone_page_regions($html);
$phone_items = [];
foreach (['header','footer','content'] as $region) {
foreach ($this->phone_region_occurrences($regions[$region] ?? '', $official_phone, $region, $page_id, $title_wp, $url) as $item) {
if (empty($item['match'])) $phone_items[$item['key']] = $item;
}
}
foreach ($phone_items as $item) {
$display = trim((string) ($item['number'] ?? ''));
if ($display !== '' && !in_array($display, $wrong_numbers, true)) $wrong_numbers[] = $display;
}
}
$add_check($status >= 200 && $status < 300, 'critical', 'http_status', 'Page is not returning a clean 2xx response', 'HTTP status: ' . $status);
$add_check($seo_title !== '', 'critical', 'missing_title', 'Missing meta title', 'No rendered tag was found.');
if ($seo_title !== '') {
$add_check(!$generic_title, 'warning', 'generic_title', 'Meta title is too generic', 'Rendered title: ' . $seo_title);
$add_check($title_len >= 30 && $title_len <= 65, 'warning', 'title_length', 'Meta title length needs review', $title_len . ' characters. Practical range: about 30–65.');
}
$add_check($meta_desc !== '', 'critical', 'missing_meta', 'Missing meta description', 'No rendered meta description was found.');
if ($meta_desc !== '') $add_check($meta_len >= 110 && $meta_len <= 165, 'warning', 'meta_length', 'Meta description length needs review', $meta_len . ' characters.');
$add_check($h1_count === 1, 'critical', $h1_count === 0 ? 'missing_h1' : 'multiple_h1', $h1_count === 0 ? 'Missing H1 heading' : 'Multiple H1 headings', $h1_count . ' H1 heading(s) found.');
if ($h1_count > 0) $add_check(!$generic_h1, 'warning', 'generic_h1', 'Primary H1 is too generic', 'H1: ' . ($h1s[0] ?? ''));
$add_check($canonical !== '', 'warning', 'missing_canonical', 'Missing canonical tag', 'No rendered canonical URL was found.');
if ($canonical !== '') $add_check($this->seo_normalize_url($canonical) === $this->seo_normalize_url($url), 'critical', 'canonical_mismatch', 'Canonical points to a different URL', $canonical);
$add_check(strpos($robots, 'noindex') === false, 'critical', 'noindex', 'Page is marked noindex', $robots !== '' ? 'Robots: ' . $robots : 'A noindex directive was detected.');
$add_check(!$placeholder_content, 'critical', 'placeholder_content', 'Placeholder/demo content detected', 'The rendered content contains placeholder/demo wording.');
$alt_ratio = $image_total > 0 ? ($missing_alt_count / max(1, $image_total)) : 0;
$add_check($missing_alt_count === 0, $alt_ratio > .5 ? 'critical' : 'warning', 'missing_alt', 'Images missing ALT attributes', $missing_alt_count . ' of ' . $image_total . ' image(s) are missing an ALT attribute.');
$content_min = $is_utility ? 60 : 250;
$add_check($word_count >= $content_min, $word_count < ($is_utility ? 30 : 120) ? 'critical' : 'warning', 'thin_content', 'Content depth is weak', $word_count . ' meaningful rendered words found' . ($is_utility ? ' (utility-page threshold applied).' : '.'));
if ($word_count >= 250) $add_check($h2_count > 0, 'warning', 'missing_h2', 'No H2 subheading found', 'This page has ' . $word_count . ' meaningful words but no H2 heading structure.');
$front_id = absint(get_option('page_on_front'));
if ($page_id !== $front_id && !$is_utility) {
$add_check($internal_links >= 2, 'warning', 'weak_internal_links', 'Weak contextual internal linking', $internal_links . ' meaningful internal content link(s) found. Header/footer/navigation links are excluded.');
} elseif ($page_id !== $front_id) {
$add_check($internal_links >= 1, 'warning', 'no_internal_links', 'No meaningful internal links found', $internal_links . ' content link(s) found. Header/footer/navigation links are excluded.');
}
$add_check(strpos($robots, 'nofollow') === false, 'warning', 'nofollow', 'Page is marked nofollow', $robots !== '' ? 'Robots: ' . $robots : 'A nofollow directive was detected.');
$add_check($schema_count > 0, 'info', 'missing_schema', 'No JSON-LD schema detected', 'No application/ld+json block was found. This is an enhancement opportunity, not automatically a ranking problem.');
$add_check($og_title !== '' && $og_image !== '', 'info', 'missing_og', 'Open Graph tags incomplete', 'OG title/image are incomplete. This mainly affects social sharing previews.');
$add_check($viewport !== '', 'warning', 'missing_viewport', 'Viewport meta tag missing', 'Mobile viewport tag was not found.');
$add_check($lang !== '', 'warning', 'missing_lang', 'HTML language attribute missing', 'The rendered element does not declare a language.');
$add_check($empty_links === 0, 'warning', 'empty_links', 'Empty or placeholder links found', $empty_links . ' content link(s) use an empty/#/javascript placeholder.');
$add_check($empty_headings === 0, 'warning', 'empty_headings', 'Empty heading elements found', $empty_headings . ' empty H1–H6 heading element(s) found.');
$add_check($heading_skips === 0, 'warning', 'heading_hierarchy', 'Heading hierarchy skips levels', $heading_skips . ' heading transition(s) skip a level, such as H2 directly to H4.');
if ($seo_title !== '' && $h1_count > 0 && count($title_tokens) >= 1 && count($h1_tokens) >= 1) {
$add_check($title_h1_overlap > 0, 'warning', 'title_h1_mismatch', 'SEO title and H1 appear poorly aligned', 'The rendered title and primary H1 share no meaningful topic words. Review whether they describe the same page intent.');
}
$add_check($url_length <= 95 && !$url_has_underscore, 'info', 'url_quality', 'URL structure could be cleaner', 'Path length: ' . $url_length . ' characters' . ($url_has_underscore ? '; underscore detected.' : '.'));
$add_check($generic_anchor_links === 0, 'info', 'generic_anchor_text', 'Generic anchor text found', $generic_anchor_links . ' contextual link(s) use text such as “click here” or “read more”.');
$add_check($nofollow_internal === 0, 'warning', 'nofollow_internal', 'Internal contextual links use nofollow', $nofollow_internal . ' internal content link(s) have a nofollow attribute.');
if ($image_total > 0) {
$add_check($missing_dimensions <= max(1, (int) floor($image_total * .35)), 'info', 'image_dimensions', 'Many images are missing width/height attributes', $missing_dimensions . ' of ' . $image_total . ' image(s) lack explicit width and/or height attributes.');
$add_check($generic_image_names <= max(2, (int) floor($image_total * .50)), 'info', 'generic_image_filenames', 'Many image filenames are generic', $generic_image_names . ' of ' . $image_total . ' image(s) use filenames such as IMG_1234 or image-123.');
}
$add_check($invalid_schema === 0, 'warning', 'invalid_schema', 'Invalid JSON-LD schema detected', $invalid_schema . ' JSON-LD block(s) could not be parsed as valid JSON.');
$add_check($twitter_card !== '' || ($og_title !== '' && $og_image !== ''), 'info', 'social_meta', 'Social sharing metadata is limited', 'No Twitter card was detected and Open Graph coverage may be incomplete.');
$mixed = 0;
if (stripos(home_url('/'), 'https://') === 0) {
$host = preg_quote((string) wp_parse_url(home_url('/'), PHP_URL_HOST), '~');
$mixed = preg_match_all("~http://" . $host . "[^\\s\"']*~i", $html, $mm);
$add_check($mixed === 0, 'warning', 'mixed_content', 'Internal HTTP URLs found on HTTPS site', absint($mixed) . ' insecure internal reference(s) found.');
}
if ($official_phone !== '' && $this->canonical_phone_digits($official_phone) !== '') {
$add_check(count($wrong_numbers) === 0, 'critical', 'wrong_phone', 'Wrong phone number found', count($wrong_numbers) . ' different mismatched phone value(s): ' . implode(', ', array_slice($wrong_numbers, 0, 4)));
}
// Earned-points scoring: pages do not start at 100. A new/unfinished page
// must actually demonstrate solid SEO setup to receive a high score.
$breakdown = [
'Technical' => ['earned' => 0, 'max' => 10],
'Metadata' => ['earned' => 0, 'max' => 25],
'Headings' => ['earned' => 0, 'max' => 15],
'Indexing' => ['earned' => 0, 'max' => 15],
'Content' => ['earned' => 0, 'max' => 15],
'Internal Linking' => ['earned' => 0, 'max' => 8],
'Images' => ['earned' => 0, 'max' => 7],
'Structured Data' => ['earned' => 0, 'max' => 3],
'Social' => ['earned' => 0, 'max' => 2],
];
// Technical 10
if ($status >= 200 && $status < 300) $breakdown['Technical']['earned'] += 5;
if ($viewport !== '') $breakdown['Technical']['earned'] += 1;
if ($lang !== '') $breakdown['Technical']['earned'] += 1;
if ($empty_links === 0) $breakdown['Technical']['earned'] += 1.5;
if ($mixed === 0) $breakdown['Technical']['earned'] += 1.5;
// Metadata 25
if ($seo_title !== '') {
$breakdown['Metadata']['earned'] += 5;
if ($title_len >= 30 && $title_len <= 65) $breakdown['Metadata']['earned'] += 5;
elseif ($title_len >= 20 && $title_len <= 75) $breakdown['Metadata']['earned'] += 2.5;
if (!$generic_title) $breakdown['Metadata']['earned'] += 5;
}
if ($meta_desc !== '') {
$breakdown['Metadata']['earned'] += 5;
if ($meta_len >= 110 && $meta_len <= 165) $breakdown['Metadata']['earned'] += 5;
elseif ($meta_len >= 70 && $meta_len <= 190) $breakdown['Metadata']['earned'] += 2.5;
}
// Headings 15
if ($h1_count === 1) $breakdown['Headings']['earned'] += 10;
elseif ($h1_count > 1) $breakdown['Headings']['earned'] += 4;
if ($h1_count > 0 && !$generic_h1) $breakdown['Headings']['earned'] += 3;
if ($word_count < 250 || $h2_count > 0) $breakdown['Headings']['earned'] += 2;
// Indexing 15
if (strpos($robots, 'noindex') === false) $breakdown['Indexing']['earned'] += 7;
if ($canonical !== '') $breakdown['Indexing']['earned'] += 3;
if ($canonical !== '' && $this->seo_normalize_url($canonical) === $this->seo_normalize_url($url)) $breakdown['Indexing']['earned'] += 5;
// Content 15
if ($placeholder_content) {
$breakdown['Content']['earned'] = min(2, $word_count > 0 ? 2 : 0);
} elseif ($is_utility) {
if ($word_count >= 120) $breakdown['Content']['earned'] = 15;
elseif ($word_count >= 60) $breakdown['Content']['earned'] = 10;
elseif ($word_count >= 30) $breakdown['Content']['earned'] = 5;
} else {
if ($word_count >= 600) $breakdown['Content']['earned'] = 15;
elseif ($word_count >= 400) $breakdown['Content']['earned'] = 13;
elseif ($word_count >= 250) $breakdown['Content']['earned'] = 9;
elseif ($word_count >= 120) $breakdown['Content']['earned'] = 4;
}
// Internal linking 8 (context/content links only)
if ($page_id === $front_id) {
if ($internal_links >= 3) $breakdown['Internal Linking']['earned'] = 8;
elseif ($internal_links >= 1) $breakdown['Internal Linking']['earned'] = 5;
} elseif ($is_utility) {
$breakdown['Internal Linking']['earned'] = $internal_links >= 1 ? 8 : 0;
} else {
if ($internal_links >= 3) $breakdown['Internal Linking']['earned'] = 8;
elseif ($internal_links === 2) $breakdown['Internal Linking']['earned'] = 6;
elseif ($internal_links === 1) $breakdown['Internal Linking']['earned'] = 3;
}
// Images 7
if ($image_total === 0 || $missing_alt_count === 0) $breakdown['Images']['earned'] = 7;
elseif ($alt_ratio <= .20) $breakdown['Images']['earned'] = 5;
elseif ($alt_ratio <= .50) $breakdown['Images']['earned'] = 3;
// Enhancements
if ($schema_count > 0) $breakdown['Structured Data']['earned'] = 3;
if ($og_title !== '' && $og_image !== '') $breakdown['Social']['earned'] = 2;
$earned_points = 0;
$max_points = 0;
foreach ($breakdown as $bucket) { $earned_points += (float) $bucket['earned']; $max_points += (float) $bucket['max']; }
$score = $max_points > 0 ? (int) round(($earned_points / $max_points) * 100) : 0;
// Critical-readiness caps prevent a serious indexing/content problem
// from being hidden by otherwise clean theme markup.
if (!($status >= 200 && $status < 300)) $score = min($score, 35);
if (strpos($robots, 'noindex') !== false) $score = min($score, 50);
if ($canonical !== '' && $this->seo_normalize_url($canonical) !== $this->seo_normalize_url($url)) $score = min($score, 60);
if ($placeholder_content) $score = min($score, 55);
if ($seo_title === '') $score = min($score, 55);
if ($h1_count === 0) $score = min($score, 65);
if (!empty($wrong_numbers)) $score = max(0, $score - 6);
if ($invalid_schema > 0) $score = max(0, $score - 3);
if ($heading_skips > 1) $score = max(0, $score - 2);
if ($empty_headings > 1) $score = max(0, $score - 2);
if ($title_h1_overlap === 0 && $seo_title !== '' && $h1_count > 0) $score = max(0, $score - 3);
usort($issues, function($a, $b) {
$rank = ['critical'=>0, 'warning'=>1, 'info'=>2];
return ($rank[$a['severity']] ?? 9) <=> ($rank[$b['severity']] ?? 9);
});
$critical = count(array_filter($issues, fn($i) => ($i['severity'] ?? '') === 'critical'));
$warnings = count(array_filter($issues, fn($i) => ($i['severity'] ?? '') === 'warning'));
return [
'id' => (int) $page_id,
'page_title' => $title_wp,
'url' => $url,
'edit_url' => $edit_url,
'status' => $status,
'score' => $score,
'base_score' => $score,
'score_breakdown' => $breakdown,
'is_utility' => $is_utility,
'placeholder_content' => $placeholder_content,
'critical' => $critical,
'warnings' => $warnings,
'passed' => $passed,
'checks' => $checks,
'title' => $seo_title,
'title_length' => $title_len,
'meta' => $meta_desc,
'meta_length' => $meta_len,
'h1' => $h1s[0] ?? '',
'h1_count' => $h1_count,
'h2_count' => $h2_count,
'canonical' => $canonical,
'issues' => $issues,
'stats' => [
'images' => $image_total,
'missing_alt' => $missing_alt_count,
'words' => $word_count,
'internal_links' => $internal_links,
'external_links' => $external_links,
'schema' => $schema_count,
'invalid_schema' => $invalid_schema,
'empty_headings' => $empty_headings,
'heading_skips' => $heading_skips,
'generic_anchors' => $generic_anchor_links,
'nofollow_internal' => $nofollow_internal,
'missing_dimensions' => $missing_dimensions,
'generic_image_names' => $generic_image_names,
'empty_alt' => $empty_alt,
'wrong_phones' => count($wrong_numbers),
],
'schema_types' => array_values(array_keys($schema_types)),
'content_hash' => $content_hash,
'content_shingles' => $content_shingles,
'wrong_numbers' => $wrong_numbers,
];
}
public function ajax_seo_audit_batch() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if ($this->maintenance_mode() !== 'off') {
wp_send_json_error(['message' => 'Disable the Maintenance / Hosting Notice page before running the SEO audit so the scanner can read the real public pages.']);
}
$page = max(1, absint($_POST['page'] ?? 1));
$limit = min(5, max(1, absint($_POST['limit'] ?? 4)));
$official_phone = sanitize_text_field(wp_unslash($_POST['official_phone'] ?? ''));
$query = new WP_Query([
'post_type' => $this->seo_auditable_post_types(),
'post_status' => 'publish',
'posts_per_page' => $limit,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => false,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
$items = [];
foreach ($query->posts as $page_id) $items[] = $this->seo_audit_page(absint($page_id), $official_phone);
wp_reset_postdata();
wp_send_json_success([
'items' => $items,
'page' => $page,
'total' => absint($query->found_posts),
'total_pages' => absint($query->max_num_pages),
'has_more' => $page < absint($query->max_num_pages),
'site_checks' => $page === 1 ? $this->seo_site_checks() : [],
]);
}
private function login_settings() {
$saved = get_option(self::LOGIN_SETTINGS_OPTION, []);
return wp_parse_args(is_array($saved) ? $saved : [], ['slug'=>'', 'hide_default'=>0]);
}
private function custom_login_url($query = []) {
$settings = $this->login_settings();
if (empty($settings['slug'])) return '';
$url = home_url('/' . trim($settings['slug'], '/') . '/');
return $query ? add_query_arg($query, $url) : $url;
}
private function site_widget_channels() {
return [
'phone' => ['label' => 'Phone', 'placeholder' => '(815) 474-3974'],
'sms' => ['label' => 'SMS', 'placeholder' => '(815) 474-3974'],
'whatsapp' => ['label' => 'WhatsApp', 'placeholder' => '+1 815 474 3974'],
'facebook' => ['label' => 'Facebook Messenger', 'placeholder' => 'username or https://m.me/username'],
'email' => ['label' => 'Email', 'placeholder' => 'hello@example.com'],
'map' => ['label' => 'Google Maps', 'placeholder' => 'https://maps.google.com/...'],
'instagram' => ['label' => 'Instagram', 'placeholder' => '@username or profile URL'],
'form' => ['label' => 'Contact Form', 'placeholder' => 'Choose a form'],
];
}
private function trust_badge_defaults() {
return [
['icon'=>'license','title'=>'Licensed Professionals','text'=>'Qualified & dependable'],
['icon'=>'shield','title'=>'Fully Insured','text'=>'Protection you can trust'],
['icon'=>'warranty','title'=>'Workmanship Warranty','text'=>'Quality backed by our work'],
['icon'=>'estimate','title'=>'Free Estimates','text'=>'Clear, no-obligation quotes'],
['icon'=>'badge','title'=>'Trusted Business','text'=>'Professional local service'],
['icon'=>'local','title'=>'Locally Owned','text'=>'Proudly serving our community'],
];
}
private function trust_badge_icon_choices() {
return [
'license'=>'License / Certificate',
'shield'=>'Shield / Insured',
'warranty'=>'Warranty / Check',
'estimate'=>'Free Estimate',
'badge'=>'Trust Badge',
'local'=>'Locally Owned',
'star'=>'Star / Rated',
'clock'=>'Fast Service',
];
}
private function svg_allowed_html() {
return [
'svg' => [
'viewbox' => true,
'aria-hidden' => true,
'focusable' => true,
'role' => true,
'xmlns' => true,
],
'path' => [
'd' => true,
'fill' => true,
'fill-rule' => true,
'clip-rule' => true,
],
];
}
/**
* Initialize WordPress' filesystem abstraction for media file operations.
*
* @return WP_Filesystem_Base|WP_Error
*/
private function wp_filesystem_instance() {
global $wp_filesystem;
if ($wp_filesystem instanceof WP_Filesystem_Base) {
return $wp_filesystem;
}
require_once ABSPATH . 'wp-admin/includes/file.php';
if (!WP_Filesystem()) {
return new WP_Error('filesystem_unavailable', 'WordPress could not initialize filesystem access.');
}
if (!($wp_filesystem instanceof WP_Filesystem_Base)) {
return new WP_Error('filesystem_unavailable', 'WordPress filesystem access is unavailable.');
}
return $wp_filesystem;
}
private function filesystem_move($from, $to, $overwrite = false) {
$fs = $this->wp_filesystem_instance();
if (is_wp_error($fs)) return false;
return (bool) $fs->move($from, $to, (bool) $overwrite);
}
private function filesystem_copy($from, $to, $overwrite = false) {
$fs = $this->wp_filesystem_instance();
if (is_wp_error($fs)) return false;
return (bool) $fs->copy($from, $to, (bool) $overwrite, FS_CHMOD_FILE);
}
private function filesystem_delete_file($file) {
if (!$file || !file_exists($file)) return true;
wp_delete_file($file);
return !file_exists($file);
}
private function trust_badge_icon_svg($key) {
$icons = [
'license'=>' ',
'shield'=>' ',
'warranty'=>' ',
'estimate'=>' ',
'badge'=>' ',
'local'=>' ',
'star'=>' ',
'clock'=>' ',
];
return $icons[$key] ?? $icons['shield'];
}
public function shortcode_trust_badges($atts = []) {
$s = $this->site_widgets_settings();
if (empty($s['trust_enabled'])) return '';
wp_enqueue_style('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.css', [], self::VERSION);
$items = [];
for ($i=1; $i<=6; $i++) {
if (empty($s['trust_'.$i.'_enabled'])) continue;
$title = trim((string)($s['trust_'.$i.'_title'] ?? ''));
if ($title === '') continue;
$items[] = [
'icon' => sanitize_key((string)($s['trust_'.$i.'_icon'] ?? 'shield')),
'title' => $title,
'text' => trim((string)($s['trust_'.$i.'_text'] ?? '')),
];
}
if (!$items) return '';
$style = in_array($s['trust_style'], ['cards','minimal','strip'], true) ? $s['trust_style'] : 'cards';
$columns = in_array((string)$s['trust_columns'], ['3','4','6'], true) ? (string)$s['trust_columns'] : '3';
$css = '--ekmt-trust-accent:' . esc_attr($s['trust_accent']) . ';--ekmt-trust-bg:' . esc_attr($s['trust_bg']) . ';--ekmt-trust-color:' . esc_attr($s['trust_color']) . ';--ekmt-trust-cols:' . esc_attr($columns) . ';';
ob_start();
echo '';
foreach ($items as $item) {
echo '
' . wp_kses($this->trust_badge_icon_svg($item['icon']), $this->svg_allowed_html()) . ' ' . esc_html($item['title']) . ' ';
if ($item['text'] !== '') echo '' . esc_html($item['text']) . ' ';
echo '
';
}
echo '
';
return ob_get_clean();
}
private function site_widgets_settings() {
$defaults = [
'floating_enabled' => 0, 'floating_label' => 'Contact Us', 'floating_position' => 'right',
'phone_enabled' => 0, 'phone' => '', 'sms_enabled' => 0, 'sms' => '', 'whatsapp_enabled' => 0, 'whatsapp' => '',
'facebook_enabled' => 0, 'facebook' => '', 'email_enabled' => 0, 'email' => '', 'map_enabled' => 0, 'map' => '',
'instagram_enabled' => 0, 'instagram' => '', 'form_enabled' => 0, 'form' => '',
'announcement_enabled' => 0, 'announcement_text' => 'Free estimates available — call today.',
'announcement_button_text' => 'Contact Us', 'announcement_button_url' => '', 'announcement_position' => 'top',
'announcement_dismissible' => '1', 'announcement_bg' => '#111827', 'announcement_color' => '#ffffff', 'announcement_button_bg' => '#1667ff',
'trust_enabled' => 1, 'trust_style' => 'cards', 'trust_columns' => '3', 'trust_accent' => '#1667ff', 'trust_bg' => '#ffffff', 'trust_color' => '#161616',
'trust_1_enabled'=>1, 'trust_1_icon'=>'license', 'trust_1_title'=>'Licensed Professionals', 'trust_1_text'=>'Qualified & dependable',
'trust_2_enabled'=>1, 'trust_2_icon'=>'shield', 'trust_2_title'=>'Fully Insured', 'trust_2_text'=>'Protection you can trust',
'trust_3_enabled'=>1, 'trust_3_icon'=>'warranty', 'trust_3_title'=>'Workmanship Warranty', 'trust_3_text'=>'Quality backed by our work',
'trust_4_enabled'=>1, 'trust_4_icon'=>'estimate', 'trust_4_title'=>'Free Estimates', 'trust_4_text'=>'Clear, no-obligation quotes',
'trust_5_enabled'=>0, 'trust_5_icon'=>'badge', 'trust_5_title'=>'Trusted Business', 'trust_5_text'=>'Professional local service',
'trust_6_enabled'=>0, 'trust_6_icon'=>'local', 'trust_6_title'=>'Locally Owned', 'trust_6_text'=>'Proudly serving our community',
'popup_enabled' => 0, 'popup_type' => 'form', 'popup_title' => 'How can we help?',
'popup_description' => 'Send us your details and our team will get back to you.', 'popup_form' => 'contact',
'popup_button_text' => 'Contact Us', 'popup_button_url' => '', 'popup_trigger' => 'delay',
'popup_delay' => 5, 'popup_scroll' => 50, 'popup_frequency' => 'session', 'popup_theme' => 'light',
'popup_position' => 'center',
];
$saved = get_option(self::SITE_WIDGETS_OPTION, []);
return wp_parse_args(is_array($saved) ? $saved : [], $defaults);
}
private function normalize_contact_number($value) {
$value = trim((string) $value);
$digits = preg_replace('/\D+/', '', $value);
return $digits ?: '';
}
private function contact_channel_url($key, $value) {
$value = trim((string) $value);
if ($value === '') return '';
if (in_array($key, ['phone','sms','whatsapp'], true)) {
$digits = $this->normalize_contact_number($value);
if (!$digits) return '';
if ($key === 'phone') return 'tel:+' . $digits;
if ($key === 'sms') return 'sms:+' . $digits;
return 'https://wa.me/' . $digits;
}
if ($key === 'email') return is_email($value) ? 'mailto:' . sanitize_email($value) : '';
if ($key === 'facebook') {
if (preg_match('#^https?://#i', $value)) return esc_url_raw($value);
$user = ltrim($value, '@/ ');
return $user ? 'https://m.me/' . rawurlencode($user) : '';
}
if ($key === 'instagram') {
if (preg_match('#^https?://#i', $value)) return esc_url_raw($value);
$user = ltrim($value, '@/ ');
return $user ? 'https://instagram.com/' . rawurlencode($user) : '';
}
if ($key === 'map') return esc_url_raw($value);
return '';
}
private function contact_icon_svg($key) {
$icons = [
'phone' => ' ',
'sms' => ' ',
'whatsapp' => ' ',
'facebook' => ' ',
'email' => ' ',
'map' => ' ',
'instagram' => ' ',
'form' => ' ',
];
return $icons[$key] ?? $icons['form'];
}
private function maintenance_mode() {
$mode = sanitize_key((string) get_option(self::MAINTENANCE_MODE_OPTION, 'off'));
return in_array($mode, ['off', 'maintenance', 'expired'], true) ? $mode : 'off';
}
private function maintenance_mode_label($mode) {
if ($mode === 'maintenance') return 'Maintenance page is active';
if ($mode === 'expired') return 'Hosting notice page is active';
return 'Live site is normal';
}
private function maintenance_mode_help($mode) {
if ($mode === 'maintenance') return 'Visitors see the maintenance page. Logged-in admins can still browse the real website.';
if ($mode === 'expired') return 'Visitors see the hosting-expired notice page until you turn it off.';
return 'Nothing is overriding your public website right now.';
}
private function maintenance_defaults($mode = 'maintenance') {
if ($mode === 'expired') {
return [
'title' => 'Hosting is expired please renew your hosting.',
'description' => 'This website is temporarily unavailable. Please renew the hosting service and then bring the website back online.',
'icon' => 'warning',
];
}
return [
'title' => 'We’ll be back shortly.',
'description' => 'We are making a few updates right now. Please check back again soon.',
'icon' => 'tools',
];
}
private function maintenance_settings($mode = '') {
$saved = get_option(self::MAINTENANCE_SETTINGS_OPTION, []);
$saved = is_array($saved) ? $saved : [];
$base_mode = $mode ?: $this->maintenance_mode();
if ($base_mode === 'off') $base_mode = 'maintenance';
$defaults = $this->maintenance_defaults($base_mode);
return [
'title' => sanitize_text_field((string) ($saved['title'] ?? $defaults['title'])),
'description' => sanitize_textarea_field((string) ($saved['description'] ?? $defaults['description'])),
'icon' => sanitize_key((string) ($saved['icon'] ?? $defaults['icon'])),
];
}
private function maintenance_icon_choices() {
return [
'warning' => 'Warning',
'tools' => 'Tools',
'clock' => 'Clock',
'info' => 'Info',
'pause' => 'Pause',
'server' => 'Server',
];
}
private function maintenance_icon_symbol($icon) {
$map = [
'warning' => '⚠',
'tools' => '🛠',
'clock' => '⏳',
'info' => 'ℹ',
'pause' => '⏸',
'server' => '🖥',
];
return $map[$icon] ?? '⚠';
}
private function maintenance_samples() {
return [
['mode' => 'maintenance', 'title' => 'We’re working on the website right now.', 'description' => 'Please check back again shortly. We are making improvements behind the scenes.', 'icon' => 'tools'],
['mode' => 'maintenance', 'title' => 'Scheduled maintenance in progress.', 'description' => 'The website is temporarily unavailable while we complete scheduled updates.', 'icon' => 'clock'],
['mode' => 'maintenance', 'title' => 'Quick updates are in progress.', 'description' => 'We are refreshing this website right now. Thank you for your patience.', 'icon' => 'info'],
['mode' => 'maintenance', 'title' => 'Website maintenance mode is active.', 'description' => 'We are updating content and functionality. Please come back again soon.', 'icon' => 'pause'],
['mode' => 'maintenance', 'title' => 'We’ll be back shortly.', 'description' => 'A few final website updates are being completed. The site will return shortly.', 'icon' => 'tools'],
['mode' => 'expired', 'title' => 'Hosting is expired please renew your hosting.', 'description' => 'This website is temporarily unavailable because the hosting service needs to be renewed.', 'icon' => 'warning'],
['mode' => 'expired', 'title' => 'Hosting renewal is required.', 'description' => 'The website is temporarily offline. Please renew the hosting plan to restore access.', 'icon' => 'server'],
['mode' => 'expired', 'title' => 'This website is temporarily unavailable.', 'description' => 'The hosting account has expired. Please renew it and try again.', 'icon' => 'warning'],
['mode' => 'expired', 'title' => 'Website service is paused.', 'description' => 'Hosting service needs attention before this website can go live again.', 'icon' => 'pause'],
['mode' => 'expired', 'title' => 'Temporary hosting notice.', 'description' => 'The website is offline until hosting renewal is completed.', 'icon' => 'info'],
];
}
private function lead_form_definitions() {
$common = [
'name' => ['label' => 'Full Name', 'type' => 'text', 'required' => true, 'autocomplete' => 'name'],
'email' => ['label' => 'Email Address', 'type' => 'email', 'required' => true, 'autocomplete' => 'email'],
'phone' => ['label' => 'Phone Number', 'type' => 'tel', 'required' => true, 'autocomplete' => 'tel'],
];
return [
'contact' => [
'title' => 'Simple Contact Form', 'button' => 'Contact Us',
'description' => 'A clean professional contact form for general inquiries and lead capture.',
'fields' => $common + [
'message' => ['label' => 'How can we help?', 'type' => 'textarea', 'required' => true],
],
],
'quote' => [
'title' => 'Request a Quote', 'button' => 'Request Quote',
'description' => 'A more detailed professional quote form with address and project details.',
'fields' => $common + [
'address' => ['label' => 'Address', 'type' => 'text', 'required' => true, 'autocomplete' => 'street-address'],
'city_state_zip' => ['label' => 'City / State / ZIP', 'type' => 'text', 'required' => false],
'service' => ['label' => 'Service Needed', 'type' => 'text', 'required' => false],
'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true],
],
],
'consultation' => [
'title' => 'Book a Consultation', 'button' => 'Request Consultation',
'description' => 'Request a preferred consultation date and time without reserving a live calendar slot.',
'fields' => $common + [
'preferred_date' => ['label' => 'Preferred Date', 'type' => 'date', 'required' => false],
'preferred_time' => ['label' => 'Preferred Time', 'type' => 'time', 'required' => false],
'message' => ['label' => 'What would you like to discuss?', 'type' => 'textarea', 'required' => false],
],
],
'service' => [
'title' => 'Service Request', 'button' => 'Send Service Request',
'description' => 'A practical service request form for local-service websites.',
'fields' => $common + [
'service' => ['label' => 'Service Needed', 'type' => 'text', 'required' => true],
'address' => ['label' => 'Service Address', 'type' => 'text', 'required' => false, 'autocomplete' => 'street-address'],
'message' => ['label' => 'Request Details', 'type' => 'textarea', 'required' => true],
],
],
'callback' => [
'title' => 'Call Back Request', 'button' => 'Request a Call Back',
'description' => 'Short form focused on phone leads.',
'fields' => [
'name' => $common['name'],
'phone' => $common['phone'],
'best_time' => ['label' => 'Best Time to Call', 'type' => 'text', 'required' => false],
'message' => ['label' => 'What can we help with?', 'type' => 'textarea', 'required' => false],
],
],
'appointment' => [
'title' => 'Appointment Request', 'button' => 'Request Appointment',
'description' => 'Collect appointment preferences without directly booking a slot.',
'fields' => $common + [
'service' => ['label' => 'Service', 'type' => 'text', 'required' => false],
'preferred_date' => ['label' => 'Preferred Date', 'type' => 'date', 'required' => true],
'preferred_time' => ['label' => 'Preferred Time', 'type' => 'time', 'required' => false],
],
],
'estimate' => [
'title' => 'Get an Estimate', 'button' => 'Request Estimate',
'description' => 'A concise estimate-request form.',
'fields' => $common + [
'service' => ['label' => 'Service / Project Type', 'type' => 'text', 'required' => true],
'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true],
],
],
'inquiry' => [
'title' => 'General Inquiry', 'button' => 'Send Inquiry',
'description' => 'Useful for general questions and non-sales messages.',
'fields' => $common + [
'subject' => ['label' => 'Subject', 'type' => 'text', 'required' => false],
'message' => ['label' => 'Message', 'type' => 'textarea', 'required' => true],
],
],
'project' => [
'title' => 'Project Information', 'button' => 'Send Project Details',
'description' => 'Capture project type, location, timeline, and details.',
'fields' => $common + [
'project_type' => ['label' => 'Project Type', 'type' => 'text', 'required' => true],
'project_location' => ['label' => 'Project Location', 'type' => 'text', 'required' => false],
'timeline' => ['label' => 'Preferred Timeline', 'type' => 'text', 'required' => false],
'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true],
],
],
'quick' => [
'title' => 'Quick Contact', 'button' => 'Contact Us',
'description' => 'Minimal lead form for sidebars, landing pages, and compact sections.',
'fields' => [
'name' => $common['name'],
'phone' => $common['phone'],
'email' => ['label' => 'Email Address', 'type' => 'email', 'required' => false, 'autocomplete' => 'email'],
'message' => ['label' => 'Message', 'type' => 'textarea', 'required' => false],
],
],
];
}
public function shortcode_lead_form($atts = []) {
$atts = shortcode_atts(['id' => 'contact', 'title' => '', 'button' => ''], $atts, 'ahmad_toolkit_form');
$form_id = sanitize_key($atts['id']);
$forms = $this->lead_form_definitions();
if (empty($forms[$form_id])) return '';
$form = $forms[$form_id];
$title = sanitize_text_field($atts['title']) ?: $form['title'];
$button = sanitize_text_field($atts['button']) ?: $form['button'];
$description = $form['description'] ?? '';
$uid = 'ekmtlf-' . $form_id . '-' . wp_rand(1000, 999999);
ob_start(); ?>
self::LEAD_POST_TYPE,
'post_status' => 'publish',
'post_title' => $this->lead_entry_title($form['title'], $values['name'] ?? '', $values['email'] ?? '', $values['phone'] ?? ''),
], true);
if (is_wp_error($post_id)) return $post_id;
update_post_meta($post_id, '_ekmt_form_id', $form_id);
update_post_meta($post_id, '_ekmt_form_title', $form['title']);
update_post_meta($post_id, '_ekmt_form_values', $values);
update_post_meta($post_id, '_ekmt_page_url', esc_url_raw($page_url));
update_post_meta($post_id, '_ekmt_mail_sent', $mail_sent ? '1' : '0');
update_post_meta($post_id, '_ekmt_recipient', $this->forms_recipient());
return $post_id;
}
public function ajax_submit_lead_form() {
if (!check_ajax_referer('ekmt_front_forms', 'nonce', false)) wp_send_json_error(['message' => 'Security check expired. Refresh the page and try again.'], 403);
if (!empty($_POST['company_website'])) wp_send_json_success(['message' => 'Thank you. Your message has been received.']);
$started_at = absint($_POST['started_at'] ?? 0);
if ($started_at && (time() - $started_at) < 2) wp_send_json_error(['message' => 'Please wait a moment and try again.'], 429);
$form_id = sanitize_key(wp_unslash($_POST['form_id'] ?? ''));
$forms = $this->lead_form_definitions();
if (!$form_id || empty($forms[$form_id])) wp_send_json_error(['message' => 'This form is not available.'], 400);
$form = $forms[$form_id];
$recipient = $this->forms_recipient();
if (!$recipient || !is_email($recipient)) wp_send_json_error(['message' => 'This form is not configured yet. Please contact the website owner.'], 500);
$ip = sanitize_text_field(wp_unslash((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')));
$rate_key = 'ekmt_lf_' . substr(hash('sha256', $ip . '|' . $form_id), 0, 24);
$rate = (int) get_transient($rate_key);
if ($rate >= 8) wp_send_json_error(['message' => 'Too many submissions. Please wait a few minutes and try again.'], 429);
set_transient($rate_key, $rate + 1, 10 * MINUTE_IN_SECONDS);
$values = [];
foreach ($form['fields'] as $name => $field) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized immediately below according to the configured field type.
$raw = wp_unslash($_POST[$name] ?? '');
$type = $field['type'] ?? 'text';
if ($type === 'email') $value = sanitize_email($raw);
elseif ($type === 'textarea') $value = sanitize_textarea_field($raw);
else $value = sanitize_text_field($raw);
if (!empty($field['required']) && trim((string) $value) === '') {
wp_send_json_error(['message' => $field['label'] . ' is required.'], 422);
}
if ($type === 'email' && $value !== '' && !is_email($value)) {
wp_send_json_error(['message' => 'Please enter a valid email address.'], 422);
}
$values[$name] = $value;
}
$page_url = esc_url_raw(wp_unslash($_POST['page_url'] ?? ''));
$site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
$subject = '[' . $site_name . '] New ' . $form['title'] . ' submission';
$lines = [
'New Website Lead',
'',
'Form: ' . $form['title'],
'Website: ' . home_url('/'),
'Page: ' . ($page_url ?: 'Not available'),
'Date: ' . wp_date('F j, Y g:i a'),
'',
];
foreach ($form['fields'] as $name => $field) {
$lines[] = $field['label'] . ': ' . (($values[$name] ?? '') !== '' ? $values[$name] : '—');
}
$lines[] = '';
$lines[] = 'Sent by Toolkit Forms using the website\'s WordPress mail system.';
$headers = ['Content-Type: text/plain; charset=UTF-8'];
if (!empty($values['email']) && is_email($values['email'])) $headers[] = 'Reply-To: ' . $values['email'];
$mail_sent = wp_mail($recipient, $subject, implode("\n", $lines), $headers);
$entry_id = $this->save_lead_entry($form_id, $form, $values, $page_url, (bool) $mail_sent);
if (is_wp_error($entry_id)) {
wp_send_json_error(['message' => 'The form could not save your submission. Please try again.'], 500);
}
$this->increment_tracking('forms');
wp_send_json_success([
'message' => 'Thank you! Your request has been received.',
'mail_sent' => (bool) $mail_sent,
]);
}
public function ajax_save_forms_recipient() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can change the destination email.'], 403);
$email = sanitize_email(wp_unslash($_POST['email'] ?? ''));
if (!$email || !is_email($email)) wp_send_json_error(['message' => 'Enter a valid destination email.'], 422);
update_option(self::FORMS_RECIPIENT_OPTION, $email, false);
wp_send_json_success(['message' => 'Submission email saved.', 'email' => $email]);
}
public function ajax_send_forms_test_email() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can send the setup test.'], 403);
$email = $this->forms_recipient();
if (!$email || !is_email($email)) wp_send_json_error(['message' => 'Save a valid destination email first.'], 422);
$subject = '[' . wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES) . '] Toolkit Forms test email';
$message = "This is a test from Toolkit Forms.\n\nThe plugin sent this message through WordPress wp_mail(). If an SMTP plugin is already configured on this website, it should handle this email automatically.\n\nReceiving this message confirms end-to-end delivery to this inbox.";
$sent = wp_mail($email, $subject, $message, ['Content-Type: text/plain; charset=UTF-8']);
if (!$sent) wp_send_json_error(['message' => 'WordPress reported that the test email could not be handed off for sending. Check the site mail/SMTP configuration.']);
wp_send_json_success(['message' => 'Test email handed to WordPress mail successfully. Check ' . $email . ' to confirm delivery.']);
}
public function ajax_get_form_entries() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$q = new WP_Query([
'post_type' => self::LEAD_POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => 30,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
]);
$items = [];
foreach ($q->posts as $post) {
$values = get_post_meta($post->ID, '_ekmt_form_values', true);
if (!is_array($values)) $values = [];
$items[] = [
'id' => (int) $post->ID,
'title' => get_post_meta($post->ID, '_ekmt_form_title', true) ?: 'Website Form',
'date' => get_the_date('M j, Y g:i a', $post),
'name' => (string) ($values['name'] ?? ''),
'email' => (string) ($values['email'] ?? ''),
'phone' => (string) ($values['phone'] ?? ''),
'values' => $values,
'page_url' => (string) get_post_meta($post->ID, '_ekmt_page_url', true),
'mail_sent' => get_post_meta($post->ID, '_ekmt_mail_sent', true) === '1',
];
}
wp_send_json_success(['items' => $items]);
}
public function ajax_delete_form_entry() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || get_post_type($id) !== self::LEAD_POST_TYPE) wp_send_json_error(['message' => 'Entry not found.'], 404);
if (!current_user_can('delete_post', $id) && !current_user_can('manage_options')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$deleted = wp_delete_post($id, true);
if (!$deleted) wp_send_json_error(['message' => 'Could not delete this entry.']);
wp_send_json_success(['message' => 'Entry deleted.']);
}
public function ajax_support() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
wp_send_json_success([
'webp' => wp_image_editor_supports(['mime_type' => 'image/webp']),
]);
}
public function ajax_get_images() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$page = max(1, absint($_POST['page'] ?? 1));
$mode = sanitize_key(wp_unslash($_POST['mode'] ?? 'rename'));
$per_page = 30;
$mimes = ['image/jpeg', 'image/png', 'image/webp'];
if ($mode === 'webp') $mimes = ['image/jpeg', 'image/png'];
$q = new WP_Query([
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => $mimes,
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
'fields' => 'ids',
'no_found_rows' => false,
]);
$items = [];
foreach ($q->posts as $id) {
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$thumb = wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id);
$items[] = [
'id' => $id,
'filename' => basename($file),
'name' => pathinfo($file, PATHINFO_FILENAME),
'ext' => strtolower(pathinfo($file, PATHINFO_EXTENSION)),
'mime' => get_post_mime_type($id),
'thumb' => $thumb,
'size' => size_format(filesize($file), 1),
'dimensions' => $this->dimensions($file),
];
}
wp_send_json_success([
'items' => $items,
'has_more' => $page < (int)$q->max_num_pages,
'page' => $page,
'total' => (int)$q->found_posts,
]);
}
private function dimensions($file) {
$d = @getimagesize($file);
return $d ? $d[0] . '×' . $d[1] : '—';
}
public function ajax_scan_oversized() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
global $wpdb;
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(10, min(80, absint($_POST['limit'] ?? 50)));
$include_total = !empty($_POST['include_total']);
$min_bytes = 1024 * 1024; // Fixed: 1 MB or larger.
$image_mime_like = 'image/%';
if ($cursor > 0) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = %s
AND post_mime_type LIKE %s
AND ID < %d
ORDER BY ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
$cursor,
$limit
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = %s
AND post_mime_type LIKE %s
ORDER BY ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
$limit
)
);
}
$ids = array_map('intval', (array) $ids);
$total = null;
if ($include_total) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status = %s
AND post_mime_type LIKE %s",
'attachment',
'inherit',
$image_mime_like
)
);
}
$items = [];
foreach ($ids as $id) {
if (get_post_meta($id, self::QUARANTINE_META, true)) continue;
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$bytes = (int) filesize($file);
if ($bytes < $min_bytes) continue;
$meta = wp_get_attachment_metadata($id);
$width = !empty($meta['width']) ? (int) $meta['width'] : 0;
$height = !empty($meta['height']) ? (int) $meta['height'] : 0;
if (!$width || !$height) {
$size = @getimagesize($file);
if ($size) {
$width = (int) $size[0];
$height = (int) $size[1];
}
}
$items[] = [
'id' => $id,
'filename' => basename($file),
'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id),
'dimensions' => ($width && $height) ? ($width . '×' . $height) : '—',
'width' => $width,
'height' => $height,
'size' => size_format($bytes, 1),
'bytes' => $bytes,
'reason' => '1 MB+ original',
'edit_url' => get_edit_post_link($id, 'raw'),
];
}
wp_send_json_success([
'items' => $items,
'scanned' => count($ids),
'found' => count($items),
'has_more' => count($ids) === $limit,
'next_cursor' => $ids ? min($ids) : 0,
'total' => $total,
]);
}
public function ajax_scan_missing_alt() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
global $wpdb;
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(10, min(60, absint($_POST['limit'] ?? 40)));
$include_total = !empty($_POST['include_total']);
$image_mime_like = 'image/%';
if ($cursor > 0) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm
ON pm.post_id = p.ID AND pm.meta_key = %s
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '')
AND p.ID < %d
ORDER BY p.ID DESC
LIMIT %d",
'_wp_attachment_image_alt',
'attachment',
'inherit',
$image_mime_like,
$cursor,
$limit
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm
ON pm.post_id = p.ID AND pm.meta_key = %s
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '')
ORDER BY p.ID DESC
LIMIT %d",
'_wp_attachment_image_alt',
'attachment',
'inherit',
$image_mime_like,
$limit
)
);
}
$ids = array_map('intval', (array) $ids);
$total = null;
if ($include_total) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm
ON pm.post_id = p.ID AND pm.meta_key = %s
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '')",
'_wp_attachment_image_alt',
'attachment',
'inherit',
$image_mime_like
)
);
}
$items = [];
foreach ($ids as $id) {
if (get_post_meta($id, self::QUARANTINE_META, true)) continue;
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$items[] = [
'id' => $id,
'filename' => basename($file),
'title' => get_the_title($id),
'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id),
'dimensions' => $this->dimensions($file),
'size' => size_format(filesize($file), 1),
];
}
wp_send_json_success([
'items' => $items,
'scanned' => count($ids),
'found' => count($items),
'has_more' => count($ids) === $limit,
'next_cursor' => $ids ? min($ids) : 0,
'total' => $total,
]);
}
public function ajax_save_alt() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
$alt = sanitize_text_field(wp_unslash($_POST['alt'] ?? ''));
if (!$id || get_post_type($id) !== 'attachment' || !wp_attachment_is_image($id)) {
wp_send_json_error(['message' => 'Invalid image.']);
}
if (!current_user_can('edit_post', $id)) {
wp_send_json_error(['message' => 'You cannot edit this image.'], 403);
}
update_post_meta($id, '_wp_attachment_image_alt', $alt);
wp_send_json_success([
'message' => $alt === '' ? 'ALT text cleared.' : 'ALT text saved.',
'alt' => $alt,
]);
}
private function normalize_phone_digits($number) {
return preg_replace('/\D+/', '', html_entity_decode((string) $number, ENT_QUOTES, 'UTF-8'));
}
private function canonical_phone_digits($number) {
$digits = $this->normalize_phone_digits($number);
// North-American country code is optional for comparison. This means
// +1 (815) 474-3974 and (815) 474-3974 are the same phone number.
if (strlen($digits) === 11 && strpos($digits, '1') === 0) {
$digits = substr($digits, 1);
}
return $digits;
}
private function phone_is_match($number, $official) {
$a = $this->canonical_phone_digits($number);
$b = $this->canonical_phone_digits($official);
return $a !== '' && $b !== '' && hash_equals($b, $a);
}
private function phone_candidate_valid($raw, $official) {
$digits = $this->normalize_phone_digits($raw);
$canonical = $this->canonical_phone_digits($raw);
$official_canonical = $this->canonical_phone_digits($official);
if ($canonical === '' || $official_canonical === '') return false;
// When the official number is a normal US/Canada 10-digit number, only
// accept 10 digits or 11 digits beginning with 1. This avoids treating
// dates, IDs, ZIP codes and tracking numbers as phones.
if (strlen($official_canonical) === 10) {
return strlen($digits) === 10 || (strlen($digits) === 11 && strpos($digits, '1') === 0);
}
$len = strlen($digits);
return $len >= 7 && $len <= 15;
}
private function phone_text_candidates($text, $official) {
$text = html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8');
if ($text === '') return [];
$pattern = '~(?phone_candidate_valid($raw, $official)) $out[] = $raw;
}
return $out;
}
private function phone_short_context($text) {
$text = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags(html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8'))));
if (strlen($text) > 180) $text = substr($text, 0, 177) . '...';
return $text;
}
private function phone_region_occurrences($region_html, $official, $region, $page_id, $page_title, $page_url) {
$region_html = (string) $region_html;
if ($region_html === '') return [];
// DOM is standard on most WordPress hosts. If a minimal PHP build does
// not include it, use the lightweight text fallback instead of failing.
if (!class_exists('DOMDocument')) {
$plain = wp_strip_all_tags($region_html);
$items = [];
foreach ($this->phone_text_candidates($plain, $official) as $raw) {
$canonical = $this->canonical_phone_digits($raw);
$kind = 'Content text';
$region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title);
$key = ($region === 'content' ? ('page-' . $page_id) : $region) . '|content text|' . $canonical;
if (!isset($items[$key])) {
$items[$key] = [
'key' => $key, 'number' => $raw, 'digits' => $canonical,
'match' => $this->phone_is_match($raw, $official), 'kind' => $kind,
'region' => $region, 'location' => $region_label . ' — ' . $kind,
'context' => $this->phone_short_context($plain), 'count' => 0,
'page_id' => $page_id, 'page_title' => $page_title, 'page_url' => $page_url,
'edit_url' => get_edit_post_link($page_id, 'raw') ?: '',
];
}
$items[$key]['count']++;
}
return array_values($items);
}
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . $region_html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) return [];
$xpath = new DOMXPath($doc);
$root_nodes = $xpath->query('//*[@id="ekmt-phone-region"]');
$root = ($root_nodes && $root_nodes->length) ? $root_nodes->item(0) : null;
if (!$root) return [];
$items = [];
$add = function($raw, $kind, $context = '') use (&$items, $official, $region, $page_id, $page_title, $page_url) {
if (!$this->phone_candidate_valid($raw, $official)) return;
$canonical = $this->canonical_phone_digits($raw);
$region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title);
$location = $region_label . ' — ' . $kind;
$dedupe = ($region === 'content' ? ('page-' . $page_id) : $region) . '|' . strtolower($kind) . '|' . $canonical;
if (!isset($items[$dedupe])) {
$items[$dedupe] = [
'key' => $dedupe,
'number' => trim((string) $raw),
'digits' => $canonical,
'match' => $this->phone_is_match($raw, $official),
'kind' => $kind,
'region' => $region,
'location' => $location,
'context' => $this->phone_short_context($context),
'count' => 0,
'page_id' => $page_id,
'page_title' => $page_title,
'page_url' => $page_url,
'edit_url' => get_edit_post_link($page_id, 'raw') ?: '',
];
}
$items[$dedupe]['count']++;
};
// Phone links/buttons. The href is checked separately from the visible
// label so a correct-looking button with the wrong tel: target is caught.
$links = $xpath->query('.//a[@href]', $root);
if ($links) {
foreach ($links as $link) {
$href = html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8');
if (stripos(trim($href), 'tel:') !== 0) continue;
$target = rawurldecode(trim(substr(trim($href), 4)));
$target = preg_split('/[?;,]/', $target)[0] ?? $target;
$class = strtolower((string) $link->getAttribute('class'));
$role = strtolower((string) $link->getAttribute('role'));
$is_button = strpos($class, 'button') !== false || preg_match('/(^|[\s_-])btn([\s_-]|$)/', $class) || $role === 'button';
$kind = $is_button ? 'Phone button' : 'Phone link';
$label = trim((string) $link->textContent);
$add($target, $kind, $label ? ('Label: ' . $label . ' · Link: ' . $href) : ('Link: ' . $href));
foreach ($this->phone_text_candidates($label, $official) as $visible_number) {
if ($this->canonical_phone_digits($visible_number) !== $this->canonical_phone_digits($target)) {
$add($visible_number, $is_button ? 'Button text' : 'Link text', 'Visible label: ' . $label);
}
}
}
}
// Less common onclick="...tel:..." buttons.
$click_nodes = $xpath->query('.//*[@onclick]', $root);
if ($click_nodes) {
foreach ($click_nodes as $node) {
$onclick = html_entity_decode((string) $node->getAttribute('onclick'), ENT_QUOTES, 'UTF-8');
if (!preg_match('~tel:\s*([^\"\'\s)]+)~i', $onclick, $m)) continue;
$add(rawurldecode($m[1]), 'Phone button', 'onclick phone action · ' . trim((string) $node->textContent));
}
}
// Visible page text, excluding scripts/styles and phone-link labels so a
// single clickable number is not counted twice.
$texts = $xpath->query('.//text()[normalize-space() != "" and not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript) and not(ancestor::a[starts-with(translate(@href,"TEL","tel"),"tel:")])]', $root);
if ($texts) {
foreach ($texts as $text_node) {
$text = (string) $text_node->nodeValue;
foreach ($this->phone_text_candidates($text, $official) as $raw) {
$context = $text;
if ($text_node->parentNode) $context = (string) $text_node->parentNode->textContent;
$add($raw, 'Content text', $context);
}
}
}
return array_values($items);
}
private function phone_page_regions($html) {
if (!class_exists('DOMDocument')) {
$html = (string) $html;
$header = ''; $footer = '';
if (preg_match_all('~~is', $html, $m)) $header = implode("\n", $m[0]);
if (preg_match_all('~~is', $html, $m)) $footer = implode("\n", $m[0]);
$content = $html;
if ($header !== '') $content = str_replace(explode("\n", $header), '', $content);
if ($footer !== '') $content = str_replace(explode("\n", $footer), '', $content);
return ['header' => $header, 'footer' => $footer, 'content' => $content];
}
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . (string) $html, LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) return ['header' => '', 'footer' => '', 'content' => (string) $html];
$xpath = new DOMXPath($doc);
$save_nodes = function($query) use ($xpath, $doc) {
$htmls = [];
$nodes = $xpath->query($query);
if ($nodes) {
foreach ($nodes as $node) $htmls[] = $doc->saveHTML($node);
}
return $htmls;
};
$headers = $save_nodes('//header');
if (!$headers) {
$headers = $save_nodes('//*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-header ") or contains(concat(" ", normalize-space(@class), " "), " site-header ") or @id="masthead" or @id="header"]');
}
$footers = $save_nodes('//footer');
if (!$footers) {
$footers = $save_nodes('//*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-footer ") or contains(concat(" ", normalize-space(@class), " "), " site-footer ") or @id="colophon" or @id="footer"]');
}
// Work with a second DOM so header/footer can be removed before content
// scanning, preventing sitewide numbers from being counted on every page.
$content_doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$content_doc->loadHTML('' . (string) $html, LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$content_xpath = new DOMXPath($content_doc);
$remove_query = '//header | //footer | //*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-header ") or contains(concat(" ", normalize-space(@class), " "), " elementor-location-footer ") or contains(concat(" ", normalize-space(@class), " "), " site-header ") or contains(concat(" ", normalize-space(@class), " "), " site-footer ") or @id="masthead" or @id="colophon" or @id="header" or @id="footer"]';
$remove = $content_xpath->query($remove_query);
$to_remove = [];
if ($remove) foreach ($remove as $node) $to_remove[] = $node;
foreach ($to_remove as $node) {
if ($node->parentNode) $node->parentNode->removeChild($node);
}
$content_html = '';
$mains = $content_xpath->query('//main');
if ($mains && $mains->length) {
foreach ($mains as $main) $content_html .= $content_doc->saveHTML($main);
} else {
$bodies = $content_xpath->query('//body');
if ($bodies && $bodies->length) $content_html = $content_doc->saveHTML($bodies->item(0));
else $content_html = $content_doc->saveHTML();
}
return [
'header' => implode("\n", $headers),
'footer' => implode("\n", $footers),
'content' => $content_html,
];
}
public function ajax_scan_phone_numbers() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$official = sanitize_text_field(wp_unslash($_POST['official'] ?? ''));
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(1, min(6, absint($_POST['limit'] ?? 3)));
$official_digits = $this->canonical_phone_digits($official);
if (!$official || strlen($official_digits) < 7 || strlen($official_digits) > 15) {
wp_send_json_error(['message' => 'Enter a valid official phone number first.']);
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$page_ids = $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE ID > %d AND post_type = 'page' AND post_status = 'publish'
ORDER BY ID ASC LIMIT %d",
$cursor, $limit
));
$items = [];
$errors = [];
foreach ($page_ids as $page_id_raw) {
$page_id = absint($page_id_raw);
$title = trim((string) get_the_title($page_id));
if ($title === '') $title = 'Page #' . $page_id;
$url = get_permalink($page_id);
if (!$url) {
$errors[] = $title . ': no public URL found.';
continue;
}
$response = wp_remote_get($url, [
'timeout' => 15,
'redirection' => 5,
'user-agent' => 'Ahmad Developer Toolkit Phone Scanner/' . self::VERSION . '; ' . home_url('/'),
'headers' => ['Cache-Control' => 'no-cache'],
]);
if (is_wp_error($response)) {
$errors[] = $title . ': ' . $response->get_error_message();
continue;
}
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 400) {
$errors[] = $title . ': HTTP ' . $code;
continue;
}
$html = (string) wp_remote_retrieve_body($response);
$regions = $this->phone_page_regions($html);
foreach (['header', 'content', 'footer'] as $region) {
foreach ($this->phone_region_occurrences($regions[$region] ?? '', $official, $region, $page_id, $title, $url) as $item) {
$items[] = $item;
}
}
}
$next_cursor = $page_ids ? absint(end($page_ids)) : $cursor;
$page_counts = wp_count_posts('page');
$total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0;
wp_send_json_success([
'items' => $items,
'errors' => $errors,
'scanned' => count($page_ids),
'has_more' => count($page_ids) === $limit,
'next_cursor' => $next_cursor,
'total_pages' => $total_pages,
]);
}
private function canonical_email_address($email) {
$email = html_entity_decode(rawurldecode((string) $email), ENT_QUOTES, 'UTF-8');
$email = trim($email);
if (stripos($email, 'mailto:') === 0) $email = substr($email, 7);
$email = preg_split('/[?]/', $email)[0] ?? $email;
$email = trim($email, " \t\n\r\0\x0B<>\"'");
return strtolower($email);
}
private function email_is_match($email, $official) {
$a = $this->canonical_email_address($email);
$b = $this->canonical_email_address($official);
return $a !== '' && $b !== '' && hash_equals($b, $a);
}
private function email_text_candidates($text) {
$text = html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8');
if ($text === '') return [];
if (!preg_match_all('/(?canonical_email_address($raw);
if (!is_email($canonical)) return;
$region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title);
$dedupe = ($region === 'content' ? ('page-' . $page_id) : $region) . '|' . strtolower($kind) . '|' . $canonical;
if (!isset($items[$dedupe])) {
$items[$dedupe] = [
'key' => $dedupe,
'email' => trim((string) $raw),
'canonical' => $canonical,
'match' => $this->email_is_match($raw, $official),
'kind' => $kind,
'region' => $region,
'location' => $region_label . ' — ' . $kind,
'context' => $this->phone_short_context($context),
'count' => 0,
'page_id' => $page_id,
'page_title' => $page_title,
'page_url' => $page_url,
'edit_url' => get_edit_post_link($page_id, 'raw') ?: '',
];
}
$items[$dedupe]['count']++;
};
if (!class_exists('DOMDocument')) {
foreach ($this->email_text_candidates(wp_strip_all_tags($region_html)) as $email) {
$add($email, 'Content email', wp_strip_all_tags($region_html));
}
if (preg_match_all('/href\s*=\s*(["\'])mailto:([^"\']+)\1/i', $region_html, $matches)) {
foreach ($matches[2] as $target) {
$target = preg_split('/[?]/', rawurldecode((string) $target))[0] ?? $target;
foreach (preg_split('/[,;]/', $target) as $addr) $add(trim($addr), 'Email link', 'mailto:' . trim($addr));
}
}
return array_values($items);
}
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . $region_html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) return [];
$xpath = new DOMXPath($doc);
$roots = $xpath->query('//*[@id="ekmt-email-region"]');
$root = ($roots && $roots->length) ? $roots->item(0) : null;
if (!$root) return [];
$links = $xpath->query('.//a[@href]', $root);
if ($links) {
foreach ($links as $link) {
$href = html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8');
if (stripos(trim($href), 'mailto:') !== 0) continue;
$target = rawurldecode(substr(trim($href), 7));
$target = preg_split('/[?]/', $target)[0] ?? $target;
$class = strtolower((string) $link->getAttribute('class'));
$role = strtolower((string) $link->getAttribute('role'));
$is_button = strpos($class, 'button') !== false || preg_match('/(^|[\s_-])btn([\s_-]|$)/', $class) || $role === 'button';
$kind = $is_button ? 'Email button' : 'Email link';
$label = trim((string) $link->textContent);
foreach (preg_split('/[,;]/', $target) as $addr) {
$addr = trim($addr);
if ($addr !== '') $add($addr, $kind, $label ? ('Label: ' . $label . ' · Link: ' . $href) : ('Link: ' . $href));
}
foreach ($this->email_text_candidates($label) as $visible) {
$add($visible, $is_button ? 'Button text' : 'Link text', 'Visible text: ' . $label);
}
}
}
$text_nodes = $xpath->query('.//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript)]', $root);
if ($text_nodes) {
foreach ($text_nodes as $node) {
$skip = false;
$parent = $node->parentNode;
while ($parent && $parent !== $root) {
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'a') {
$href = (string) $parent->getAttribute('href');
if (stripos(trim($href), 'mailto:') === 0) { $skip = true; break; }
}
$parent = $parent->parentNode;
}
if ($skip) continue;
$text = trim((string) $node->nodeValue);
if ($text === '') continue;
foreach ($this->email_text_candidates($text) as $email) {
$context = $node->parentNode ? (string) $node->parentNode->textContent : $text;
$add($email, 'Content email', $context);
}
}
}
return array_values($items);
}
public function ajax_scan_email_addresses() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$official_raw = sanitize_email(wp_unslash($_POST['official'] ?? ''));
$official = $this->canonical_email_address($official_raw);
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(1, min(6, absint($_POST['limit'] ?? 3)));
if (!$official || !is_email($official)) wp_send_json_error(['message' => 'Enter a valid official email address first.']);
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$page_ids = $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE ID > %d AND post_type = 'page' AND post_status = 'publish'
ORDER BY ID ASC LIMIT %d",
$cursor, $limit
));
$items = [];
$errors = [];
foreach ($page_ids as $page_id_raw) {
$page_id = absint($page_id_raw);
$title = trim((string) get_the_title($page_id));
if ($title === '') $title = 'Page #' . $page_id;
$url = get_permalink($page_id);
if (!$url) { $errors[] = $title . ': no public URL found.'; continue; }
$response = wp_remote_get($url, [
'timeout' => 15,
'redirection' => 5,
'user-agent' => 'Ahmad Developer Toolkit Email Scanner/' . self::VERSION . '; ' . home_url('/'),
'headers' => ['Cache-Control' => 'no-cache'],
]);
if (is_wp_error($response)) { $errors[] = $title . ': ' . $response->get_error_message(); continue; }
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 400) { $errors[] = $title . ': HTTP ' . $code; continue; }
$html = (string) wp_remote_retrieve_body($response);
$regions = $this->phone_page_regions($html);
foreach (['header', 'content', 'footer'] as $region) {
foreach ($this->email_region_occurrences($regions[$region] ?? '', $official, $region, $page_id, $title, $url) as $item) $items[] = $item;
}
}
$next_cursor = $page_ids ? absint(end($page_ids)) : $cursor;
$page_counts = wp_count_posts('page');
$total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0;
wp_send_json_success([
'items' => $items,
'errors' => $errors,
'scanned' => count($page_ids),
'has_more' => count($page_ids) === $limit,
'next_cursor' => $next_cursor,
'total_pages' => $total_pages,
]);
}
private function form_fetch_page($page_id) {
$page_id = absint($page_id);
$url = get_permalink($page_id);
if (!$url) return new WP_Error('no_url', 'No public URL found for this page.');
$response = wp_remote_get($url, [
'timeout' => 18,
'redirection' => 5,
'user-agent' => 'Toolkit Form Health/' . self::VERSION . '; ' . home_url('/'),
'headers' => ['Cache-Control' => 'no-cache'],
]);
if (is_wp_error($response)) return $response;
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 400) return new WP_Error('http_error', 'HTTP ' . $code);
return ['url' => $url, 'html' => (string) wp_remote_retrieve_body($response), 'response' => $response];
}
private function form_absolute_url($action, $page_url) {
$action = html_entity_decode(trim((string) $action), ENT_QUOTES, 'UTF-8');
if ($action === '' || $action === '#') return $page_url;
if (preg_match('~^javascript:~i', $action)) return '';
if (preg_match('~^https?://~i', $action)) return $action;
if (strpos($action, '//') === 0) {
$scheme = wp_parse_url($page_url, PHP_URL_SCHEME) ?: 'https';
return $scheme . ':' . $action;
}
if (strpos($action, '/') === 0) {
$parts = wp_parse_url($page_url);
if (!$parts || empty($parts['host'])) return '';
$scheme = $parts['scheme'] ?? 'https';
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $parts['host'] . $port . $action;
}
return trailingslashit($page_url) . ltrim($action, './');
}
private function form_same_origin($url) {
$site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
$target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
return $site_host !== '' && $target_host !== '' && $site_host === $target_host;
}
private function form_detect_type(DOMElement $form) {
$class = strtolower($form->getAttribute('class'));
$id = strtolower($form->getAttribute('id'));
$action = strtolower($form->getAttribute('action'));
$html = strtolower($form->ownerDocument->saveHTML($form));
$hay = $class . ' ' . $id . ' ' . $action . ' ' . $html;
if (strpos($hay, 'wpcf7') !== false || strpos($hay, '_wpcf7') !== false) return 'Contact Form 7';
if (strpos($hay, 'wpforms') !== false) return 'WPForms';
if (strpos($hay, 'elementor-form') !== false || strpos($hay, 'elementor_form') !== false) return 'Elementor Form';
if (strpos($hay, 'gform_') !== false || strpos($hay, 'gravityforms') !== false) return 'Gravity Forms';
if (strpos($hay, 'fluentform') !== false || strpos($hay, 'frm-fluent-form') !== false) return 'Fluent Forms';
if (strpos($hay, 'formidable') !== false || preg_match('/(^|[\\s_-])frm([\\s_-]|$)/', $hay)) return 'Formidable Forms';
if (strpos($hay, 'ninja-forms') !== false || strpos($hay, 'nf-form') !== false) return 'Ninja Forms';
if (strpos($hay, 'woocommerce') !== false) return 'WooCommerce Form';
return 'HTML / Custom Form';
}
private function form_region(DOMElement $form) {
$node = $form;
while ($node && $node instanceof DOMElement) {
$tag = strtolower($node->tagName);
$class = strtolower($node->getAttribute('class'));
$id = strtolower($node->getAttribute('id'));
if ($tag === 'header' || strpos($class, 'elementor-location-header') !== false || strpos($class, 'site-header') !== false || in_array($id, ['header','masthead'], true)) return 'header';
if ($tag === 'footer' || strpos($class, 'elementor-location-footer') !== false || strpos($class, 'site-footer') !== false || in_array($id, ['footer','colophon'], true)) return 'footer';
$node = $node->parentNode;
}
return 'content';
}
private function form_analyse_element(DOMElement $form, $page_id, $page_title, $page_url, $index) {
$type = $this->form_detect_type($form);
$region = $this->form_region($form);
$method = strtoupper(trim($form->getAttribute('method')) ?: 'GET');
$action_raw = trim($form->getAttribute('action'));
$action = $this->form_absolute_url($action_raw, $page_url);
$form_html = (string) $form->ownerDocument->saveHTML($form);
$lower = strtolower($form_html . ' ' . $type . ' ' . $form->getAttribute('class') . ' ' . $form->getAttribute('id'));
$booking = (bool) preg_match('/book|booking|appointment|reservation|schedule|calendar|preferred[ _-]?(date|time)|time[ _-]?slot|amelia|bookly|latepoint|simplybook|acuity|setmore/', $lower);
$payment = (bool) preg_match('/payment|checkout|stripe|paypal|credit[\\s_-]*card|card[\\s_-]*number|woocommerce-checkout|authorize\\.net/', $lower);
$captcha = (bool) preg_match('/recaptcha|hcaptcha|turnstile|captcha/', $lower);
$file_upload = stripos($lower, 'type="file"') !== false || stripos($lower, "type='file'") !== false;
$xpath = new DOMXPath($form->ownerDocument);
$fields = $xpath->query('.//input[@name] | .//textarea[@name] | .//select[@name]', $form);
$submit = $xpath->query('.//button[@type="submit"] | .//input[@type="submit"] | .//button[not(@type)]', $form);
$field_count = $fields ? $fields->length : 0;
$required_count = 0;
if ($fields) {
foreach ($fields as $field) {
if ($field instanceof DOMElement && ($field->hasAttribute('required') || strtolower($field->getAttribute('aria-required')) === 'true')) $required_count++;
}
}
$has_submit = $submit && $submit->length > 0;
$same_origin = $action !== '' && $this->form_same_origin($action);
$issues = [];
if (!$has_submit) $issues[] = 'No submit button detected';
if ($field_count === 0) $issues[] = 'No named form fields detected';
if ($action === '') $issues[] = 'Form action is not testable';
if ($booking) $issues[] = 'Booking workflow — manual live test protected';
if ($payment) $issues[] = 'Payment workflow — automatic submission disabled';
if ($captcha) $issues[] = 'CAPTCHA detected — manual/browser test required';
if ($file_upload) $issues[] = 'File upload detected — automatic submission disabled';
if ($action && !$same_origin) $issues[] = 'External form action — automatic submission disabled';
if ($method !== 'POST') $issues[] = 'GET form — structure checked only';
$safe_test = $has_submit && $field_count > 0 && $method === 'POST' && $same_origin && !$booking && !$payment && !$captcha && !$file_upload;
$status = (!$has_submit || $field_count === 0) ? 'broken' : ($safe_test ? 'ready' : 'review');
$label = trim($form->getAttribute('aria-label'));
if ($label === '') $label = trim($form->getAttribute('name'));
if ($label === '') $label = trim($form->getAttribute('id'));
if ($label === '') $label = $booking ? 'Booking form' : ($payment ? 'Payment form' : $type);
$fingerprint_bits = [$type, $region, $method, $action, $label, (string) $field_count];
$key = ($region === 'content' ? ('page-' . $page_id . '-' . $index) : $region) . '|' . md5(implode('|', $fingerprint_bits));
$location = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title);
return [
'key' => $key,
'page_id' => (int) $page_id,
'page_title' => $page_title,
'page_url' => $page_url,
'edit_url' => get_edit_post_link($page_id, 'raw') ?: '',
'form_index' => (int) $index,
'label' => $label,
'type' => $type,
'region' => $region,
'location' => $location,
'method' => $method,
'action' => $action,
'fields' => (int) $field_count,
'required' => (int) $required_count,
'has_submit' => (bool) $has_submit,
'booking' => (bool) $booking,
'payment' => (bool) $payment,
'captcha' => (bool) $captcha,
'file_upload' => (bool) $file_upload,
'safe_test' => (bool) $safe_test,
'status' => $status,
'issues' => $issues,
];
}
private function form_parse_page_regex($html, $page_id, $page_title, $page_url) {
$items = [];
if (!preg_match_all('~]*)>(.*?) ~is', (string) $html, $matches, PREG_SET_ORDER)) return $items;
$attr = function($attrs, $name) {
if (preg_match("~\\b" . preg_quote($name, '~') . "\\s*=\\s*([\"'])(.*?)\\1~is", $attrs, $m)) return html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
if (preg_match('~\b' . preg_quote($name, '~') . '\s*=\s*([^\s>]+)~i', $attrs, $m)) return html_entity_decode(trim($m[1], "\"'"), ENT_QUOTES, 'UTF-8');
return '';
};
foreach ($matches as $index => $m) {
$attrs = (string) ($m[1] ?? ''); $body = (string) ($m[2] ?? ''); $all = $attrs . ' ' . $body;
$class = strtolower($attr($attrs, 'class')); $id = strtolower($attr($attrs, 'id'));
$action_raw = $attr($attrs, 'action'); $action = $this->form_absolute_url($action_raw, $page_url);
$method = strtoupper($attr($attrs, 'method') ?: 'GET'); $lower = strtolower($all);
$type = 'HTML / Custom Form';
if (strpos($lower, 'wpcf7') !== false || strpos($lower, '_wpcf7') !== false) $type = 'Contact Form 7';
elseif (strpos($lower, 'wpforms') !== false) $type = 'WPForms';
elseif (strpos($lower, 'elementor-form') !== false || strpos($lower, 'elementor_form') !== false) $type = 'Elementor Form';
elseif (strpos($lower, 'gform_') !== false || strpos($lower, 'gravityforms') !== false) $type = 'Gravity Forms';
elseif (strpos($lower, 'fluentform') !== false || strpos($lower, 'frm-fluent-form') !== false) $type = 'Fluent Forms';
elseif (strpos($lower, 'formidable') !== false) $type = 'Formidable Forms';
$booking = (bool) preg_match('/book|booking|appointment|reservation|schedule|calendar|preferred[ _-]?(date|time)|time[ _-]?slot|amelia|bookly|latepoint|simplybook|acuity|setmore/', $lower);
$payment = (bool) preg_match('/payment|checkout|stripe|paypal|credit[\s_-]*card|card[\s_-]*number|woocommerce-checkout|authorize\.net/', $lower);
$captcha = (bool) preg_match('/recaptcha|hcaptcha|turnstile|captcha/', $lower);
$file_upload = (bool) preg_match('~ ]*type\s*=\s*(["\']?)file\1~i', $all);
preg_match_all('~<(?:input|textarea|select)\b[^>]*\bname\s*=~i', $body, $fm); $field_count = count($fm[0]);
preg_match_all('~<(?:input|textarea|select)\b[^>]*(?:\brequired\b|aria-required\s*=\s*(["\']?)true\1)~i', $body, $rm); $required_count = count($rm[0]);
$has_submit = (bool) preg_match('~<(?:button\b[^>]*(?:type\s*=\s*(["\']?)submit\1)?|input\b[^>]*type\s*=\s*(["\']?)submit\2)~i', $body);
$same_origin = $action !== '' && $this->form_same_origin($action);
$issues = ['Limited scanner mode: PHP DOM extension is unavailable'];
if (!$has_submit) $issues[] = 'No submit button detected';
if ($field_count === 0) $issues[] = 'No named form fields detected';
if ($booking) $issues[] = 'Booking workflow — manual live test protected';
if ($payment) $issues[] = 'Payment workflow — automatic submission disabled';
if ($captcha) $issues[] = 'CAPTCHA detected — manual/browser test required';
if ($file_upload) $issues[] = 'File upload detected — automatic submission disabled';
if ($action && !$same_origin) $issues[] = 'External form action — automatic submission disabled';
$label = $attr($attrs, 'aria-label') ?: ($attr($attrs, 'name') ?: ($id ?: ($booking ? 'Booking form' : $type)));
$status = (!$has_submit || $field_count === 0) ? 'broken' : 'review';
$items[] = [
'key' => 'page-' . (int)$page_id . '-' . (int)$index . '|' . md5($type . '|' . $action . '|' . $label),
'page_id'=>(int)$page_id,'page_title'=>$page_title,'page_url'=>$page_url,'edit_url'=>get_edit_post_link($page_id,'raw') ?: '',
'form_index'=>(int)$index,'label'=>$label,'type'=>$type,'region'=>'content','location'=>$page_title,'method'=>$method,'action'=>$action,
'fields'=>(int)$field_count,'required'=>(int)$required_count,'has_submit'=>(bool)$has_submit,'booking'=>(bool)$booking,'payment'=>(bool)$payment,
'captcha'=>(bool)$captcha,'file_upload'=>(bool)$file_upload,'safe_test'=>false,'status'=>$status,'issues'=>$issues,
];
}
return $items;
}
private function form_parse_page($html, $page_id, $page_title, $page_url) {
if (!class_exists('DOMDocument')) return $this->form_parse_page_regex($html, $page_id, $page_title, $page_url);
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . (string) $html, LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) return new WP_Error('parse_failed', 'Could not parse rendered page HTML.');
$forms = $doc->getElementsByTagName('form');
$items = [];
$i = 0;
foreach ($forms as $form) {
if (!$form instanceof DOMElement) continue;
$items[] = $this->form_analyse_element($form, $page_id, $page_title, $page_url, $i);
$i++;
}
// Booking systems are frequently embedded as iframes and do not expose a
// normal HTML form to WordPress. Surface those as protected manual checks.
$iframes = $doc->getElementsByTagName('iframe');
foreach ($iframes as $iframe) {
if (!$iframe instanceof DOMElement) continue;
$src = trim($iframe->getAttribute('src'));
$hay = strtolower($src . ' ' . $iframe->getAttribute('class') . ' ' . $iframe->getAttribute('title'));
if (!preg_match('/calendly|acuity|squarespacescheduling|setmore|simplybook|booksy|booking|appointment|reservation|schedule/', $hay)) continue;
$region = 'content';
$node = $iframe;
while ($node && $node instanceof DOMElement) {
if (strtolower($node->tagName) === 'header') { $region = 'header'; break; }
if (strtolower($node->tagName) === 'footer') { $region = 'footer'; break; }
$node = $node->parentNode;
}
$location = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title);
$key = ($region === 'content' ? ('page-' . $page_id . '-iframe-' . $i) : $region) . '|booking-widget|' . md5($src);
$items[] = [
'key' => $key, 'page_id' => (int) $page_id, 'page_title' => $page_title,
'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '',
'form_index' => -1, 'label' => 'Embedded booking widget', 'type' => 'Booking Widget',
'region' => $region, 'location' => $location, 'method' => 'EMBED', 'action' => $src,
'fields' => 0, 'required' => 0, 'has_submit' => true, 'booking' => true,
'payment' => false, 'captcha' => false, 'file_upload' => false, 'safe_test' => false,
'status' => 'review', 'issues' => ['Embedded booking workflow — open page for manual live test'],
];
$i++;
}
$xpath = new DOMXPath($doc);
$widgets = $xpath->query('//*[@data-url]');
if ($widgets) {
foreach ($widgets as $widget) {
if (!$widget instanceof DOMElement) continue;
$src = trim($widget->getAttribute('data-url'));
$hay = strtolower($src . ' ' . $widget->getAttribute('class') . ' ' . $widget->getAttribute('id'));
if (!preg_match('/calendly|acuity|squarespacescheduling|setmore|simplybook|booksy|booking|appointment|reservation|schedule/', $hay)) continue;
$key = 'page-' . (int)$page_id . '-booking-data|' . md5($src);
$items[] = [
'key'=>$key,'page_id'=>(int)$page_id,'page_title'=>$page_title,'page_url'=>$page_url,'edit_url'=>get_edit_post_link($page_id,'raw') ?: '',
'form_index'=>-1,'label'=>'Embedded booking widget','type'=>'Booking Widget','region'=>'content','location'=>$page_title,'method'=>'EMBED','action'=>$src,
'fields'=>0,'required'=>0,'has_submit'=>true,'booking'=>true,'payment'=>false,'captcha'=>false,'file_upload'=>false,'safe_test'=>false,'status'=>'review',
'issues'=>['Booking widget loaded on the public page — open it for a manual end-to-end availability/submission test'],
];
}
}
return $items;
}
public function ajax_scan_forms() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
global $wpdb;
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(1, min(6, absint($_POST['limit'] ?? 3)));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$page_ids = $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type='page' AND post_status='publish' ORDER BY ID ASC LIMIT %d",
$cursor, $limit
));
$items = [];
$errors = [];
foreach ($page_ids as $raw_id) {
$page_id = absint($raw_id);
$title = trim((string) get_the_title($page_id));
if ($title === '') $title = 'Page #' . $page_id;
$fetched = $this->form_fetch_page($page_id);
if (is_wp_error($fetched)) {
$errors[] = $title . ': ' . $fetched->get_error_message();
continue;
}
$parsed = $this->form_parse_page($fetched['html'], $page_id, $title, $fetched['url']);
if (is_wp_error($parsed)) {
$errors[] = $title . ': ' . $parsed->get_error_message();
continue;
}
foreach ($parsed as $item) $items[] = $item;
}
$next_cursor = $page_ids ? absint(end($page_ids)) : $cursor;
$page_counts = wp_count_posts('page');
$total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0;
wp_send_json_success([
'items' => $items,
'errors' => $errors,
'scanned' => count($page_ids),
'has_more' => count($page_ids) === $limit,
'next_cursor' => $next_cursor,
'total_pages' => $total_pages,
]);
}
private function form_value_for_field(DOMElement $field, $test_email) {
$tag = strtolower($field->tagName);
$name = strtolower($field->getAttribute('name'));
$type = strtolower($field->getAttribute('type'));
if ($tag === 'textarea') return 'Ahmad Developer Toolkit automated form health test. Please ignore this test submission.';
if ($tag === 'select') {
foreach ($field->getElementsByTagName('option') as $option) {
if (!$option instanceof DOMElement) continue;
$value = $option->getAttribute('value');
if ($value !== '' && !$option->hasAttribute('disabled')) return $value;
}
return '';
}
if ($type === 'email' || strpos($name, 'email') !== false) return $test_email;
if ($type === 'tel' || strpos($name, 'phone') !== false || strpos($name, 'tel') !== false) return '(555) 555-0111';
if ($type === 'url' || strpos($name, 'website') !== false || strpos($name, 'url') !== false) return home_url('/');
if ($type === 'number') return $field->getAttribute('min') !== '' ? $field->getAttribute('min') : '1';
if ($type === 'date') return gmdate('Y-m-d', time() + 7 * DAY_IN_SECONDS);
if ($type === 'time') return '10:00';
if (strpos($name, 'first') !== false) return 'Eleven';
if (strpos($name, 'last') !== false) return 'Keys';
if (strpos($name, 'name') !== false) return 'Ahmad Toolkit Test';
if (strpos($name, 'subject') !== false) return 'Toolkit Form Health Test';
if (strpos($name, 'message') !== false || strpos($name, 'comment') !== false) return 'Ahmad Developer Toolkit automated form health test. Please ignore this test submission.';
$value = $field->getAttribute('value');
if ($value !== '') return $value;
return 'Ahmad Toolkit Test';
}
private function form_build_submission(DOMElement $form, $test_email) {
$xpath = new DOMXPath($form->ownerDocument);
$fields = $xpath->query('.//input[@name] | .//textarea[@name] | .//select[@name]', $form);
$body = [];
if (!$fields) return $body;
foreach ($fields as $field) {
if (!$field instanceof DOMElement || $field->hasAttribute('disabled')) continue;
$name = trim($field->getAttribute('name'));
if ($name === '') continue;
$tag = strtolower($field->tagName);
$type = strtolower($field->getAttribute('type'));
if (in_array($type, ['file','reset','button','image'], true)) continue;
if ($type === 'submit') {
if ($field->getAttribute('value') !== '') $body[$name] = $field->getAttribute('value');
continue;
}
if (in_array($type, ['checkbox','radio'], true)) {
if (!$field->hasAttribute('checked') && !$field->hasAttribute('required')) continue;
$body[$name] = $field->getAttribute('value') !== '' ? $field->getAttribute('value') : '1';
continue;
}
if ($type === 'hidden') {
$body[$name] = $field->getAttribute('value');
continue;
}
$body[$name] = $this->form_value_for_field($field, $test_email);
}
return $body;
}
public function ajax_test_form() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$page_id = absint($_POST['page_id'] ?? 0);
$form_index = intval($_POST['form_index'] ?? -1);
$test_email = sanitize_email(wp_unslash($_POST['test_email'] ?? ''));
if (!$test_email) $test_email = sanitize_email((string) get_option('admin_email'));
if (!$page_id || $form_index < 0 || get_post_type($page_id) !== 'page' || get_post_status($page_id) !== 'publish') {
wp_send_json_error(['message' => 'This form can no longer be tested from the saved scan result.']);
}
$title = trim((string) get_the_title($page_id));
$fetched = $this->form_fetch_page($page_id);
if (is_wp_error($fetched)) wp_send_json_error(['message' => $fetched->get_error_message()]);
if (!class_exists('DOMDocument')) wp_send_json_error(['message' => 'PHP DOM extension is required.']);
$doc = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $doc->loadHTML('' . $fetched['html'], LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) wp_send_json_error(['message' => 'Could not parse the current page.']);
$forms = $doc->getElementsByTagName('form');
if ($form_index >= $forms->length) wp_send_json_error(['message' => 'The form changed since the scan. Scan again and retry.']);
$form = $forms->item($form_index);
if (!$form instanceof DOMElement) wp_send_json_error(['message' => 'Could not locate this form.']);
$analysis = $this->form_analyse_element($form, $page_id, $title ?: ('Page #' . $page_id), $fetched['url'], $form_index);
if (empty($analysis['safe_test'])) {
wp_send_json_error(['message' => 'Automatic submission is protected for this form. Open the page and test it manually.']);
}
$body = $this->form_build_submission($form, $test_email);
if (!$body) wp_send_json_error(['message' => 'No safe fields could be prepared for this form.']);
$action = $analysis['action'];
if (!$action || !$this->form_same_origin($action)) wp_send_json_error(['message' => 'Only same-site forms can be automatically tested.']);
$request_action = $action;
if (($analysis['type'] ?? '') === 'Contact Form 7' && !empty($body['_wpcf7'])) {
$request_action = rest_url('contact-form-7/v1/contact-forms/' . absint($body['_wpcf7']) . '/feedback');
} elseif (($analysis['type'] ?? '') === 'Elementor Form') {
// Elementor Pro forms normally submit through admin-ajax.php. Using
// the same endpoint makes the health test far more representative
// than POSTing the rendered Page URL.
$request_action = admin_url('admin-ajax.php');
$body['action'] = 'elementor_pro_forms_send_form';
if (empty($body['referrer'])) $body['referrer'] = $fetched['url'];
}
$response = wp_remote_post($request_action, [
'timeout' => 22,
'redirection' => 0,
'user-agent' => 'Toolkit Form Health Test/' . self::VERSION . '; ' . home_url('/'),
'headers' => [
'Referer' => $fetched['url'],
'Cache-Control' => 'no-cache',
'X-11-Keys-Test' => '1',
],
'cookies' => wp_remote_retrieve_cookies($fetched['response']),
'body' => $body,
]);
if (is_wp_error($response)) wp_send_json_error(['message' => 'Test request failed: ' . $response->get_error_message()]);
$code = (int) wp_remote_retrieve_response_code($response);
$raw_body = (string) wp_remote_retrieve_body($response);
$json_body = json_decode($raw_body, true);
if (($analysis['type'] ?? '') === 'Contact Form 7' && is_array($json_body)) {
$cf7_status = sanitize_key((string) ($json_body['status'] ?? ''));
$cf7_message = sanitize_text_field((string) ($json_body['message'] ?? ''));
if (in_array($cf7_status, ['mail_sent','sent'], true)) {
wp_send_json_success(['status'=>'passed','message'=>$cf7_message ?: 'Contact Form 7 accepted the test and reported mail sent. Verify inbox delivery separately.','http_code'=>$code]);
}
if ($cf7_status) {
wp_send_json_error(['message'=>($cf7_message ?: 'Contact Form 7 returned ' . $cf7_status . '.') . ' Status: ' . $cf7_status . '.']);
}
}
if (($analysis['type'] ?? '') === 'Elementor Form' && is_array($json_body) && array_key_exists('success', $json_body)) {
$message = '';
if (isset($json_body['data']['message'])) $message = sanitize_text_field((string)$json_body['data']['message']);
elseif (isset($json_body['data']) && is_string($json_body['data'])) $message = sanitize_text_field($json_body['data']);
if (!empty($json_body['success'])) {
wp_send_json_success(['status'=>'passed','message'=>$message ?: 'Elementor accepted the test submission. Verify the notification inbox separately.','http_code'=>$code]);
}
wp_send_json_error(['message'=>$message ?: 'Elementor rejected the test submission.']);
}
$resp_body = strtolower(wp_strip_all_tags($raw_body));
$location = (string) wp_remote_retrieve_header($response, 'location');
if ($code >= 300 && $code < 400) {
wp_send_json_success([
'status' => 'passed',
'message' => 'Submission was accepted and redirected' . ($location ? ' to ' . $location : '') . '.',
'http_code' => $code,
]);
}
if ($code < 200 || $code >= 400) {
wp_send_json_error(['message' => 'Form returned HTTP ' . $code . ' during the test.']);
}
if (preg_match('/thank you|thanks for|successfully|submission received|message has been sent|we have received|your message was sent/', $resp_body)) {
wp_send_json_success(['status' => 'passed', 'message' => 'Test submission accepted and a success response was detected.', 'http_code' => $code]);
}
if (preg_match('/validation error|invalid field|please fill|required field|captcha|spam detected|nonce|forbidden|submission failed|error occurred/', $resp_body)) {
wp_send_json_error(['message' => 'The test reached the form but the response contained a validation/error message.']);
}
wp_send_json_success([
'status' => 'unverified',
'message' => 'The server accepted the test request (HTTP ' . $code . '), but no clear success message or redirect was detected. Check the test email/entry or run a manual browser test.',
'http_code' => $code,
]);
}
public function ajax_rename() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
$new = sanitize_file_name(wp_unslash($_POST['new_name'] ?? ''));
$new = pathinfo($new, PATHINFO_FILENAME);
if (!$id || !$new) wp_send_json_error(['message' => 'Missing image or filename.']);
if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403);
$result = $this->rename_attachment($id, $new);
if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]);
wp_send_json_success($result);
}
private function rename_attachment($id, $new_base) {
$old_file = get_attached_file($id);
if (!$old_file || !file_exists($old_file)) return new WP_Error('missing', 'Image file does not exist.');
$dir = dirname($old_file);
$ext = strtolower(pathinfo($old_file, PATHINFO_EXTENSION));
$new_base = sanitize_title($new_base);
if (!$new_base) return new WP_Error('invalid', 'Invalid filename.');
$candidate = wp_unique_filename($dir, $new_base . '.' . $ext);
$new_file = trailingslashit($dir) . $candidate;
if ($new_file === $old_file) return new WP_Error('same', 'New filename is the same as the current filename.');
$old_url = wp_get_attachment_url($id);
$old_meta = wp_get_attachment_metadata($id);
$old_files = $this->metadata_files($old_file, $old_meta);
if (!$this->filesystem_move($old_file, $new_file, false)) return new WP_Error('rename_failed', 'WordPress could not rename the original image.');
update_attached_file($id, $new_file);
// Regenerate metadata/thumbnails under the new base name, then remove old generated sizes.
require_once ABSPATH . 'wp-admin/includes/image.php';
$new_meta = wp_generate_attachment_metadata($id, $new_file);
if (is_wp_error($new_meta) || empty($new_meta)) {
$this->filesystem_move($new_file, $old_file, true);
update_attached_file($id, $old_file);
return new WP_Error('metadata_failed', 'Could not regenerate image metadata. Rename rolled back.');
}
wp_update_attachment_metadata($id, $new_meta);
foreach ($old_files as $old_generated) {
if ($old_generated !== $old_file && file_exists($old_generated)) $this->filesystem_delete_file($old_generated);
}
$new_url = wp_get_attachment_url($id);
$this->replace_references($old_url, $new_url);
$this->replace_references(basename($old_file), basename($new_file));
clean_post_cache($id);
$this->clear_elementor_cache();
return ['message' => 'Renamed successfully.', 'filename' => basename($new_file), 'url' => $new_url];
}
public function ajax_convert_webp() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
$delete_old = !empty($_POST['delete_old']);
if (!$id) wp_send_json_error(['message' => 'Missing image.']);
if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403);
$result = $this->convert_to_webp($id, $delete_old);
if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]);
wp_send_json_success($result);
}
private function convert_to_webp($id, $delete_old = false) {
if (!wp_image_editor_supports(['mime_type' => 'image/webp'])) {
return new WP_Error('no_webp', 'This server image library does not support WebP output.');
}
$old_file = get_attached_file($id);
if (!$old_file || !file_exists($old_file)) return new WP_Error('missing', 'Image file does not exist.');
$mime = get_post_mime_type($id);
if (!in_array($mime, ['image/jpeg', 'image/png'], true)) return new WP_Error('mime', 'Only JPG/JPEG and PNG can be converted here.');
$dir = dirname($old_file);
$base = pathinfo($old_file, PATHINFO_FILENAME);
$webp_name = wp_unique_filename($dir, $base . '.webp');
$new_file = trailingslashit($dir) . $webp_name;
$editor = wp_get_image_editor($old_file);
if (is_wp_error($editor)) return $editor;
$editor->set_quality(82);
$saved = $editor->save($new_file, 'image/webp');
if (is_wp_error($saved)) return $saved;
$old_url = wp_get_attachment_url($id);
$old_meta = wp_get_attachment_metadata($id);
$old_files = $this->metadata_files($old_file, $old_meta);
update_attached_file($id, $new_file);
wp_update_post(['ID' => $id, 'post_mime_type' => 'image/webp']);
require_once ABSPATH . 'wp-admin/includes/image.php';
$new_meta = wp_generate_attachment_metadata($id, $new_file);
if (empty($new_meta) || is_wp_error($new_meta)) {
update_attached_file($id, $old_file);
wp_update_post(['ID' => $id, 'post_mime_type' => $mime]);
$this->filesystem_delete_file($new_file);
return new WP_Error('metadata_failed', 'WebP was created but metadata failed. Conversion rolled back.');
}
wp_update_attachment_metadata($id, $new_meta);
$new_url = wp_get_attachment_url($id);
$this->replace_references($old_url, $new_url);
$this->replace_references(basename($old_file), basename($new_file));
if ($delete_old) {
foreach ($old_files as $f) if (file_exists($f) && $f !== $new_file) $this->filesystem_delete_file($f);
}
clean_post_cache($id);
$this->clear_elementor_cache();
return [
'message' => 'Converted to WebP.',
'filename' => basename($new_file),
'size' => file_exists($new_file) ? size_format(filesize($new_file), 1) : '',
'url' => $new_url,
];
}
public function ajax_compress() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
$quality = max(40, min(95, absint($_POST['quality'] ?? 82)));
if (!$id) wp_send_json_error(['message' => 'Missing image.']);
if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403);
$result = $this->compress_attachment($id, $quality);
if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]);
wp_send_json_success($result);
}
private function compress_attachment($id, $quality) {
$file = get_attached_file($id);
if (!$file || !file_exists($file)) return new WP_Error('missing', 'Image file does not exist.');
$mime = get_post_mime_type($id);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
return new WP_Error('mime', 'Unsupported image type.');
}
$before = (int) filesize($file);
if ($before <= 0) return new WP_Error('empty', 'Image file is empty.');
$dir = dirname($file);
$base = pathinfo($file, PATHINFO_FILENAME);
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!$ext) {
$ext = $mime === 'image/png' ? 'png' : ($mime === 'image/webp' ? 'webp' : 'jpg');
}
// Keep a normal image extension. Some GD/Imagick adapters fail when the
// temporary path ends in a custom non-image extension.
$tmp_name = wp_unique_filename($dir, '.' . $base . '-ekmt-compressed.' . $ext);
$tmp = trailingslashit($dir) . $tmp_name;
$editor = wp_get_image_editor($file);
if (is_wp_error($editor)) return $editor;
$quality_set = $editor->set_quality($quality);
if (is_wp_error($quality_set)) return $quality_set;
$saved = $editor->save($tmp, $mime);
if (is_wp_error($saved)) {
$this->filesystem_delete_file($tmp);
return new WP_Error('compress_save', 'Image editor could not create the compressed file: ' . $saved->get_error_message());
}
if (!file_exists($tmp) || filesize($tmp) <= 0) {
$this->filesystem_delete_file($tmp);
return new WP_Error('compress_failed', 'Compressed file could not be created.');
}
clearstatcache(true, $tmp);
$candidate = (int) filesize($tmp);
if ($candidate >= $before) {
$this->filesystem_delete_file($tmp);
return [
'message' => 'Already optimized — this quality setting would not make the image smaller.',
'before' => size_format($before, 1),
'after' => size_format($before, 1),
'saved' => '0 B',
];
}
// Create the backup only when we know there is a smaller replacement.
$backup = $this->backup_file($file, $id);
if (is_wp_error($backup)) {
$this->filesystem_delete_file($tmp);
return $backup;
}
$old_meta = wp_get_attachment_metadata($id);
$old_files = $this->metadata_files($file, $old_meta);
if (!$this->filesystem_move($tmp, $file, true)) {
$this->filesystem_delete_file($tmp);
return new WP_Error('replace_failed', 'WordPress could not replace the original image with the compressed version.');
}
clearstatcache(true, $file);
// Regenerate WordPress image sizes from the newly compressed master.
// Do not delete old sizes first; that way a metadata failure does not
// instantly remove thumbnails that the live site may still use.
require_once ABSPATH . 'wp-admin/includes/image.php';
$new_meta = wp_generate_attachment_metadata($id, $file);
if (is_wp_error($new_meta) || empty($new_meta)) {
// Restore the original master from the backup.
$this->filesystem_copy($backup, $file, true);
clearstatcache(true, $file);
return new WP_Error('metadata_failed', 'The master image compressed, but WordPress could not regenerate its image sizes. The original master was restored.');
}
wp_update_attachment_metadata($id, $new_meta);
// Remove only old generated files that are no longer part of the current metadata.
$new_files = $this->metadata_files($file, $new_meta);
$keep = array_map('wp_normalize_path', $new_files);
foreach ($old_files as $old_generated) {
if ($old_generated === $file || !file_exists($old_generated)) continue;
if (!in_array(wp_normalize_path($old_generated), $keep, true)) $this->filesystem_delete_file($old_generated);
}
clean_post_cache($id);
$this->clear_elementor_cache();
clearstatcache(true, $file);
$after = (int) filesize($file);
return [
'message' => 'Compressed successfully.',
'before' => size_format($before, 1),
'after' => size_format($after, 1),
'saved' => size_format(max(0, $before - $after), 1),
];
}
private function backup_file($file, $id) {
$uploads = wp_upload_dir();
if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']);
$dir = trailingslashit($uploads['basedir']) . self::BACKUP_DIR . '/' . $id;
if (!wp_mkdir_p($dir)) return new WP_Error('backup_dir', 'Could not create backup directory.');
$dest = trailingslashit($dir) . gmdate('Ymd-His') . '-' . basename($file);
if (!$this->filesystem_copy($file, $dest, false)) return new WP_Error('backup', 'WordPress could not create the image backup.');
return $dest;
}
private function metadata_files($original, $meta) {
$files = [$original];
$dir = trailingslashit(dirname($original));
if (!empty($meta['sizes']) && is_array($meta['sizes'])) {
foreach ($meta['sizes'] as $size) if (!empty($size['file'])) $files[] = $dir . $size['file'];
}
if (!empty($meta['original_image'])) $files[] = $dir . $meta['original_image'];
return array_values(array_unique($files));
}
private function replace_references($old, $new) {
if (!$old || !$new || $old === $new) return;
global $wpdb;
// Post content.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-triggered reference replacement intentionally performs a fresh direct content update.
$wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
$old, $new, '%' . $wpdb->esc_like($old) . '%'
));
// Post meta including Elementor JSON. REPLACE preserves serialized string length only when not serialized;
// Elementor data is JSON. Skip PHP-serialized values for safety.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-triggered reference replacement must search current metadata values.
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_value LIKE %s",
'%' . $wpdb->esc_like($old) . '%'
));
foreach ($rows as $row) {
if (is_serialized($row->meta_value)) continue;
$updated = str_replace($old, $new, $row->meta_value);
if ($updated !== $row->meta_value) {
update_metadata_by_mid('post', (int) $row->meta_id, $updated);
}
}
}
public function ajax_start_unused_scan() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
global $wpdb;
$token = wp_generate_password(32, false, false);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)",
'attachment',
'inherit',
'image/%',
self::QUARANTINE_META
)
);
$state = [
'token' => $token,
'status' => 'running',
'cursor' => 0,
'total' => $total,
'scanned' => 0,
'unused' => 0,
'unused_bytes' => 0,
'results' => [],
'started' => time(),
'updated' => time(),
'error' => '',
];
update_option(self::SCAN_STATE_OPTION, $state, false);
if (!wp_next_scheduled('ekmt_unused_scan_cron', [$token])) {
wp_schedule_single_event(time() + 30, 'ekmt_unused_scan_cron', [$token]);
}
$this->dispatch_unused_scan_worker($token);
wp_send_json_success([
'status' => 'running',
'total' => $total,
'scanned' => 0,
'unused' => 0,
'unused_bytes' => 0,
]);
}
public function ajax_unused_scan_status() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$offset = max(0, absint($_POST['offset'] ?? 0));
$state = get_option(self::SCAN_STATE_OPTION, []);
if (!is_array($state) || empty($state['token'])) {
wp_send_json_success([
'exists' => false,
'status' => 'idle',
'items' => [],
'next_offset' => 0,
'total_results' => 0,
]);
}
// If a host dropped the loopback worker, touching the status endpoint
// nudges it back to life without restarting the scan.
if (($state['status'] ?? '') === 'running' && (time() - (int) ($state['updated'] ?? 0)) > 12) {
$this->dispatch_unused_scan_worker((string) $state['token']);
}
$results = !empty($state['results']) && is_array($state['results']) ? $state['results'] : [];
$slice = array_slice($results, $offset, 40);
$items = [];
foreach ($slice as $entry) {
$id = absint($entry['id'] ?? 0);
if (!$id || get_post_meta($id, self::QUARANTINE_META, true)) continue;
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$items[] = $this->cleaner_item($id, (int) ($entry['bytes'] ?? 0), [
'checked' => [
'Page/post content',
'Elementor, WooCommerce, ACF & post meta',
'Theme/customizer/widget options',
'Taxonomy & user meta',
'Attachment IDs, URLs & generated image sizes',
],
]);
}
$next_offset = min(count($results), $offset + count($slice));
wp_send_json_success([
'exists' => true,
'status' => $state['status'] ?? 'idle',
'total' => (int) ($state['total'] ?? 0),
'scanned' => (int) ($state['scanned'] ?? 0),
'unused' => (int) ($state['unused'] ?? 0),
'unused_bytes' => (int) ($state['unused_bytes'] ?? 0),
'error' => (string) ($state['error'] ?? ''),
'items' => $items,
'next_offset' => $next_offset,
'total_results' => count($results),
'updated' => (int) ($state['updated'] ?? 0),
]);
}
public function ajax_background_unused_worker() {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Loopback worker is authenticated by a high-entropy, server-stored scan token.
$token = sanitize_text_field(wp_unslash($_POST['token'] ?? ''));
if (!$token) wp_send_json_error(['message' => 'Missing scan token.'], 400);
$state = get_option(self::SCAN_STATE_OPTION, []);
if (!is_array($state) || empty($state['token']) || !hash_equals((string) $state['token'], $token)) {
wp_send_json_error(['message' => 'Invalid or expired scan token.'], 403);
}
$this->process_background_unused_scan($token);
wp_send_json_success(['message' => 'Background batch finished.']);
}
public function cron_unused_scan_worker($token) {
$token = sanitize_text_field((string) $token);
if (!$token) return;
$this->process_background_unused_scan($token);
}
private function dispatch_unused_scan_worker($token) {
$url = admin_url('admin-ajax.php');
wp_remote_post($url, [
'timeout' => 0.01,
'blocking' => false,
'body' => [
'action' => 'ekmt_background_unused_worker',
'token' => $token,
],
]);
}
private function process_background_unused_scan($token) {
global $wpdb;
$lock = 'ekmt_scan_lock_' . md5($token);
$existing = get_option($lock, 0);
if ($existing && (time() - (int) $existing) < 45) return;
if ($existing) delete_option($lock);
if (!add_option($lock, time(), '', false)) return;
$continue_scan = false;
try {
$state = get_option(self::SCAN_STATE_OPTION, []);
if (!is_array($state) || ($state['status'] ?? '') !== 'running' || empty($state['token']) || !hash_equals((string) $state['token'], (string) $token)) {
return;
}
$cursor = absint($state['cursor'] ?? 0);
$limit = 15;
$image_mime_like = 'image/%';
if ($cursor > 0) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)
AND p.ID < %d
ORDER BY p.ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
self::QUARANTINE_META,
$cursor,
$limit
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)
ORDER BY p.ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
self::QUARANTINE_META,
$limit
)
);
}
$ids = array_map('intval', (array) $ids);
if (!$ids) {
$state['status'] = 'completed';
$state['updated'] = time();
update_option(self::SCAN_STATE_OPTION, $state, false);
wp_clear_scheduled_hook('ekmt_unused_scan_cron', [$token]);
return;
}
$usage_map = $this->detect_image_usage_batch($ids);
if (is_wp_error($usage_map)) {
$state['status'] = 'failed';
$state['error'] = $usage_map->get_error_message();
$state['updated'] = time();
update_option(self::SCAN_STATE_OPTION, $state, false);
return;
}
foreach ($ids as $id) {
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$usage = $usage_map[$id] ?? ['used' => true];
if (!empty($usage['used'])) continue;
$bytes = $this->attachment_total_bytes($id);
$state['results'][] = ['id' => (int) $id, 'bytes' => (int) $bytes];
$state['unused'] = (int) ($state['unused'] ?? 0) + 1;
$state['unused_bytes'] = (int) ($state['unused_bytes'] ?? 0) + $bytes;
}
$state['scanned'] = (int) ($state['scanned'] ?? 0) + count($ids);
$state['cursor'] = min($ids);
$state['updated'] = time();
if (count($ids) < $limit) {
$state['status'] = 'completed';
wp_clear_scheduled_hook('ekmt_unused_scan_cron', [$token]);
}
update_option(self::SCAN_STATE_OPTION, $state, false);
if (($state['status'] ?? '') === 'running') {
// Schedule a fallback and dispatch the next loopback only after
// the batch lock is released in finally below.
$continue_scan = true;
if (!wp_next_scheduled('ekmt_unused_scan_cron', [$token])) {
wp_schedule_single_event(time() + 30, 'ekmt_unused_scan_cron', [$token]);
}
}
} catch (Throwable $e) {
$state = get_option(self::SCAN_STATE_OPTION, []);
if (is_array($state) && ($state['token'] ?? '') === $token) {
$state['status'] = 'failed';
$state['error'] = 'Background scan stopped: ' . $e->getMessage();
$state['updated'] = time();
update_option(self::SCAN_STATE_OPTION, $state, false);
}
} finally {
delete_option($lock);
}
if ($continue_scan) {
// Continue independently of whichever WordPress admin page the user visits.
$this->dispatch_unused_scan_worker($token);
}
}
/**
* Scan Media Library images in small cursor-based batches.
*
* v1.1.1 uses one bulk reference pass per storage area for the whole batch instead
* of repeating several full-table LIKE searches for every image. This is dramatically
* faster on Elementor/WooCommerce sites and avoids common max_execution_time failures.
*/
public function ajax_scan_unused() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
global $wpdb;
$cursor = absint($_POST['cursor'] ?? 0);
$limit = max(5, min(25, absint($_POST['limit'] ?? 20)));
$include_total = !empty($_POST['include_total']);
$image_mime_like = 'image/%';
if ($cursor > 0) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)
AND p.ID < %d
ORDER BY p.ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
self::QUARANTINE_META,
$cursor,
$limit
)
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)
ORDER BY p.ID DESC
LIMIT %d",
'attachment',
'inherit',
$image_mime_like,
self::QUARANTINE_META,
$limit
)
);
}
$ids = array_map('intval', (array) $ids);
$total = null;
if ($include_total) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts} p
WHERE p.post_type = %s
AND p.post_status = %s
AND p.post_mime_type LIKE %s
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->postmeta} qm
WHERE qm.post_id = p.ID AND qm.meta_key = %s
)",
'attachment',
'inherit',
$image_mime_like,
self::QUARANTINE_META
)
);
}
if (!$ids) {
wp_send_json_success([
'items' => [],
'has_more' => false,
'next_cursor' => 0,
'total' => $total,
'scanned' => 0,
'unused' => 0,
'unused_bytes' => 0,
'batch_size' => $limit,
]);
}
$usage_map = $this->detect_image_usage_batch($ids);
if (is_wp_error($usage_map)) {
wp_send_json_error(['message' => $usage_map->get_error_message()], 500);
}
$items = [];
$unused_bytes = 0;
foreach ($ids as $id) {
$file = get_attached_file($id);
if (!$file || !file_exists($file)) continue;
$usage = $usage_map[$id] ?? ['used' => true, 'reasons' => ['Could not complete the fast safety check.'], 'checked' => []];
if (!empty($usage['used'])) continue;
$bytes = $this->attachment_total_bytes($id);
$unused_bytes += $bytes;
$items[] = $this->cleaner_item($id, $bytes, $usage);
}
$next_cursor = min($ids);
wp_send_json_success([
'items' => $items,
'has_more' => count($ids) === $limit,
'next_cursor' => $next_cursor,
'total' => $total,
'scanned' => count($ids),
'unused' => count($items),
'unused_bytes' => $unused_bytes,
'batch_size' => $limit,
]);
}
/**
* Fast bulk detector used only for the gallery scan.
* The quarantine action still calls detect_image_usage() for a slower, detailed,
* per-image second check immediately before any file is moved.
*/
private function detect_image_usage_batch($ids) {
global $wpdb;
$ids = array_values(array_unique(array_filter(array_map('absint', (array) $ids))));
$checked = [
'Page/post content',
'Elementor, WooCommerce, ACF & post meta',
'Theme/widget/plugin options',
'Taxonomy & user meta',
'Attachment IDs, URLs & generated image filenames',
];
$results = [];
$infos = [];
$sql_terms = [];
foreach ($ids as $id) {
$results[$id] = ['used' => false, 'reasons' => [], 'checked' => $checked];
$file = get_attached_file($id);
if (!$file || !file_exists($file)) {
$results[$id]['used'] = true;
$results[$id]['reasons'][] = 'Original file is missing, so it is protected from cleanup.';
continue;
}
$meta = wp_get_attachment_metadata($id);
$files = $this->metadata_files($file, $meta);
$uploads = wp_upload_dir();
$file_needles = [];
$relative_stem = '';
foreach ($files as $candidate) {
$name = basename($candidate);
if ($name) {
$file_needles[] = $name;
$sql_terms[] = $name;
}
if (empty($uploads['error']) && !empty($uploads['basedir']) && !empty($uploads['baseurl'])) {
$norm_base = trailingslashit(wp_normalize_path($uploads['basedir']));
$norm_file = wp_normalize_path($candidate);
if (strpos($norm_file, $norm_base) === 0) {
$relative = ltrim(substr($norm_file, strlen($norm_base)), '/');
if ($relative) {
$file_needles[] = $relative;
$sql_terms[] = $relative;
$file_needles[] = trailingslashit($uploads['baseurl']) . str_replace('%2F', '/', rawurlencode($relative));
}
}
}
}
if (!empty($meta['file']) && is_string($meta['file'])) {
$relative_original = ltrim($meta['file'], '/');
if ($relative_original) {
$relative_stem = preg_replace('/\.[^.\/]+$/', '', $relative_original);
if ($relative_stem) $sql_terms[] = $relative_stem;
}
}
$sql_terms[] = basename($file);
$url = wp_get_attachment_url($id);
if ($url) $file_needles[] = $url;
$infos[$id] = [
'file_needles' => array_values(array_unique(array_filter($file_needles, 'strlen'))),
'relative_stem' => $relative_stem,
'id' => $id,
];
}
if (!$infos) return $results;
$sql_terms = array_values(array_unique(array_filter($sql_terms, function($value) {
return is_string($value) && strlen($value) >= 4;
})));
$regex_parts = [];
foreach ($sql_terms as $term) {
// Keep the SQL regular expression bounded and escape regex metacharacters.
$term = substr($term, 0, 512);
if ($term !== '') $regex_parts[] = preg_quote($term, '/');
}
$id_alt = implode('|', array_map('intval', array_keys($infos)));
if ($id_alt !== '') {
$regex_parts[] = '((wp-image-|attachment[_-])(' . $id_alt . ')([^0-9]|$)|"(id|image|attachment_id)"[[:space:]]*:[[:space:]]*"?(' . $id_alt . ')"?([^0-9]|$)|i:(' . $id_alt . ');|(^|,)[[:space:]]*(' . $id_alt . ')[[:space:]]*(,|$))';
}
$reference_regex = $regex_parts ? '(' . implode('|', $regex_parts) . ')' : '';
if ($reference_regex === '') return $results;
// Gutenberg/classic content and builder shortcodes.
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_type, post_title, post_content, post_excerpt
FROM {$wpdb->posts}
WHERE post_status NOT IN (%s, %s)
AND (post_content REGEXP %s OR post_excerpt REGEXP %s)",
'trash',
'auto-draft',
$reference_regex,
$reference_regex
)
);
if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking page content.');
foreach ((array) $rows as $row) {
$this->map_batch_reference_text(
$results,
$infos,
(string) $row->post_content . "\n" . (string) $row->post_excerpt,
'Content: ' . ($row->post_title ?: ('#' . $row->ID)) . ' (' . $row->post_type . ')',
(int) $row->ID
);
}
// Elementor JSON, WooCommerce galleries, ACF and other post meta.
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE meta_value REGEXP %s",
$reference_regex
)
);
if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking post meta.');
foreach ((array) $rows as $row) {
$this->map_batch_reference_text(
$results,
$infos,
(string) $row->meta_value,
'Post meta: ' . $row->meta_key . ' on #' . $row->post_id,
(int) $row->post_id
);
}
// Customizer/theme mods, widgets, logos and plugin settings.
$transient_prefix = $wpdb->esc_like('_transient_') . '%';
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT option_name, option_value
FROM {$wpdb->options}
WHERE option_name NOT LIKE %s
AND option_value REGEXP %s",
$transient_prefix,
$reference_regex
)
);
if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking theme and plugin options.');
foreach ((array) $rows as $row) {
$this->map_batch_reference_text(
$results,
$infos,
(string) $row->option_value,
'Option/theme setting: ' . $row->option_name,
0
);
}
if (!empty($wpdb->termmeta)) {
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT term_id, meta_key, meta_value
FROM {$wpdb->termmeta}
WHERE meta_value REGEXP %s",
$reference_regex
)
);
if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking taxonomy meta.');
foreach ((array) $rows as $row) {
$this->map_batch_reference_text(
$results,
$infos,
(string) $row->meta_value,
'Term meta: ' . $row->meta_key . ' on term #' . $row->term_id,
0
);
}
}
if (!empty($wpdb->usermeta)) {
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT user_id, meta_key, meta_value
FROM {$wpdb->usermeta}
WHERE meta_value REGEXP %s",
$reference_regex
)
);
if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking user meta.');
foreach ((array) $rows as $row) {
$this->map_batch_reference_text(
$results,
$infos,
(string) $row->meta_value,
'User meta: ' . $row->meta_key . ' on user #' . $row->user_id,
0
);
}
}
foreach ($infos as $id => $info) {
if (!empty($results[$id]['used'])) continue;
if ((bool) apply_filters('ekmt_attachment_is_used', false, $id, $info['file_needles'])) {
$results[$id]['used'] = true;
$results[$id]['reasons'][] = 'Protected by a custom usage filter.';
}
}
return $results;
}
private function map_batch_reference_text(&$results, $infos, $text, $reason, $owner_post_id = 0) {
if ($text === '') return;
foreach ($infos as $id => $info) {
if (!empty($results[$id]['used'])) continue;
// Ignore an attachment's own post/meta row; its internal metadata is not site usage.
if ($owner_post_id && (int) $owner_post_id === (int) $id) continue;
if ($this->fast_text_references_attachment($text, $id, $info)) {
$results[$id]['used'] = true;
$results[$id]['reasons'][] = $reason;
}
}
}
private function fast_text_references_attachment($text, $id, $info) {
foreach ((array) ($info['file_needles'] ?? []) as $needle) {
if ($needle !== '' && stripos($text, $needle) !== false) return true;
}
// Relative stem catches original URLs and generated sizes such as -300x300 or -scaled.
if (!empty($info['relative_stem']) && stripos($text, $info['relative_stem']) !== false) return true;
$id = (int) $id;
$trimmed = trim($text, " \t\n\r\0\x0B\"'");
if ($trimmed === (string) $id) return true;
$patterns = [
'/wp-image-' . $id . '(?:[^0-9]|$)/i',
'/attachment[_-]' . $id . '(?:[^0-9]|$)/i',
'/["\'](?:id|image|attachment_id)["\']\s*:\s*["\']?' . $id . '["\']?(?:[^0-9]|$)/i',
'/i:' . $id . ';/i',
'/(?:^|,)\s*' . $id . '\s*(?:,|$)/',
];
foreach ($patterns as $pattern) if (preg_match($pattern, $text)) return true;
return false;
}
private function cleaner_item($id, $bytes = null, $usage = []) {
$file = get_attached_file($id);
$bytes = $bytes === null ? $this->attachment_total_bytes($id) : $bytes;
return [
'id' => (int) $id,
'filename' => $file ? basename($file) : get_the_title($id),
'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id),
'size' => size_format($bytes, 1),
'bytes' => (int) $bytes,
'dimensions' => ($file && file_exists($file)) ? $this->dimensions($file) : '—',
'uploaded' => get_the_date('M j, Y', $id),
'confidence' => 'High confidence',
'checked' => !empty($usage['checked']) ? $usage['checked'] : [
'Page/post content', 'Elementor & post meta', 'Theme/widget options', 'Taxonomy/user meta', 'Attachment IDs & generated sizes'
],
];
}
/**
* Detect references across common WordPress storage locations.
* This intentionally over-matches some ID patterns to reduce deletion risk.
*/
private function detect_image_usage($id) {
$id = absint($id);
if (!$id) {
return ['used' => true, 'reasons' => ['Invalid attachment ID.'], 'checked' => []];
}
$map = $this->detect_image_usage_batch([$id]);
if (is_wp_error($map)) {
return [
'used' => true,
'reasons' => ['The safety scan could not complete, so this image was protected from cleanup.'],
'checked' => [],
];
}
return $map[$id] ?? [
'used' => true,
'reasons' => ['The safety scan returned no result, so this image was protected from cleanup.'],
'checked' => [],
];
}
private function like_group_sql($columns, $needles, &$args) {
global $wpdb;
$args = [];
$parts = [];
$needles = array_values(array_unique(array_filter($needles, 'strlen')));
foreach ($columns as $column) {
foreach ($needles as $needle) {
$parts[] = "{$column} LIKE %s";
$args[] = '%' . $wpdb->esc_like($needle) . '%';
}
}
return implode(' OR ', $parts);
}
private function attachment_total_bytes($id) {
$file = get_attached_file($id);
if (!$file) return 0;
$meta = wp_get_attachment_metadata($id);
$total = 0;
foreach ($this->metadata_files($file, $meta) as $candidate) {
if (file_exists($candidate) && is_file($candidate)) $total += (int) filesize($candidate);
}
return $total;
}
public function ajax_quarantine() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || !current_user_can('delete_post', $id)) {
wp_send_json_error(['message' => 'You cannot remove this attachment.'], 403);
}
if (get_post_meta($id, self::QUARANTINE_META, true)) {
wp_send_json_error(['message' => 'Image is already quarantined.']);
}
// v1.3: quarantine is intentionally immediate. The expensive reference
// analysis already happened during the unused-media scan. Quarantine is
// reversible, so we do not repeat the full scan here.
$result = $this->quarantine_attachment($id);
if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]);
$this->remove_quarantined_from_scan_state($id, (int) ($result['bytes'] ?? 0));
wp_send_json_success($result);
}
private function remove_quarantined_from_scan_state($id, $bytes = 0) {
$state = get_option(self::SCAN_STATE_OPTION, []);
if (!is_array($state) || empty($state['results']) || !is_array($state['results'])) return;
$found = false;
$new = [];
foreach ($state['results'] as $entry) {
if ((int) ($entry['id'] ?? 0) === (int) $id) {
$found = true;
if (!$bytes) $bytes = (int) ($entry['bytes'] ?? 0);
continue;
}
$new[] = $entry;
}
if (!$found) return;
$state['results'] = $new;
$state['unused'] = max(0, (int) ($state['unused'] ?? 0) - 1);
$state['unused_bytes'] = max(0, (int) ($state['unused_bytes'] ?? 0) - (int) $bytes);
$state['updated'] = time();
update_option(self::SCAN_STATE_OPTION, $state, false);
}
private function quarantine_attachment($id) {
$uploads = wp_upload_dir();
if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']);
$basedir = wp_normalize_path($uploads['basedir']);
$file = get_attached_file($id);
if (!$file || !file_exists($file)) return new WP_Error('missing', 'Original image file does not exist.');
$meta = wp_get_attachment_metadata($id);
$files = $this->metadata_files($file, $meta);
$manifest = [];
$moved = [];
$bytes = 0;
$qroot = trailingslashit($basedir) . self::QUARANTINE_DIR . '/' . $id;
foreach ($files as $src) {
if (!file_exists($src) || !is_file($src)) continue;
$norm = wp_normalize_path($src);
$prefix = trailingslashit($basedir);
if (strpos($norm, $prefix) !== 0) {
$this->rollback_quarantine_moves($moved);
return new WP_Error('outside_uploads', 'A related file is outside the WordPress uploads directory, so Safe Mode stopped.');
}
$relative = ltrim(substr($norm, strlen($prefix)), '/');
$dest = trailingslashit($qroot) . $relative;
if (!wp_mkdir_p(dirname($dest))) {
$this->rollback_quarantine_moves($moved);
return new WP_Error('quarantine_dir', 'Could not create the quarantine directory.');
}
if (!$this->move_file($src, $dest)) {
$this->rollback_quarantine_moves($moved);
return new WP_Error('quarantine_move', 'Could not move all image files to quarantine. Nothing was intentionally deleted.');
}
$size = file_exists($dest) ? (int) filesize($dest) : 0;
$bytes += $size;
$moved[] = ['from' => $dest, 'to' => $src];
$manifest[] = ['relative' => $relative, 'bytes' => $size];
}
if (!$manifest) return new WP_Error('nothing_moved', 'No image files were available to quarantine.');
update_post_meta($id, self::QUARANTINE_META, gmdate('Y-m-d H:i:s'));
update_post_meta($id, self::QUARANTINE_FILES_META, $manifest);
update_post_meta($id, self::QUARANTINE_BYTES_META, $bytes);
clean_post_cache($id);
$this->clear_elementor_cache();
return [
'message' => 'Moved to quarantine. Test the website before permanent deletion.',
'id' => $id,
'size' => size_format($bytes, 1),
'bytes' => $bytes,
];
}
private function rollback_quarantine_moves($moves) {
foreach (array_reverse($moves) as $move) {
if (!empty($move['from']) && !empty($move['to']) && file_exists($move['from'])) {
if (!is_dir(dirname($move['to']))) wp_mkdir_p(dirname($move['to']));
$this->move_file($move['from'], $move['to']);
}
}
}
private function move_file($from, $to) {
return $this->filesystem_move($from, $to, false);
}
public function ajax_get_quarantine() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$q = new WP_Query([
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => 'image',
'posts_per_page' => 200,
'orderby' => 'ID',
'order' => 'DESC',
'fields' => 'ids',
'no_found_rows' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Admin-only quarantine list needs the quarantine marker.
'meta_query' => [[
'key' => self::QUARANTINE_META,
'compare' => 'EXISTS',
]],
]);
$uploads = wp_upload_dir();
$items = [];
foreach ($q->posts as $id) {
$manifest = get_post_meta($id, self::QUARANTINE_FILES_META, true);
$bytes = (int) get_post_meta($id, self::QUARANTINE_BYTES_META, true);
$thumb = '';
if (is_array($manifest) && $manifest && empty($uploads['error'])) {
$first = reset($manifest);
if (!empty($first['relative'])) {
$thumb = trailingslashit($uploads['baseurl']) . self::QUARANTINE_DIR . '/' . $id . '/' . ltrim($first['relative'], '/');
}
}
$original = get_attached_file($id);
$items[] = [
'id' => (int) $id,
'filename' => $original ? basename($original) : get_the_title($id),
'thumb' => $thumb,
'size' => size_format($bytes, 1),
'bytes' => $bytes,
'quarantined' => get_post_meta($id, self::QUARANTINE_META, true),
];
}
wp_send_json_success(['items' => $items]);
}
public function ajax_restore_quarantine() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || !current_user_can('delete_post', $id)) wp_send_json_error(['message' => 'You cannot restore this attachment.'], 403);
$result = $this->restore_quarantine($id);
if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]);
wp_send_json_success($result);
}
private function restore_quarantine($id) {
$manifest = get_post_meta($id, self::QUARANTINE_FILES_META, true);
if (!is_array($manifest) || !$manifest) return new WP_Error('not_quarantined', 'No quarantine manifest was found.');
$uploads = wp_upload_dir();
if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']);
$basedir = wp_normalize_path($uploads['basedir']);
$qroot = trailingslashit($basedir) . self::QUARANTINE_DIR . '/' . $id;
// Refuse to overwrite any file that appeared after quarantine.
foreach ($manifest as $entry) {
if (empty($entry['relative'])) continue;
$dest = trailingslashit($basedir) . ltrim($entry['relative'], '/');
if (file_exists($dest)) return new WP_Error('restore_conflict', 'Restore stopped because a file now exists at the original path: ' . basename($dest));
}
$restored = [];
foreach ($manifest as $entry) {
if (empty($entry['relative'])) continue;
$src = trailingslashit($qroot) . ltrim($entry['relative'], '/');
$dest = trailingslashit($basedir) . ltrim($entry['relative'], '/');
if (!file_exists($src)) continue;
if (!wp_mkdir_p(dirname($dest)) || !$this->move_file($src, $dest)) {
// Best-effort rollback back into quarantine.
foreach (array_reverse($restored) as $move) $this->move_file($move['from'], $move['to']);
return new WP_Error('restore_failed', 'Could not restore every quarantined file.');
}
$restored[] = ['from' => $dest, 'to' => $src];
}
if (!$restored) return new WP_Error('missing_quarantine', 'Quarantine files are missing.');
delete_post_meta($id, self::QUARANTINE_META);
delete_post_meta($id, self::QUARANTINE_FILES_META);
delete_post_meta($id, self::QUARANTINE_BYTES_META);
$this->remove_empty_dirs($qroot);
clean_post_cache($id);
$this->clear_elementor_cache();
return ['message' => 'Image restored to its original upload path.', 'id' => $id];
}
public function ajax_delete_quarantine() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || !current_user_can('delete_post', $id)) wp_send_json_error(['message' => 'You cannot delete this attachment.'], 403);
if (!get_post_meta($id, self::QUARANTINE_META, true)) wp_send_json_error(['message' => 'Only quarantined images can be permanently deleted.']);
$uploads = wp_upload_dir();
$qroot = empty($uploads['error']) ? trailingslashit($uploads['basedir']) . self::QUARANTINE_DIR . '/' . $id : '';
$deleted = wp_delete_attachment($id, true);
if (!$deleted) wp_send_json_error(['message' => 'WordPress could not delete the attachment record. Quarantine files were kept.']);
if ($qroot) $this->delete_tree($qroot);
wp_send_json_success(['message' => 'Permanently deleted from WordPress and quarantine.', 'id' => $id]);
}
private function delete_tree($dir) {
if (!$dir || !is_dir($dir)) return;
$fs = $this->wp_filesystem_instance();
if (is_wp_error($fs)) return;
$fs->delete($dir, true, 'd');
}
private function remove_empty_dirs($dir) {
if (!$dir || !is_dir($dir)) return;
$fs = $this->wp_filesystem_instance();
if (is_wp_error($fs)) return;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) {
$fs->rmdir($file->getPathname(), false);
}
}
$fs->rmdir($dir, false);
}
public function ajax_track_event() {
if (!check_ajax_referer('ekmt_track_event', 'nonce', false)) wp_send_json_error(['message'=>'Invalid tracking request.'], 403);
$event = sanitize_key(wp_unslash((string) ($_POST['event'] ?? '')));
if (!in_array($event, ['calls','whatsapp','sms','email','directions'], true)) wp_send_json_error(['message'=>'Invalid event.'], 400);
$ip = sanitize_text_field(wp_unslash((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')));
$path = sanitize_text_field(wp_unslash($_POST['path'] ?? ''));
$rate_key = 'ekmt_track_' . substr(hash('sha256', $ip . '|' . $event . '|' . $path), 0, 24);
if (get_transient($rate_key)) wp_send_json_success(['counted'=>false]);
set_transient($rate_key, 1, 2);
$counted = $this->increment_tracking($event);
wp_send_json_success(['counted'=>$counted]);
}
public function ajax_reset_tracking() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can reset tracking.'], 403);
$empty = ['calls'=>0,'forms'=>0,'whatsapp'=>0,'sms'=>0,'email'=>0,'directions'=>0];
update_option(self::TRACKING_OPTION, $empty, false);
wp_send_json_success(['message'=>'All conversion counters were reset.', 'totals'=>$empty]);
}
public function ajax_save_tracking_settings() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can change tracking settings.'], 403);
$enabled = !empty($_POST['enabled']) ? 1 : 0;
update_option(self::TRACKING_SETTINGS_OPTION, ['enabled'=>$enabled], false);
wp_send_json_success(['message'=>$enabled ? 'Conversion tracking is enabled.' : 'Conversion tracking is disabled.', 'enabled'=>$enabled]);
}
public function ajax_save_login_settings() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can change the login URL.'], 403);
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Converted to a safe slug with sanitize_title() on the next line.
$raw = trim((string) wp_unslash($_POST['slug'] ?? ''));
$slug = sanitize_title($raw);
$hide = !empty($_POST['hide_default']) ? 1 : 0;
if ($raw !== '' && (strlen($slug) < 3 || in_array($slug, ['wp-admin','wp-login','login','admin','dashboard','feed','wp-json'], true))) {
wp_send_json_error(['message'=>'Choose a different login slug with at least 3 characters.'], 422);
}
if ($hide && $slug === '') wp_send_json_error(['message'=>'Set a custom login slug before hiding wp-login.php.'], 422);
update_option(self::LOGIN_SETTINGS_OPTION, ['slug'=>$slug,'hide_default'=>$hide], false);
delete_option('rewrite_rules');
wp_send_json_success([
'message'=>$slug ? 'Custom login URL saved. Test it in a private/incognito window before relying on it.' : 'Custom login URL disabled.',
'slug'=>$slug,
'url'=>$slug ? home_url('/' . $slug . '/') : '',
'hide_default'=>$hide,
]);
}
public function register_custom_login_rewrite() {
$settings = $this->login_settings();
if (empty($settings['slug'])) return;
add_rewrite_rule('^' . preg_quote($settings['slug'], '#') . '/?$', 'index.php?ekmt_login=1', 'top');
}
public function custom_login_query_vars($vars) {
$vars[] = 'ekmt_login';
return $vars;
}
public function maybe_render_custom_login() {
$settings = $this->login_settings();
if (empty($settings['slug'])) return;
$is_custom = ((string) get_query_var('ekmt_login') === '1');
$request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
if (!$is_custom && $request_uri !== '') {
$request_path = trim(sanitize_text_field((string) wp_parse_url($request_uri, PHP_URL_PATH)), '/');
$home_path = trim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/');
if ($home_path && strpos($request_path, $home_path . '/') === 0) $request_path = substr($request_path, strlen($home_path) + 1);
$is_custom = ($request_path === trim($settings['slug'], '/'));
}
if (!$is_custom) return;
if (!defined('EKMT_CUSTOM_LOGIN_ENTRY')) define('EKMT_CUSTOM_LOGIN_ENTRY', true);
// wp-login.php is normally executed as a top-level WordPress entry file.
// Because our custom slug loads it from this callback scope, initialize the
// variables core login templates may expect before including the file.
// This prevents PHP 8+ undefined-variable warnings on login/register views.
$user_login = '';
$error = '';
$errors = new WP_Error();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only compatibility with core wp-login.php request parameters.
$redirect_to = isset($_REQUEST['redirect_to']) ? esc_url_raw(wp_unslash($_REQUEST['redirect_to'])) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only compatibility with core wp-login.php request parameters.
$interim_login = isset($_REQUEST['interim-login']);
global $pagenow;
$pagenow = 'wp-login.php';
require ABSPATH . 'wp-login.php';
exit;
}
private function replace_wp_login_url($url) {
$settings = $this->login_settings();
if (empty($settings['slug']) || strpos($url, 'wp-login.php') === false) return $url;
$parts = wp_parse_url($url);
$query = [];
if (!empty($parts['query'])) parse_str($parts['query'], $query);
return $this->custom_login_url($query);
}
public function filter_login_site_url($url, $path, $scheme, $blog_id) {
return $this->replace_wp_login_url($url);
}
public function filter_login_network_site_url($url, $path, $scheme) {
return $this->replace_wp_login_url($url);
}
public function maybe_block_default_login() {
if (defined('EKMT_CUSTOM_LOGIN_ENTRY')) return;
$settings = $this->login_settings();
if (empty($settings['slug']) || empty($settings['hide_default'])) return;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only core login routing parameter.
$action = sanitize_key(wp_unslash((string) ($_REQUEST['action'] ?? 'login')));
if ($action === 'postpass') return;
status_header(404);
nocache_headers();
wp_die('Login page not found.', 'Not Found', ['response'=>404]);
}
public function page_duplicate_action($actions, $post) {
if (!$post instanceof WP_Post || $post->post_type !== 'page' || !current_user_can('edit_page', $post->ID)) return $actions;
$url = wp_nonce_url(admin_url('admin-post.php?action=ekmt_duplicate_page&post=' . absint($post->ID)), 'ekmt_duplicate_page_' . $post->ID);
$actions['ekmt_duplicate_page'] = 'Duplicate Page ';
return $actions;
}
public function handle_duplicate_page() {
$post_id = absint(wp_unslash($_GET['post'] ?? 0));
if (!$post_id || !current_user_can('edit_page', $post_id)) wp_die('You cannot duplicate this page.', 'Permission denied', ['response'=>403]);
check_admin_referer('ekmt_duplicate_page_' . $post_id);
$post = get_post($post_id);
if (!$post || $post->post_type !== 'page') wp_die('Page not found.', 'Error', ['response'=>404]);
$new_id = wp_insert_post([
'post_type'=>'page',
'post_status'=>'draft',
'post_title'=>$post->post_title . ' — Copy',
'post_content'=>$post->post_content,
'post_excerpt'=>$post->post_excerpt,
'post_author'=>get_current_user_id(),
'post_parent'=>$post->post_parent,
'menu_order'=>$post->menu_order,
'comment_status'=>$post->comment_status,
'ping_status'=>$post->ping_status,
], true);
if (is_wp_error($new_id)) wp_die(esc_html($new_id->get_error_message()), 'Duplicate failed', ['response'=>500]);
$taxonomies = get_object_taxonomies('page');
foreach ($taxonomies as $taxonomy) {
$terms = wp_get_object_terms($post_id, $taxonomy, ['fields'=>'ids']);
if (!is_wp_error($terms)) wp_set_object_terms($new_id, $terms, $taxonomy, false);
}
$skip = ['_edit_lock','_edit_last','_wp_old_slug'];
$meta = get_post_meta($post_id);
foreach ($meta as $key => $values) {
if (in_array($key, $skip, true)) continue;
foreach ($values as $value) add_post_meta($new_id, $key, maybe_unserialize($value));
}
clean_post_cache($new_id);
wp_safe_redirect(add_query_arg(['post_type'=>'page','ekmt_duplicated'=>$new_id], admin_url('edit.php')));
exit;
}
public function duplicate_page_notice() {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected action.
if (empty($_GET['ekmt_duplicated']) || !$screen || $screen->id !== 'edit-page') return;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected action.
$id = absint(wp_unslash($_GET['ekmt_duplicated']));
if (!$id) return;
echo 'Ahmad Developer Toolkit: Page duplicated successfully as a Draft. Edit duplicate
';
}
public function ajax_save_site_widgets() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'You do not have permission to manage website widgets.'], 403);
$old = $this->site_widgets_settings();
$channels = $this->site_widget_channels();
$data = [
'floating_enabled' => !empty($_POST['floating_enabled']) ? 1 : 0,
'floating_label' => sanitize_text_field(wp_unslash($_POST['floating_label'] ?? 'Contact Us')),
'floating_position' => (($floating_position = sanitize_key(wp_unslash($_POST['floating_position'] ?? 'right'))) && in_array($floating_position, ['left','right'], true)) ? $floating_position : 'right',
'announcement_enabled' => !empty($_POST['announcement_enabled']) ? 1 : 0,
'announcement_text' => sanitize_text_field(wp_unslash($_POST['announcement_text'] ?? '')),
'announcement_button_text' => sanitize_text_field(wp_unslash($_POST['announcement_button_text'] ?? '')),
'announcement_button_url' => esc_url_raw(wp_unslash($_POST['announcement_button_url'] ?? '')),
'announcement_position' => (($announcement_position = sanitize_key(wp_unslash($_POST['announcement_position'] ?? 'top'))) && in_array($announcement_position, ['top','bottom'], true)) ? $announcement_position : 'top',
'announcement_dismissible' => sanitize_key(wp_unslash($_POST['announcement_dismissible'] ?? '1')) === '0' ? '0' : '1',
'announcement_bg' => sanitize_hex_color(wp_unslash($_POST['announcement_bg'] ?? '')) ?: '#111827',
'announcement_color' => sanitize_hex_color(wp_unslash($_POST['announcement_color'] ?? '')) ?: '#ffffff',
'announcement_button_bg' => sanitize_hex_color(wp_unslash($_POST['announcement_button_bg'] ?? '')) ?: '#1667ff',
'trust_enabled' => !empty($_POST['trust_enabled']) ? 1 : 0,
'trust_style' => (($trust_style = sanitize_key(wp_unslash($_POST['trust_style'] ?? 'cards'))) && in_array($trust_style, ['cards','minimal','strip'], true)) ? $trust_style : 'cards',
'trust_columns' => in_array(sanitize_key(wp_unslash((string)($_POST['trust_columns'] ?? '3'))), ['3','4','6'], true) ? sanitize_key(wp_unslash((string)($_POST['trust_columns'] ?? '3'))) : '3',
'trust_accent' => sanitize_hex_color(wp_unslash($_POST['trust_accent'] ?? '')) ?: '#1667ff',
'trust_bg' => sanitize_hex_color(wp_unslash($_POST['trust_bg'] ?? '')) ?: '#ffffff',
'trust_color' => sanitize_hex_color(wp_unslash($_POST['trust_color'] ?? '')) ?: '#161616',
'popup_enabled' => !empty($_POST['popup_enabled']) ? 1 : 0,
'popup_type' => (($popup_type = sanitize_key(wp_unslash($_POST['popup_type'] ?? 'form'))) && in_array($popup_type, ['form','cta'], true)) ? $popup_type : 'form',
'popup_title' => sanitize_text_field(wp_unslash($_POST['popup_title'] ?? '')),
'popup_description' => sanitize_textarea_field(wp_unslash($_POST['popup_description'] ?? '')),
'popup_form' => sanitize_key(wp_unslash($_POST['popup_form'] ?? 'contact')),
'popup_button_text' => sanitize_text_field(wp_unslash($_POST['popup_button_text'] ?? '')),
'popup_button_url' => esc_url_raw(wp_unslash($_POST['popup_button_url'] ?? '')),
'popup_trigger' => (($popup_trigger = sanitize_key(wp_unslash($_POST['popup_trigger'] ?? 'delay'))) && in_array($popup_trigger, ['delay','scroll','exit'], true)) ? $popup_trigger : 'delay',
'popup_delay' => max(1, min(120, absint($_POST['popup_delay'] ?? 5))),
'popup_scroll' => max(10, min(95, absint($_POST['popup_scroll'] ?? 50))),
'popup_frequency' => (($popup_frequency = sanitize_key(wp_unslash($_POST['popup_frequency'] ?? 'session'))) && in_array($popup_frequency, ['session','always'], true)) ? $popup_frequency : 'session',
'popup_theme' => (($popup_theme = sanitize_key(wp_unslash($_POST['popup_theme'] ?? 'light'))) && in_array($popup_theme, ['light','dark'], true)) ? $popup_theme : 'light',
'popup_position' => (($popup_position = sanitize_key(wp_unslash($_POST['popup_position'] ?? 'center'))) && in_array($popup_position, ['center','bottom-right'], true)) ? $popup_position : 'center',
];
$valid_trust_icons = array_keys($this->trust_badge_icon_choices());
for ($i=1; $i<=6; $i++) {
$data['trust_'.$i.'_enabled'] = !empty($_POST['trust_'.$i.'_enabled']) ? 1 : 0;
$icon = sanitize_key(wp_unslash($_POST['trust_'.$i.'_icon'] ?? 'shield'));
$data['trust_'.$i.'_icon'] = in_array($icon, $valid_trust_icons, true) ? $icon : 'shield';
$data['trust_'.$i.'_title'] = sanitize_text_field(wp_unslash($_POST['trust_'.$i.'_title'] ?? ''));
$data['trust_'.$i.'_text'] = sanitize_text_field(wp_unslash($_POST['trust_'.$i.'_text'] ?? ''));
}
foreach ($channels as $key => $channel) {
$data[$key . '_enabled'] = !empty($_POST[$key . '_enabled']) ? 1 : 0;
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized immediately below according to the channel type.
$raw = wp_unslash($_POST[$key] ?? '');
if ($key === 'email') $data[$key] = sanitize_email($raw);
elseif ($key === 'form') $data[$key] = sanitize_key($raw);
else $data[$key] = sanitize_text_field($raw);
}
if ($data['email_enabled'] && $data['email'] && !is_email($data['email'])) wp_send_json_error(['message' => 'Please enter a valid email address for the floating widget.'], 422);
if ($data['form_enabled'] && $data['form'] && !array_key_exists($data['form'], $this->lead_form_definitions())) wp_send_json_error(['message' => 'Please choose a valid Toolkit Form.'], 422);
if ($data['popup_enabled'] && $data['popup_type'] === 'form' && !array_key_exists($data['popup_form'], $this->lead_form_definitions())) wp_send_json_error(['message' => 'Please choose a valid form for the popup.'], 422);
update_option(self::SITE_WIDGETS_OPTION, $data, false);
wp_send_json_success(['message' => 'Website widgets saved. Open the website in a new tab to see the live result.']);
}
public function render_site_widgets() {
if (is_admin()) return;
$s = $this->site_widgets_settings();
if (empty($s['floating_enabled']) && empty($s['announcement_enabled']) && empty($s['popup_enabled'])) return;
if (!empty($s['announcement_enabled']) && trim($s['announcement_text']) !== '') {
$style = '--ekmt-ann-bg:' . esc_attr($s['announcement_bg']) . ';--ekmt-ann-color:' . esc_attr($s['announcement_color']) . ';--ekmt-ann-btn:' . esc_attr($s['announcement_button_bg']) . ';';
echo '' . esc_html($s['announcement_text']) . ' ';
if (!empty($s['announcement_button_text']) && !empty($s['announcement_button_url'])) echo '
' . esc_html($s['announcement_button_text']) . ' ';
if ($s['announcement_dismissible'] === '1') echo '
× ';
echo '
';
}
if (!empty($s['floating_enabled'])) {
$items = [];
foreach ($this->site_widget_channels() as $key => $channel) {
if (empty($s[$key . '_enabled']) || empty($s[$key])) continue;
if ($key === 'form') {
if (array_key_exists($s[$key], $this->lead_form_definitions())) $items[] = ['key'=>$key,'label'=>$channel['label'],'url'=>''];
continue;
}
$url = $this->contact_channel_url($key, $s[$key]);
if ($url) $items[] = ['key'=>$key,'label'=>$channel['label'],'url'=>$url];
}
if ($items) {
echo '' . wp_kses($this->contact_icon_svg('sms'), $this->svg_allowed_html()) . ' ' . esc_html($s['floating_label'] ?: 'Contact Us') . '
';
if (!empty($s['form_enabled']) && !empty($s['form']) && array_key_exists($s['form'], $this->lead_form_definitions())) {
echo '
× ' . do_shortcode('[ahmad_toolkit_form id="' . esc_attr($s['form']) . '"]') . '
';
}
}
}
if (!empty($s['popup_enabled'])) {
$popup_type = in_array($s['popup_type'], ['form','cta'], true) ? $s['popup_type'] : 'form';
$trigger = in_array($s['popup_trigger'], ['delay','scroll','exit'], true) ? $s['popup_trigger'] : 'delay';
$frequency = $s['popup_frequency'] === 'always' ? 'always' : 'session';
$theme = $s['popup_theme'] === 'dark' ? 'dark' : 'light';
$position = $s['popup_position'] === 'bottom-right' ? 'bottom-right' : 'center';
echo '';
}
}
public function ajax_save_maintenance_mode() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'You do not have permission to manage live maintenance mode.'], 403);
$mode = sanitize_key(wp_unslash((string) ($_POST['mode'] ?? 'off')));
if (!in_array($mode, ['off', 'maintenance', 'expired'], true)) wp_send_json_error(['message' => 'Invalid mode selected.'], 400);
if ($mode === 'off') {
update_option(self::MAINTENANCE_MODE_OPTION, 'off', false);
wp_send_json_success([
'mode' => 'off',
'label' => $this->maintenance_mode_label('off'),
'help' => $this->maintenance_mode_help('off'),
'message' => 'The live override page has been disabled.',
]);
}
$defaults = $this->maintenance_defaults($mode);
$title = sanitize_text_field(wp_unslash((string) ($_POST['title'] ?? '')));
$description = sanitize_textarea_field(wp_unslash((string) ($_POST['description'] ?? '')));
$icon = sanitize_key(wp_unslash((string) ($_POST['icon'] ?? $defaults['icon'])));
if (!array_key_exists($icon, $this->maintenance_icon_choices())) $icon = $defaults['icon'];
$settings = [
'title' => $title !== '' ? $title : $defaults['title'],
'description' => $description !== '' ? $description : $defaults['description'],
'icon' => $icon,
];
update_option(self::MAINTENANCE_SETTINGS_OPTION, $settings, false);
update_option(self::MAINTENANCE_MODE_OPTION, $mode, false);
wp_send_json_success([
'mode' => $mode,
'label' => $this->maintenance_mode_label($mode),
'help' => $this->maintenance_mode_help($mode),
'message' => $mode === 'maintenance' ? 'The maintenance page is now active for visitors.' : 'The hosting notice page is now active for visitors.',
'settings' => $settings,
'symbol' => $this->maintenance_icon_symbol($settings['icon']),
]);
}
public function maybe_render_maintenance_page() {
if (is_admin() || wp_doing_ajax()) return;
if ((defined('REST_REQUEST') && REST_REQUEST) || (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST)) return;
$mode = $this->maintenance_mode();
if ($mode === 'off') return;
if (is_user_logged_in() && current_user_can('manage_options')) return;
if ($mode === 'maintenance') {
status_header(503);
header('Retry-After: 3600');
} else {
status_header(200);
}
nocache_headers();
header('X-Robots-Tag: noindex, nofollow', true);
$site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
$settings = $this->maintenance_settings($mode);
$title = $settings['title'];
$description = $settings['description'];
$symbol = $this->maintenance_icon_symbol($settings['icon']);
$badge = $mode === 'expired' ? 'Hosting Notice' : 'Maintenance Mode';
$html_lang = get_bloginfo('language');
$html_dir = is_rtl() ? 'rtl' : 'ltr';
wp_enqueue_style(
'ahmad-developer-toolkit-maintenance',
plugin_dir_url(__FILE__) . 'assets/maintenance.css',
[],
self::VERSION
);
echo '' . esc_html($site) . ' ';
wp_print_styles(['ahmad-developer-toolkit-maintenance']);
echo ' ' . esc_html($symbol) . '
' . esc_html($badge) . '
' . esc_html($title) . ' ' . esc_html($description) . '
';
exit;
}
public function page_note_columns($columns) {
$new = [];
foreach ($columns as $key => $label) {
$new[$key] = $label;
if ($key === 'title') $new['ekmt_admin_note'] = 'Toolkit Note';
}
if (!isset($new['ekmt_admin_note'])) $new['ekmt_admin_note'] = 'Toolkit Note';
return $new;
}
public function page_note_column_content($column, $post_id) {
if ($column !== 'ekmt_admin_note') return;
$note = trim((string) get_post_meta($post_id, self::NOTE_META, true));
if ($note !== '') {
$snippet = wp_html_excerpt($note, 62, '…');
echo ''
. 'Note added ' . esc_html($snippet) . ' ';
} else {
echo ''
. 'Add note Internal agency note ';
}
}
public function add_page_note_metabox() {
add_meta_box(
'ekmt-page-note',
'Toolkit Admin Note',
[$this, 'render_page_note_metabox'],
'page',
'side',
'default'
);
}
public function render_page_note_metabox($post) {
$note = (string) get_post_meta($post->ID, self::NOTE_META, true);
wp_nonce_field('ekmt_save_page_note', 'ekmt_page_note_nonce');
echo ' Internal only — never shown on the public page.
';
echo '' . esc_textarea($note) . ' ';
if ($note !== '') echo ' Note added
';
}
public function save_page_note($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
if (empty($_POST['ekmt_page_note_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ekmt_page_note_nonce'])), 'ekmt_save_page_note')) return;
if (!current_user_can('edit_post', $post_id)) return;
$note = sanitize_textarea_field(wp_unslash($_POST['ekmt_admin_note'] ?? ''));
if ($note === '') delete_post_meta($post_id, self::NOTE_META);
else update_post_meta($post_id, self::NOTE_META, $note);
}
public function render_page_note_modal() {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->post_type !== 'page') return;
?>
×
AD
AHMAD • PAGE NOTE
Admin Note
Private agency/developer note. It is not displayed on the website.
Cancel
Save Note
'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || get_post_type($id) !== 'page' || !current_user_can('edit_post', $id)) {
wp_send_json_error(['message' => 'You cannot edit this page note.'], 403);
}
wp_send_json_success([
'note' => (string) get_post_meta($id, self::NOTE_META, true),
'title' => get_the_title($id),
]);
}
public function ajax_save_admin_note() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$id = absint($_POST['id'] ?? 0);
if (!$id || get_post_type($id) !== 'page' || !current_user_can('edit_post', $id)) {
wp_send_json_error(['message' => 'You cannot edit this page note.'], 403);
}
$note = sanitize_textarea_field(wp_unslash($_POST['note'] ?? ''));
if ($note === '') delete_post_meta($id, self::NOTE_META);
else update_post_meta($id, self::NOTE_META, $note);
wp_send_json_success([
'message' => $note === '' ? 'Note removed.' : 'Note saved.',
'note' => $note,
'snippet' => $note === '' ? '' : wp_html_excerpt($note, 62, '…'),
]);
}
public function add_privacy_policy_content() {
if (!function_exists('wp_add_privacy_policy_content')) return;
$content = 'Ahmad Developer Toolkit stores data locally on this WordPress website when site administrators use certain tools.
';
$content .= 'Toolkit Forms can store submitted names, email addresses, phone numbers, messages, selected services, and technical submission metadata as private WordPress entries. The plugin also stores aggregate local click/conversion counters, SEO scan history, administrator page notes, and IndexNow request history.
';
$content .= 'The plugin does not send analytics or feature-usage telemetry to the developer. When an administrator explicitly submits a URL through the IndexNow tool, the selected URL, site host, generated IndexNow key, and key-location URL are sent to the IndexNow service for search-engine notification.
';
wp_add_privacy_policy_content('Ahmad Developer Toolkit', wp_kses_post($content));
}
/* -------------------------------------------------------------------------
* Free Instant Indexing / IndexNow
* ---------------------------------------------------------------------- */
public function indexnow_enforce_manual_mode() {
if (get_option('ekmt_indexnow_manual_mode_v308') === '1') return;
$settings = get_option(self::INDEXNOW_SETTINGS_OPTION, []);
if (!is_array($settings)) $settings = [];
$settings['auto_submit'] = 0;
update_option(self::INDEXNOW_SETTINGS_OPTION, $settings, false);
wp_clear_scheduled_hook('ekmt_indexnow_submit_event');
update_option('ekmt_indexnow_manual_mode_v308', '1', false);
}
private function indexnow_tracking_all() {
$items = get_option(self::INDEXNOW_TRACKING_OPTION, []);
return is_array($items) ? $items : [];
}
private function indexnow_tracking_key($url) {
return md5(untrailingslashit((string)$url));
}
private function indexnow_tracking_for($url) {
$url = $this->indexnow_normalize_local_url($url);
if ($url === '') return null;
$all = $this->indexnow_tracking_all();
$key = $this->indexnow_tracking_key($url);
return isset($all[$key]) && is_array($all[$key]) ? $all[$key] : null;
}
private function indexnow_update_tracking($url, array $changes) {
$url = $this->indexnow_normalize_local_url($url);
if ($url === '') return;
$all = $this->indexnow_tracking_all();
$key = $this->indexnow_tracking_key($url);
$current = isset($all[$key]) && is_array($all[$key]) ? $all[$key] : [];
$current = wp_parse_args($current, [
'url' => $url,
'first_submitted_at' => 0,
'last_submitted_at' => 0,
'last_status' => '',
'last_code' => 0,
'last_message' => '',
'last_source' => '',
'confirmed_indexed_at' => 0,
'confirmed_by' => '',
]);
foreach ($changes as $k => $v) $current[$k] = $v;
$current['url'] = $url;
$all[$key] = $current;
if (count($all) > 500) {
uasort($all, function($a, $b){ return absint($b['last_submitted_at'] ?? 0) <=> absint($a['last_submitted_at'] ?? 0); });
$all = array_slice($all, 0, 500, true);
}
update_option(self::INDEXNOW_TRACKING_OPTION, $all, false);
}
public function indexnow_row_action($actions, $post) {
if (!$post instanceof WP_Post || $post->post_status !== 'publish') return $actions;
$obj = get_post_type_object($post->post_type);
if (!$obj || empty($obj->public) || $post->post_type === 'attachment') return $actions;
if (!current_user_can('edit_post', $post->ID)) return $actions;
$url = wp_nonce_url(
admin_url('admin-post.php?action=ekmt_indexnow_submit_post&post=' . absint($post->ID)),
'ekmt_indexnow_submit_post_' . absint($post->ID)
);
$track = $this->indexnow_tracking_for(get_permalink($post->ID));
$label = !empty($track['last_submitted_at']) ? 'IndexNow Again' : 'IndexNow';
$actions['ekmt_indexnow'] = '' . esc_html($label) . ' ';
return $actions;
}
public function handle_indexnow_submit_post() {
$post_id = absint(wp_unslash($_GET['post'] ?? 0));
if (!$post_id || !current_user_can('edit_post', $post_id)) wp_die('You cannot submit this URL.', 'Permission denied', ['response'=>403]);
check_admin_referer('ekmt_indexnow_submit_post_' . $post_id);
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') wp_die('Only a published public URL can be submitted.', 'IndexNow', ['response'=>400]);
$obj = get_post_type_object($post->post_type);
if (!$obj || empty($obj->public) || $post->post_type === 'attachment') wp_die('This post type is not publicly indexable.', 'IndexNow', ['response'=>400]);
$url = get_permalink($post_id);
$result = $this->indexnow_submit_urls([$url], 'Page list');
$redirect = wp_get_referer();
if (!$redirect) $redirect = admin_url('edit.php?post_type=' . urlencode($post->post_type));
if (is_wp_error($result)) {
$redirect = add_query_arg(['ekmt_ix'=>'error','ekmt_ix_msg'=>rawurlencode($result->get_error_message())], $redirect);
} else {
$redirect = add_query_arg(['ekmt_ix'=>'sent','ekmt_ix_post'=>$post_id], $redirect);
}
wp_safe_redirect($redirect);
exit;
}
public function indexnow_admin_notice() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action.
if (empty($_GET['ekmt_ix'])) return;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action.
$type = sanitize_key(wp_unslash($_GET['ekmt_ix']));
if ($type === 'sent') {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action.
$post_id = absint(wp_unslash($_GET['ekmt_ix_post'] ?? 0));
$title = $post_id ? get_the_title($post_id) : '';
echo 'Toolkit IndexNow: Request accepted for ' . esc_html($title ?: 'the selected URL') . '.
';
} elseif ($type === 'error') {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action.
$raw_message = isset($_GET['ekmt_ix_msg']) ? sanitize_text_field(wp_unslash($_GET['ekmt_ix_msg'])) : 'IndexNow request failed.';
$message = sanitize_text_field(rawurldecode($raw_message));
echo 'Toolkit IndexNow: ' . esc_html($message) . '
';
}
}
private function indexnow_settings() {
$saved = get_option(self::INDEXNOW_SETTINGS_OPTION, []);
if (!is_array($saved)) $saved = [];
return wp_parse_args($saved, [
'auto_submit' => 0,
]);
}
private function indexnow_key() {
$settings = $this->indexnow_settings();
$key = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', (string)($settings['key'] ?? '')));
if (strlen($key) < 8) {
try {
$key = bin2hex(random_bytes(16));
} catch (Exception $e) {
$key = strtolower(wp_generate_password(32, false, false));
}
$settings['key'] = $key;
update_option(self::INDEXNOW_SETTINGS_OPTION, $settings, false);
}
return $key;
}
private function indexnow_key_location() {
return home_url('/' . $this->indexnow_key() . '.txt');
}
public function indexnow_maybe_serve_key_file() {
$request_path = wp_parse_url(sanitize_text_field(wp_unslash((string)($_SERVER['REQUEST_URI'] ?? ''))), PHP_URL_PATH);
$key_path = wp_parse_url($this->indexnow_key_location(), PHP_URL_PATH);
if (!is_string($request_path) || !is_string($key_path)) return;
if (untrailingslashit(rawurldecode($request_path)) !== untrailingslashit(rawurldecode($key_path))) return;
status_header(200);
nocache_headers();
header('Content-Type: text/plain; charset=utf-8');
echo esc_html($this->indexnow_key());
exit;
}
private function indexnow_normalize_local_url($url) {
$url = esc_url_raw(trim((string)$url));
if ($url === '') return '';
$scheme = strtolower((string)wp_parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http','https'], true)) return '';
$url_host = strtolower((string)wp_parse_url($url, PHP_URL_HOST));
$home_host = strtolower((string)wp_parse_url(home_url('/'), PHP_URL_HOST));
$url_host = preg_replace('/^www\./', '', $url_host);
$home_host = preg_replace('/^www\./', '', $home_host);
if ($url_host === '' || $home_host === '' || $url_host !== $home_host) return '';
return $url;
}
private function indexnow_history() {
$history = get_option(self::INDEXNOW_HISTORY_OPTION, []);
return is_array($history) ? $history : [];
}
private function indexnow_add_history($url, $source, $ok, $code, $message) {
$history = $this->indexnow_history();
array_unshift($history, [
'time' => time(),
'url' => esc_url_raw($url),
'source' => sanitize_text_field($source),
'status' => $ok ? 'accepted' : 'failed',
'code' => absint($code),
'message' => sanitize_text_field($message),
]);
if (count($history) > 200) $history = array_slice($history, 0, 200);
update_option(self::INDEXNOW_HISTORY_OPTION, $history, false);
$now = time();
$track = $this->indexnow_tracking_for($url);
$first = absint($track['first_submitted_at'] ?? 0);
$this->indexnow_update_tracking($url, [
'first_submitted_at' => $first ?: $now,
'last_submitted_at' => $now,
'last_status' => $ok ? 'accepted' : 'failed',
'last_code' => absint($code),
'last_message' => sanitize_text_field($message),
'last_source' => sanitize_text_field($source),
]);
}
private function indexnow_submit_urls(array $urls, $source = 'Manual') {
$clean = [];
foreach ($urls as $url) {
$url = $this->indexnow_normalize_local_url($url);
if ($url !== '') $clean[$url] = true;
if (count($clean) >= 10000) break;
}
$urls = array_keys($clean);
if (!$urls) return new WP_Error('indexnow_no_urls', 'No valid URLs from this website were supplied.');
$payload = [
'host' => (string)wp_parse_url(home_url('/'), PHP_URL_HOST),
'key' => $this->indexnow_key(),
'keyLocation' => $this->indexnow_key_location(),
'urlList' => array_values($urls),
];
$response = wp_remote_post('https://api.indexnow.org/indexnow', [
'timeout' => 18,
'redirection' => 3,
'headers' => [
'Content-Type' => 'application/json; charset=utf-8',
'Accept' => 'application/json, text/plain, */*',
],
'user-agent' => '11KeysMediaToolkit/' . self::VERSION . '; ' . home_url('/'),
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
$message = $response->get_error_message();
foreach ($urls as $url) $this->indexnow_add_history($url, $source, false, 0, $message);
return $response;
}
$code = (int)wp_remote_retrieve_response_code($response);
$ok = in_array($code, [200, 202], true);
$messages = [
200 => 'IndexNow accepted the URL notification.',
202 => 'IndexNow accepted the notification and may still be validating the key.',
400 => 'IndexNow rejected the request as invalid.',
403 => 'IndexNow could not validate the key for this website.',
422 => 'One or more URLs do not belong to the submitted host or failed validation.',
429 => 'IndexNow rate limit reached. Try again later.',
];
$message = $messages[$code] ?? ('IndexNow returned HTTP ' . $code . '.');
foreach ($urls as $url) $this->indexnow_add_history($url, $source, $ok, $code, $message);
if (!$ok) return new WP_Error('indexnow_http_' . $code, $message, ['status'=>$code]);
return [
'count' => count($urls),
'code' => $code,
'message' => $message,
];
}
private function indexnow_last_submission_for($url) {
$needle = untrailingslashit((string)$url);
foreach ($this->indexnow_history() as $item) {
if (untrailingslashit((string)($item['url'] ?? '')) === $needle) return $item;
}
return null;
}
private function indexnow_check_page($url) {
$url = $this->indexnow_normalize_local_url($url);
if ($url === '') return new WP_Error('indexnow_bad_url', 'Enter a valid URL from this website.');
$response = wp_safe_remote_get($url, [
'timeout' => 15,
'redirection' => 5,
'user-agent' => '11KeysIndexabilityChecker/' . self::VERSION,
]);
if (is_wp_error($response)) return $response;
$code = (int)wp_remote_retrieve_response_code($response);
$html = (string)wp_remote_retrieve_body($response);
$xrobots = strtolower((string)wp_remote_retrieve_header($response, 'x-robots-tag'));
$noindex = strpos($xrobots, 'noindex') !== false;
$meta_robots = '';
if (preg_match('/ ]+name=["\']robots["\'][^>]+content=["\']([^"\']*)["\'][^>]*>/i', $html, $m) || preg_match('/ ]+content=["\']([^"\']*)["\'][^>]+name=["\']robots["\'][^>]*>/i', $html, $m)) {
$meta_robots = strtolower(trim($m[1]));
if (strpos($meta_robots, 'noindex') !== false) $noindex = true;
}
$canonical = '';
if (preg_match('/ ]+rel=["\'][^"\']*canonical[^"\']*["\'][^>]+href=["\']([^"\']+)["\']/i', $html, $m) || preg_match('/ ]+href=["\']([^"\']+)["\'][^>]+rel=["\'][^"\']*canonical[^"\']*["\']/i', $html, $m)) {
$canonical = esc_url_raw(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$global_block = (string)get_option('blog_public', '1') === '0';
$indexable = $code >= 200 && $code < 300 && !$noindex && !$global_block;
$reasons = [];
if ($code < 200 || $code >= 300) $reasons[] = 'HTTP status is ' . $code;
if ($global_block) $reasons[] = 'WordPress is set to discourage search engines';
if ($noindex) $reasons[] = 'A noindex directive was detected';
if (!$reasons) $reasons[] = 'No obvious noindex block was detected';
$last = $this->indexnow_last_submission_for($url);
return [
'url' => $url,
'http_code' => $code,
'indexable' => $indexable,
'reason' => implode('. ', $reasons) . '.',
'canonical' => $canonical,
'canonical_matches' => $canonical === '' ? null : (untrailingslashit($canonical) === untrailingslashit($url)),
'meta_robots' => $meta_robots,
'x_robots' => $xrobots,
'last_submission' => $last,
'tracking' => $this->indexnow_tracking_for($url),
'google_search' => 'https://www.google.com/search?q=' . rawurlencode('site:' . $url),
'bing_search' => 'https://www.bing.com/search?q=' . rawurlencode('url:' . $url),
];
}
public function ajax_indexnow_submit() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? '')));
if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400);
$result = $this->indexnow_submit_urls([$url], 'Manual');
if (is_wp_error($result)) wp_send_json_error(['message'=>$result->get_error_message()], 400);
wp_send_json_success($result + ['url'=>$url]);
}
public function ajax_indexnow_check() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$result = $this->indexnow_check_page(esc_url_raw(wp_unslash($_POST['url'] ?? '')));
if (is_wp_error($result)) wp_send_json_error(['message'=>$result->get_error_message()], 400);
wp_send_json_success($result);
}
public function ajax_indexnow_history() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$rows = [];
$seen = [];
foreach ($this->indexnow_history() as $item) {
$url = $this->indexnow_normalize_local_url($item['url'] ?? '');
if ($url === '') continue;
$key = $this->indexnow_tracking_key($url);
if (isset($seen[$key])) continue;
$seen[$key] = true;
$item['tracking'] = $this->indexnow_tracking_for($url);
$rows[] = $item;
}
wp_send_json_success(['items'=>$rows]);
}
public function ajax_indexnow_mark_indexed() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? '')));
if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400);
$track = $this->indexnow_tracking_for($url);
if (!$track || empty($track['first_submitted_at'])) wp_send_json_error(['message'=>'Submit this URL to IndexNow before recording indexing confirmation.'], 400);
if (empty($track['confirmed_indexed_at'])) {
$this->indexnow_update_tracking($url, [
'confirmed_indexed_at' => time(),
'confirmed_by' => 'Manual search verification',
]);
}
wp_send_json_success(['message'=>'Indexed status confirmed and timing recorded.','tracking'=>$this->indexnow_tracking_for($url)]);
}
public function ajax_indexnow_unmark_indexed() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
$url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? '')));
if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400);
$this->indexnow_update_tracking($url, ['confirmed_indexed_at'=>0,'confirmed_by'=>'']);
wp_send_json_success(['message'=>'Index confirmation removed.']);
}
public function ajax_indexnow_clear_history() {
check_ajax_referer(self::NONCE, 'nonce');
if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403);
if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can clear IndexNow history.'], 403);
delete_option(self::INDEXNOW_HISTORY_OPTION);
delete_option(self::INDEXNOW_TRACKING_OPTION);
wp_send_json_success(['message'=>'IndexNow request and confirmation history cleared.']);
}
private function clear_elementor_cache() {
if (did_action('elementor/loaded') && class_exists('\\Elementor\\Plugin')) {
try {
$plugin = \Elementor\Plugin::$instance;
if (isset($plugin->files_manager)) $plugin->files_manager->clear_cache();
} catch (Throwable $e) {}
}
}
}
new Eleven_Keys_Media_Toolkit();