From 0e57484ba7d4481be69f6992115153ec267e5f5f Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Tue, 11 Aug 2026 09:58:23 +0200 Subject: [PATCH 1/4] Keep the plugin's notices on the plugin's own screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory's pre-review flagged guideline 11 — notices limited in scope and used with moderation — and it was right to. Two of the three notices printed on every admin screen there is: the rejected-key error, which persists until someone fixes the key, and the duplicate-install error, which persists until someone deletes a directory. Neither asks for anything that can be done from the media library. All three are now confined to the Plugins list and the CiteCue settings screen, which are the only places their advice is actionable, and the settings screen's status card states the same conditions a second time — so a notice nobody sees costs no information. The reconnect prompt goes further and can be dismissed for good, per user. It is advice rather than an error, and a message an administrator has read and decided against should stop arriving. Uninstall clears the dismissal rows with everything else. The readme's summary was 167 characters, over the 150 the directory allows, and led with llms.txt — which is one of four features and is generated by CiteCue, not here. It now leads with the part that only a plugin can do: choosing per request which version of a page WordPress returns. Co-Authored-By: Claude Opus 5 --- citecue.php | 12 +- includes/class-citecue-admin.php | 124 +++++++++++-- readme.txt | 33 +++- tests/cases/test-admin-notices.php | 288 +++++++++++++++++++++++++++++ tests/cases/test-lifecycle.php | 38 +++- uninstall.php | 3 + 6 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 tests/cases/test-admin-notices.php diff --git a/citecue.php b/citecue.php index 535b52e..da6453c 100644 --- a/citecue.php +++ b/citecue.php @@ -3,7 +3,7 @@ * Plugin Name: CiteCue AI Auto-Fix * Plugin URI: https://github.com/citecue/wordpress-plugin * Description: Serves CiteCue-optimized versions of your pages to AI bots and crawlers, adds CiteCue's enriched SEO metadata to your live pages, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress. - * Version: 1.1.0 + * Version: 1.1.1 * Requires at least: 5.8 * Requires PHP: 7.4 * Author: CiteCue @@ -31,6 +31,10 @@ * * The copy that loses the race does nothing and says so, which turns a white * screen into an admin notice naming the directory to delete. + * + * The notice belongs on the Plugins screen and nowhere else: deleting a plugin + * directory is a Plugins-screen job, and nothing about this is urgent enough to + * follow an administrator through the rest of their dashboard. */ if ( defined( 'CITECUE_VERSION' ) ) { add_action( @@ -39,6 +43,10 @@ static function () { if ( ! current_user_can( 'activate_plugins' ) ) { return; } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || 'plugins' !== $screen->id ) { + return; + } printf( '

%s

', esc_html( @@ -56,7 +64,7 @@ static function () { return; } -define( 'CITECUE_VERSION', '1.1.0' ); +define( 'CITECUE_VERSION', '1.1.1' ); define( 'CITECUE_PLUGIN_FILE', __FILE__ ); define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index 95f7447..b96565f 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -20,6 +20,11 @@ */ class Citecue_Admin { + /** + * User-meta key prefix recording a notice this user has dismissed. + */ + const DISMISSED_META_PREFIX = 'citecue_dismissed_'; + /** * Plugin container. * @@ -45,6 +50,7 @@ public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'maybe_claim_connect' ) ); + add_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) ); add_action( 'admin_notices', array( $this, 'notices' ) ); add_action( 'admin_post_citecue_connect_start', array( $this, 'handle_connect_start' ) ); add_action( 'admin_post_citecue_disconnect', array( $this, 'handle_disconnect' ) ); @@ -119,17 +125,45 @@ private function redirect_with( $code ) { } /** - * Admin notices: action feedback plus a persistent auth-failure warning. + * Whether the screen being rendered is one of the plugin's own. + * + * This plugin has no business putting messages on the comment queue, the + * media library or anyone's post editor, so every notice it emits is gated + * on this. Two screens qualify: the settings page the message is about, and + * the Plugins list, which is where an administrator looks when a plugin + * needs attention and the only screen on which some of these are actionable. + * + * @return bool + */ + private function is_plugin_screen() { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen ) { + return false; + } + + $id = (string) $screen->id; + + return 'plugins' === $id || false !== strpos( $id, 'citecue' ); + } + + /** + * Admin notices: action feedback plus the two conditions worth interrupting + * an administrator over. + * + * Confined to the plugin's own screens — see is_plugin_screen(). Nothing + * here is urgent enough to follow someone around their whole dashboard, and + * the settings screen states all of it a second time in its status card, so + * a dismissed or unseen notice costs no information. * * @return void */ public function notices() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( 'manage_options' ) || ! $this->is_plugin_screen() ) { return; } if ( get_option( 'citecue_auth_failed' ) ) { - echo '

' . esc_html__( 'CiteCue:', 'citecue-ai-auto-fix' ) . ' ' + echo '

' . esc_html__( 'CiteCue:', 'citecue-ai-auto-fix' ) . ' ' . esc_html__( 'the API key was rejected, so optimized pages are not being served to AI crawlers. Update the key in the CiteCue settings.', 'citecue-ai-auto-fix' ) . ' ' . esc_html__( 'Open settings', 'citecue-ai-auto-fix' ) . '

'; } @@ -183,10 +217,11 @@ public function notices() { * CiteCue learns the capability only from the connect exchange, so a site * that connected before this release injects enriched metadata while the * app still reports the channel as unable to — and tells the customer their - * "Live" fix is not reaching human visitors. One reconnect fixes it. Shown - * on the Plugins and CiteCue screens only: it is worth acting on, but it is - * not an error, and it has no business following an administrator around - * their whole dashboard. + * "Live" fix is not reaching human visitors. One reconnect fixes it. + * + * It is advice, not an error, so it can be turned off for good: the same + * state is on the settings screen's status card either way, and a message + * an administrator has read and decided against should not keep arriving. * * @return void */ @@ -194,10 +229,7 @@ private function seo_head_reconnect_notice() { if ( ! $this->plugin->settings->needs_seo_head_reconnect() ) { return; } - - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - $here = $screen ? (string) $screen->id : ''; - if ( 'plugins' !== $here && false === strpos( $here, 'citecue' ) ) { + if ( $this->notice_is_dismissed( 'seo_head_reconnect' ) ) { return; } @@ -211,11 +243,79 @@ private function seo_head_reconnect_notice() {

-

action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?>

+

+ action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?> + +

current_admin_url() ), + 'citecue_dismiss_' . $notice + ); + } + + /** + * The admin URL currently being rendered, for links that come back here. + * + * @return string + */ + private function current_admin_url() { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + return ( $screen && 'plugins' === $screen->id ) ? admin_url( 'plugins.php' ) : $this->settings_url(); + } + + /** + * Records a dismissal and reloads the screen without the query arguments. + * + * @return void + */ + public function maybe_dismiss_notice() { + if ( ! isset( $_GET['citecue_dismiss'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked below, once there is something to check it against. + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $notice = sanitize_key( wp_unslash( $_GET['citecue_dismiss'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- checked on the next line. + check_admin_referer( 'citecue_dismiss_' . $notice ); + + if ( 'seo_head_reconnect' !== $notice ) { + return; + } + + update_user_meta( get_current_user_id(), self::DISMISSED_META_PREFIX . $notice, time() ); + + $back = wp_get_referer(); + wp_safe_redirect( $back ? remove_query_arg( array( 'citecue_dismiss', '_wpnonce' ), $back ) : $this->settings_url() ); + exit; + } + /** * Completes a handshake when CiteCue redirects back with a one-time code. * diff --git a/readme.txt b/readme.txt index 496f2d9..83d9935 100644 --- a/readme.txt +++ b/readme.txt @@ -1,27 +1,37 @@ === CiteCue AI Auto-Fix === Contributors: citecue -Tags: ai, llms.txt, gptbot, ai-seo, woocommerce +Tags: ai, ai-crawlers, gptbot, ai-seo, woocommerce Requires at least: 5.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.1.0 +Stable tag: 1.1.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html -Serve AI-optimized versions of your pages to AI bots and crawlers, enrich your live pages' SEO metadata, publish your llms.txt, and receive draft content from CiteCue. +Answers AI crawlers with a CiteCue-optimized version of the page they asked for, and fills only the metadata gaps your SEO plugin leaves behind. == Description == -CiteCue AI Auto-Fix connects your WordPress site to CiteCue: +CiteCue AI Auto-Fix is the WordPress end of CiteCue. It decides, per request, which version of a page WordPress returns — the optimized one to a recognised AI crawler, your normal page to everyone else — and it can do that only from inside WordPress, before the theme renders. -* **AI crawler middleware** — when an AI bot or crawler (GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User and more) requests a page, the plugin serves the CiteCue-optimized version of that page. Human visitors always see your normal site. Any miss, timeout or outage passes straight through to the normal page. -* **Enriched page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: any tag WordPress, your theme or your SEO plugin already outputs is left exactly as it is, so there is never a second title or canonical. -* **llms.txt** — publishes the llms.txt file CiteCue generates for your brand at your site root. +* **Per-request delivery to AI crawlers** — when GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User or any other agent in the crawler registry requests a page, the plugin returns the CiteCue-optimized version of that URL. Human visitors always see your normal site, and optimized responses are never cached for regular traffic. Any miss, timeout or outage passes straight through to the normal page. +* **Gap-filling page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: it reads what your theme, WordPress and your SEO plugin actually printed into `` and adds only what none of them emitted, so there is never a second title or canonical. +* **llms.txt** — serves the llms.txt file CiteCue maintains for your brand at your site root, refreshed from CiteCue rather than regenerated here. * **Content from CiteCue** — a signed endpoint through which CiteCue can push new brand-building content (content briefs, FAQ packs, gap-filling pages) into WordPress as drafts for your review. * **WooCommerce-aware** — cart, checkout, account pages and cart-modifying links are never intercepted, while product and shop pages are served optimized. Pushed content can also create or enrich WooCommerce products (draft by default, matched by SKU with explicit consent). This plugin requires a CiteCue account (citecue.com) and does nothing until you connect one. See "External services" below for exactly what is sent where. += What the plugin actually does = + +llms.txt is one of the four features above, and CiteCue writes that file — the plugin serves it. The rest of the code is about what happens on a live request: + +* **It serves a different representation per requester, safely.** Crawler matching runs against a registry that refreshes daily, so an agent launched last week is recognised without a plugin update. A logged-in user, a cart URL, a WooCommerce endpoint or a cart-modifying link is never intercepted. A circuit breaker, a per-minute lookup budget, negative caching and a stale-while-revalidate cache mean an outage at CiteCue costs a passthrough, never a broken page or a slow one. +* **It composes with your SEO plugin rather than replacing it.** The metadata layer detects what was actually printed into `` rather than looking for particular plugins, so it behaves correctly beside Yoast, Rank Math, a plugin nobody has heard of, or none at all. Every tag it adds carries a `data-citecue` attribute, so View Source tells you exactly which ones came from CiteCue. +* **Nothing from the API is trusted as markup.** Every returned tag is parsed, matched against an allowlist of shapes and rebuilt from escaped values, with structured data re-encoded so it cannot escape its own script element. +* **It never makes a visitor wait on a third party.** The render path reads cache only; a URL with nothing cached yet renders untouched and the fetch is queued to WP-Cron. +* **Content flows back in.** The signed `citecue/v1` endpoint is how CiteCue delivers new content into WordPress — as drafts, with replayed signatures rejected — so the loop from "this page is missing" to "this page exists" closes without anyone copying and pasting. + == Installation == 1. Install and activate the plugin from Plugins → Add New, or upload it under Plugins → Add New → Upload Plugin. @@ -108,6 +118,9 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never == Upgrade Notice == += 1.1.1 = +Admin notices now appear only on the Plugins and CiteCue screens, and the reconnect prompt can be dismissed for good. No functional change to delivery. + = 1.1.0 = Adds enriched page metadata for live pages. Existing connections need one reconnect before CiteCue knows this site can do it — Settings → CiteCue will ask. @@ -116,6 +129,12 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin == Changelog == += 1.1.1 = +* Admin notices are confined to the Plugins screen and the CiteCue settings screen. The rejected-key warning and the duplicate-install warning used to print on every screen in the dashboard; neither asks for anything that can be done anywhere else, and the settings screen states both a second time in its status card. +* The reconnect prompt can now be dismissed permanently, per user. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. +* Uninstall removes the dismissal records along with everything else. +* readme: shorter summary, and the description now says plainly what this plugin does that a static llms.txt generator does not. + = 1.1.0 = * New: enriched page metadata. CiteCue's title, meta description, OpenGraph, canonical and structured-data tags are added to your live pages, so search engines and AI answer engines see them, not just AI crawlers. Uses CiteCue's `/api/delivery/v2/seo-head` endpoint. * Fills gaps only: anything WordPress, your theme or your SEO plugin already prints is left untouched, so the plugin never emits a second title or canonical. Detection reads the real `` output rather than looking for particular plugins. diff --git a/tests/cases/test-admin-notices.php b/tests/cases/test-admin-notices.php new file mode 100644 index 0000000..dbd54b5 --- /dev/null +++ b/tests/cases/test-admin-notices.php @@ -0,0 +1,288 @@ +admin = new Citecue_Admin( $this->plugin ); + $this->user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $this->user_id ); + } + + /** + * @return void + */ + public function tear_down() { + $GLOBALS['current_screen'] = null; + parent::tear_down(); + } + + /** + * Everything the notices print while pretending to be on one screen. + * + * @param string $screen_id Screen to render as, e.g. 'dashboard'. + * @return string + */ + private function notices_on( $screen_id ) { + set_current_screen( $screen_id ); + + ob_start(); + $this->admin->notices(); + return (string) ob_get_clean(); + } + + /** + * Puts the site in the state that raises the auth-failure notice. + * + * @return void + */ + private function reject_the_key() { + $this->configure_delivery(); + update_option( 'citecue_auth_failed', time() ); + } + + /** + * Puts the site in the state that raises the reconnect notice: connected, + * injecting metadata, and CiteCue never told. + * + * @return void + */ + private function owe_a_reconnect() { + $this->configure_delivery( + array( + 'seo_head_enabled' => true, + 'seo_head_reported' => false, + ) + ); + } + + /** + * @return void + */ + public function test_the_auth_failure_reaches_the_settings_screen() { + $this->reject_the_key(); + + $this->assertStringContainsString( 'the API key was rejected', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * The Plugins screen is where an administrator goes when a plugin needs + * attention, so it is in scope even though it is not the plugin's page. + * + * @return void + */ + public function test_the_auth_failure_reaches_the_plugins_screen() { + $this->reject_the_key(); + + $this->assertStringContainsString( 'the API key was rejected', $this->notices_on( 'plugins' ) ); + } + + /** + * The one that mattered: an error condition that persists until someone + * fixes it used to print on every admin screen there is. + * + * @dataProvider unrelated_screens + * @param string $screen_id Screen that is none of the plugin's business. + * @return void + */ + public function test_no_notice_follows_the_administrator_elsewhere( $screen_id ) { + $this->reject_the_key(); + $this->owe_a_reconnect(); + + $this->assertSame( '', $this->notices_on( $screen_id ) ); + } + + /** + * Screens the plugin has nothing to say on. + * + * @return array + */ + public function unrelated_screens() { + return array( + 'dashboard' => array( 'dashboard' ), + 'post editor' => array( 'post' ), + 'media' => array( 'upload' ), + 'comments' => array( 'edit-comments' ), + 'users' => array( 'users' ), + 'other plugin' => array( 'settings_page_some-other-plugin' ), + ); + } + + /** + * @return void + */ + public function test_the_reconnect_notice_reaches_the_plugin_screens() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( 'Reconnect to CiteCue', $this->notices_on( 'plugins' ) ); + } + + /** + * @return void + */ + public function test_the_reconnect_notice_offers_a_way_out() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( 'citecue_dismiss=seo_head_reconnect', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * Dismissed means dismissed: the condition is still true, and the notice + * still does not come back. + * + * @return void + */ + public function test_a_dismissed_reconnect_notice_stays_dismissed() { + $this->owe_a_reconnect(); + update_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + + $html = $this->notices_on( 'settings_page_citecue' ); + + $this->assertTrue( $this->plugin->settings->needs_seo_head_reconnect() ); + $this->assertStringNotContainsString( 'Reconnect to CiteCue', $html ); + } + + /** + * One administrator's decision is not made on their colleagues' behalf. + * + * @return void + */ + public function test_a_dismissal_belongs_to_the_user_who_made_it() { + $this->owe_a_reconnect(); + update_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $this->assertStringContainsString( 'Reconnect to CiteCue', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * A subscriber who reaches an admin screen is told nothing. + * + * @return void + */ + public function test_a_user_who_cannot_manage_options_sees_nothing() { + $this->reject_the_key(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertSame( '', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * The dismissal is a state change, so it is behind a nonce. + * + * @return void + */ + public function test_the_dismissal_link_carries_a_nonce() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( '_wpnonce', $this->notices_on( 'plugins' ) ); + } + + /** + * Requests a dismissal the way the link does. + * + * The handler redirects and exits, so the redirect is turned into an + * exception: what happened before it is the whole subject of these tests. + * A refusal throws too — wp_die() does, under the test suite — and that one + * is the caller's to see, so it is passed straight back out. + * + * @param string $notice Notice key to dismiss. + * @param string $nonce Nonce to present. + * @return void + * @throws WPDieException When the request is refused. + */ + private function request_dismissal( $notice, $nonce ) { + $_GET['citecue_dismiss'] = $notice; + $_REQUEST['_wpnonce'] = $nonce; + + add_filter( + 'wp_redirect', + static function () { + throw new Exception( 'redirected' ); + } + ); + + try { + $this->admin->maybe_dismiss_notice(); + } catch ( Exception $e ) { + if ( $e instanceof WPDieException ) { + throw $e; + } + } finally { + unset( $_GET['citecue_dismiss'], $_REQUEST['_wpnonce'] ); + } + } + + /** + * @return void + */ + public function test_a_signed_dismissal_is_recorded() { + $this->owe_a_reconnect(); + + $this->request_dismissal( 'seo_head_reconnect', wp_create_nonce( 'citecue_dismiss_seo_head_reconnect' ) ); + + $this->assertNotEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + } + + /** + * Without a valid nonce this is a link someone else can put in front of an + * administrator, so it has to die rather than write anything. + * + * @return void + */ + public function test_an_unsigned_dismissal_is_refused() { + $this->owe_a_reconnect(); + + $this->expectException( 'WPDieException' ); + try { + $this->request_dismissal( 'seo_head_reconnect', 'not-a-nonce' ); + } finally { + $this->assertEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + } + } + + /** + * The notice key is part of the nonce action, but it also reaches + * update_user_meta() — so only keys the plugin actually issues are honoured. + * + * @return void + */ + public function test_an_unknown_notice_key_writes_nothing() { + $this->request_dismissal( 'made_up', wp_create_nonce( 'citecue_dismiss_made_up' ) ); + + $this->assertEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'made_up', true ) ); + } +} diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index c42b07c..ca507ca 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -193,6 +193,17 @@ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() $this->assertSame( '', $this->render_duplicate_notice_as( 'subscriber' ) ); } + /** + * Deleting a plugin directory happens on the Plugins screen, and the notice + * asks for nothing that can be done anywhere else — so that is the only + * screen it appears on. + * + * @return void + */ + public function test_the_duplicate_notice_stays_on_the_plugins_screen() { + $this->assertSame( '', $this->render_duplicate_notice_as( 'administrator', 'dashboard' ) ); + } + /** * Loads the main file a second time — which is the state the duplicate * copy boots into — and renders what it hooked onto `admin_notices`. @@ -203,19 +214,25 @@ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() * null outside a genuine admin request. Isolating the hook keeps this a * test of the guard rather than of whatever else happens to be active. * - * @param string $role Role of the user viewing the admin screen. + * @param string $role Role of the user viewing the admin screen. + * @param string $screen_id Screen being viewed. * @return string Rendered notice markup. */ - private function render_duplicate_notice_as( $role ) { + private function render_duplicate_notice_as( $role, $screen_id = 'plugins' ) { remove_all_actions( 'admin_notices' ); require dirname( __DIR__, 2 ) . '/citecue.php'; wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) ); + set_current_screen( $screen_id ); ob_start(); do_action( 'admin_notices' ); - return ob_get_clean(); + $notice = ob_get_clean(); + + $GLOBALS['current_screen'] = null; + + return $notice; } /** @@ -241,6 +258,21 @@ public function test_uninstall_removes_plugin_options() { $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); } + /** + * Dismissals are one row per administrator, in a table nobody else cleans + * up — so they go with everything else rather than outliving the plugin. + * + * @return void + */ + public function test_uninstall_removes_dismissed_notices() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + update_user_meta( $user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + + $this->run_uninstall(); + + $this->assertEmpty( get_user_meta( $user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + } + /** * …but content pushed by CiteCue belongs to the site, so it stays. * diff --git a/uninstall.php b/uninstall.php index 3996fc5..2da8e46 100644 --- a/uninstall.php +++ b/uninstall.php @@ -20,6 +20,9 @@ delete_option( 'citecue_last_config_at' ); delete_option( 'citecue_install_verified' ); +// One row per administrator who dismissed the reconnect notice. +delete_metadata( 'user', 0, 'citecue_dismissed_seo_head_reconnect', '', true ); + delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); delete_transient( 'citecue_connect_state' ); From 84493b7c1408ea66e1d28d3a8ff606c973002beb Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Wed, 12 Aug 2026 17:08:02 +0200 Subject: [PATCH 2/4] Yield to the one duplicate copy that cannot yield to us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the previous commit found two defects. The duplicate-install guard never fires in the case its own comment describes. citecue/ only ever held 1.0.0, which predates the guard and so defines the constants and runs its requires unconditionally, and activate_plugin() sorts active_plugins, where '-' sorts before '/' — so citecue-ai-auto-fix/ is always included first and 1.0.0 is always the copy doing the redeclaring. The guard is checked by the copy that never loses the race, and the copy that does lose it has no guard to check. The outcome on such a site is not a stray notice, it is a fatal error. So this copy now stands aside when it finds citecue/ active with its file still on disk, mirroring the file_exists() test core itself applies, so a stale active_plugins entry for a deleted folder cannot strand the site on a copy that is not installed. The site runs on 1.0.0 — the worse version, but a running one — and the notice names the folder to delete. The original guard stays for every other duplicate directory, which does load second and can stand down. Dismissals moved from user meta to user options. The usermeta table is network-wide on multisite while needs_seo_head_reconnect() reads a per-site option, so one administrator dismissing the prompt on one site silenced a still-true prompt on every other site in the network. Co-Authored-By: Claude Opus 5 --- citecue.php | 86 ++++++++++++++++++++++++++---- includes/class-citecue-admin.php | 18 +++++-- readme.txt | 5 +- tests/cases/test-admin-notices.php | 38 +++++++++++-- tests/cases/test-lifecycle.php | 82 +++++++++++++++++++++++++++- uninstall.php | 8 ++- 6 files changed, 212 insertions(+), 25 deletions(-) diff --git a/citecue.php b/citecue.php index da6453c..db2c23a 100644 --- a/citecue.php +++ b/citecue.php @@ -20,17 +20,85 @@ } /* - * Stand down if another copy of this plugin already loaded. + * Stand down for a pre-WordPress.org copy, which cannot stand down for us. + * + * Releases before the move to WordPress.org unpacked to citecue/, and the only + * one that ever did is 1.0.0 — which predates the guard below and so defines + * the constants and runs its requires unconditionally. The directory's copy + * installs as citecue-ai-auto-fix/, so a site carrying the old one gains a + * second plugin rather than an upgrade, and `require_once` does not save us: + * the two copies are two paths, so the second to load redeclares every class + * and takes the site down. + * + * Which of them is second is not a coin toss. activate_plugin() sorts + * active_plugins before storing it, '-' sorts before '/', so + * citecue-ai-auto-fix/citecue.php is always included first and citecue/ is + * always the one that fatals. The guard below therefore never gets the chance + * to fire in the case it was written for: by the time 1.0.0 runs, it is this + * copy's classes it is redeclaring, and 1.0.0 has no guard to check. * - * Releases before the move to WordPress.org shipped an archive that unpacked - * to citecue/. The directory's copy installs as citecue-ai-auto-fix/, so a - * site carrying the old one gains a second plugin rather than an upgrade — - * and `require_once` does not save us, because the two copies are two paths. - * Both would run their requires, the second would redeclare every class, and - * the site would go down with a fatal error on the next request. + * So this copy yields instead. The site keeps running — on 1.0.0, which is the + * worse version but a working one — and the notice says which directory to + * delete to get this one back. Deleting it is also what makes this branch stop + * running, so the check confirms the file is really still there rather than + * trusting a stale active_plugins entry, which would strand the site on a copy + * that is no longer installed. + */ +$citecue_legacy_is_running = ( static function () { + $legacy = 'citecue/citecue.php'; + + if ( plugin_basename( __FILE__ ) === $legacy ) { + return false; + } + + $active = (array) get_option( 'active_plugins', array() ); + if ( is_multisite() ) { + $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); + } + + return in_array( $legacy, $active, true ) && file_exists( WP_PLUGIN_DIR . '/' . $legacy ); +} )(); + +if ( $citecue_legacy_is_running ) { + add_action( + 'admin_notices', + static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || 'plugins' !== $screen->id ) { + return; + } + printf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: the older plugin directory, e.g. citecue/. 2: this plugin's directory. */ + __( 'CiteCue AI Auto-Fix is installed twice. An older copy in %1$s is the one WordPress is running, so the copy in %2$s has not loaded. Deactivate and delete the older one to switch to this version — your settings and connection are stored in the database and carry over untouched.', 'citecue-ai-auto-fix' ), + 'citecue/', + dirname( plugin_basename( __FILE__ ) ) . '/' + ) + ) + ); + } + ); + + unset( $citecue_legacy_is_running ); + return; +} + +unset( $citecue_legacy_is_running ); + +/* + * Stand down if another copy of this plugin already loaded. * - * The copy that loses the race does nothing and says so, which turns a white - * screen into an admin notice naming the directory to delete. + * The case above is the one duplicate this plugin has actually shipped. This + * one catches the rest: a GitHub "Download ZIP" unpacks to + * wordpress-plugin-main/, and any directory sorting after + * citecue-ai-auto-fix/ loads second, sees the constant and does nothing — + * which turns a white screen into an admin notice naming the directory to + * delete. * * The notice belongs on the Plugins screen and nowhere else: deleting a plugin * directory is a Plugins-screen job, and nothing about this is urgent enough to diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index b96565f..78e0986 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -21,9 +21,17 @@ class Citecue_Admin { /** - * User-meta key prefix recording a notice this user has dismissed. + * User-option prefix recording a notice this user has dismissed. + * + * A user option rather than user meta, because on multisite the usermeta + * table is shared across the whole network while the condition these + * notices report on comes from per-site options. Stored as meta, one + * administrator dismissing the prompt on one site in a network would + * silence an unrelated, still-true prompt on all the others; + * update_user_option() prefixes the key with the current blog's, so each + * site gets its own answer. */ - const DISMISSED_META_PREFIX = 'citecue_dismissed_'; + const DISMISSED_OPTION_PREFIX = 'citecue_dismissed_'; /** * Plugin container. @@ -256,13 +264,13 @@ private function seo_head_reconnect_notice() { * * Per user rather than per site: one administrator deciding they do not * want to be told again is not a decision to make on their colleagues' - * behalf. + * behalf. Per site as well as per user — see DISMISSED_OPTION_PREFIX. * * @param string $notice Notice key. * @return bool */ private function notice_is_dismissed( $notice ) { - return (bool) get_user_meta( get_current_user_id(), self::DISMISSED_META_PREFIX . $notice, true ); + return (bool) get_user_option( self::DISMISSED_OPTION_PREFIX . $notice, get_current_user_id() ); } /** @@ -309,7 +317,7 @@ public function maybe_dismiss_notice() { return; } - update_user_meta( get_current_user_id(), self::DISMISSED_META_PREFIX . $notice, time() ); + update_user_option( get_current_user_id(), self::DISMISSED_OPTION_PREFIX . $notice, time() ); $back = wp_get_referer(); wp_safe_redirect( $back ? remove_query_arg( array( 'citecue_dismiss', '_wpnonce' ), $back ) : $this->settings_url() ); diff --git a/readme.txt b/readme.txt index 83d9935..0c8eaec 100644 --- a/readme.txt +++ b/readme.txt @@ -119,7 +119,7 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never == Upgrade Notice == = 1.1.1 = -Admin notices now appear only on the Plugins and CiteCue screens, and the reconnect prompt can be dismissed for good. No functional change to delivery. +Fixes a fatal error on sites that still have the old citecue/ folder installed alongside this plugin. Admin notices now appear only on the Plugins and CiteCue screens. = 1.1.0 = Adds enriched page metadata for live pages. Existing connections need one reconnect before CiteCue knows this site can do it — Settings → CiteCue will ask. @@ -131,7 +131,8 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin = 1.1.1 = * Admin notices are confined to the Plugins screen and the CiteCue settings screen. The rejected-key warning and the duplicate-install warning used to print on every screen in the dashboard; neither asks for anything that can be done anywhere else, and the settings screen states both a second time in its status card. -* The reconnect prompt can now be dismissed permanently, per user. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. +* The reconnect prompt can now be dismissed permanently, per user and per site. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. On multisite the dismissal is scoped to the site it was made on, since the condition it reports on is per-site while WordPress stores user metadata network-wide. +* Fixed a fatal error on a site that still has the pre-WordPress.org copy in a citecue/ folder alongside this one. That copy is 1.0.0, which predates the duplicate-install guard and loads its classes unconditionally — and WordPress always includes citecue-ai-auto-fix/ first, so 1.0.0 was always the copy that redeclared them and took the site down. This copy now stands aside for it and says which folder to delete, so the site keeps running either way. * Uninstall removes the dismissal records along with everything else. * readme: shorter summary, and the description now says plainly what this plugin does that a static llms.txt generator does not. diff --git a/tests/cases/test-admin-notices.php b/tests/cases/test-admin-notices.php index dbd54b5..b737293 100644 --- a/tests/cases/test-admin-notices.php +++ b/tests/cases/test-admin-notices.php @@ -166,7 +166,7 @@ public function test_the_reconnect_notice_offers_a_way_out() { */ public function test_a_dismissed_reconnect_notice_stays_dismissed() { $this->owe_a_reconnect(); - update_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + update_user_option( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); $html = $this->notices_on( 'settings_page_citecue' ); @@ -181,7 +181,7 @@ public function test_a_dismissed_reconnect_notice_stays_dismissed() { */ public function test_a_dismissal_belongs_to_the_user_who_made_it() { $this->owe_a_reconnect(); - update_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + update_user_option( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); @@ -254,7 +254,35 @@ public function test_a_signed_dismissal_is_recorded() { $this->request_dismissal( 'seo_head_reconnect', wp_create_nonce( 'citecue_dismiss_seo_head_reconnect' ) ); - $this->assertNotEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + $this->assertNotEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $this->user_id ) ); + } + + /** + * The usermeta table is shared across a whole multisite network, while the + * condition this notice reports comes from a per-site option — so the key + * has to carry this site's prefix, or dismissing it on one site in a + * network silences a still-true prompt on all the others. + * + * Asserting on the stored key rather than switching blogs, because that is + * the part a refactor back to update_user_meta() would quietly undo, and it + * is checkable on a single-site install. + * + * @return void + */ + public function test_a_dismissal_is_scoped_to_this_site() { + global $wpdb; + $this->owe_a_reconnect(); + + $this->request_dismissal( 'seo_head_reconnect', wp_create_nonce( 'citecue_dismiss_seo_head_reconnect' ) ); + + $this->assertNotEmpty( + get_user_meta( $this->user_id, $wpdb->get_blog_prefix() . Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', true ), + 'The dismissal should be stored under this blog’s prefix.' + ); + $this->assertEmpty( + get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', true ), + 'Nothing should be stored under a network-wide key.' + ); } /** @@ -270,7 +298,7 @@ public function test_an_unsigned_dismissal_is_refused() { try { $this->request_dismissal( 'seo_head_reconnect', 'not-a-nonce' ); } finally { - $this->assertEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $this->user_id ) ); } } @@ -283,6 +311,6 @@ public function test_an_unsigned_dismissal_is_refused() { public function test_an_unknown_notice_key_writes_nothing() { $this->request_dismissal( 'made_up', wp_create_nonce( 'citecue_dismiss_made_up' ) ); - $this->assertEmpty( get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'made_up', true ) ); + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'made_up', $this->user_id ) ); } } diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index ca507ca..0aca1bb 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -204,6 +204,84 @@ public function test_the_duplicate_notice_stays_on_the_plugins_screen() { $this->assertSame( '', $this->render_duplicate_notice_as( 'administrator', 'dashboard' ) ); } + /** + * The one duplicate this plugin has actually shipped, and the one the guard + * above cannot cover. + * + * The citecue/ directory only ever held 1.0.0, which predates the guard and + * so loads its classes unconditionally. WordPress sorts active_plugins and + * '-' sorts before + * '/', so citecue-ai-auto-fix/ is always included first — meaning 1.0.0 is + * always the copy that redeclares and fatals, and it has no guard with + * which to stand down. This copy has to be the one that yields. + * + * @return void + */ + public function test_this_copy_yields_to_a_legacy_copy_that_cannot_yield() { + $this->install_legacy_copy(); + + $notice = $this->render_duplicate_notice_as( 'administrator' ); + + $this->assertStringContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * A deleted directory can outlive its active_plugins entry. Yielding to a + * copy that is not there any more would leave the site running neither. + * + * @return void + */ + public function test_a_stale_entry_for_a_deleted_copy_is_ignored() { + $this->install_legacy_copy( false ); + + $notice = $this->render_duplicate_notice_as( 'administrator' ); + + $this->assertStringNotContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * Puts a pre-WordPress.org copy in active_plugins, optionally with the file + * on disk to match. Registered for removal so the plugins directory is left + * as it was found. + * + * @param bool $on_disk Whether the plugin file also exists. + * @return void + */ + private function install_legacy_copy( $on_disk = true ) { + update_option( 'active_plugins', array( 'citecue/citecue.php' ) ); + + if ( ! $on_disk ) { + return; + } + + $dir = WP_PLUGIN_DIR . '/citecue'; + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0777, true ); + } + file_put_contents( $dir . '/citecue.php', "legacy_copy_path = $dir; + } + + /** + * Directory created by install_legacy_copy(), to remove afterwards. + * + * @var string + */ + private $legacy_copy_path = ''; + + /** + * @return void + */ + public function tear_down() { + if ( '' !== $this->legacy_copy_path ) { + @unlink( $this->legacy_copy_path . '/citecue.php' ); + @rmdir( $this->legacy_copy_path ); + $this->legacy_copy_path = ''; + } + parent::tear_down(); + } + /** * Loads the main file a second time — which is the state the duplicate * copy boots into — and renders what it hooked onto `admin_notices`. @@ -266,11 +344,11 @@ public function test_uninstall_removes_plugin_options() { */ public function test_uninstall_removes_dismissed_notices() { $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - update_user_meta( $user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', time() ); + update_user_option( $user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); $this->run_uninstall(); - $this->assertEmpty( get_user_meta( $user_id, Citecue_Admin::DISMISSED_META_PREFIX . 'seo_head_reconnect', true ) ); + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $user_id ) ); } /** diff --git a/uninstall.php b/uninstall.php index 2da8e46..85117e1 100644 --- a/uninstall.php +++ b/uninstall.php @@ -20,8 +20,12 @@ delete_option( 'citecue_last_config_at' ); delete_option( 'citecue_install_verified' ); -// One row per administrator who dismissed the reconnect notice. -delete_metadata( 'user', 0, 'citecue_dismissed_seo_head_reconnect', '', true ); +// One row per administrator who dismissed the reconnect notice. Stored through +// update_user_option(), so the key carries this site's table prefix — which is +// also what scopes the delete to this site on a multisite network, matching +// what the delete_option() calls above reach. +global $wpdb; +delete_metadata( 'user', 0, $wpdb->get_blog_prefix() . 'citecue_dismissed_seo_head_reconnect', '', true ); delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); From a056de63758165e41c7e5d4ee1df3cff88211222 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Wed, 12 Aug 2026 20:54:57 +0200 Subject: [PATCH 3/4] Say it where a super admin can hear it, and clean up after every site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two multisite defects from the Codex review of the last commit, both of which the single-site test suite was structurally unable to see. WordPress fires admin_notices and network_admin_notices in mutually exclusive branches, so neither duplicate-install warning appeared anywhere in Network Admin — including Plugins, which is the only screen a network-activated copy can be deactivated from. Both now register for both hooks and accept the plugins-network screen alongside plugins. The screen check is duplicated between the two callbacks rather than shared through a helper: this is the one file that can legitimately be included twice, and a named function in it is a redeclaration waiting to happen. Uninstall reached one site's dismissal rows. usermeta is a single table for the whole network while WordPress includes uninstall.php once, so every other site's rows survived the plugin's own deletion in a table that outlives the sites that wrote to them. It now walks get_sites() and deletes each site's prefixed key. The suite now also runs as a network, in CI and through `composer test:multisite`. Both bugs above were multisite-only, which is enough evidence that a single-site run is not the whole story. Doing so turned up a third thing: the duplicate-notice tests were signing in as a site administrator, who on multisite does not hold activate_plugins — it maps through manage_network_plugins — so they would have gone on passing while asserting nothing. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++ citecue.php | 100 +++++++++++++++++---------------- composer.json | 6 +- readme.txt | 2 + tests/cases/test-lifecycle.php | 88 ++++++++++++++++++++++++++++- uninstall.php | 36 ++++++++++-- 6 files changed, 182 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a7e08..9c0a7cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,13 @@ jobs: - name: Run the test suite with the WooCommerce store rules active run: composer run test:woocommerce + # Two of this plugin's storage decisions are only wrong on multisite — + # a network-wide usermeta key standing in for a per-site one, and a + # notice hook that does not fire in Network Admin. Neither is visible to + # a single-site run. + - name: Run the test suite as a multisite network + run: composer run test:multisite + test-woocommerce: name: WooCommerce integration runs-on: ubuntu-latest diff --git a/citecue.php b/citecue.php index db2c23a..5a509da 100644 --- a/citecue.php +++ b/citecue.php @@ -60,31 +60,34 @@ } )(); if ( $citecue_legacy_is_running ) { - add_action( - 'admin_notices', - static function () { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; - } - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( ! $screen || 'plugins' !== $screen->id ) { - return; - } - printf( - '

%s

', - esc_html( - sprintf( - /* translators: 1: the older plugin directory, e.g. citecue/. 2: this plugin's directory. */ - __( 'CiteCue AI Auto-Fix is installed twice. An older copy in %1$s is the one WordPress is running, so the copy in %2$s has not loaded. Deactivate and delete the older one to switch to this version — your settings and connection are stored in the database and carry over untouched.', 'citecue-ai-auto-fix' ), - 'citecue/', - dirname( plugin_basename( __FILE__ ) ) . '/' - ) - ) - ); + $citecue_legacy_notice = static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + // Duplicated in the guard below rather than shared through a helper: + // this is the one file that can legitimately be included twice, and a + // named function here is a redeclaration waiting to happen. + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || ( 'plugins' !== $screen->id && 'plugins-network' !== $screen->id ) ) { + return; } - ); + printf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: the older plugin directory, e.g. citecue/. 2: this plugin's directory. */ + __( 'CiteCue AI Auto-Fix is installed twice. An older copy in %1$s is the one WordPress is running, so the copy in %2$s has not loaded. Deactivate and delete the older one to switch to this version — your settings and connection are stored in the database and carry over untouched.', 'citecue-ai-auto-fix' ), + 'citecue/', + dirname( plugin_basename( __FILE__ ) ) . '/' + ) + ) + ); + }; + + add_action( 'admin_notices', $citecue_legacy_notice ); + add_action( 'network_admin_notices', $citecue_legacy_notice ); - unset( $citecue_legacy_is_running ); + unset( $citecue_legacy_is_running, $citecue_legacy_notice ); return; } @@ -100,35 +103,38 @@ static function () { * which turns a white screen into an admin notice naming the directory to * delete. * - * The notice belongs on the Plugins screen and nowhere else: deleting a plugin + * The notice belongs on a Plugins screen and nowhere else: deleting a plugin * directory is a Plugins-screen job, and nothing about this is urgent enough to - * follow an administrator through the rest of their dashboard. + * follow an administrator through the rest of their dashboard. Both Plugins + * screens count, though — a network-activated copy can only be deactivated from + * Network Admin, and `admin_notices` does not fire there at all. */ if ( defined( 'CITECUE_VERSION' ) ) { - add_action( - 'admin_notices', - static function () { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; - } - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( ! $screen || 'plugins' !== $screen->id ) { - return; - } - printf( - '

%s

', - esc_html( - sprintf( - /* translators: 1: plugin file that is running, e.g. citecue/citecue.php. 2: duplicate plugin file that did not load. */ - __( 'CiteCue AI Auto-Fix is installed twice. WordPress is running %1$s, so the copy in %2$s did not load. Deactivate and delete whichever of the two you do not want to keep.', 'citecue-ai-auto-fix' ), - plugin_basename( CITECUE_PLUGIN_FILE ), - plugin_basename( __FILE__ ) - ) - ) - ); + $citecue_duplicate_notice = static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || ( 'plugins' !== $screen->id && 'plugins-network' !== $screen->id ) ) { + return; } - ); + printf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: plugin file that is running, e.g. citecue/citecue.php. 2: duplicate plugin file that did not load. */ + __( 'CiteCue AI Auto-Fix is installed twice. WordPress is running %1$s, so the copy in %2$s did not load. Deactivate and delete whichever of the two you do not want to keep.', 'citecue-ai-auto-fix' ), + plugin_basename( CITECUE_PLUGIN_FILE ), + plugin_basename( __FILE__ ) + ) + ) + ); + }; + + add_action( 'admin_notices', $citecue_duplicate_notice ); + add_action( 'network_admin_notices', $citecue_duplicate_notice ); + unset( $citecue_duplicate_notice ); return; } diff --git a/composer.json b/composer.json index 78408fc..8fc94af 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,11 @@ "phpcbf": "phpcbf", "test": [ "@test:core", - "@test:woocommerce" + "@test:woocommerce", + "@test:multisite" ], "test:core": "phpunit", - "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit" + "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit", + "test:multisite": "WP_MULTISITE=1 phpunit" } } diff --git a/readme.txt b/readme.txt index 0c8eaec..549dca5 100644 --- a/readme.txt +++ b/readme.txt @@ -133,6 +133,8 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin * Admin notices are confined to the Plugins screen and the CiteCue settings screen. The rejected-key warning and the duplicate-install warning used to print on every screen in the dashboard; neither asks for anything that can be done anywhere else, and the settings screen states both a second time in its status card. * The reconnect prompt can now be dismissed permanently, per user and per site. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. On multisite the dismissal is scoped to the site it was made on, since the condition it reports on is per-site while WordPress stores user metadata network-wide. * Fixed a fatal error on a site that still has the pre-WordPress.org copy in a citecue/ folder alongside this one. That copy is 1.0.0, which predates the duplicate-install guard and loads its classes unconditionally — and WordPress always includes citecue-ai-auto-fix/ first, so 1.0.0 was always the copy that redeclared them and took the site down. This copy now stands aside for it and says which folder to delete, so the site keeps running either way. +* Both duplicate-install warnings now also appear in Network Admin → Plugins. A network-activated copy can only be removed from there, and the hook they were on does not fire anywhere in Network Admin — so on multisite the warning was missing from the one screen where a super admin could act on it. +* Uninstalling from a network now clears dismissal records for every site, not just the one running the uninstall. WordPress stores user metadata in a single table shared by the whole network, so anything left there is left for good. * Uninstall removes the dismissal records along with everything else. * readme: shorter summary, and the description now says plainly what this plugin does that a static llms.txt generator does not. diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 0aca1bb..23ed0d3 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -204,6 +204,43 @@ public function test_the_duplicate_notice_stays_on_the_plugins_screen() { $this->assertSame( '', $this->render_duplicate_notice_as( 'administrator', 'dashboard' ) ); } + /** + * A network-activated copy can only be deactivated from Network Admin → + * Plugins, and `admin_notices` does not fire anywhere in Network Admin — so + * scoping to that one hook hides the warning from the only screen where a + * super admin can act on it. + * + * @return void + */ + public function test_the_duplicate_notice_reaches_network_admin() { + $notice = $this->render_duplicate_notice_as( 'administrator', 'plugins-network', 'network_admin_notices' ); + + $this->assertStringContainsString( 'installed twice', $notice ); + } + + /** + * @return void + */ + public function test_the_legacy_notice_reaches_network_admin() { + $this->install_legacy_copy(); + + $notice = $this->render_duplicate_notice_as( 'administrator', 'plugins-network', 'network_admin_notices' ); + + $this->assertStringContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * Network Admin has its own dashboard and its own settings screens, and the + * notice has no more business on those than on the per-site ones. + * + * @return void + */ + public function test_the_duplicate_notice_stays_off_the_network_dashboard() { + $notice = $this->render_duplicate_notice_as( 'administrator', 'dashboard-network', 'network_admin_notices' ); + + $this->assertSame( '', $notice ); + } + /** * The one duplicate this plugin has actually shipped, and the one the guard * above cannot cover. @@ -294,18 +331,30 @@ public function tear_down() { * * @param string $role Role of the user viewing the admin screen. * @param string $screen_id Screen being viewed. + * @param string $hook Notice hook the screen fires: WordPress fires + * `admin_notices` and `network_admin_notices` in + * mutually exclusive branches, never both. * @return string Rendered notice markup. */ - private function render_duplicate_notice_as( $role, $screen_id = 'plugins' ) { + private function render_duplicate_notice_as( $role, $screen_id = 'plugins', $hook = 'admin_notices' ) { remove_all_actions( 'admin_notices' ); + remove_all_actions( 'network_admin_notices' ); require dirname( __DIR__, 2 ) . '/citecue.php'; - wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) ); + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + // On multisite `activate_plugins` maps through `manage_network_plugins`, + // so a site administrator does not have it — which is correct, since a + // network-activated duplicate is only a super admin's to remove. The + // notice is gated on that capability, so the viewer has to hold it. + if ( is_multisite() && 'administrator' === $role ) { + grant_super_admin( $user_id ); + } + wp_set_current_user( $user_id ); set_current_screen( $screen_id ); ob_start(); - do_action( 'admin_notices' ); + do_action( $hook ); $notice = ob_get_clean(); $GLOBALS['current_screen'] = null; @@ -351,6 +400,39 @@ public function test_uninstall_removes_dismissed_notices() { $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $user_id ) ); } + /** + * The usermeta table is shared by a whole network, and WordPress includes + * uninstall.php once. A dismissal made on any other site in the network is + * therefore still ours to remove — nothing else ever will, and unlike the + * per-site options tables, this one outlives every site that wrote to it. + * + * @group multisite + * @return void + */ + public function test_uninstall_removes_dismissals_from_every_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires a multisite install.' ); + } + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $other = self::factory()->blog->create(); + $key = Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect'; + + switch_to_blog( $other ); + update_user_option( $user_id, $key, time() ); + restore_current_blog(); + update_user_option( $user_id, $key, time() ); + + $this->run_uninstall(); + + switch_to_blog( $other ); + $elsewhere = get_user_option( $key, $user_id ); + restore_current_blog(); + + $this->assertEmpty( $elsewhere, 'The other site’s dismissal should be gone too.' ); + $this->assertEmpty( get_user_option( $key, $user_id ) ); + } + /** * …but content pushed by CiteCue belongs to the site, so it stays. * diff --git a/uninstall.php b/uninstall.php index 85117e1..e0bc0e3 100644 --- a/uninstall.php +++ b/uninstall.php @@ -20,12 +20,38 @@ delete_option( 'citecue_last_config_at' ); delete_option( 'citecue_install_verified' ); -// One row per administrator who dismissed the reconnect notice. Stored through -// update_user_option(), so the key carries this site's table prefix — which is -// also what scopes the delete to this site on a multisite network, matching -// what the delete_option() calls above reach. +/* + * One row per administrator who dismissed the reconnect notice, stored through + * update_user_option() — so the key carries the site's table prefix. + * + * Every site's prefix, not just this one's. WordPress includes this file once, + * for the site uninstalling the plugin, which is the right scope for the + * options above: those live in per-site tables that go away with the site. + * usermeta is one shared table for the whole network, so a key left behind + * there is left behind for good, on a table that outlives every site that ever + * wrote to it. + */ global $wpdb; -delete_metadata( 'user', 0, $wpdb->get_blog_prefix() . 'citecue_dismissed_seo_head_reconnect', '', true ); + +$citecue_dismissal_key = 'citecue_dismissed_seo_head_reconnect'; +$citecue_site_ids = array( null ); + +if ( is_multisite() ) { + // One indexed query returning ids. Uninstall happens once, so paying for + // the full list here is cheaper than orphaning rows nothing else cleans up. + $citecue_site_ids = get_sites( + array( + 'fields' => 'ids', + 'number' => 0, + ) + ); +} + +foreach ( $citecue_site_ids as $citecue_site_id ) { + delete_metadata( 'user', 0, $wpdb->get_blog_prefix( $citecue_site_id ) . $citecue_dismissal_key, '', true ); +} + +unset( $citecue_dismissal_key, $citecue_site_ids, $citecue_site_id ); delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); From 849dd766f70bf078938ec9611bbfde955f2825b9 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Wed, 12 Aug 2026 21:03:33 +0200 Subject: [PATCH 4/4] Let the stale-entry test state the half it was assuming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The entry is stale" means two things — active_plugins lists the copy, and the file is not there — and the test said only the first, inheriting the second from whatever the plugins directory happened to hold. A run that died before tear_down left the directory behind and the next run failed there instead of where the problem was. install_legacy_copy() now clears the directory on both paths and asserts the absence on the one that depends on it, so the test carries its own premise. The directory it removes can only be its own leftover: under the test suite WP_PLUGIN_DIR is the vendored WordPress, which ships with no plugins at all. Found by CodeRabbit, which read the helper more carefully than I did. Co-Authored-By: Claude Opus 5 --- tests/cases/test-lifecycle.php | 50 ++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 23ed0d3..1b4f6b6 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -278,8 +278,13 @@ public function test_a_stale_entry_for_a_deleted_copy_is_ignored() { /** * Puts a pre-WordPress.org copy in active_plugins, optionally with the file - * on disk to match. Registered for removal so the plugins directory is left - * as it was found. + * on disk to match. + * + * Both halves are stated, including the absent one: "the entry is stale" + * means the file is not there, and a test that only assumes that is at the + * mercy of whatever a previous run left behind. The directory removed here + * can only ever be this helper's own leftover — WP_PLUGIN_DIR under the + * test suite is the vendored WordPress, which ships with no plugins at all. * * @param bool $on_disk Whether the plugin file also exists. * @return void @@ -287,35 +292,50 @@ public function test_a_stale_entry_for_a_deleted_copy_is_ignored() { private function install_legacy_copy( $on_disk = true ) { update_option( 'active_plugins', array( 'citecue/citecue.php' ) ); + // Registered before the branch, so the cleanup runs either way. + $this->legacy_copy_path = WP_PLUGIN_DIR . '/citecue'; + $this->remove_legacy_copy(); + if ( ! $on_disk ) { + $this->assertFileDoesNotExist( $this->legacy_copy_path . '/citecue.php' ); return; } - $dir = WP_PLUGIN_DIR . '/citecue'; - if ( ! is_dir( $dir ) ) { - mkdir( $dir, 0777, true ); - } - file_put_contents( $dir . '/citecue.php', "legacy_copy_path = $dir; + mkdir( $this->legacy_copy_path, 0777, true ); + file_put_contents( $this->legacy_copy_path . '/citecue.php', "legacy_copy_path ) { - @unlink( $this->legacy_copy_path . '/citecue.php' ); - @rmdir( $this->legacy_copy_path ); - $this->legacy_copy_path = ''; + private function remove_legacy_copy() { + if ( '' === $this->legacy_copy_path ) { + return; + } + + if ( file_exists( $this->legacy_copy_path . '/citecue.php' ) ) { + unlink( $this->legacy_copy_path . '/citecue.php' ); + } + if ( is_dir( $this->legacy_copy_path ) ) { + rmdir( $this->legacy_copy_path ); } + } + + /** + * @return void + */ + public function tear_down() { + $this->remove_legacy_copy(); + $this->legacy_copy_path = ''; parent::tear_down(); }