get_error_message() ); } else { add_settings_error( 'citolume', 'citolume_add', __( 'Question added. It joins the next scan.', 'citolume' ), 'success' ); citolume_clear_status_cache(); } } if ( 'suggest' === $action ) { $result = citolume_site_suggestions( true ); if ( is_wp_error( $result ) ) { add_settings_error( 'citolume', 'citolume_suggest', $result->get_error_message() ); } elseif ( ! $result ) { add_settings_error( 'citolume', 'citolume_suggest', __( 'A read of your site offered nothing new just now — suggestions refresh within the hour.', 'citolume' ), 'info' ); } // On success the screen below simply renders them; a notice saying // "we found some" above a list of them is one message too many. } /* * Re-check the ticked questions now, paying a credit each (ticket 130). * * A selection, not a button: one credit is one question (117), so the * cost is however many were ticked and the control has to let somebody * choose. The API refuses an unverified site, a second concurrent run and * an unaffordable selection, and refunds a failed start — none of that is * re-implemented here. */ if ( 'recheck' === $action ) { $ids = isset( $_POST['citolume_q'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['citolume_q'] ) ) : array(); $ids = array_values( array_filter( $ids ) ); if ( ! $ids ) { add_settings_error( 'citolume', 'citolume_recheck', __( 'Tick the questions you want re-checked first.', 'citolume' ) ); } else { $result = citolume_api_send( '/v1/checks/questions', 'POST', array( 'query_ids' => $ids ) ); // The balance moved either way — a refusal that named a price has // already read it, and a success has spent it. delete_transient( 'citolume_credits' ); if ( is_wp_error( $result ) ) { add_settings_error( 'citolume', 'citolume_recheck', $result->get_error_message() ); } else { add_settings_error( 'citolume', 'citolume_recheck', sprintf( /* translators: 1: number of questions, 2: number of credits spent. */ _n( 'Re-checking %1$d question — %2$d credit spent. Results land here when it finishes.', 'Re-checking %1$d questions — %2$d credits spent. Results land here when it finishes.', count( $ids ), 'citolume' ), count( $ids ), (int) ( $result['spent'] ?? count( $ids ) ) ), 'success' ); citolume_clear_status_cache(); } } } /* * "What's weak on this page?" (ticket 147) — the platform's diagnosis. * It reads this site's sitemap and best-matching page, never an engine, * so it costs no credit; the API holds the cooldown and the daily cap. */ if ( 'diagnose' === $action && isset( $_POST['citolume_question_id'] ) ) { $qid = absint( $_POST['citolume_question_id'] ); $result = citolume_api_send( '/v1/queries/' . $qid . '/diagnose', 'POST', array(), 45 ); citolume_question_open( $qid ); if ( is_wp_error( $result ) ) { add_settings_error( 'citolume', 'citolume_diagnose', $result->get_error_message() ); } else { delete_transient( 'citolume_answers' ); add_settings_error( 'citolume', 'citolume_diagnose', __( 'Checked a fresh read of your page — the findings are open below.', 'citolume' ), 'success' ); } } if ( 'remove' === $action && isset( $_POST['citolume_question_id'] ) ) { $result = citolume_delete_query( absint( $_POST['citolume_question_id'] ) ); if ( is_wp_error( $result ) ) { add_settings_error( 'citolume', 'citolume_remove', $result->get_error_message() ); } else { add_settings_error( 'citolume', 'citolume_remove', __( 'Question removed. Its history is kept.', 'citolume' ), 'success' ); citolume_clear_status_cache(); } } } /** * The question card to render open, after an action taken inside it. * * The action posts back to this screen, which re-renders every card closed — * so asking for a diagnosis would throw away the reader's place. The id is * held for the one render that follows. * * @param int|null $set Card to open; null to read. * @return int */ function citolume_question_open( $set = null ) { static $open = 0; if ( null !== $set ) { $open = (int) $set; } return $open; } /** * Renders the Questions screen. * * @return void */ function citolume_render_questions_screen() { if ( ! current_user_can( 'manage_options' ) ) { return; } $connected = '' !== citolume_license_key(); $status = $connected ? citolume_site_status() : null; $results = $connected ? citolume_results() : null; $domain = preg_replace( '/^www\./', '', (string) preg_replace( '/:.*$/', '', citolume_site_host() ) ); // The funnel carries next_scheduled_at; the site status does not. $funnel_next = $connected ? citolume_funnel() : null; $funnel_next = is_wp_error( $funnel_next ) ? null : $funnel_next; $next = is_array( $funnel_next ) && ! empty( $funnel_next['next_scheduled_at'] ) ? (int) strtotime( (string) $funnel_next['next_scheduled_at'] . ' UTC' ) : 0; $freq = is_array( $status ) ? (string) ( $status['scan_frequency'] ?? '' ) : ''; // "Weekly · next scan Mon 26 Oct" — the platform puts this at the right of // the head's own row, not on a line underneath it. if ( $next && '' !== $freq ) { $rhythm = sprintf( /* translators: 1: scan frequency, 2: date of the next scan. */ __( '%1$s · next scan %2$s', 'citolume' ), ucfirst( $freq ), date_i18n( 'D j M', $next ) ); } elseif ( $next ) { /* translators: %s: date of the next scan. */ $rhythm = sprintf( __( 'Next scan %s', 'citolume' ), date_i18n( 'D j M', $next ) ); } else { $rhythm = '' !== $freq ? ucfirst( $freq ) : ''; } $head_meta = is_array( $status ) ? sprintf( /* translators: 1: questions used, 2: questions allowed, 3: plan name. */ __( '%1$d of %2$d used on %3$s', 'citolume' ), (int) ( $status['queries_used'] ?? 0 ), (int) ( $status['max_queries'] ?? 0 ), ucfirst( (string) ( $status['plan'] ?? 'free' ) ) ) : ''; ?>

0 && $used >= $limit; ?>

—

5 ) { return; } $next = ! empty( $changes['next_at'] ) ? (int) strtotime( (string) $changes['next_at'] . ' UTC' ) : 0; ?>

