without alt gets, in this order, the */ /* media-library alt, the caption, the title, a humanised file */ /* name. Nothing is written to the database; the page just stops */ /* shipping empty alts. */ /* 2. Description (opt-in): attachments with no alt at all are sent */ /* to the Citami platform, a vision model describes them in the */ /* site's language, the text lands in the media library where the */ /* editor can review it. Monthly quota per account; over quota */ /* the count is shown, never hidden. */ /* ────────────────────────────────────────────────────────────────── */ define('CITAMI_ALT_SETTINGS_OPT', 'citami_alt_settings'); define('CITAMI_ALT_LOG_OPT', 'citami_alt_log'); define('CITAMI_ALT_HOOK', 'citami_alt_backfill'); define('CITAMI_ALT_BATCH', 10); define('CITAMI_ALT_LOG_MAX', 30); define('CITAMI_META_ALT_SOURCE', '_citami_alt_source'); define('CITAMI_ALT_LAST_ITEMS', 10); // 2.60 — images of the last run listed under the row define('CITAMI_ALT_MISSING_SHOWN', 30); // 2.60 — images without alt listed behind the count function citami_alt_settings() { $d = array('fill' => 'on', 'ai' => 'off'); $s = get_option(CITAMI_ALT_SETTINGS_OPT, array()); if (is_array($s)) { if (isset($s['fill']) && $s['fill'] === 'off') { $d['fill'] = 'off'; } if (isset($s['ai']) && $s['ai'] === 'on') { $d['ai'] = 'on'; } } return $d; } add_action('admin_init', function () { register_setting('citami_seo', CITAMI_ALT_SETTINGS_OPT, array( 'type' => 'array', 'sanitize_callback' => function ($v) { $in = is_array($v) ? $v : array(); return array( 'fill' => (isset($in['fill']) && $in['fill'] === 'off') ? 'off' : 'on', 'ai' => (isset($in['ai']) && $in['ai'] === 'on') ? 'on' : 'off', ); }, 'default' => array(), )); }); /* ---------------------------------------------------------------- */ /* Deterministic alt */ /* ---------------------------------------------------------------- */ /** "impianto-dentale_torino-2024-scaled.jpg" → "Impianto dentale torino". */ function citami_alt_from_filename($url_or_name) { $name = (string) $url_or_name; $path = (string) wp_parse_url($name, PHP_URL_PATH); if ($path !== '') { $name = $path; } $name = basename($name); $name = preg_replace('/\.[a-z0-9]{2,5}$/i', '', $name); $name = preg_replace('/-\d{2,4}x\d{2,4}$/', '', $name); // -300x200 (WordPress sizes) $name = preg_replace('/-(scaled|rotated|e\d{10,})$/i', '', $name); $name = preg_replace('/[-_+.]+/', ' ', rawurldecode($name)); $name = preg_replace('/\b(img|dsc|dscn|dcim|pxl|mvimg|p)[\s_-]*\d{3,}\b/i', ' ', $name); // camera names glued to digits (DSC00012) $name = preg_replace('/\b(img|image|dsc|dscn|dcim|photo|pic|screenshot|screen shot|whatsapp image|foto)\b/i', ' ', $name); $name = preg_replace('/\b\d{1,2}[.\-]\d{1,2}[.\-]\d{2,4}\b/', ' ', $name); // dates $name = preg_replace('/\b\d{4,}\b/', ' ', $name); // long numbers $name = trim(preg_replace('/\s+/', ' ', $name)); if ($name === '' || mb_strlen($name) < 3 || !preg_match('/\p{L}{3,}/u', $name)) { return ''; } return mb_strtoupper(mb_substr($name, 0, 1)) . mb_substr($name, 1); } /** Best alt for an attachment: library alt → caption → title → file name. */ function citami_alt_for_attachment($attachment_id) { $id = (int) $attachment_id; if ($id <= 0) { return ''; } $alt = trim((string) get_post_meta($id, '_wp_attachment_image_alt', true)); if ($alt !== '') { return $alt; } $p = get_post($id); if (!$p) { return ''; } $caption = isset($p->post_excerpt) ? trim(wp_strip_all_tags((string) $p->post_excerpt)) : ''; if ($caption !== '') { return mb_substr($caption, 0, 200); } $title = isset($p->post_title) ? trim((string) $p->post_title) : ''; $from_title = citami_alt_from_filename($title); if ($from_title !== '' && !preg_match('/^(img|dsc|image|photo)[\s\d]*$/i', $title)) { return $from_title; } return citami_alt_from_filename(wp_get_attachment_url($id)); } /** Attachment id for an tag: wp-image-N class, else the URL. */ function citami_alt_attachment_from_tag($tag, $src) { if (preg_match('/\bwp-image-(\d+)\b/', $tag, $m)) { return (int) $m[1]; } if ($src !== '' && function_exists('attachment_url_to_postid')) { $clean = preg_replace('/-\d{2,4}x\d{2,4}(\.[a-z0-9]{2,5})$/i', '$1', $src); $id = (int) attachment_url_to_postid($clean); return $id > 0 ? $id : 0; } return 0; } /** Fills missing / empty alt attributes in HTML. Decorative images are left alone. */ function citami_alt_fill_html($html) { if (stripos((string) $html, ']*>/i', function ($m) { $tag = $m[0]; if (preg_match('/\balt\s*=\s*(["\'])(.*?)\1/is', $tag, $a)) { if (trim($a[2]) !== '') { return $tag; } if (preg_match('/\brole\s*=\s*(["\'])presentation\1/i', $tag) || preg_match('/\baria-hidden\s*=\s*(["\'])true\1/i', $tag)) { return $tag; // alt="" on purpose } } $src = preg_match('/\bsrc\s*=\s*(["\'])(.*?)\1/i', $tag, $s) ? $s[2] : ''; $alt = ''; $id = citami_alt_attachment_from_tag($tag, $src); if ($id > 0) { $alt = citami_alt_for_attachment($id); } if ($alt === '') { $alt = citami_alt_from_filename($src); } if ($alt === '') { return $tag; } $attr = 'alt="' . esc_attr($alt) . '"'; if (isset($a[0])) { return str_replace($a[0], $attr, $tag); } return preg_replace('/^ID); if ($alt !== '') { $attr['alt'] = $alt; } return $attr; }, 10, 2); /* ---------------------------------------------------------------- */ /* Described alt via the platform */ /* ---------------------------------------------------------------- */ function citami_alt_ai_enabled() { return citami_alt_settings()['ai'] === 'on' && citami_get_api_key() !== ''; } /** Image attachments with no alt at all (not yet described, not skipped). */ function citami_alt_missing_attachments($limit = CITAMI_ALT_BATCH, $offset = 0) { $posts = get_posts(array( 'post_type' => 'attachment', 'post_status' => array('inherit', 'publish'), 'post_mime_type' => 'image', 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'DESC', )); $out = array(); $seen = 0; foreach ((array) $posts as $p) { if (!is_object($p)) { continue; } $mime = isset($p->post_mime_type) ? (string) $p->post_mime_type : ''; if ($mime !== '' && !preg_match('#^image/(jpeg|png|webp|gif|avif)$#', $mime)) { continue; // svg / bmp / tiff: the model cannot read them } if (trim((string) get_post_meta($p->ID, '_wp_attachment_image_alt', true)) !== '') { continue; } if ((string) get_post_meta($p->ID, CITAMI_META_ALT_SOURCE, true) !== '') { continue; // already described (maybe as decorative) or failed for good } if ($seen++ < $offset) { continue; } $out[] = $p; if (count($out) >= $limit) { break; } } return $out; } function citami_alt_missing_count() { return count(citami_alt_missing_attachments(PHP_INT_MAX)); } /** 2.60 — mime types the model can read (svg / bmp / tiff are left out). */ function citami_alt_readable_mime($mime) { $mime = (string) $mime; return $mime === '' || (bool) preg_match('#^image/(jpeg|png|webp|gif|avif)$#', $mime); } /** * 2.60 — the list behind "N images without alt": up to $limit of them, * newest first, one bounded query (alt meta missing or empty, never * described). Same rules as citami_alt_missing_attachments(), re-checked * in PHP so the list never disagrees with the count. */ function citami_alt_missing_preview($limit = CITAMI_ALT_MISSING_SHOWN) { $limit = max(1, (int) $limit); $posts = get_posts(array( 'post_type' => 'attachment', 'post_status' => array('inherit', 'publish'), 'post_mime_type' => 'image', 'numberposts' => $limit + 20, // headroom for svg / tiff rows the filter drops 'orderby' => 'ID', 'order' => 'DESC', 'no_found_rows' => true, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- attachments without alt: a one-off count, cached in a transient 'relation' => 'AND', array( 'relation' => 'OR', array('key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'), array('key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='), ), array('key' => CITAMI_META_ALT_SOURCE, 'compare' => 'NOT EXISTS'), ), )); $out = array(); foreach ((array) $posts as $p) { if (!is_object($p) || !citami_alt_readable_mime(isset($p->post_mime_type) ? $p->post_mime_type : '')) { continue; } if (trim((string) get_post_meta($p->ID, '_wp_attachment_image_alt', true)) !== '' || (string) get_post_meta($p->ID, CITAMI_META_ALT_SOURCE, true) !== '') { continue; } $out[] = $p; if (count($out) >= $limit) { break; } } return $out; } /** 2.60 — how many images the AI marked decorative (the "offer again" button says it). */ function citami_alt_decorative_count() { return count(get_posts(array('post_type' => 'attachment', 'post_status' => array('inherit', 'publish'), 'numberposts' => -1, 'fields' => 'ids', 'meta_key' => CITAMI_META_ALT_SOURCE, 'meta_value' => 'decorative'))); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- counted once, then cached } /** 2.60 — thumbnail URL of an attachment (full URL as a fallback). */ function citami_alt_thumb_url($id) { $url = function_exists('wp_get_attachment_image_url') ? wp_get_attachment_image_url((int) $id, 'thumbnail') : ''; return $url ? (string) $url : (string) wp_get_attachment_url((int) $id); } /** 2.60 — one image row: thumbnail, text, edit link in the media library. */ function citami_alt_image_li($id, $text_html) { $id = (int) $id; return '
  • ' . $text_html . ' ' . esc_html(citami_t('modifica', 'edit')) . '
  • '; } /** 2.60 — the last run's images: what was written on each, with the way to fix it. */ function citami_alt_last_items_html(array $last) { $items = isset($last['items']) && is_array($last['items']) ? array_slice($last['items'], 0, CITAMI_ALT_LAST_ITEMS) : array(); if (empty($items)) { return ''; } $html = ''; } /** 2.60 — the images behind "N without alt" (up to 30), folded. */ function citami_alt_missing_details_html($missing) { $missing = (int) $missing; $label = sprintf(citami_t('%d immagini senza alt nella libreria.', '%d images without alt in the library.'), $missing); if ($missing <= 0) { return '' . esc_html($label) . ''; } $html = '
    ' . esc_html($label) . ' ' . esc_html(citami_t('Mostra', 'Show')) . '
      '; $shown = 0; foreach (citami_alt_missing_preview(CITAMI_ALT_MISSING_SHOWN) as $p) { $title = isset($p->post_title) && trim((string) $p->post_title) !== '' ? (string) $p->post_title : basename((string) wp_parse_url((string) wp_get_attachment_url($p->ID), PHP_URL_PATH)); $html .= citami_alt_image_li((int) $p->ID, esc_html($title)); $shown++; } $html .= '
    '; if ($missing > $shown) { $html .= '

    ' . esc_html(sprintf(citami_t('Ne mostro %1$d su %2$d. Le altre sono nella libreria media.', 'Showing %1$d of %2$d. The others are in the media library.'), $shown, $missing)) . ' ' . esc_html(citami_t('Apri la libreria', 'Open the library')) . '

    '; } return $html . '
    '; } function citami_alt_log_add(array $entry) { $log = get_option(CITAMI_ALT_LOG_OPT, array()); if (!is_array($log)) { $log = array(); } array_unshift($log, $entry); update_option(CITAMI_ALT_LOG_OPT, array_slice($log, 0, CITAMI_ALT_LOG_MAX), false); } /** * One batch: send up to CITAMI_ALT_BATCH images without alt to the * platform, store what comes back. Returns a report (also logged). */ function citami_alt_generate_batch($limit = CITAMI_ALT_BATCH) { // 2.60 — 'items': what this run wrote (id + alt, '' = decorative), shown under the row. $report = array('ts' => time(), 'sent' => 0, 'written' => 0, 'decorative' => 0, 'skipped' => 0, 'code' => 0, 'message' => '', 'used' => null, 'quota' => null, 'items' => array()); if (!citami_alt_ai_enabled()) { $report['message'] = citami_t('Descrizione automatica disattivata o API key mancante.', 'Automatic description off or API key missing.'); return $report; } $batch = citami_alt_missing_attachments($limit); if (empty($batch)) { $report['message'] = citami_t('Nessuna immagine senza alt.', 'No image without alt.'); return $report; } $images = array(); foreach ($batch as $p) { $url = (string) wp_get_attachment_url($p->ID); if (!preg_match('#^https?://#i', $url)) { continue; } $parent = !empty($p->post_parent) ? get_post((int) $p->post_parent) : null; $images[] = array( 'id' => (int) $p->ID, 'url' => $url, 'filename' => basename((string) wp_parse_url($url, PHP_URL_PATH)), 'context' => mb_substr($parent && isset($parent->post_title) ? (string) $parent->post_title : '', 0, 120), ); } if (empty($images)) { $report['message'] = citami_t('Nessun URL immagine valido.', 'No valid image URL.'); return $report; } $report['sent'] = count($images); $res = wp_remote_post(citami_get_endpoint() . '/api/plugin/alt-text', array( 'timeout' => 60, 'headers' => array( 'Authorization' => 'Bearer ' . citami_get_api_key(), 'Content-Type' => 'application/json', 'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'), ), 'body' => wp_json_encode(array('images' => $images, 'locale' => citami_lang()), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG), )); if (is_wp_error($res)) { $report['message'] = $res->get_error_message(); citami_alt_log_add($report); return $report; } $report['code'] = (int) wp_remote_retrieve_response_code($res); $data = json_decode((string) wp_remote_retrieve_body($res), true); if ($report['code'] < 200 || $report['code'] >= 300 || !is_array($data)) { $report['message'] = 'HTTP ' . $report['code']; citami_alt_log_add($report); return $report; } foreach ((array) (isset($data['results']) ? $data['results'] : array()) as $r) { if (!is_array($r) || empty($r['id'])) { continue; } $id = (int) $r['id']; $alt = mb_substr(sanitize_text_field((string) (isset($r['alt']) ? $r['alt'] : '')), 0, 200); if (count($report['items']) < CITAMI_ALT_LAST_ITEMS) { $report['items'][] = array('id' => $id, 'alt' => $alt); } if ($alt === '') { update_post_meta($id, CITAMI_META_ALT_SOURCE, 'decorative'); $report['decorative']++; continue; } update_post_meta($id, '_wp_attachment_image_alt', $alt); update_post_meta($id, CITAMI_META_ALT_SOURCE, 'ai'); $report['written']++; } $report['skipped'] = isset($data['skipped']) && is_array($data['skipped']) ? count($data['skipped']) : 0; $report['used'] = isset($data['used']) ? (int) $data['used'] : null; $report['quota'] = isset($data['quota']) ? (int) $data['quota'] : null; if ($report['skipped'] > 0 && $report['written'] === 0 && $report['decorative'] === 0) { $report['message'] = citami_t('Descrizioni delle immagini finite per questo mese.', 'Image descriptions used up for this month.'); $report['upgrade'] = 'alt_text'; } citami_alt_log_add($report); if ($report['written'] > 0) { do_action('citami_alt_generated', (int) $report['written']); // 2.42 — the activity log } return $report; } /** Cron: one batch, then re-arm while there is work and quota. */ function citami_alt_backfill_handler() { $r = citami_alt_generate_batch(); if ($r['written'] + $r['decorative'] > 0 && $r['skipped'] === 0 && citami_alt_missing_count() > 0) { if (!wp_next_scheduled(CITAMI_ALT_HOOK)) { wp_schedule_single_event(time() + 2 * MINUTE_IN_SECONDS, CITAMI_ALT_HOOK); } } return $r; } add_action(CITAMI_ALT_HOOK, 'citami_alt_backfill_handler'); // A new upload: describe it shortly after (gives the editor's own alt a head start). add_action('add_attachment', function ($post_id) { if (!citami_alt_ai_enabled()) { return; } if (!wp_next_scheduled(CITAMI_ALT_HOOK)) { wp_schedule_single_event(time() + 90, CITAMI_ALT_HOOK); } }); /* ---------------------------------------------------------------- */ /* Admin */ /* ---------------------------------------------------------------- */ function citami_handle_alt_action() { if (!current_user_can('manage_options')) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($_POST['_citami_alt_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_alt_nonce'])) : '', 'citami_alt')) { wp_die('Nonce non valido.', '', array('response' => 403)); } $do = isset($_POST['do']) ? sanitize_key((string) $_POST['do']) : ''; $notice = array('type' => 'success', 'message' => ''); $anchor = '#citami-seo-alt'; if ($do === 'generate') { $r = citami_alt_generate_batch(); if ($r['written'] + $r['decorative'] > 0) { $anchor = '#citami-alt-last'; // the list of what was just written $notice['message'] = sprintf(citami_t('Descritte %d immagini (%d decorative).', 'Described %d images (%d decorative).'), $r['written'] + $r['decorative'], $r['decorative']); } else { $notice = array('type' => 'error', 'message' => $r['message'] !== '' ? $r['message'] : sprintf(citami_t('Nessuna descrizione scritta (HTTP %d).', 'No description written (HTTP %d).'), $r['code']), 'upgrade' => isset($r['upgrade']) ? $r['upgrade'] : ''); } } elseif ($do === 'reset_skipped') { $n = 0; foreach (get_posts(array('post_type' => 'attachment', 'post_status' => array('inherit', 'publish'), 'numberposts' => -1, 'meta_key' => CITAMI_META_ALT_SOURCE, 'meta_value' => 'decorative')) as $p) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- backfill batch delete_post_meta($p->ID, CITAMI_META_ALT_SOURCE); $n++; } $notice['message'] = sprintf(citami_t('%d immagini segnate come decorative verranno riproposte al prossimo giro.', '%d images marked decorative will be offered again on the next run.'), $n); } set_transient('citami_action_notice', $notice, 30); wp_safe_redirect(citami_admin_url('seo', array('open' => 1)) . $anchor); exit; } add_action('admin_post_citami_alt_action', 'citami_handle_alt_action'); add_action('citami_seo_settings_rows', function () { $s = citami_alt_settings(); $opt = CITAMI_ALT_SETTINGS_OPT; $missing = citami_alt_missing_count(); $log = get_option(CITAMI_ALT_LOG_OPT, array()); $last = is_array($log) && !empty($log) ? $log[0] : null; ?>