From f7f77afb591b01263ea12c0cacb221833aaa7dd5 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Tue, 4 Aug 2026 19:18:49 +0200 Subject: [PATCH 1/5] Prepare the plugin for the WordPress.org directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things stood between this and a review approval. An unconnected install was not silent. `citecue_daily_sync` is scheduled at activation and refreshed the crawler registry from app.citecue.com whether or not anyone had connected the site — and activating a plugin is not consent to talk to a third party. The refresh now waits for a connection. Nothing is lost by waiting: an unconnected site is not serving crawlers, and the bundled token list is exactly what the registry would have refreshed. readme.txt did not disclose the service. Guidelines 6 and 7 want a SaaS-backed plugin to say what the service is, what leaves the site, when, and where the terms and privacy policy are — so `== External services ==` now walks through each endpoint, including the two that are easy to overlook: the daily keyless registry poll, and the loopback verification that requests the site's own llms.txt. The header claimed WordPress 6.8, three majors behind 7.0. The claim is now 7.0, and the test matrix genuinely reaches it: roots/wordpress-no-content was capped at ^6.8 while wp-phpunit already floated on ^7.0, so "latest" in CI was running a 7.0 test library against 6.9 core. Also points Plugin URI at the repository that actually exists. --- citecue.php | 2 +- composer.json | 2 +- includes/class-citecue-plugin.php | 10 ++++++++++ readme.txt | 32 ++++++++++++++++++++++++++++--- tests/cases/test-lifecycle.php | 16 ++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/citecue.php b/citecue.php index 820b5f3..dc042f6 100644 --- a/citecue.php +++ b/citecue.php @@ -1,7 +1,7 @@ settings->is_connected() ) { + return; + } + $this->crawlers->refresh( $this->api ); } diff --git a/readme.txt b/readme.txt index 4280601..ea201e9 100644 --- a/readme.txt +++ b/readme.txt @@ -2,7 +2,7 @@ Contributors: citecue Tags: ai, llms.txt, gptbot, ai-seo, woocommerce Requires at least: 5.8 -Tested up to: 6.8 +Tested up to: 7.0 Requires PHP: 7.4 Stable tag: 1.0.0 License: GPLv2 or later @@ -19,17 +19,42 @@ CiteCue AI Auto-Fix connects your WordPress site to CiteCue: * **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). -Requires a CiteCue account. +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. == Installation == -1. In WordPress, go to Plugins → Add New → Upload Plugin, choose `citecue.zip` and activate it. +1. Install and activate the plugin from Plugins → Add New, or upload it under Plugins → Add New → Upload Plugin. 2. Go to Settings → CiteCue and click "Connect to CiteCue". 3. Confirm the project for this site in CiteCue. You are redirected back and the plugin checks itself. 4. Add and generate optimized pages on CiteCue's Auto-Fix page. There is nothing to copy or paste: the connection brings the API key back to WordPress and hands CiteCue this site's address and content-push secret. Sites that cannot complete a browser round-trip to CiteCue can still connect with an organization API key — "Connect with an API key instead" on the settings screen. +Until you complete step 2, the plugin makes no outbound requests at all. + +== External services == + +This plugin is the WordPress end of CiteCue, a hosted service at https://citecue.com that generates AI-optimized versions of your pages. The optimized pages, your llms.txt and the pushed draft content are all produced by that service, so the plugin cannot work without it. Nothing below happens until an administrator connects the site. + +Terms of Service: https://citecue.com/terms +Privacy Policy: https://citecue.com/privacy + +The service is reached at `https://app.citecue.com` (or the origin you pin with the `CITECUE_API_BASE` constant, for self-hosted CiteCue deployments). + +**Connecting the site** — once, when an administrator clicks "Connect to CiteCue". Your browser is sent to `app.citecue.com/connect/wordpress` with this site's address so CiteCue can show you which project you are pairing. WordPress then posts to `/api/delivery/v2/connect/claim`: the one-time code from that redirect, this site's address, its REST API address, this site's content-push secret, the plugin version, and whether WooCommerce is active. CiteCue returns the API key it issued for this site. The API-key fallback instead sends the key you paste to `/api/delivery/v2/config`, which returns your organization's projects. + +**Serving a page to an AI crawler** — on each request from a matched AI crawler, and never for a human visitor or a logged-in user. The plugin sends the requested URL, the matched crawler's User-Agent token and the site's project key to `/api/delivery/v2/page`. No visitor data — no IP address, no cookies, no personal data — is sent. CiteCue records the crawler hit so it can report it back to you. Responses are cached, misses are remembered for a minute, and a per-minute budget caps the total. + +**Serving llms.txt** — when `/llms.txt` is requested and the feature is on. The site's project key is sent to `/api/delivery/v2/llms.txt`. The response is cached. + +**Refreshing the AI-crawler list** — once a day, on WP-Cron, for a connected site only. An unauthenticated request to `/api/delivery/v1/crawlers` fetches the current list of AI crawler User-Agent tokens, so newly launched crawlers are recognised without a plugin update. + +**Verifying the installation** — when you connect, and whenever you click "Verify installation". The plugin requests your own site's `/llms.txt` over HTTP, identifying itself as an AI crawler, to confirm the plugin answers rather than a cache or CDN. This request goes to your site, not to CiteCue. + +Every outbound request identifies itself with a `CiteCue-WordPress/ (+)` User-Agent. + +In the other direction: when content pushes are enabled, CiteCue sends new content to this site's `citecue/v1` REST route. Each request is signed with the shared secret exchanged during connection, replayed signatures are rejected, and the content is created as a draft unless you raise that limit yourself. + == Frequently Asked Questions == = Do I need to create an API key by hand? = @@ -75,3 +100,4 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never * WooCommerce support: store-page exclusions for the middleware; product create/enrich through the ingest endpoint. * Hardening: single-use ingest signatures (replay rejection), per-minute delivery lookup budget, CiteCue-compatible cache-key URL normalization, cache eviction on delivery misses, and crawler-registry downgrade rejection with the bundled token floor. * One-click connect: a pairing handshake sets up the site without copying an API key in or a signing secret out, with a built-in "Verify installation" check. Connecting with an API key remains available as a fallback. +* An install that has not been connected to CiteCue makes no outbound requests of any kind. diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 653dd3b..56ca57a 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -102,6 +102,7 @@ public function test_a_missing_cron_is_rescheduled_on_init() { * @return void */ public function test_the_daily_sync_refreshes_the_crawler_registry() { + $this->configure_delivery(); $this->http->queue( 'crawlers', 200, @@ -124,6 +125,7 @@ public function test_the_daily_sync_refreshes_the_crawler_registry() { * @return void */ public function test_the_daily_sync_survives_an_outage() { + $this->configure_delivery(); $this->http->queue_error( 'crawlers' ); $this->plugin->daily_sync(); @@ -131,6 +133,20 @@ public function test_the_daily_sync_survives_an_outage() { $this->assertSame( 'GPTBot', $this->plugin->crawlers->match( 'GPTBot/1.2' ) ); } + /** + * Activating a plugin is not consent to talk to a third party. Until the + * site has connected itself, the cron must reach nothing — the HTTP mock + * throws on any unqueued call, so an outbound request fails this test. + * + * @return void + */ + public function test_the_daily_sync_is_silent_until_the_site_connects() { + $this->plugin->daily_sync(); + + $this->assertSame( 0, $this->http->count() ); + $this->assertSame( Citecue_Crawlers::bundled_tokens(), $this->plugin->crawlers->get_tokens() ); + } + /** * Uninstall removes the plugin's own settings… * From 8ff3bb46b8186d5e2bbb8b351df9780c1a11cd9b Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Tue, 4 Aug 2026 19:22:19 +0200 Subject: [PATCH 2/5] Package under the WordPress.org slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress.org will name this plugin citecue-ai-auto-fix, derived from the Plugin Name header, and that is the directory it installs into. The zip built here unpacked to citecue/, so a site that installed from a GitHub release and later moved to the directory would have run two copies of the plugin out of two directories instead of upgrading one. Only packaging and documentation are affected — the runtime resolves its own paths through plugin_basename() and never named a directory. --- .github/workflows/release.yml | 15 ++++++++------- README.md | 6 +++--- bin/build-plugin-zip.sh | 9 ++++++++- tests/cases/test-lifecycle.php | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 594b66b..daba8e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ name: Release -# Publishes citecue.zip — an archive that unpacks to citecue/ and contains only -# the runtime files — as a release asset, so that installing the plugin is -# "Plugins → Add New → Upload" rather than "download the source, rename the -# folder, delete the tests". +# Publishes citecue-ai-auto-fix.zip — an archive that unpacks to +# citecue-ai-auto-fix/ (the WordPress.org slug, so the two install channels +# share one directory) and contains only the runtime files — as a release +# asset, so that installing the plugin is "Plugins → Add New → Upload" rather +# than "download the source, rename the folder, delete the tests". on: push: @@ -45,7 +46,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: citecue-plugin-zip - path: dist/citecue.zip + path: dist/citecue-ai-auto-fix.zip if-no-files-found: error - name: Attach the zip to the release @@ -54,9 +55,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" dist/citecue.zip --clobber + gh release upload "$GITHUB_REF_NAME" dist/citecue-ai-auto-fix.zip --clobber else - gh release create "$GITHUB_REF_NAME" dist/citecue.zip \ + gh release create "$GITHUB_REF_NAME" dist/citecue-ai-auto-fix.zip \ --title "$GITHUB_REF_NAME" \ --generate-notes fi diff --git a/README.md b/README.md index 05bb987..0aa2ba4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor ## Setup -1. Download `citecue.zip` from the [latest release](https://github.com/citecue/wordpress-plugin/releases/latest), then install it under **Plugins → Add New → Upload Plugin** and activate it. +1. Download `citecue-ai-auto-fix.zip` from the [latest release](https://github.com/citecue/wordpress-plugin/releases/latest), then install it under **Plugins → Add New → Upload Plugin** and activate it. 2. Open **Settings → CiteCue** and click **Connect to CiteCue**. 3. Confirm the project for this site in CiteCue. You are redirected back, and the plugin checks itself. @@ -228,12 +228,12 @@ Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout GitHub's **Download ZIP** button is not an install path: it produces `wordpress-plugin-main.zip`, which unpacks to `wordpress-plugin-main/` and carries the tests and Composer files with it. WordPress keys a plugin by its directory name, so installs have to come from the release asset instead. ```bash -bin/build-plugin-zip.sh # writes dist/citecue.zip from HEAD +bin/build-plugin-zip.sh # writes dist/citecue-ai-auto-fix.zip from HEAD ``` The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue/` directory. CI runs the same script on every pull request. -To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue.zip` to the GitHub release. +To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue-ai-auto-fix.zip` to the GitHub release. ### Tests diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index a059a41..337d761 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -14,11 +14,18 @@ # in .gitattributes, so an untracked vendor/, .env or editor backup can never # be swept into a release. # +# SLUG must stay equal to the WordPress.org slug. WordPress.org derives that +# from the Plugin Name header and will not change it after approval, and it is +# what the directory installs into. If this zip used a different name, a site +# that installed from GitHub and later switched to the directory (or the +# reverse) would end up running two copies of the plugin from two directories +# rather than upgrading one. +# # Usage: bin/build-plugin-zip.sh [ref] (ref defaults to HEAD) set -euo pipefail -SLUG=citecue +SLUG=citecue-ai-auto-fix REF=${1:-HEAD} ROOT=$(git rev-parse --show-toplevel) diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 56ca57a..cd1da93 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -191,7 +191,7 @@ public function test_uninstall_keeps_pushed_content() { */ private function run_uninstall() { if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { - define( 'WP_UNINSTALL_PLUGIN', 'citecue/citecue.php' ); + define( 'WP_UNINSTALL_PLUGIN', 'citecue-ai-auto-fix/citecue.php' ); } require dirname( __DIR__, 2 ) . '/uninstall.php'; From 945a44dda7840fe01fc9a400136477c6b4e341f9 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Tue, 4 Aug 2026 20:41:39 +0200 Subject: [PATCH 3/5] Name the archive's directory correctly in the README Left behind by the slug change: the build script's guard checks for citecue-ai-auto-fix/, not citecue/. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0aa2ba4..d2d1a78 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ GitHub's **Download ZIP** button is not an install path: it produces `wordpress- bin/build-plugin-zip.sh # writes dist/citecue-ai-auto-fix.zip from HEAD ``` -The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue/` directory. CI runs the same script on every pull request. +The script archives tracked files only, honouring the `export-ignore` rules in `.gitattributes`, so nothing untracked (a `vendor/`, a stray `.env`) can be swept in. It refuses to build unless `citecue.php`'s `Version:` header, `CITECUE_VERSION` and `readme.txt`'s `Stable tag:` all agree, and it checks the result unpacks to a single `citecue-ai-auto-fix/` directory — the WordPress.org slug, so a site that switches between the two install channels upgrades one plugin rather than ending up with two. CI runs the same script on every pull request. To publish: bump those three version strings, then push a `vX.Y.Z` tag. The release workflow rebuilds the zip, fails if the tag disagrees with the plugin header, and attaches `citecue-ai-auto-fix.zip` to the GitHub release. From 8f78593b1c17de3380cafc37d4f5cef86b2a5eb5 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Wed, 5 Aug 2026 19:33:37 +0200 Subject: [PATCH 4/5] Match the text domain to the slug, and survive a duplicate install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of packaging under citecue-ai-auto-fix that the slug change alone did not carry. WordPress.org keys language packs by slug: it ships them as citecue-ai-auto-fix-{locale}.mo, while every gettext call here asked for the domain "citecue", so no translation would ever have loaded. The domain now matches the slug in the header, in load_plugin_textdomain() and across all 144 gettext calls. The seven other 'citecue' literals are deliberately untouched — the menu slug, the settings group and the two $_GET['page'] comparisons are an internal identifier that the ?page=citecue URL and settings_fields() both depend on, and the ingest payload's 'plugin' key is read by CiteCue. The PrefixAllGlobals prefix stays "citecue" too: a PHP class or constant name cannot contain a hyphen. The second is the transition itself. A site that installed the v1.0.0 GitHub asset has this plugin at citecue/citecue.php; the directory's copy arrives as citecue-ai-auto-fix/citecue.php and sits beside it rather than upgrading it. require_once is no help — two directories are two paths — so both copies would run their requires and the second would redeclare every class, taking the site down. The main file now stands down when it finds CITECUE_VERSION already defined, and names both directories in an admin notice so the fix is obvious rather than a white screen. No data migration is needed alongside it: settings, the connection and the cron are keyed to the database, not to the directory, so deleting the old copy leaves a working install. --- .phpcs.xml.dist | 7 +- citecue.php | 39 ++++- includes/class-citecue-admin.php | 218 +++++++++++++------------- includes/class-citecue-api-client.php | 22 +-- includes/class-citecue-connect.php | 8 +- includes/class-citecue-ingest.php | 36 ++--- includes/class-citecue-plugin.php | 2 +- readme.txt | 1 + tests/cases/test-lifecycle.php | 43 +++++ 9 files changed, 231 insertions(+), 145 deletions(-) diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index bd754e9..e59049a 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -24,11 +24,16 @@ - + + diff --git a/citecue.php b/citecue.php index dc042f6..d47a27e 100644 --- a/citecue.php +++ b/citecue.php @@ -10,7 +10,7 @@ * Author URI: https://app.citecue.com * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html - * Text Domain: citecue + * Text Domain: citecue-ai-auto-fix * * @package Citecue */ @@ -19,6 +19,43 @@ exit; } +/* + * Stand down if another copy of this plugin already loaded. + * + * 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. + * + * 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. + */ +if ( defined( 'CITECUE_VERSION' ) ) { + add_action( + 'admin_notices', + static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + 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__ ) + ) + ) + ); + } + ); + + return; +} + define( 'CITECUE_VERSION', '1.0.0' ); 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 fafaf32..d4a5d7f 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -63,8 +63,8 @@ public function register() { */ public function add_menu() { add_options_page( - __( 'CiteCue AI Auto-Fix', 'citecue' ), - __( 'CiteCue', 'citecue' ), + __( 'CiteCue AI Auto-Fix', 'citecue-ai-auto-fix' ), + __( 'CiteCue', 'citecue-ai-auto-fix' ), 'manage_options', 'citecue', array( $this, 'render_page' ) @@ -94,7 +94,7 @@ public function register_settings() { * @return array */ public function action_links( $links ) { - array_unshift( $links, '' . esc_html__( 'Settings', 'citecue' ) . '' ); + array_unshift( $links, '' . esc_html__( 'Settings', 'citecue-ai-auto-fix' ) . '' ); return $links; } @@ -129,9 +129,9 @@ public function notices() { } if ( get_option( 'citecue_auth_failed' ) ) { - echo '

' . esc_html__( 'CiteCue:', 'citecue' ) . ' ' - . 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' ) - . ' ' . esc_html__( 'Open settings', 'citecue' ) . '

'; + 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' ) . '

'; } if ( ! isset( $_GET['citecue_msg'] ) || ! isset( $_GET['page'] ) || 'citecue' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only feedback. @@ -139,21 +139,21 @@ public function notices() { } $messages = array( - 'connected' => array( 'success', __( 'Connected to CiteCue.', 'citecue' ) ), - 'auto_selected' => array( 'success', __( 'Connected to CiteCue — the project matching this site was selected automatically.', 'citecue' ) ), - 'paired' => array( 'success', __( 'Connected. CiteCue now knows this site’s address and can serve optimized pages to AI crawlers.', 'citecue' ) ), - 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue' ) ), - 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue' ) ), - 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue' ) ), - 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s llms.txt.', 'citecue' ) ), - 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue' ) ), - 'verify_skip' => array( 'info', __( 'The check could not run. See the details below.', 'citecue' ) ), - 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue' ) ), - 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection and try again.', 'citecue' ) ), - 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue' ) ), - 'crawlers_fail' => array( 'warning', __( 'Could not refresh the crawler registry; the current list stays active.', 'citecue' ) ), - 'flushed' => array( 'success', __( 'Delivery cache flushed.', 'citecue' ) ), - 'secret' => array( 'success', __( 'New ingest secret generated. Update it anywhere the old secret was used.', 'citecue' ) ), + 'connected' => array( 'success', __( 'Connected to CiteCue.', 'citecue-ai-auto-fix' ) ), + 'auto_selected' => array( 'success', __( 'Connected to CiteCue — the project matching this site was selected automatically.', 'citecue-ai-auto-fix' ) ), + 'paired' => array( 'success', __( 'Connected. CiteCue now knows this site’s address and can serve optimized pages to AI crawlers.', 'citecue-ai-auto-fix' ) ), + 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue-ai-auto-fix' ) ), + 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue-ai-auto-fix' ) ), + 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue-ai-auto-fix' ) ), + 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s llms.txt.', 'citecue-ai-auto-fix' ) ), + 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue-ai-auto-fix' ) ), + 'verify_skip' => array( 'info', __( 'The check could not run. See the details below.', 'citecue-ai-auto-fix' ) ), + 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue-ai-auto-fix' ) ), + 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection and try again.', 'citecue-ai-auto-fix' ) ), + 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue-ai-auto-fix' ) ), + 'crawlers_fail' => array( 'warning', __( 'Could not refresh the crawler registry; the current list stays active.', 'citecue-ai-auto-fix' ) ), + 'flushed' => array( 'success', __( 'Delivery cache flushed.', 'citecue-ai-auto-fix' ) ), + 'secret' => array( 'success', __( 'New ingest secret generated. Update it anywhere the old secret was used.', 'citecue-ai-auto-fix' ) ), ); $code = sanitize_key( wp_unslash( $_GET['citecue_msg'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended @@ -220,7 +220,7 @@ public function maybe_claim_connect() { */ public function handle_connect_start() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_connect_start' ); @@ -237,7 +237,7 @@ public function handle_connect_start() { */ public function handle_disconnect() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_disconnect' ); @@ -252,7 +252,7 @@ public function handle_disconnect() { */ public function handle_verify_install() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_verify_install' ); @@ -273,7 +273,7 @@ public function handle_verify_install() { */ public function handle_test_connection() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_test_connection', 'citecue_test_nonce' ); @@ -339,7 +339,7 @@ public function handle_test_connection() { */ public function handle_refresh_crawlers() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_refresh_crawlers' ); @@ -354,7 +354,7 @@ public function handle_refresh_crawlers() { */ public function handle_flush_cache() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_flush_cache' ); @@ -369,7 +369,7 @@ public function handle_flush_cache() { */ public function handle_regen_secret() { if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue-ai-auto-fix' ) ); } check_admin_referer( 'citecue_regen_secret' ); @@ -388,7 +388,7 @@ public function render_page() { } ?>
-

