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 '
' . esc_html($alt) . '' : '' . esc_html(citami_t('decorativa: alt lasciato vuoto', 'decorative: alt left empty')) . ''; $html .= citami_alt_image_li((int) $it['id'], $text); } return $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 . '|
0): ?>
|