/s', ' ', (string) $html); $html = preg_replace('#<(script|style)\b[^>]*>.*?#is', ' ', $html); $html = preg_replace('#]*>#i', ' ', $html); $text = html_entity_decode(wp_strip_all_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = mb_strtolower(preg_replace('/\s+/u', ' ', $text)); return ' ' . trim($text) . ' '; } /** * 2.63 — only the text a link can be written into: headings, existing * links, code and buttons removed, exactly what citami_links_wrap_first() * skips. Suggestions are counted on this, so the plugin never offers a * phrase that sits only in a heading (the insert would then refuse it). */ function citami_links_linkable_plain($html) { $html = preg_replace('//s', ' ', (string) $html); $html = preg_replace('#<(h[1-6]|a|code|pre|button|script|style|textarea)\b[^>]*>.*?#is', ' ', $html); $html = preg_replace('/\[[^\]]+\]/', ' ', $html); return citami_links_plain($html); } /** Why a phrase cannot be linked in this HTML: 'heading', 'link', 'missing'. */ function citami_links_unlinkable_reason($html, $phrase) { $phrase = mb_strtolower(trim((string) $phrase)); if (citami_links_count_phrase(citami_links_plain($html), $phrase) === 0) { return 'missing'; } if (preg_match_all('#<(h[1-6])\b[^>]*>(.*?)#is', (string) $html, $m)) { foreach ($m[2] as $h) { if (citami_links_count_phrase(citami_links_plain($h), $phrase) > 0) { return 'heading'; } } } return 'link'; } /** Title without the site name / separator tail, lower-cased. */ function citami_links_clean_title($title) { $t = mb_strtolower(trim((string) $title)); $t = preg_replace('/\s*[\|\-–—·»:]\s*' . preg_quote(mb_strtolower(get_bloginfo('name')), '/') . '\s*$/u', '', $t); $t = trim(preg_replace('/[^\p{L}\p{N}\'’\s-]/u', ' ', $t)); return trim(preg_replace('/\s+/u', ' ', $t)); } /** Candidate anchor phrases for a target: focus keyword, cleaned title, title minus leading stopwords. */ function citami_links_phrases(array $entry) { $phrases = array(); $kw = mb_strtolower(trim((string) $entry['keyword'])); if ($kw !== '' && mb_strlen($kw) >= 4) { $phrases[] = $kw; } $title = citami_links_clean_title($entry['title']); $words = $title === '' ? array() : explode(' ', $title); if (count($words) >= 2) { $phrases[] = $title; // "Come scegliere un dentista" → "scegliere un dentista": the question // word rarely appears mid-sentence, the rest does. $stop = citami_links_stopwords(); $trimmed = $words; while (!empty($trimmed) && in_array($trimmed[0], $stop, true)) { array_shift($trimmed); } if (count($trimmed) >= 2 && count($trimmed) < count($words)) { $phrases[] = implode(' ', $trimmed); } // "Come funziona la sedazione cosciente" → "sedazione cosciente": the // tail after the last stopword is what other pages actually write. $last = -1; foreach ($words as $i => $w) { if (in_array($w, $stop, true)) { $last = $i; } } $tail = $last >= 0 ? array_slice($words, $last + 1) : array(); if (count($tail) >= 2 && count($tail) < count($words)) { $phrases[] = implode(' ', $tail); } } foreach ((array) $entry['terms'] as $term) { $term = mb_strtolower(trim((string) $term)); if ($term !== '' && mb_strlen($term) >= 6 && strpos($term, ' ') !== false) { $phrases[] = $term; } } return array_values(array_unique($phrases)); } /** Occurrences of a phrase in plain text (whole words, case-insensitive). */ function citami_links_count_phrase($plain, $phrase) { $p = preg_quote($phrase, '/'); $p = strtr($p, array('\\ ' => '\s+', "'" => "['’]", '’' => "['’]")); // simultaneous: no double replacement return preg_match_all('/(?]*\bhref\s*=\s*["\']([^"\']+)["\']/i', (string) $html, $m)) { return $ids; } $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); foreach ($m[1] as $href) { $href = trim(html_entity_decode($href)); if ($href === '' || $href[0] === '#' || preg_match('/^(mailto|tel|javascript):/i', $href)) { continue; } if (preg_match('#^https?://#i', $href)) { $host = strtolower((string) wp_parse_url($href, PHP_URL_HOST)); if (preg_replace('/^www\./', '', $host) !== preg_replace('/^www\./', '', $home_host)) { continue; } } elseif ($href[0] === '/') { $href = home_url($href); } else { continue; } $id = (int) url_to_postid(preg_replace('/[#?].*$/', '', $href)); if ($id > 0) { $ids[$id] = true; } } return array_keys($ids); } /** * LINKS-1 — the internal links of a page with their anchor text: * target post id => anchor (the first link to each target), plus how many * internal links the content holds in total. Same href resolution as * citami_links_outbound_ids(); the anchor is the link's visible text. */ function citami_links_outbound_anchors($html) { $out = array('anchors' => array(), 'count' => 0); if (!preg_match_all('/]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*>(.*?)<\/a\s*>/is', (string) $html, $m, PREG_SET_ORDER)) { return $out; } foreach ($m as $link) { $ids = citami_links_outbound_ids('x'); if (empty($ids)) { continue; } $out['count']++; $id = (int) $ids[0]; if (!isset($out['anchors'][$id])) { $text = html_entity_decode(wp_strip_all_tags((string) $link[2]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $out['anchors'][$id] = mb_strtolower(trim(preg_replace('/\s+/u', ' ', $text))); } } return $out; } /* ---------------------------------------------------------------- */ /* Index */ /* ---------------------------------------------------------------- */ function citami_links_entry_for_post($post) { $terms = array(); if (function_exists('get_the_terms')) { foreach (array('category', 'post_tag') as $tax) { $list = get_the_terms($post->ID, $tax); if (is_array($list)) { foreach ($list as $t) { if (is_object($t) && isset($t->name) && $t->name !== 'Uncategorized' && $t->name !== 'Senza categoria') { $terms[] = (string) $t->name; } } } } } $html = citami_post_content_html($post); $anchored = citami_links_outbound_anchors($html); return array( 'id' => (int) $post->ID, 'type' => (string) $post->post_type, 'title' => (string) $post->post_title, 'url' => get_permalink($post), 'keyword' => (string) get_post_meta($post->ID, CITAMI_META_FOCUS_KEYWORD, true), 'terms' => array_values(array_unique($terms)), 'outbound' => citami_links_outbound_ids($html), // LINKS-1 — anchor text per linked page and the total of internal links in the content. 'anchors' => $anchored['anchors'], 'links_n' => (int) $anchored['count'], 'noindex' => (string) get_post_meta($post->ID, CITAMI_META_ROBOTS_INDEX, true) === 'noindex', ); } /** Rebuilds the whole index (published, public types). */ function citami_links_index_build() { $posts = get_posts(array( 'post_type' => citami_seo_post_types(), 'post_status' => 'publish', 'numberposts' => CITAMI_LINKS_MAX_POSTS, 'orderby' => 'ID', 'order' => 'DESC', )); $entries = array(); foreach ((array) $posts as $p) { if (is_object($p)) { $entries[(int) $p->ID] = citami_links_entry_for_post($p); } } $index = array('built' => time(), 'version' => citami_links_next_version(), 'entries' => $entries); update_option(CITAMI_LINKS_INDEX_OPT, $index, false); return $index; } /** Monotonic index version: every build/touch bumps it, per-page caches key on it. */ function citami_links_next_version() { $current = get_option(CITAMI_LINKS_INDEX_OPT, null); $v = is_array($current) && isset($current['version']) ? (int) $current['version'] : 0; return max($v + 1, time()); } function citami_links_index($build_if_missing = true) { $index = get_option(CITAMI_LINKS_INDEX_OPT, null); if ((!is_array($index) || !isset($index['entries'])) && $build_if_missing) { $index = citami_links_index_build(); } if (is_array($index) && isset($index['entries'])) { if (!isset($index['version'])) { $index['version'] = (int) $index['built']; } return $index; } return array('built' => 0, 'version' => 0, 'entries' => array()); } /** Updates one entry (on save) without a full rebuild. */ function citami_links_index_touch($post_id) { $index = citami_links_index(false); if (empty($index['entries']) && $index['built'] === 0) { return; // never built: the first reader builds it whole } $post = get_post($post_id); if ($post && $post->post_status === 'publish' && in_array($post->post_type, citami_seo_post_types(), true)) { $index['entries'][(int) $post_id] = citami_links_entry_for_post($post); } else { unset($index['entries'][(int) $post_id]); } $index['version'] = citami_links_next_version(); update_option(CITAMI_LINKS_INDEX_OPT, $index, false); } add_action('save_post', function ($post_id) { if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } if (function_exists('wp_is_post_revision') && wp_is_post_revision($post_id)) { return; } citami_links_index_touch($post_id); }, 30, 1); add_action('before_delete_post', function ($post_id) { citami_links_index_touch($post_id); }); add_action('wp_trash_post', function ($post_id) { $index = citami_links_index(false); unset($index['entries'][(int) $post_id]); $index['version'] = citami_links_next_version(); update_option(CITAMI_LINKS_INDEX_OPT, $index, false); }); // Daily full rebuild (menus, term renames, anything the hooks miss). add_action(CITAMI_LINKS_HOOK, 'citami_links_index_build'); add_action('init', function () { if (!wp_next_scheduled(CITAMI_LINKS_HOOK)) { wp_schedule_event(time() + 3 * HOUR_IN_SECONDS, 'daily', CITAMI_LINKS_HOOK); } }, 20); /** Inbound internal link counts, keyed by target id. */ function citami_links_inbound_counts(array $index) { $counts = array(); foreach ($index['entries'] as $id => $e) { $counts[$id] = isset($counts[$id]) ? $counts[$id] : 0; } foreach ($index['entries'] as $id => $e) { foreach ((array) $e['outbound'] as $target) { if ((int) $target !== (int) $id && isset($index['entries'][$target])) { $counts[$target] = (isset($counts[$target]) ? $counts[$target] : 0) + 1; } } } return $counts; } /* ---------------------------------------------------------------- */ /* Suggestions */ /* ---------------------------------------------------------------- */ /** * Outbound suggestions for a piece of content: targets whose anchor phrase * appears in the text and that are not linked yet. Each: id, title, url, * anchor, count, score. */ function citami_links_suggest($post_id, $content, $limit = CITAMI_LINKS_MAX_SUGGESTIONS) { $index = citami_links_index(); $plain = citami_links_linkable_plain($content); if (trim($plain) === '' || empty($index['entries'])) { return array(); } $linked = array_flip(citami_links_outbound_ids($content)); $inbound = citami_links_inbound_counts($index); $out = array(); foreach ($index['entries'] as $id => $e) { if ((int) $id === (int) $post_id || isset($linked[$id]) || !empty($e['noindex'])) { continue; } $best = null; foreach (citami_links_phrases($e) as $phrase) { $n = citami_links_count_phrase($plain, $phrase); if ($n === 0) { continue; } $words = substr_count($phrase, ' ') + 1; $score = $words * 10 + min($n, 3) * 2 + ($e['type'] === 'page' ? 3 : 0) + (empty($inbound[$id]) ? 5 : 0) + ($phrase === mb_strtolower(trim((string) $e['keyword'])) ? 4 : 0); if ($best === null || $score > $best['score']) { $best = array('id' => (int) $id, 'title' => $e['title'], 'url' => $e['url'], 'anchor' => $phrase, 'count' => $n, 'score' => $score, 'inbound' => isset($inbound[$id]) ? (int) $inbound[$id] : 0); } } if ($best !== null) { $out[] = $best; } } usort($out, function ($a, $b) { return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); }); return array_slice($out, 0, $limit); } /** Pages that mention this post's phrases but do not link to it. */ /** * Plain text of every indexed post, loaded ONCE per request with a single * query (2000 get_post() calls were the cost of opening the editor on a * big site). Only the ids the index knows; empty content → ''. */ function citami_links_all_plain($reset = false) { static $cache = null; if ($reset) { $cache = null; return array(); } if ($cache !== null) { return $cache; } $cache = array(); $posts = get_posts(array( 'post_type' => citami_seo_post_types(), 'post_status' => 'publish', 'numberposts' => CITAMI_LINKS_MAX_POSTS, 'orderby' => 'ID', 'order' => 'DESC', )); foreach ((array) $posts as $p) { if (is_object($p)) { $cache[(int) $p->ID] = citami_links_linkable_plain(citami_post_content_html($p)); } } return $cache; } function citami_links_inbound_cache_key($post_id) { return 'citami_links_in_' . (int) $post_id; } /** * Pages that mention this post's phrases but do not link to it. Cached per * page in a transient keyed on the index version: a save anywhere bumps * the version, so no stale answer survives a content change and no * request ever scans the site twice for the same page. */ function citami_links_inbound_candidates($post_id, $limit = CITAMI_LINKS_MAX_SUGGESTIONS) { $index = citami_links_index(); if (!isset($index['entries'][(int) $post_id])) { return array(); } $key = citami_links_inbound_cache_key($post_id); $cached = get_transient($key); if (is_array($cached) && isset($cached['version'], $cached['items']) && (int) $cached['version'] === (int) $index['version']) { return array_slice((array) $cached['items'], 0, $limit); } $target = $index['entries'][(int) $post_id]; $phrases = citami_links_phrases($target); $out = array(); if (!empty($phrases)) { $plain_by_id = citami_links_all_plain(); foreach ($index['entries'] as $id => $e) { if ((int) $id === (int) $post_id || in_array((int) $post_id, (array) $e['outbound'], true) || !isset($plain_by_id[(int) $id])) { continue; } $plain = $plain_by_id[(int) $id]; foreach ($phrases as $phrase) { $n = citami_links_count_phrase($plain, $phrase); if ($n > 0) { $out[] = array('id' => (int) $id, 'title' => $e['title'], 'url' => $e['url'], 'anchor' => $phrase, 'count' => $n, 'score' => (substr_count($phrase, ' ') + 1) * 10 + min($n, 3) * 2); break; } } } usort($out, function ($a, $b) { return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); }); $out = array_slice($out, 0, CITAMI_LINKS_MAX_SUGGESTIONS); } set_transient($key, array('version' => (int) $index['version'], 'items' => $out), DAY_IN_SECONDS); return array_slice($out, 0, $limit); } /** Orphans: published, indexable, no internal inbound link. */ function citami_links_orphans($limit = 50) { $index = citami_links_index(); $inbound = citami_links_inbound_counts($index); $out = array(); foreach ($index['entries'] as $id => $e) { if (empty($inbound[$id]) && empty($e['noindex'])) { $out[] = $e; } } usort($out, function ($a, $b) { return strcmp($a['title'], $b['title']); }); return array_slice($out, 0, $limit); } /* ---------------------------------------------------------------- */ /* REST (live, editors) + editor panel */ /* ---------------------------------------------------------------- */ function citami_rest_link_suggestions(WP_REST_Request $request) { $body = $request->get_json_params(); $body = is_array($body) ? $body : array(); $post_id = isset($body['post_id']) ? (int) $body['post_id'] : 0; $post = $post_id > 0 ? get_post($post_id) : null; if (!$post) { return new WP_Error('citami_links_post', 'post_id required', array('status' => 400)); } if (!current_user_can('edit_post', $post_id)) { return new WP_Error('citami_links_forbidden', 'forbidden', array('status' => 403)); } $content = isset($body['content']) && is_string($body['content']) ? $body['content'] : (string) (isset($post->post_content) ? $post->post_content : ''); return rest_ensure_response(array( 'outbound' => citami_links_suggest($post_id, $content), 'inbound' => citami_links_inbound_candidates($post_id), )); } add_action('rest_api_init', function () { register_rest_route('citami/v1', '/link-suggestions', array( 'methods' => 'POST', 'callback' => 'citami_rest_link_suggestions', 'permission_callback' => function () { return current_user_can('edit_posts'); }, )); }); /** 2.49 — the editor's own insert link: GET, nonce keyed by the post, back to the editor. */ function citami_links_editor_insert_base($post_id) { return wp_nonce_url(admin_url('admin-post.php?action=citami_links_action&do=insert&back=editor&post_id=' . (int) $post_id), 'citami_links_editor_' . (int) $post_id); } function citami_links_editor_insert_url($post_id, $target_id, $anchor) { return citami_links_editor_insert_base($post_id) . '&target=' . (int) $target_id . '&pick=' . rawurlencode((int) $post_id . '::' . (string) $anchor); } function citami_links_render_list(array $rows, $kind, $post_id = 0, $editable = false) { if (empty($rows)) { return ''; } $html = ''; } function citami_links_render_panel($post) { $rest = function_exists('rest_url') ? rest_url('citami/v1/link-suggestions') : ''; $nonce = wp_create_nonce('wp_rest'); $editable = citami_links_post_editable($post->ID); $out = citami_links_suggest($post->ID, citami_post_content_html($post)); $in = citami_links_inbound_candidates($post->ID); ?> citami_t('Nessuna frase del testo corrisponde a un\'altra pagina del sito.', 'No phrase in the text matches another page of the site.'), 'noInbound' => citami_t('Nessuna pagina menziona questo argomento senza linkarlo.', 'No page mentions this topic without linking it.'), 'insert' => citami_t('Inserisci il link', 'Insert the link'), 'nobody' => citami_t('nessuno la linka ancora', 'nobody links it yet'), 'mentions' => citami_t('menziona', 'mentions'), 'network' => citami_t('Errore di rete', 'Network error'), ); ?> post_status !== 'publish') { return false; } if (get_post_meta($post->ID, '_elementor_edit_mode', true) === 'builder') { return false; } return trim(isset($post->post_content) ? (string) $post->post_content : '') !== ''; } /** * Wraps the first occurrence of $anchor in $html with a link to $url. * Skips anything inside , headings, code/pre/script/style/textarea/button, * HTML comments (Gutenberg block delimiters) and shortcodes; the original * casing of the phrase is kept. Returns the new HTML, or null when the * phrase is not found in linkable text. */ function citami_links_wrap_first($html, $anchor, $url) { $anchor = trim((string) $anchor); if ($anchor === '' || $html === '') { return null; } $p = preg_quote($anchor, '/'); $p = strtr($p, array('\\ ' => '\s+', "'" => "['’]", '’' => "['’]")); $re = '/(?|<[^>]+>|\[[^\]]+\])/s', (string) $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); $skip = 0; $in_a = 0; $out = ''; $done = false; foreach ($tokens as $tok) { if ($tok !== '' && ($tok[0] === '<' || $tok[0] === '[')) { if (preg_match('/^]/i', $tok)) $in_a++; elseif (preg_match('/^<\/a\s*>/i', $tok)) $in_a = max(0, $in_a - 1); elseif (preg_match('/^<(h[1-6]|code|pre|script|style|textarea|button)[\s>]/i', $tok)) $skip++; elseif (preg_match('/^<\/(h[1-6]|code|pre|script|style|textarea|button)\s*>/i', $tok)) $skip = max(0, $skip - 1); $out .= $tok; continue; } if ($done || $in_a > 0 || $skip > 0) { $out .= $tok; continue; } $replaced = preg_replace($re, '$1', $tok, 1, $n); if ($n > 0) { $done = true; $out .= $replaced; } else { $out .= $tok; } } return $done ? $out : null; } define('CITAMI_LINKS_SNIPPET_URL', 'https://citami.invalid/snippet'); /** * 2.60 — the sentence where the link would land, as safe HTML: about * $width characters of plain text around the anchor, the anchor in . * Uses citami_links_wrap_first() with a sentinel URL, so the preview is * exactly the occurrence the insert would link (headings, code, existing * links and block comments skipped the same way). '' when not found. */ function citami_links_snippet_from_html($html, $anchor, $width = 120) { $wrapped = citami_links_wrap_first((string) $html, (string) $anchor, CITAMI_LINKS_SNIPPET_URL); if ($wrapped === null) { return ''; } $open = "\x01"; $close = "\x02"; $wrapped = preg_replace('#(.*?)#s', $open . '$1' . $close, $wrapped, 1); $text = preg_replace('//s', ' ', (string) $wrapped); $text = preg_replace('#<(script|style)\b[^>]*>.*?#is', ' ', $text); $text = preg_replace('/\[[^\]]+\]/', ' ', $text); // A heading ends where its paragraph starts: "Steam the milk. Fill a pitcher", not "Steam the milk Fill a pitcher". $text = preg_replace('#([^.:?!\s])\s*#i', '$1.', $text); $text = preg_replace('#]*>#i', ' ', $text); $text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = trim(preg_replace('/\s+/u', ' ', $text)); $a = mb_strpos($text, $open); $b = mb_strpos($text, $close); if ($a === false || $b === false || $b < $a) { return ''; } $match = mb_substr($text, $a + 1, $b - $a - 1); $side = max(20, (int) floor(((int) $width - mb_strlen($match)) / 2)); $before = mb_substr($text, 0, $a); $after = mb_substr($text, $b + 1); $cut_before = mb_strlen($before) > $side; $cut_after = mb_strlen($after) > $side; if ($cut_before) { $before = mb_substr($before, -$side); $before = preg_replace('/^\S*\s/u', '', $before); // start on a whole word } if ($cut_after) { $after = mb_substr($after, 0, $side); $after = preg_replace('/\s\S*$/u', '', $after); } return ($cut_before ? '…' : '') . esc_html($before) . '' . esc_html($match) . '' . esc_html($after) . ($cut_after ? '…' : ''); } /** The snippet for a source page (its saved content). */ function citami_links_context_snippet($source_post_id, $anchor, $width = 120) { $post = get_post((int) $source_post_id); if (!$post || !isset($post->post_content)) { return ''; } return citami_links_snippet_from_html((string) $post->post_content, $anchor, $width); } /** Log of the links the plugin wrote (newest first). */ function citami_links_log() { $log = get_option(CITAMI_LINKS_LOG_OPT, array()); return is_array($log) ? array_values(array_filter($log, 'is_array')) : array(); } /** * Writes the link from $source_id to $target_id on the first occurrence of * $anchor. Returns array('ok' => bool, 'message' => string, 'entry' => array|null). */ function citami_links_insert($source_id, $target_id, $anchor) { $source_id = (int) $source_id; $target_id = (int) $target_id; $index = citami_links_index(); if (!isset($index['entries'][$target_id]) || !isset($index['entries'][$source_id]) || $source_id === $target_id) { return array('ok' => false, 'message' => citami_t('Pagina non trovata nell\'indice.', 'Page not found in the index.'), 'entry' => null); } if (!citami_links_post_editable($source_id)) { return array('ok' => false, 'message' => citami_t('La pagina sorgente è costruita con un page builder o non è pubblicata: aggiungi il link dal suo editor.', 'The source page is built with a page builder or is not published: add the link from its editor.'), 'entry' => null); } if (in_array($target_id, (array) $index['entries'][$source_id]['outbound'], true)) { return array('ok' => false, 'message' => citami_t('La pagina sorgente linka già questa pagina.', 'The source page already links this page.'), 'entry' => null); } $source = get_post($source_id); $target = $index['entries'][$target_id]; $url = (string) $target['url']; $html = citami_links_wrap_first((string) $source->post_content, $anchor, $url); if ($html === null) { $why = citami_links_unlinkable_reason((string) $source->post_content, $anchor); $msg = $why === 'heading' ? sprintf(citami_t('"%s" compare solo in un titolo della pagina: nei titoli non mettiamo link. Scrivi la frase in un paragrafo, salva, e il link si potrà inserire.', '"%s" only appears in a heading of the page: we do not put links in headings. Write the phrase in a paragraph, save, and the link can be inserted.'), $anchor) : ($why === 'link' ? sprintf(citami_t('"%s" è già dentro un altro link: non ne mettiamo uno dentro l\'altro.', '"%s" is already inside another link: we do not nest links.'), $anchor) : sprintf(citami_t('"%s" non è nel testo salvato della pagina. Se l\'hai appena scritta, salva la pagina e riprova.', '"%s" is not in the saved text of the page. If you just wrote it, save the page and try again.'), $anchor)); return array('ok' => false, 'message' => $msg, 'entry' => null, 'reason' => $why); } $r = wp_update_post(array('ID' => $source_id, 'post_content' => $html), true); if (!$r || is_wp_error($r)) { return array('ok' => false, 'message' => citami_t('Salvataggio non riuscito.', 'Save failed.'), 'entry' => null); } $entry = array('id' => substr(md5($source_id . '|' . $target_id . '|' . $anchor . '|' . microtime(true)), 0, 12), 'source' => $source_id, 'target' => $target_id, 'anchor' => (string) $anchor, 'url' => $url, 'ts' => time()); $log = citami_links_log(); array_unshift($log, $entry); update_option(CITAMI_LINKS_LOG_OPT, array_slice($log, 0, CITAMI_LINKS_LOG_MAX), false); citami_links_index_touch($source_id); delete_transient(citami_links_inbound_cache_key($target_id)); if (function_exists('citami_activity_log')) { citami_activity_log('link', sprintf(citami_t('Link inserito: "%1$s" → %2$s', 'Link written: "%1$s" → %2$s'), $anchor, $target['title']), 1, array('source' => $source_id, 'target' => $target_id)); } return array('ok' => true, 'message' => sprintf(citami_t('Link inserito: "%s" in "%s" → "%s".', 'Link inserted: "%s" in "%s" → "%s".'), $anchor, get_the_title($source_id), $target['title']), 'entry' => $entry); } /** Undoes a logged link: the is unwrapped, the text stays. */ function citami_links_remove($log_id) { $log = citami_links_log(); $entry = null; foreach ($log as $e) { if (isset($e['id']) && $e['id'] === (string) $log_id) { $entry = $e; break; } } if ($entry === null) { return array('ok' => false, 'message' => citami_t('Link non trovato nel registro.', 'Link not found in the log.')); } $post = get_post((int) $entry['source']); $found = false; if ($post && isset($entry['before_html'], $entry['after_html'])) { // LINKS-2 — a rewritten sentence: the original sentence comes back. $html = (string) $post->post_content; $pos = strpos($html, (string) $entry['after_html']); if ($pos !== false) { $html = substr($html, 0, $pos) . (string) $entry['before_html'] . substr($html, $pos + strlen((string) $entry['after_html'])); wp_update_post(array('ID' => (int) $entry['source'], 'post_content' => $html)); citami_links_index_touch((int) $entry['source']); delete_transient(citami_links_inbound_cache_key((int) $entry['target'])); $found = true; } $post = null; // handled } if ($post) { $re = '/]*>(' . preg_quote((string) $entry['anchor'], '/') . ')<\/a>/iu'; $html = preg_replace($re, '$1', (string) $post->post_content, 1, $n); if ($n > 0) { wp_update_post(array('ID' => (int) $entry['source'], 'post_content' => $html)); citami_links_index_touch((int) $entry['source']); delete_transient(citami_links_inbound_cache_key((int) $entry['target'])); $found = true; } } $log = array_values(array_filter($log, function ($e) use ($log_id) { return !isset($e['id']) || $e['id'] !== (string) $log_id; })); update_option(CITAMI_LINKS_LOG_OPT, $log, false); if (isset($entry['before_html'])) { return array('ok' => true, 'message' => $found ? citami_t('Frase originale ripristinata, il link è stato tolto.', 'Original sentence restored, the link is gone.') : citami_t('La frase non era più nella pagina: tolta dal registro.', 'The sentence was no longer on the page: dropped from the log.')); } return array('ok' => true, 'message' => $found ? citami_t('Link rimosso, il testo è rimasto.', 'Link removed, the text stays.') : citami_t('Il link non era più nella pagina: tolto dal registro.', 'The link was no longer on the page: dropped from the log.')); } /** Orphans with their best candidates, ready for the buttons. */ function citami_links_orphans_actionable($limit = CITAMI_LINKS_TAB_CANDIDATES) { $with = array(); $without = array(); foreach (citami_links_orphans(200) as $i => $e) { $cands = $i < $limit ? citami_links_inbound_candidates((int) $e['id'], 3) : array(); $cands = array_values(array_filter($cands, function ($c) { return citami_links_post_editable((int) $c['id']); })); if (!empty($cands)) { $e['candidates'] = $cands; $with[] = $e; } else { $e['scanned'] = $i < $limit; $without[] = $e; } } return array('with' => $with, 'without' => $without); } /** * Connects up to $max orphans to their first candidate. Returns counts, * the log ids written ('entries', newest last) and every skipped page with * the reason ('skipped_items': id, title, reason). */ function citami_links_connect_orphans($max = CITAMI_LINKS_BULK_MAX) { $done = 0; $entries = array(); $skipped_items = array(); foreach (citami_links_orphans_actionable(100)['with'] as $e) { if ($done + count($skipped_items) >= (int) $max) { break; } $c = $e['candidates'][0]; $r = citami_links_insert((int) $c['id'], (int) $e['id'], (string) $c['anchor']); if (!empty($r['ok'])) { $done++; $entries[] = (string) $r['entry']['id']; } else { $skipped_items[] = array('id' => (int) $e['id'], 'title' => (string) $e['title'], 'reason' => (string) $r['message']); } } return array('done' => $done, 'skipped' => count($skipped_items), 'entries' => $entries, 'skipped_items' => $skipped_items); } /** The bulk result as one sentence: how many, and each skipped page with its reason. */ function citami_links_bulk_message(array $r) { $msg = sprintf(citami_t('%1$d link inseriti, %2$d saltati.', '%1$d links inserted, %2$d skipped.'), (int) $r['done'], (int) $r['skipped']); if (!empty($r['skipped_items'])) { $parts = array(); foreach ((array) $r['skipped_items'] as $s) { $parts[] = '«' . $s['title'] . '»: ' . rtrim((string) $s['reason'], '.'); } $msg .= ' ' . citami_t('Saltate', 'Skipped') . ' — ' . implode('; ', $parts) . '.'; } return $msg; } /* ---------------------------------------------------------------- */ /* Admin tab */ /* ---------------------------------------------------------------- */ function citami_handle_links_action() { $req = array_merge((array) $_GET, (array) $_POST); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- the nonce is verified right below (editor link or admin form); every value read from $req is sanitised where it is used // 2.49 — from the editor: a GET link on the page being edited, allowed to whoever can edit that page. $back = isset($req['back']) ? sanitize_key(wp_unslash((string) $req['back'])) : ''; $editor_post = $back === 'editor' ? absint(isset($req['post_id']) ? wp_unslash((string) $req['post_id']) : 0) : 0; if ($editor_post > 0) { if (!current_user_can('edit_post', $editor_post)) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($req['_wpnonce']) ? sanitize_text_field(wp_unslash((string) $req['_wpnonce'])) : '', 'citami_links_editor_' . $editor_post)) { wp_die('Nonce non valido.', '', array('response' => 403)); } } else { if (!current_user_can('manage_options')) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($_POST['_citami_links_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_links_nonce'])) : '', 'citami_links')) { wp_die('Nonce non valido.', '', array('response' => 403)); } } $do = isset($req['do']) ? sanitize_key((string) $req['do']) : 'rebuild'; if ($editor_post > 0 && $do !== 'insert') { wp_die('Permesso negato.', '', array('response' => 403)); } // 2.60 — back to where the result is: the log row just written, or the // orphan row that failed, with the outcome printed next to it. $back_url = citami_admin_url('links'); $card = null; if ($do === 'insert') { $pick = isset($req['pick']) ? (string) wp_unslash($req['pick']) : ''; $parts = explode('::', $pick, 2); $source = (int) ($parts[0] ?? 0); if ($editor_post > 0 && $source !== $editor_post) { wp_die('Permesso negato.', '', array('response' => 403)); } $target = (int) ($req['target'] ?? 0); $r = citami_links_insert($source, $target, isset($parts[1]) ? sanitize_text_field($parts[1]) : ''); $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message']); if (!empty($r['ok'])) { $card = $notice + array('where' => 'log'); $back_url = citami_links_log_url(array('done' => (string) $r['entry']['id'])); } else { $card = $notice + array('where' => 'orphan-' . $target); $back_url = citami_admin_url('links', array('focus' => $target)) . '#citami-links-orphan-' . $target; } } elseif ($do === 'remove') { $r = citami_links_remove(sanitize_key((string) ($_POST['log_id'] ?? ''))); $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message']); $card = $notice + array('where' => 'log'); $back_url = citami_links_log_url(array('done' => 'removed')); } elseif ($do === 'plan_apply') { // LINKS-1 — the selected rows of the site link plan. $ids = isset($_POST['plan_ids']) && is_array($_POST['plan_ids']) ? array_map('sanitize_key', wp_unslash($_POST['plan_ids'])) : array(); $r = empty($ids) ? array('done' => 0, 'skipped' => 0, 'entries' => array(), 'skipped_items' => array()) : citami_links_plan_apply($ids); $notice = array('type' => $r['done'] > 0 ? ($r['skipped'] > 0 ? 'warning' : 'success') : 'warning', 'message' => empty($ids) ? citami_t('Nessun link selezionato.', 'No link selected.') : citami_links_bulk_message($r)); $card = $notice + array('where' => 'log'); $back_url = citami_links_log_url(!empty($r['entries']) ? array('done' => (string) end($r['entries']), 'n' => min(CITAMI_LINKS_BULK_MAX, count($r['entries']))) : array('done' => 'none')); } elseif ($do === 'ai_draft' || $do === 'ai_apply' || $do === 'ai_discard') { // LINKS-2 — a sentence rewritten by the AI to hold the link: propose, apply, discard. $target = absint(wp_unslash($_POST['target'] ?? 0)); $edited = isset($_POST['sentence']) ? sanitize_text_field(wp_unslash((string) $_POST['sentence'])) : ''; $r = $do === 'ai_draft' ? citami_links_ai_request($target) : ($do === 'ai_apply' ? citami_links_ai_apply($target, $edited) : citami_links_ai_discard($target)); $notice = array('type' => !empty($r['ok']) ? 'success' : 'error', 'message' => $r['message']); if ($do === 'ai_apply' && !empty($r['ok'])) { $card = $notice + array('where' => 'log'); $back_url = citami_links_log_url(array('done' => (string) $r['entry']['id'])); } else { $back_url = citami_admin_url('links') . '#citami-links-ai-' . $target; } } elseif ($do === 'plan_auto') { $on = isset($_POST['auto']) && sanitize_key(wp_unslash((string) $_POST['auto'])) === '1'; update_option(CITAMI_LINKS_PLAN_AUTO_OPT, $on ? '1' : '0', false); if (!$on) { wp_clear_scheduled_hook(CITAMI_LINKS_PLAN_AUTO_HOOK); } $notice = array('type' => 'success', 'message' => $on ? sprintf(citami_t('Attivato: ogni settimana il plugin inserisce fino a %d link del piano.', 'On: every week the plugin writes up to %d links of the plan.'), CITAMI_LINKS_PLAN_AUTO_MAX) : citami_t('Spento: i link del piano si inseriscono solo quando premi il pulsante.', 'Off: plan links are written only when you press the button.')); $back_url = citami_admin_url('links') . '#citami-links-plan'; } elseif ($do === 'bulk') { $r = citami_links_connect_orphans(min(CITAMI_LINKS_BULK_MAX, max(1, absint(wp_unslash($_POST['max'] ?? CITAMI_LINKS_BULK_MAX))))); $notice = array('type' => $r['done'] > 0 ? ($r['skipped'] > 0 ? 'warning' : 'success') : 'warning', 'message' => citami_links_bulk_message($r)); $card = $notice + array('where' => 'log'); // The newest entry is the last one written; highlight all of this run. $back_url = citami_links_log_url(!empty($r['entries']) ? array('done' => (string) end($r['entries']), 'n' => count($r['entries'])) : array('done' => 'none')); } else { $index = citami_links_index_build(); $notice = array('type' => 'success', 'message' => sprintf(citami_t('Indice ricostruito: %d contenuti.', 'Index rebuilt: %d items.'), count($index['entries']))); } if ($editor_post > 0) { set_transient((defined('CITAMI_EDITOR_NOTICE') ? CITAMI_EDITOR_NOTICE : 'citami_editor_notice_') . $editor_post, $notice, 60); wp_safe_redirect(get_edit_post_link($editor_post, 'raw') . '#citami-aeo'); exit; } set_transient('citami_action_notice', $notice, 30); if ($card !== null) { set_transient(CITAMI_LINKS_CARD_NOTICE, $card, 60); } wp_safe_redirect($back_url); exit; } /** The links tab, scrolled to the log card. */ function citami_links_log_url(array $args = array()) { return citami_admin_url('links', $args) . '#citami-links-log'; } /** The outcome printed next to the item it is about (read once). */ function citami_links_card_notice($where) { $n = get_transient(CITAMI_LINKS_CARD_NOTICE); if (!is_array($n) || !isset($n['where']) || $n['where'] !== $where) { return ''; } delete_transient(CITAMI_LINKS_CARD_NOTICE); return '