+

plugin->settings->is_connected() ) { $this->render_connected(); @@ -413,16 +413,16 @@ private function render_setup() { $host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ); ?>

- +

-

+

' . esc_html( $host ) . '' ); ?> @@ -430,12 +430,12 @@ private function render_setup() {

- +
- +
- + render_api_base_row( $settings ); ?> - +
-

+

- + - + -

+

- +

- + - + - + - + - + - +
- + - + - + render_api_base_row( $settings ); ?> @@ -635,42 +635,42 @@ private function render_connected() { wp_nonce_field( 'citecue_test_connection', 'citecue_test_nonce' ); ?>
-

+

- action_button( 'citecue_refresh_crawlers', __( 'Refresh crawler list', 'citecue' ) ); ?> - action_button( 'citecue_flush_cache', __( 'Flush delivery cache', 'citecue' ) ); ?> - action_button( 'citecue_regen_secret', __( 'Regenerate ingest secret', 'citecue' ) ); ?> + action_button( 'citecue_refresh_crawlers', __( 'Refresh crawler list', 'citecue-ai-auto-fix' ) ); ?> + action_button( 'citecue_flush_cache', __( 'Flush delivery cache', 'citecue-ai-auto-fix' ) ); ?> + action_button( 'citecue_regen_secret', __( 'Regenerate ingest secret', 'citecue-ai-auto-fix' ) ); ?>

0 ) { printf( /* translators: %s: human time diff. */ - ', ' . esc_html__( 'refreshed %s ago', 'citecue' ), + ', ' . esc_html__( 'refreshed %s ago', 'citecue-ai-auto-fix' ), esc_html( human_time_diff( $registry['fetched_at'] ) ) ); } else { - echo ', ' . esc_html__( 'bundled list (not refreshed yet)', 'citecue' ); + echo ', ' . esc_html__( 'bundled list (not refreshed yet)', 'citecue-ai-auto-fix' ); } ?>

-

+

render_activity(); ?> -

+

plugin->connect->last_verification(); ?>
-

+

- + - - + + - + - - + + - +
- +
get( 'serve_enabled' ) ? esc_html__( 'Served to AI crawlers', 'citecue' ) : esc_html__( 'Off', 'citecue' ); ?>get( 'serve_enabled' ) ? esc_html__( 'Served to AI crawlers', 'citecue-ai-auto-fix' ) : esc_html__( 'Off', 'citecue-ai-auto-fix' ); ?>
get( 'llms_txt_enabled' ) ) : ?> - +
get( 'ingest_enabled' ) ? esc_html__( 'Accepted (as drafts, unless raised below)', 'citecue' ) : esc_html__( 'Not accepted', 'citecue' ); ?>get( 'ingest_enabled' ) ? esc_html__( 'Accepted (as drafts, unless raised below)', 'citecue-ai-auto-fix' ) : esc_html__( 'Not accepted', 'citecue-ai-auto-fix' ); ?>
- + @@ -742,8 +742,8 @@ private function render_status_card() {

- action_button( 'citecue_verify_install', __( 'Verify installation', 'citecue' ) ); ?> - action_button( 'citecue_disconnect', __( 'Disconnect', 'citecue' ) ); ?> + action_button( 'citecue_verify_install', __( 'Verify installation', 'citecue-ai-auto-fix' ) ); ?> + action_button( 'citecue_disconnect', __( 'Disconnect', 'citecue-ai-auto-fix' ) ); ?>

- + api_base_is_locked() ) : ?> api_base() ); ?> -

