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' ) )
)
: '';
?>
$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 ) ) {
?>
/>
>
/>
○ –▾
⚑ —
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 `