' . citami_notice_text_html($n) . '

'; } add_action('admin_post_citami_links_action', 'citami_handle_links_action'); function citami_links_form_open($do, $extra = '', $id = '') { return '
' . wp_nonce_field('citami_links', '_citami_links_nonce', true, false) . '' . $extra; } /** 2.60 — "the link will land here": the snippet line under a candidate. */ function citami_links_snippet_line($source_id, $anchor) { $snippet = citami_links_context_snippet((int) $source_id, (string) $anchor); if ($snippet === '') { return ''; } return ''; } function citami_render_links_tab() { $index = citami_links_index(); $inbound = citami_links_inbound_counts($index); $groups = citami_links_orphans_actionable(); $with = $groups['with']; $without = $groups['without']; $orphans_total = count($with) + count($without); $log = citami_links_log(); $ranked = $index['entries']; uasort($ranked, function ($a, $b) use ($inbound) { return ($inbound[$b['id']] ?? 0) <=> ($inbound[$a['id']] ?? 0); }); $rel = function ($url) { return str_replace(home_url('/'), '/', (string) $url); }; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only: which rows to highlight / show $all_log = !empty($_GET['all_log']); $done = isset($_GET['done']) ? sanitize_key((string) wp_unslash($_GET['done'])) : ''; $done_n = isset($_GET['n']) ? max(1, min(CITAMI_LINKS_BULK_MAX, absint(wp_unslash($_GET['n'])))) : 1; $focus = isset($_GET['focus']) ? absint(wp_unslash($_GET['focus'])) : 0; // phpcs:enable $bulk_n = (int) min(CITAMI_LINKS_BULK_MAX, count($with)); $log_notice = $done !== '' ? citami_links_card_notice('log') : ''; $shown_log = $all_log ? $log : array_slice($log, 0, CITAMI_LINKS_LOG_SHOWN); $focus_from = -1; if ($done !== '') { foreach ($shown_log as $i => $l) { if (isset($l['id']) && (string) $l['id'] === $done) { $focus_from = $i; break; } } } ?>

'), citami_kses_allowed()); ?>

  • ·