From d56038676d2f07bf48352c3039f5f1e5ff15f0bd Mon Sep 17 00:00:00 2001 From: colorlink Date: Fri, 16 Oct 2026 01:08:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(batch5):=20media/content=20utilities=20?= =?UTF-8?q?=E2=80=94=20set/bulk=20alt,=20broken=20internal=20links,=20larg?= =?UTF-8?q?e=20images,=20duplicate=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- includes/Extensions/Batch5.php | 149 +++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 includes/Extensions/Batch5.php diff --git a/includes/Extensions/Batch5.php b/includes/Extensions/Batch5.php new file mode 100644 index 0000000..b519f3f --- /dev/null +++ b/includes/Extensions/Batch5.php @@ -0,0 +1,149 @@ + 'object', 'properties' => $props]; + if ($required) $schema['required'] = $required; + return ['name' => $name, 'description' => $desc, 'scope' => $scope, + 'handler' => 'AskTamMCP\\Extensions\\Batch5::' . $method, 'inputSchema' => $schema]; + }; + + $registry->register($def('set_image_alt', 'Set (or clear) the alt text of a media-library image attachment.', + ['attachment_id' => ['type' => 'integer'], 'alt' => ['type' => 'string']], + ['attachment_id', 'alt'], 'write', 'setAlt')); + + $registry->register($def('bulk_set_alt_from_title', 'Backfill alt text for image attachments from their title (or a humanized filename). By default only fills images that have no alt; set overwrite=true to replace existing alts.', + ['per_page' => ['type' => 'integer', 'default' => 100], 'overwrite' => ['type' => 'boolean', 'default' => false]], + [], 'write', 'bulkAlt')); + + $registry->register($def('find_broken_internal_links', 'Scan published posts/pages for internal links whose target slug does not resolve to a post/page (candidate broken links). Heuristic: term/archive URLs may appear as false positives.', + ['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['post', 'page']], 'per_page' => ['type' => 'integer', 'default' => 100]], + [], 'read', 'brokenLinks')); + + $registry->register($def('list_large_images', 'List media-library images whose file size exceeds a threshold (KB), largest first.', + ['min_kb' => ['type' => 'integer', 'default' => 500], 'per_page' => ['type' => 'integer', 'default' => 200]], + [], 'read', 'largeImages')); + + $registry->register($def('duplicate_title_finder', 'Find published posts/pages that share an identical title.', + ['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['post', 'page']], 'per_page' => ['type' => 'integer', 'default' => 500]], + [], 'read', 'dupTitles')); + } + + public static function setAlt(array $args): array { + $id = (int) ($args['attachment_id'] ?? 0); + $post = $id ? get_post($id) : null; + if (!$post || $post->post_type !== 'attachment' || strpos((string) get_post_mime_type($id), 'image/') !== 0) { + throw new \RuntimeException("Not an image attachment: {$id}"); + } + $alt = sanitize_text_field((string) ($args['alt'] ?? '')); + update_post_meta($id, '_wp_attachment_image_alt', $alt); + return ['success' => true, 'attachment_id' => $id, 'alt' => $alt]; + } + + private static function humanizeFilename(int $id): string { + $file = get_attached_file($id); + if (!$file) return ''; + $name = pathinfo($file, PATHINFO_FILENAME); + $name = preg_replace('/[-_]+/', ' ', $name); + $name = preg_replace('/\b\d{2,4}x\d{2,4}\b/', '', $name); // drop size suffixes + return ucwords(trim(preg_replace('/\s+/', ' ', $name))); + } + + public static function bulkAlt(array $args): array { + $per = (int) ($args['per_page'] ?? 100); + $overwrite = !empty($args['overwrite']); + $atts = get_posts(['post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'posts_per_page' => $per]); + $changed = []; + foreach ($atts as $a) { + $cur = trim((string) get_post_meta($a->ID, '_wp_attachment_image_alt', true)); + if ($cur !== '' && !$overwrite) continue; + $alt = trim((string) $a->post_title); + if ($alt === '') $alt = self::humanizeFilename($a->ID); + if ($alt === '') continue; + update_post_meta($a->ID, '_wp_attachment_image_alt', sanitize_text_field($alt)); + $changed[] = ['id' => $a->ID, 'alt' => $alt]; + } + return ['scanned' => count($atts), 'updated' => count($changed), 'overwrite' => $overwrite, 'samples' => array_slice($changed, 0, 20)]; + } + + public static function brokenLinks(array $args): array { + $types = $args['post_types'] ?? ['post', 'page']; + $per = (int) ($args['per_page'] ?? 100); + $home = home_url(); + $host = parse_url($home, PHP_URL_HOST); + $posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per, 'fields' => 'ids']); + $issues = []; + if (!class_exists('DOMDocument')) return ['note' => 'DOMDocument unavailable', 'issues' => []]; + foreach ($posts as $pid) { + $pid = (int) $pid; + $html = (string) get_post_field('post_content', $pid); + if (trim($html) === '') continue; + $dom = new \DOMDocument(); + libxml_use_internal_errors(true); + $dom->loadHTML('' . $html); + libxml_clear_errors(); + foreach ($dom->getElementsByTagName('a') as $a) { + $href = trim($a->getAttribute('href')); + if ($href === '' || preg_match('#^(mailto:|tel:|#|javascript:)#i', $href)) continue; + $h = parse_url($href, PHP_URL_HOST); + if ($h && $h !== $host) continue; // external + $pathUrl = $h ? $href : rtrim($home, '/') . '/' . ltrim($href, '/'); + $resolved = url_to_postid($pathUrl); + if (!$resolved) { + $issues[] = ['post_id' => $pid, 'href' => $href, 'anchor' => trim($a->textContent)]; + } + } + } + return ['scanned' => count($posts), 'candidate_broken' => $issues, 'count' => count($issues), + 'note' => 'Heuristic: links to category/tag/author archives or custom routes can show as false positives.']; + } + + public static function largeImages(array $args): array { + $minKb = max(1, (int) ($args['min_kb'] ?? 500)); + $per = (int) ($args['per_page'] ?? 200); + $atts = get_posts(['post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'posts_per_page' => $per]); + $big = []; + foreach ($atts as $a) { + $file = get_attached_file($a->ID); + if (!$file || !file_exists($file)) continue; + $kb = (int) round(filesize($file) / 1024); + if ($kb >= $minKb) { + $big[] = ['id' => $a->ID, 'title' => $a->post_title, 'kb' => $kb, 'url' => wp_get_attachment_url($a->ID)]; + } + } + usort($big, fn($x, $y) => $y['kb'] <=> $x['kb']); + return ['scanned' => count($atts), 'threshold_kb' => $minKb, 'large_images' => $big, 'count' => count($big)]; + } + + public static function dupTitles(array $args): array { + $types = $args['post_types'] ?? ['post', 'page']; + $per = (int) ($args['per_page'] ?? 500); + $posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per]); + $byTitle = []; + foreach ($posts as $p) { + $key = mb_strtolower(trim($p->post_title)); + if ($key === '') continue; + $byTitle[$key][] = ['id' => $p->ID, 'type' => $p->post_type]; + } + $dups = []; + foreach ($byTitle as $title => $group) { + if (count($group) > 1) $dups[] = ['title' => $title, 'posts' => $group, 'count' => count($group)]; + } + return ['scanned' => count($posts), 'duplicates' => $dups, 'duplicate_sets' => count($dups)]; + } +}