getMessage()); $chunks = []; } self::log(sprintf('context for %d: %s', $attachment_id, $chunks[0] ?? '(none)')); self::$cache[$attachment_id] = $chunks; return $chunks; } /** * Context for several images, resolved in one pass. * * @param int[] $attachment_ids * @return array Attachment id => zero or one chunk */ public static function for_attachments(array $attachment_ids): array { $ids = array_values(array_unique(array_filter(array_map('intval', $attachment_ids)))); if (!self::enabled() || !$ids) { return []; } try { self::prime($ids); } catch (\Throwable $e) { self::log('batch context lookup failed: ' . $e->getMessage()); } $out = []; foreach ($ids as $id) { $out[$id] = self::for_attachment($id); } return $out; } /** * Resolve placements for many ids with two queries instead of two per id. * * @param int[] $ids */ private static function prime(array $ids): void { global $wpdb; // Already-cached ids need no query; the chunk-level prime must not undo // work the single path did earlier in the same request. $ids = array_values(array_filter($ids, static fn(int $id): bool => !isset(self::$cache[$id]))); if (!$ids) { return; } $placeholders = implode(',', array_fill(0, count($ids), '%d')); $featured = $wpdb->get_results($wpdb->prepare( "SELECT pm.meta_value AS attachment_id, pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value IN ($placeholders) AND p.post_status = 'publish'", ...$ids )); $by_id = []; foreach ($featured as $row) { $by_id[(int) $row->attachment_id][] = (int) $row->post_id; } // One scan for the whole chunk. post_content is returned so each post can // be attributed to the ids it actually mentions. $rows = $wpdb->get_results($wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_content REGEXP %s LIMIT %d", self::id_pattern($ids), count($ids) * self::MAX_POSTS )); foreach ($rows as $row) { foreach ($ids as $id) { if (preg_match('/wp-image-' . $id . '(?![0-9])/', (string) $row->post_content)) { $by_id[$id][] = (int) $row->ID; } } } foreach ($ids as $id) { $post_ids = array_slice(array_values(array_unique($by_id[$id] ?? [])), 0, self::MAX_POSTS); if (!$post_ids) { self::$cache[$id] = []; self::log(sprintf('context for %d: %s', $id, '(none)')); continue; } [$titles, $bodies] = self::collect_text($post_ids); self::$cache[$id] = ($titles || $bodies) ? self::merge_chunk($titles, $bodies, self::tokenize_filename($id)) : []; self::log(sprintf('context for %d: %s', $id, self::$cache[$id][0] ?? '(none)')); } } /** * Published posts displaying this image. * * @return int[] */ private static function locate_posts(int $attachment_id): array { global $wpdb; // Featured images never appear in post_content, so both sources are needed. $featured = $wpdb->get_col($wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %d AND p.post_status = 'publish' LIMIT %d", $attachment_id, self::MAX_POSTS )); // The block editor writes class="wp-image-" on inserted images, which // is the only durable link from an attachment to the posts displaying it. // REGEXP rather than LIKE: measurably faster, and the digit boundary stops // image 14 matching wp-image-140. $embedded = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_content REGEXP %s LIMIT %d", self::id_pattern([$attachment_id]), self::MAX_POSTS )); $ids = array_map('intval', array_merge((array) $featured, (array) $embedded)); return array_slice(array_values(array_unique($ids)), 0, self::MAX_POSTS); } /** * REGEXP pattern matching any of the given attachment ids as a wp-image class. * * Alternation costs the same as a single id, so the batch path in phase 3 * resolves a whole chunk in one scan. * * @param int[] $attachment_ids */ private static function id_pattern(array $attachment_ids): string { $alternation = implode('|', array_map('intval', $attachment_ids)); return 'wp-image-(' . $alternation . ')([^0-9]|$)'; } /** * Titles and body texts from the located posts. * * @param int[] $post_ids * @return array{0: string[], 1: string[]} */ private static function collect_text(array $post_ids): array { $titles = []; $bodies = []; foreach ($post_ids as $post_id) { $post = get_post($post_id); if (!$post) { continue; } $title = trim((string) $post->post_title); if ($title !== '') { $titles[] = $title; } $body = self::normalize((string) $post->post_content); if ($body !== '') { $bodies[] = $body; } } return [$titles, $bodies]; } /** * Convert the original filename to search terms. * "thilo-hartmann-vorsitzender.jpg" -> "thilo hartmann vorsitzender" * * The filename often disambiguates which of several images on one post is * shown, e.g. three speaker portraits on a single article. */ private static function tokenize_filename(int $attachment_id): string { $path = get_attached_file($attachment_id); if (!$path) { return ''; } $name = pathinfo(wp_basename($path), PATHINFO_FILENAME); // Camera and phone prefixes carry no meaning. $name = (string) preg_replace('/^(IMG|DSC|DSCN|DSCF|P|SAM|MOV|VID)[-_]?\d+/i', '', $name); // WordPress appends -scaled to the stored original above the big-image threshold. $name = (string) preg_replace('/-scaled$/', '', $name); // Defensive: a size suffix if a resized name ever reaches here. $name = (string) preg_replace('/-\d+x\d+$/', '', $name); $terms = (string) preg_replace('/[-_]+/', ' ', $name); // Dates and counters add noise. $terms = (string) preg_replace('/\b\d+\b/', '', $terms); return trim((string) preg_replace('/\s+/', ' ', $terms)); } /** * Strip markup and decode entities. * * post_content is raw HTML, so entities survive strip_tags and would reach * the prompt as literal " ". Decoded non-breaking spaces are not * matched by \s, so they are replaced explicitly. */ private static function normalize(string $text): string { $text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = str_replace("\xC2\xA0", ' ', $text); return trim((string) preg_replace('/\s+/', ' ', $text)); } /** * Titles from every placement plus the single richest body, capped. * * @param string[] $titles * @param string[] $bodies * @return string[] */ private static function merge_chunk(array $titles, array $bodies, string $filename_terms): array { usort($bodies, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); $hint = $filename_terms !== '' ? 'Image filename: ' . $filename_terms : ''; $parts = array_filter([$hint, ...$titles, $bodies[0] ?? '']); $chunk = implode('. ', array_unique($parts)); if ($chunk === '') { return []; } return [mb_substr($chunk, 0, self::CONTEXT_MAX_LENGTH)]; } private static function log(string $message): void { AIGUDE_Tools_Plugin::log($message); } }