+

-

+

@@ -821,24 +821,24 @@ private function action_button( $action, $label ) { private function render_activity() { $entries = $this->plugin->activity->entries(); if ( empty( $entries ) ) { - echo '

' . esc_html__( 'No AI crawler visits recorded yet.', 'citecue' ) . '

'; + echo '

' . esc_html__( 'No AI crawler visits recorded yet.', 'citecue-ai-auto-fix' ) . '

'; return; } $outcome_labels = array( - 'served' => __( 'Served optimized', 'citecue' ), - 'served-stale' => __( 'Served (stale cache)', 'citecue' ), - 'passthrough' => __( 'Passed through', 'citecue' ), - 'error' => __( 'API error — passed through', 'citecue' ), + 'served' => __( 'Served optimized', 'citecue-ai-auto-fix' ), + 'served-stale' => __( 'Served (stale cache)', 'citecue-ai-auto-fix' ), + 'passthrough' => __( 'Passed through', 'citecue-ai-auto-fix' ), + 'error' => __( 'API error — passed through', 'citecue-ai-auto-fix' ), ); ?> - - - - + + + + @@ -848,7 +848,7 @@ private function render_activity() { diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php index d73f486..46016fb 100644 --- a/includes/class-citecue-api-client.php +++ b/includes/class-citecue-api-client.php @@ -161,10 +161,10 @@ public function claim_connect_code( $code, array $site ) { if ( 200 !== $result['status'] ) { $reasons = array( - 'invalid_code' => __( 'That connection link is not valid. Start the connection again from WordPress.', 'citecue' ), - 'code_used' => __( 'That connection link has already been used. Start the connection again from WordPress.', 'citecue' ), - 'code_expired' => __( 'That connection link expired. Start the connection again from WordPress.', 'citecue' ), - 'site_mismatch' => __( 'CiteCue issued that link for a different site address than this one.', 'citecue' ), + 'invalid_code' => __( 'That connection link is not valid. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ), + 'code_used' => __( 'That connection link has already been used. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ), + 'code_expired' => __( 'That connection link expired. Start the connection again from WordPress.', 'citecue-ai-auto-fix' ), + 'site_mismatch' => __( 'CiteCue issued that link for a different site address than this one.', 'citecue-ai-auto-fix' ), ); $code_key = isset( $data['error'] ) ? (string) $data['error'] : ''; @@ -172,11 +172,11 @@ public function claim_connect_code( $code, array $site ) { return new WP_Error( 'citecue_connect_' . $code_key, $reasons[ $code_key ] ); } /* translators: %d: HTTP status code. */ - return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) ); + return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $result['status'] ) ); } if ( empty( $data['apiKey'] ) || empty( $data['publicKey'] ) ) { - return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) ); + return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) ); } $connection = array( @@ -206,16 +206,16 @@ public function get_config() { } if ( 401 === $result['status'] ) { - return new WP_Error( 'citecue_invalid_key', __( 'CiteCue rejected the API key. Check it under CiteCue → Settings → API keys.', 'citecue' ) ); + return new WP_Error( 'citecue_invalid_key', __( 'CiteCue rejected the API key. Check it under CiteCue → Settings → API keys.', 'citecue-ai-auto-fix' ) ); } if ( 200 !== $result['status'] ) { /* translators: %d: HTTP status code. */ - return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) ); + return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $result['status'] ) ); } $data = json_decode( $result['body'], true ); if ( ! is_array( $data ) || ! isset( $data['projects'] ) || ! is_array( $data['projects'] ) ) { - return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) ); + return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) ); } return $data['projects']; @@ -302,12 +302,12 @@ public function get_crawler_registry() { } if ( 200 !== $result['status'] ) { /* translators: %d: HTTP status code. */ - return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) ); + return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue-ai-auto-fix' ), $result['status'] ) ); } $data = json_decode( $result['body'], true ); if ( ! is_array( $data ) || empty( $data['tokens'] ) || ! is_array( $data['tokens'] ) ) { - return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) ); + return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue-ai-auto-fix' ) ); } return $data; diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php index e2b0443..6c3c8ab 100644 --- a/includes/class-citecue-connect.php +++ b/includes/class-citecue-connect.php @@ -247,7 +247,7 @@ private function run_verification() { false, 0, '', - __( 'This check asks for the site’s llms.txt, which is switched off below. Turn on “Serve llms.txt” to run it.', 'citecue' ), + __( 'This check asks for the site’s llms.txt, which is switched off below. Turn on “Serve llms.txt” to run it.', 'citecue-ai-auto-fix' ), true ); } @@ -280,7 +280,7 @@ private function run_verification() { if ( 200 !== $status ) { /* translators: %d: HTTP status code. */ - return self::verdict( false, $status, $marker, sprintf( __( 'The site answered with HTTP %d.', 'citecue' ), $status ) ); + return self::verdict( false, $status, $marker, sprintf( __( 'The site answered with HTTP %d.', 'citecue-ai-auto-fix' ), $status ) ); } if ( '' === $marker ) { @@ -288,7 +288,7 @@ private function run_verification() { false, $status, $marker, - __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue' ) + __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue-ai-auto-fix' ) ); } @@ -298,7 +298,7 @@ private function run_verification() { $marker, sprintf( /* translators: 1: header value received, 2: header value expected. */ - __( 'Something other than the llms.txt handler answered — the response carried “x-citecue: %1$s” rather than “%2$s”. CiteCue most likely has no llms.txt for this project yet.', 'citecue' ), + __( 'Something other than the llms.txt handler answered — the response carried “x-citecue: %1$s” rather than “%2$s”. CiteCue most likely has no llms.txt for this project yet.', 'citecue-ai-auto-fix' ), $marker, self::VERIFY_MARKER ) diff --git a/includes/class-citecue-ingest.php b/includes/class-citecue-ingest.php index 70aa030..6d1d08f 100644 --- a/includes/class-citecue-ingest.php +++ b/includes/class-citecue-ingest.php @@ -125,27 +125,27 @@ public function verify_request( WP_REST_Request $request ) { $settings = $this->plugin->settings; if ( ! $settings->get( 'ingest_enabled' ) ) { - return new WP_Error( 'citecue_ingest_disabled', __( 'Content ingest is disabled in the CiteCue plugin settings.', 'citecue' ), array( 'status' => 403 ) ); + return new WP_Error( 'citecue_ingest_disabled', __( 'Content ingest is disabled in the CiteCue plugin settings.', 'citecue-ai-auto-fix' ), array( 'status' => 403 ) ); } $secret = (string) $settings->get( 'ingest_secret' ); if ( '' === $secret ) { - return new WP_Error( 'citecue_no_secret', __( 'No ingest secret is configured.', 'citecue' ), array( 'status' => 403 ) ); + return new WP_Error( 'citecue_no_secret', __( 'No ingest secret is configured.', 'citecue-ai-auto-fix' ), array( 'status' => 403 ) ); } $timestamp = (int) $request->get_header( 'x-citecue-timestamp' ); if ( abs( time() - $timestamp ) > self::TIMESTAMP_WINDOW ) { - return new WP_Error( 'citecue_stale_timestamp', __( 'Missing or expired X-Citecue-Timestamp header.', 'citecue' ), array( 'status' => 401 ) ); + return new WP_Error( 'citecue_stale_timestamp', __( 'Missing or expired X-Citecue-Timestamp header.', 'citecue-ai-auto-fix' ), array( 'status' => 401 ) ); } $signature = (string) $request->get_header( 'x-citecue-signature' ); if ( 0 !== strpos( $signature, 'sha256=' ) ) { - return new WP_Error( 'citecue_bad_signature', __( 'Missing or malformed X-Citecue-Signature header.', 'citecue' ), array( 'status' => 401 ) ); + return new WP_Error( 'citecue_bad_signature', __( 'Missing or malformed X-Citecue-Signature header.', 'citecue-ai-auto-fix' ), array( 'status' => 401 ) ); } $expected = hash_hmac( 'sha256', $timestamp . '.' . $request->get_body(), $secret ); if ( ! hash_equals( 'sha256=' . $expected, $signature ) ) { - return new WP_Error( 'citecue_bad_signature', __( 'Invalid request signature.', 'citecue' ), array( 'status' => 401 ) ); + return new WP_Error( 'citecue_bad_signature', __( 'Invalid request signature.', 'citecue-ai-auto-fix' ), array( 'status' => 401 ) ); } // Signatures are single-use: a captured request replayed within the @@ -153,14 +153,14 @@ public function verify_request( WP_REST_Request $request ) { // Legitimate retries recompute the timestamp, minting a new signature. $replay_key = 'citecue_replay_' . md5( $signature ); if ( get_transient( $replay_key ) ) { - return new WP_Error( 'citecue_replayed', __( 'This signature was already used; sign each request freshly.', 'citecue' ), array( 'status' => 401 ) ); + return new WP_Error( 'citecue_replayed', __( 'This signature was already used; sign each request freshly.', 'citecue-ai-auto-fix' ), array( 'status' => 401 ) ); } set_transient( $replay_key, 1, 2 * self::TIMESTAMP_WINDOW ); // After authentication on purpose: unsigned traffic can never consume // the budget and lock out legitimate pushes. if ( ! $this->within_rate_limit() ) { - return new WP_Error( 'citecue_rate_limited', __( 'Too many ingest requests; try again later.', 'citecue' ), array( 'status' => 429 ) ); + return new WP_Error( 'citecue_rate_limited', __( 'Too many ingest requests; try again later.', 'citecue-ai-auto-fix' ), array( 'status' => 429 ) ); } return true; @@ -195,7 +195,7 @@ private function within_rate_limit() { public function handle_content( WP_REST_Request $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { - return new WP_Error( 'citecue_bad_json', __( 'Request body must be JSON.', 'citecue' ), array( 'status' => 400 ) ); + return new WP_Error( 'citecue_bad_json', __( 'Request body must be JSON.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) ); } $external_id = isset( $params['external_id'] ) ? $this->sanitize_external_id( $params['external_id'] ) : ''; @@ -203,7 +203,7 @@ public function handle_content( WP_REST_Request $request ) { $content_raw = isset( $params['content'] ) ? (string) $params['content'] : ''; if ( '' === $external_id || '' === $title || '' === trim( $content_raw ) ) { - return new WP_Error( 'citecue_missing_fields', __( 'external_id, title and content are required.', 'citecue' ), array( 'status' => 400 ) ); + return new WP_Error( 'citecue_missing_fields', __( 'external_id, title and content are required.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) ); } $content = wp_kses_post( $content_raw ); @@ -213,7 +213,7 @@ public function handle_content( WP_REST_Request $request ) { $requested_type = isset( $params['type'] ) ? (string) $params['type'] : ''; if ( 'product' === $requested_type && ! class_exists( 'WooCommerce' ) ) { - return new WP_Error( 'citecue_woocommerce_missing', __( 'This payload targets a WooCommerce product, but WooCommerce is not active on this site.', 'citecue' ), array( 'status' => 400 ) ); + return new WP_Error( 'citecue_woocommerce_missing', __( 'This payload targets a WooCommerce product, but WooCommerce is not active on this site.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) ); } $allowed_types = array( 'post', 'page' ); if ( class_exists( 'WooCommerce' ) ) { @@ -236,7 +236,7 @@ public function handle_content( WP_REST_Request $request ) { if ( ! $force ) { return new WP_Error( 'citecue_sku_exists', - __( 'A product with this SKU already exists; send force=true to adopt and update it.', 'citecue' ), + __( 'A product with this SKU already exists; send force=true to adopt and update it.', 'citecue-ai-auto-fix' ), array( 'status' => 409, 'post_id' => $sku_match, @@ -250,7 +250,7 @@ public function handle_content( WP_REST_Request $request ) { if ( $existing_id && get_post_type( $existing_id ) !== $post_type ) { return new WP_Error( 'citecue_type_conflict', - __( 'This external_id already exists with a different content type.', 'citecue' ), + __( 'This external_id already exists with a different content type.', 'citecue-ai-auto-fix' ), array( 'status' => 409, 'post_id' => $existing_id, @@ -263,7 +263,7 @@ public function handle_content( WP_REST_Request $request ) { if ( $existing_id && 'trash' === get_post_status( $existing_id ) ) { return new WP_Error( 'citecue_trashed', - __( 'A post with this external_id was trashed in WordPress; restore or delete it permanently first.', 'citecue' ), + __( 'A post with this external_id was trashed in WordPress; restore or delete it permanently first.', 'citecue-ai-auto-fix' ), array( 'status' => 410, 'post_id' => $existing_id, @@ -274,7 +274,7 @@ public function handle_content( WP_REST_Request $request ) { if ( $existing_id && ! $force && $this->edited_locally( $existing_id ) ) { return new WP_Error( 'citecue_edited_locally', - __( 'This post was edited in WordPress after the last push; send force=true to overwrite.', 'citecue' ), + __( 'This post was edited in WordPress after the last push; send force=true to overwrite.', 'citecue-ai-auto-fix' ), array( 'status' => 409, 'post_id' => $existing_id, @@ -444,13 +444,13 @@ private function resolve_author() { */ private function upsert_product( $existing_id, array $params, $title, $content, $status ) { if ( ! function_exists( 'wc_get_product' ) ) { - return new WP_Error( 'citecue_woocommerce_missing', __( 'WooCommerce is not active on this site.', 'citecue' ), array( 'status' => 400 ) ); + return new WP_Error( 'citecue_woocommerce_missing', __( 'WooCommerce is not active on this site.', 'citecue-ai-auto-fix' ), array( 'status' => 400 ) ); } try { $product = $existing_id ? wc_get_product( $existing_id ) : new WC_Product_Simple(); if ( ! $product ) { - return new WP_Error( 'citecue_product_load_failed', __( 'The existing product could not be loaded.', 'citecue' ), array( 'status' => 500 ) ); + return new WP_Error( 'citecue_product_load_failed', __( 'The existing product could not be loaded.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) ); } $product->set_name( $title ); @@ -474,11 +474,11 @@ private function upsert_product( $existing_id, array $params, $title, $content, // E.g. the SKU belongs to a different product. return new WP_Error( 'citecue_product_invalid', $e->getMessage(), array( 'status' => 409 ) ); } catch ( Throwable $e ) { - return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue' ), array( 'status' => 500 ) ); + return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) ); } if ( $post_id <= 0 ) { - return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue' ), array( 'status' => 500 ) ); + return new WP_Error( 'citecue_product_failed', __( 'WooCommerce rejected the product.', 'citecue-ai-auto-fix' ), array( 'status' => 500 ) ); } // WC's CRUD does not take an author; attribute newly created products diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index 671d0b5..1761897 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -107,7 +107,7 @@ private function __construct() { * @return void */ public function on_init() { - load_plugin_textdomain( 'citecue', false, dirname( plugin_basename( CITECUE_PLUGIN_FILE ) ) . '/languages' ); + load_plugin_textdomain( 'citecue-ai-auto-fix', false, dirname( plugin_basename( CITECUE_PLUGIN_FILE ) ) . '/languages' ); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK ); diff --git a/readme.txt b/readme.txt index ea201e9..08a0890 100644 --- a/readme.txt +++ b/readme.txt @@ -101,3 +101,4 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never * Hardening: single-use ingest signatures (replay rejection), per-minute delivery lookup budget, CiteCue-compatible cache-key URL normalization, cache eviction on delivery misses, and crawler-registry downgrade rejection with the bundled token floor. * One-click connect: a pairing handshake sets up the site without copying an API key in or a signing secret out, with a built-in "Verify installation" check. Connecting with an API key remains available as a fallback. * An install that has not been connected to CiteCue makes no outbound requests of any kind. +* A second copy of the plugin, from a pre-directory release installed in another folder, stands down with an admin notice instead of taking the site down. diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index cd1da93..b5d8360 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -147,6 +147,49 @@ public function test_the_daily_sync_is_silent_until_the_site_connects() { $this->assertSame( Citecue_Crawlers::bundled_tokens(), $this->plugin->crawlers->get_tokens() ); } + /** + * A site that installed a pre-WordPress.org release has this plugin in a + * citecue/ directory, and the directory's copy installs alongside it + * rather than over it. Loading the second copy must be inert: without the + * guard it redeclares every class and the site fatals. + * + * The bootstrap has already loaded the plugin, so requiring the main file + * again puts us in exactly the state the second copy sees. + * + * @return void + */ + public function test_a_second_copy_stands_down_with_a_notice() { + $this->assertTrue( defined( 'CITECUE_VERSION' ), 'The first copy should already be loaded.' ); + + require dirname( __DIR__, 2 ) . '/citecue.php'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + ob_start(); + do_action( 'admin_notices' ); + $notice = ob_get_clean(); + + $this->assertStringContainsString( 'installed twice', $notice ); + $this->assertStringContainsString( plugin_basename( CITECUE_PLUGIN_FILE ), $notice ); + } + + /** + * The notice names directories to delete, so it is only for someone who + * can act on it. + * + * @return void + */ + public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() { + require dirname( __DIR__, 2 ) . '/citecue.php'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + ob_start(); + do_action( 'admin_notices' ); + + $this->assertSame( '', ob_get_clean() ); + } + /** * Uninstall removes the plugin's own settings… * From 4c5c6f5a8322fd16cc0f00b4e383306a017c8f67 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Wed, 5 Aug 2026 19:53:20 +0200 Subject: [PATCH 5/5] Test the duplicate-copy guard without firing every other plugin's notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new tests called do_action( 'admin_notices' ), which runs every listener on the hook, not just the guard's. With a real WooCommerce installed one of those listeners reads get_current_screen() and dereferences it — null outside a genuine admin request — so the WooCommerce job failed inside FeaturesController while the seven other jobs passed. Emptying the hook before loading the second copy leaves the guard's callback as the only listener, which is what these tests are about. The test case restores $wp_filter wholesale in tear_down, so the removal does not outlive the test. Verified against a local MySQL in all three modes CI runs — plain, CITECUE_STUB_WOOCOMMERCE and CITECUE_WITH_WOOCOMMERCE with WooCommerce 11 installed — after first reproducing the exact failure. --- tests/cases/test-lifecycle.php | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index b5d8360..250d988 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -161,13 +161,7 @@ public function test_the_daily_sync_is_silent_until_the_site_connects() { public function test_a_second_copy_stands_down_with_a_notice() { $this->assertTrue( defined( 'CITECUE_VERSION' ), 'The first copy should already be loaded.' ); - require dirname( __DIR__, 2 ) . '/citecue.php'; - - wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); - - ob_start(); - do_action( 'admin_notices' ); - $notice = ob_get_clean(); + $notice = $this->render_duplicate_notice_as( 'administrator' ); $this->assertStringContainsString( 'installed twice', $notice ); $this->assertStringContainsString( plugin_basename( CITECUE_PLUGIN_FILE ), $notice ); @@ -180,14 +174,32 @@ public function test_a_second_copy_stands_down_with_a_notice() { * @return void */ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() { + $this->assertSame( '', $this->render_duplicate_notice_as( 'subscriber' ) ); + } + + /** + * Loads the main file a second time — which is the state the duplicate + * copy boots into — and renders what it hooked onto `admin_notices`. + * + * The hook is emptied first so that firing it runs only the guard's + * callback. Other plugins listen on `admin_notices` too, and with a real + * WooCommerce installed one of them reads `get_current_screen()`, which is + * 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. + * @return string Rendered notice markup. + */ + private function render_duplicate_notice_as( $role ) { + remove_all_actions( 'admin_notices' ); + require dirname( __DIR__, 2 ) . '/citecue.php'; - wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) ); ob_start(); do_action( 'admin_notices' ); - - $this->assertSame( '', ob_get_clean() ); + return ob_get_clean(); } /**