$engine Engine row from the results payload. * @return array{class: string, mark: string, title: string} */ function citolume_question_cell( $engine ) { $cited = (int) ( $engine['runs_cited'] ?? 0 ); $mentioned = (int) ( $engine['runs_mentioned'] ?? 0 ); $total = (int) ( $engine['runs_total'] ?? 0 ); $status = (string) ( $engine['status'] ?? 'unknown' ); if ( 'cited' === $status || 'partial' === $status || $cited > 0 ) { return array( 'class' => 'is-cited', 'mark' => '✓ ' . $cited . '/' . $total, 'title' => __( 'Cited — your site was in the sources', 'citolume' ), ); } if ( $mentioned > 0 ) { return array( 'class' => 'is-mentioned', 'mark' => '❝ ' . __( 'named', 'citolume' ), 'title' => __( 'Mentioned — named in the answer, not linked', 'citolume' ), ); } if ( 'unknown' === $status ) { $reason = (string) ( $engine['unknown_reason'] ?? 'not_searched' ); // Three different facts hid behind one word (ticket 081: Gemini out of // credit read as a finding). Our failure says so; the other two stay // "unknown" with the reason a hover away. if ( 'engine_failed' === $reason ) { return array( 'class' => 'is-absent', 'mark' => '! ' . __( 'check failed', 'citolume' ), 'title' => __( 'Every call to this engine failed in the last scan — a problem on our side, not a result. It is retried on the next scan.', 'citolume' ), ); } return array( 'class' => 'is-absent', 'mark' => '? ' . __( 'unknown', 'citolume' ), 'title' => 'searched_cited_nobody' === $reason ? __( 'The engine searched and cited nobody at all for this question.', 'citolume' ) : __( 'The engine answered from memory without searching, so it could not have cited anyone.', 'citolume' ), ); } return array( 'class' => 'is-none', 'mark' => '✗ ' . $cited . '/' . $total, 'title' => __( 'The engine searched and cited other sites, not yours', 'citolume' ), ); } /** * The engine columns, in the design's fixed order — never alphabetical. * * Every canonical engine gets a column whether or not the plan carries it, so * "— not on plan" stays visible as the thing an upgrade adds (design 3d). * Engines that only exist outside production (the mock) are appended when the * results actually contain them. * * @param array> $queries Query rows from the results payload. * @return array */ function citolume_question_columns( $queries ) { /* * The platform's ENGINE_COLUMNS, exactly — gemini, openai, perplexity. * * **Claude is not here, and must not be added back by hand.** It is on no * plan, there is no `src/engines/claude.ts`, and `plans.ts` says "+ * 'claude' once ticket 003 verifies it against the live API". The column * rendered a permanent "— not on plan" cell for an engine that does not * exist, which on a wordpress.org listing is advertising an unbuilt * feature. It comes back when 003 does, in `plans.ts` first. * * An engine the API actually returns still shows: unknown names are * appended below, so shipping a new engine server-side does not need a * plugin release. */ // Gemini, Perplexity, then ChatGPT: the order plans add them, so an off-plan column sits at the end. $canonical = array( 'gemini', 'perplexity', 'openai' ); $seen = array(); foreach ( $queries as $query ) { foreach ( (array) ( $query['engines'] ?? array() ) as $engine ) { $seen[ (string) $engine['engine'] ] = true; } } $extra = array_diff( array_keys( $seen ), $canonical ); sort( $extra ); return array_merge( $canonical, $extra ); } /** * The one-line verdict, twice: short under the question in the row, long at * the top of the panel. * * Both read off the same engine rows, which is the whole reason they are * computed together — a summary line and a panel heading that disagree about * whether you were cited is worse than either one alone. * * Ported from the platform's Questions screen so the two surfaces say the * same sentence about the same question. * * @param array $query Query row from the results payload. * @param array $entitled Engines this plan asks. * @param string $domain The site's own host. * @return array{summary: string, title: string, tone: string, sub: string} */ function citolume_question_verdict( $query, $entitled, $domain ) { $rows = (array) ( $query['engines'] ?? array() ); $is_gap = ! empty( $query['coverage_gap'] ); $cited = array(); $mentioned = array(); $scanned = array(); $cited_runs = 0; $total_runs = 0; foreach ( $rows as $row ) { $name = (string) ( $row['engine'] ?? '' ); if ( (int) ( $row['runs_cited'] ?? 0 ) > 0 ) { $cited[] = citolume_engine_label( $name ); $cited_runs += (int) $row['runs_cited']; } elseif ( (int) ( $row['runs_mentioned'] ?? 0 ) > 0 ) { $mentioned[] = citolume_engine_label( $name ); } if ( ! $entitled || in_array( $name, $entitled, true ) ) { $scanned[] = citolume_engine_label( $name ); $total_runs += (int) ( $row['runs_total'] ?? 0 ); } } $names = static function ( $items ) { return implode( __( ' and ', 'citolume' ), $items ); }; $runs = static function ( $n ) { /* translators: %d: number of runs. */ return sprintf( _n( '%d run', '%d runs', $n, 'citolume' ), $n ); }; if ( ! $scanned ) { return array( 'summary' => __( 'No scan has covered this question yet', 'citolume' ), 'title' => __( 'No scan yet', 'citolume' ), 'tone' => 'is-neutral', 'sub' => __( 'It joins the next scheduled scan.', 'citolume' ), ); } if ( $is_gap ) { return array( 'summary' => __( 'Coverage gap — no engine answers it well', 'citolume' ), 'title' => __( 'Coverage gap', 'citolume' ), 'tone' => 'is-gap', 'sub' => __( 'No engine answers this well yet. The first page that does takes it.', 'citolume' ), ); } if ( $cited ) { return array( 'summary' => sprintf( /* translators: 1: engine names, 2: runs cited, 3: total runs phrase. */ __( 'Cited by %1$s · %2$d of %3$s', 'citolume' ), $names( $cited ), $cited_runs, $runs( $total_runs ) ), 'title' => sprintf( /* translators: 1: runs cited, 2: total runs phrase. */ __( 'Cited in %1$d of %2$s', 'citolume' ), $cited_runs, $runs( $total_runs ) ), 'tone' => 'is-cited', 'sub' => sprintf( /* translators: 1: engine names, 2: the site's domain. */ __( '%1$s listed %2$s as a source.', 'citolume' ), $names( $cited ), $domain ), ); } if ( $mentioned ) { return array( 'summary' => sprintf( /* translators: %s: engine names. */ __( 'Mentioned by %s, not linked', 'citolume' ), $names( $mentioned ) ), 'title' => __( 'Mentioned, not cited', 'citolume' ), 'tone' => 'is-mention', 'sub' => sprintf( /* translators: 1: engine names, 2: the site's domain. */ __( "%1\$s named %2\$s in the answer's own words — there is no link to point at.", 'citolume' ), $names( $mentioned ), $domain ), ); } return array( 'summary' => sprintf( /* translators: 1: engine names, 2: total runs phrase. */ __( 'Not cited by %1$s · 0 of %2$s', 'citolume' ), $names( $scanned ), $runs( $total_runs ) ), 'title' => __( 'Not cited', 'citolume' ), 'tone' => 'is-miss', 'sub' => sprintf( /* translators: 1: engine names, 2: the site's domain. */ __( '%1$s answered without linking %2$s.', 'citolume' ), $names( $scanned ), $domain ), ); } /** * The question list. * * Cards, not table rows — the platform's "Questions Screen" shape (ticket * 085), ported in ticket 128 because a table row with a cramped grey band * under it was not the same product. Each card carries the question, a * one-line verdict under it, the per-engine badges, and a chevron; opening it * reads verdict → diagnosis → engine tabs → answer excerpt → ranked sources. * * The header row and the cards are separate elements that must line up, and * they cannot share a grid — each card is its own `
` and subgrid does * not reach across them. So they share the numbers instead, through * --cit-qcol / --cit-qgap / --cit-qpad. Change a column once, in the CSS. * * `
`, so a row opens with no JavaScript at all — which also retired * the old chevron-button script. * * @param array|WP_Error|null $results Results payload. * @param array|WP_Error|null $status Site status, for the plan's engine list. * @param int $next Next scheduled scan as a timestamp, 0 if unknown. * @return void */ function citolume_render_questions_table( $results, $status = null, $next = 0 ) { $freq = is_array( $status ) ? (string) ( $status['scan_frequency'] ?? '' ) : ''; if ( is_wp_error( $results ) ) { ?>

0 ) : ?> · ·
/>
> /> ○ –

⚑ —

hides its // contents, so an overlay inside one would open onto nothing. citolume_answer_modals(); ?>

✓ ❝ ✗ ○ —

array of run texts. * @param array $sources engine => array of URLs. * @param array $verdict The shared verdict. * @param array $diagnosis The last page diagnosis for this question, if any. * @return void */ function citolume_render_question_detail( $query, $answers = array(), $sources = array(), $verdict = array(), $diagnosis = array() ) { $mention_note = ''; $cited_any = false; $has_hosts = false; foreach ( (array) ( $query['engines'] ?? array() ) as $engine ) { if ( ! empty( $engine['competitors'] ) ) { $has_hosts = true; } if ( (int) ( $engine['runs_cited'] ?? 0 ) > 0 ) { $cited_any = true; } if ( '' === $mention_note && (int) ( $engine['runs_mentioned'] ?? 0 ) > 0 && 0 === (int) ( $engine['runs_cited'] ?? 0 ) ) { $mention_note = sprintf( /* translators: 1: engine name, 2: mentioned runs, 3: total runs. */ __( 'Mentioned by %1$s in %2$d of %3$d runs — named, not linked.', 'citolume' ), citolume_engine_label( (string) $engine['engine'] ), (int) $engine['runs_mentioned'], (int) $engine['runs_total'] ); } } // "Why it's missing" — only for uncited questions, and only from data we // actually hold. The structural page checks the design also sketches need // the deferred fixes engine (§14) and are deliberately not faked here. $diag = null; if ( ! $cited_any ) { $page = citolume_guess_page_for( (string) $query['text'] ); if ( ! empty( $query['coverage_gap'] ) ) { $diag = array( 'tone' => 'is-gap', 'title' => __( 'No engine answers this well yet', 'citolume' ), 'body' => $page ? __( 'Nobody at all is being cited for this. An opening, not a failure — your page below is well placed to take it.', 'citolume' ) : __( 'Nobody at all is being cited for this — and nothing on this site addresses it yet. A post that answers it directly in its first paragraph would be the first of its kind.', 'citolume' ), 'page' => $page, ); } elseif ( 'low' === ( $query['winnability'] ?? '' ) ) { $diag = array( 'tone' => 'is-lowwin', 'title' => __( 'Not winnable — and not a content problem', 'citolume' ), 'body' => sprintf( /* translators: %s: the directory/forum domains dominating this question. */ __( 'Engines answer this with %s. No individual site like yours is being cited, and a page will not change that. Swap it for a question about your content.', 'citolume' ), (string) ( $query['winnability_reason'] ?? __( 'directories and forums', 'citolume' ) ) ), 'page' => 0, ); } elseif ( '' !== $mention_note ) { $diag = array( 'tone' => 'is-mention', 'title' => __( 'Mentioned, not linked', 'citolume' ), 'body' => $mention_note . ' ' . __( 'The engines already know you — making the answer quotable in the first paragraph of your page usually closes this.', 'citolume' ), 'page' => $page, ); } elseif ( $has_hosts ) { $diag = array( 'tone' => 'is-others', 'title' => __( 'Others are being cited', 'citolume' ), 'body' => __( 'The engines have an answer for this and cite other sites. Read what they wrote and see what your page is missing.', 'citolume' ), 'page' => $page, ); } } if ( $diag ) : ?>
>

` hides its contents, * so an overlay opened by `:target` from a closed card would open onto * nothing — the same reason the platform keeps its modals at page level. * * Arguments are queued rather than markup, so the overlays are *rendered* at * drain time by the same escaped path as everything else. Buffering HTML and * echoing it back would have to pass `wp_kses_post()`, which strips exactly * the `role`, `aria-*` and `data-panel` attributes the tabs are built on. * * @param array|null $args Arguments for citolume_render_answer_modal(), or null to drain. * @return void */ function citolume_answer_modals( $args = null ) { static $queue = array(); if ( null !== $args ) { $queue[] = $args; return; } foreach ( $queue as $queued ) { citolume_render_answer_modal( ...$queued ); } $queue = array(); } /** * The engines' own answers for every tracked question, cached briefly so the * answer panels on one screen cost a single API call. * * @return array */ function citolume_question_answers() { $cached = get_transient( 'citolume_answers' ); if ( false !== $cached ) { return $cached; } $body = citolume_api_get( '/v1/answers' ); if ( is_wp_error( $body ) || ! is_array( $body ) ) { return array( 'answers' => array(), 'sources' => array(), 'diagnoses' => array(), 'cache_days' => 7, ); } $shaped = array( 'answers' => (array) ( $body['answers'] ?? array() ), 'sources' => (array) ( $body['sources'] ?? array() ), 'diagnoses' => (array) ( $body['diagnoses'] ?? array() ), 'cache_days' => (int) ( $body['cache_days'] ?? 7 ), ); set_transient( 'citolume_answers', $shaped, 5 * MINUTE_IN_SECONDS ); return $shaped; } /** * The small Markdown subset engines actually use, on top of escaped text. * * Bold, inline code, headings and links — rendered so "**bold**" reads as bold * rather than as asterisks. **Escaping happens first**, so nothing the engine * wrote can become markup; links are lifted out before the domain-mark runs so * the mark can never land inside an href. * * @param string $raw The engine's answer. * @param string $own The site's bare host. * @return string Safe HTML. */ function citolume_render_answer_html( $raw, $own ) { $links = array(); $text = esc_html( $raw ); $text = preg_replace_callback( '/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/', static function ( $m ) use ( &$links, $own ) { $links[] = sprintf( '%s', esc_url( html_entity_decode( $m[2], ENT_QUOTES, 'UTF-8' ) ), citolume_mark_own( $m[1], $own ) ); return "\0" . ( count( $links ) - 1 ) . "\0"; }, $text ); // Fence markers and thematic breaks. The platform leaves both as literal // text and they read as line noise in the middle of prose — a deliberate // improvement on it rather than a drift away. Fences go first, so the // ``` on its own line never survives into the inline-code pass below. $text = preg_replace( '/^```[a-z0-9+-]*\s*$/mi', '', $text ); $text = preg_replace( '/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/m', '
', $text ); $text = preg_replace( '/\*\*([^*\n]+)\*\*/', '$1', $text ); $text = preg_replace( '/`([^`\n]+)`/', '$1', $text ); $text = preg_replace( '/^#{1,6}\s+(.+)$/m', '$1', $text ); $text = citolume_mark_own( $text, $own ); return preg_replace_callback( "/\0(\d+)\0/", static function ( $m ) use ( $links ) { return $links[ (int) $m[1] ] ?? ''; }, $text ); } /** * The allowlist every answer fragment passes through before it is echoed. * * @return array> */ function citolume_answer_tags() { return array( 'strong' => array(), 'code' => array(), 'mark' => array(), 'p' => array(), 'hr' => array(), 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ); } /** * Wraps the site's own domain in a , wherever it appears. * * @param string $escaped Already-escaped text. * @param string $own The site's bare host. * @return string */ function citolume_mark_own( $escaped, $own ) { if ( '' === $own ) { return $escaped; } return (string) preg_replace( '/' . preg_quote( esc_html( $own ), '/' ) . '/i', '$0', $escaped ); } /** * "example.com/posts/why/?utm_source=openai" reads as the page, not the tracker. * * @param string $url The source URL. * @return string */ function citolume_display_url( $url ) { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $path = (string) wp_parse_url( $url, PHP_URL_PATH ); if ( '' === $host ) { return $url; } return rtrim( $host . $path, '/' ); } /** * One ranked source list, your pages first. * * It was two blocks on the platform once — the site's own cited URLs, then the * rival hosts — which split one ranking in half and turned the comparison the * screen exists for into arithmetic. Ranked means: your site at the top, * marked, then everyone else in the order the engine gave them. The number is * the position in that list, which is the number the answer's own footnotes * use. * * @param string $engine The engine. * @param array $urls Source URLs for that engine. * @param string $own The site's bare host. * @return void */ function citolume_render_sources( $engine, $urls, $own ) { $hosts = array(); foreach ( $urls as $url ) { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( '' !== $host && ! isset( $hosts[ $host ] ) ) { $hosts[ $host ] = $url; } } if ( ! $hosts ) { ?>

$url ) : ?>
__( 'Free', 'citolume' ), 'perplexity' => __( 'Starter', 'citolume' ), 'openai' => __( 'Growth', 'citolume' ), ); return $plans[ $engine ] ?? __( 'a later plan', 'citolume' ); } /** * The answer card: one tab per engine, an excerpt, and the way to the rest. * * The panel used to carry the full text, which made every row as long as * whatever the engine felt like writing. Two paragraphs and a read time say * the same thing in a fixed height, and the overlay is where somebody who * wants the rest goes — opened by `:target`, so it works with scripting off, * which a `` does not. * * @param array $query The question, as /v1/results sends it. * @param array $answers engine => array of run texts. * @param array $sources engine => array of URLs. * @param array $verdict The shared verdict, for the overlay's header. * @return void */ function citolume_render_answer_panel( $query, $answers, $sources, $verdict ) { if ( ! $answers && ! $sources ) { return; } $own = preg_replace( '/^www\./', '', (string) preg_replace( '/:.*$/', '', citolume_site_host() ) ); $engines = array_keys( $answers ? $answers : $sources ); $query_id = (int) $query['query_id']; $first = $engines[0]; /* * Engines this plan does not ask, named with the plan that would — the * platform's `.atab.off`. A tab strip that silently omits ChatGPT answers * "did you ask?" and leaves "what would make you?" to the pricing page. * Not buttons: there is nothing behind them to show. */ $offplan = array(); foreach ( citolume_question_columns( array() ) as $name ) { if ( ! in_array( $name, $engines, true ) ) { $offplan[ $name ] = citolume_plan_with_engine( $name ); } } // Prefer an engine that actually has text — a tab strip opening on // "expired" when the engine beside it has the answer is a bad first read. foreach ( $engines as $engine ) { if ( ! empty( $answers[ $engine ] ) ) { $first = $engine; break; } } ?>
1 || $offplan ) : ?>
$plan_name ) : ?>

`, so it opens with scripting off. The scrim * is a link back to the row, so clicking outside closes it the way people * expect, and Esc is the one job left for the script. * * @param string $modal_id The overlay's id. * @param array $query The question. * @param string $engine The engine. * @param array $runs Every run's text. * @param array $verdict The shared verdict. * @param string $own The site's bare host. * @param array $urls The URLs this engine grounded on. * @return void */ function citolume_render_answer_modal( $modal_id, $query, $engine, $runs, $verdict, $own, $urls = array() ) { $query_id = (int) $query['query_id']; ?> |null|WP_Error */ function citolume_site_suggestions( $run = false ) { $body = $run ? citolume_api_send( '/v1/questions/suggest' ) : citolume_api_get( '/v1/questions/suggest' ); if ( is_wp_error( $body ) ) { return $body; } if ( ! is_array( $body ) || ! array_key_exists( 'suggestions', $body ) || null === $body['suggestions'] ) { return null; } $out = array(); foreach ( (array) $body['suggestions'] as $item ) { // The service sends objects; entries cached before it carried a // source are bare strings. Read both shapes. $text = is_array( $item ) ? (string) ( $item['text'] ?? '' ) : (string) $item; $text = trim( $text ); if ( '' !== $text ) { $out[] = $text; } } return $out; } /** * Question ideas from the site's own content — never an empty box (§3.3). * * Shared with the wizard's step 3: suggestions come from recent post titles, * question-shaped ones first. * * @param int $count How many to offer. * @return array */ function citolume_question_suggestions( $count = 4 ) { $titles = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 30, 'orderby' => 'date', 'fields' => 'ids', ) ); $questions = array(); $plain = array(); foreach ( $titles as $post_id ) { $title = wp_strip_all_tags( get_the_title( $post_id ) ); if ( strlen( $title ) < 15 || strlen( $title ) > 120 ) { continue; } if ( preg_match( '/^(how|what|why|when|which|where|can|should|is|are|do|does)\b/i', $title ) || false !== strpos( $title, '?' ) ) { $questions[] = rtrim( $title, '?' ) . '?'; } else { $plain[] = $title; } } $picked = array_slice( array_merge( $questions, $plain ), 0, $count ); // A brand-new site with no usable titles still gets a workable seed. if ( ! $picked ) { $picked[] = sprintf( /* translators: %s: site name. */ __( 'what does %s help with?', 'citolume' ), get_bloginfo( 'name' ) ); } return $picked; } /** * The empty state: the suggestion list, so this is never a blank table. * * @return void */ function citolume_render_question_suggestions() { ?>