=== Essiow — AI SEO Suite for WooCommerce ===
Contributors: boni58
Tags: woocommerce, seo, ai, search-console, merchant-center
Requires at least: 5.8
Tested up to: 6.9
Requires PHP: 7.4
Stable tag: 1.4.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Get more Google traffic on WooCommerce. AI rewrites your products, categories and articles using your real Google Search Console data — and pings every search engine to index changes instantly.

== Description ==

**Essiow turns your WooCommerce store into a search-traffic machine.** It plugs into Google Search Console, watches what your customers actually search for, and rewrites your product pages, category pages and blog articles to capture every query you nearly rank on.

You don't write SEO. You don't pick keywords. You don't guess what works. You click a button and the right pages get fixed.

= What Essiow does for you =

**1. Auto-rewrites your product pages.** Long description, short pitch, meta title, meta description, focus keyword, image alt texts — all generated from your real GSC queries when connected, in 8 languages, in your store's tone. Compatible with Yoast SEO, Rank Math and All in One SEO.

**2. Turns empty category pages into landing pages.** Bare category pages don't rank. Essiow generates 1,500-2,500 words of category content with FAQ, comparison tables and links to your top products — the page Google needs to rank you in position 1 instead of position 30.

**3. Writes blog articles that pull traffic to your products.** 1,500-5,000 word articles with internal links to the products mentioned, a structured FAQ, automatic featured image. Suggestions based on what your audience already searches.

**4. Spots and grabs every "almost-ranking" keyword.** When Google Search Console is connected, Essiow surfaces every query where your store sits at position 11-20 — the closest gains. One click rewrites the matching page targeting that exact query.

**5. Resolves cannibalization in two clicks.** Two of your pages competing for the same query? Essiow detects it, picks the strongest one, and consolidates the canonical from the others — without deleting anything.

**6. Indexes everything instantly.** Bing, Yandex, Naver, Seznam, Amazon and Yep are pinged the second you publish. Google gets the URL pushed via sitemap re-submit + URL Inspection refresh + a one-click manual indexation request. The Search Console page shows clicks, impressions, CTR and position over 7 days to 16 months, the Google status of every URL, and a diagnostics panel when a sync returns nothing.

**7. Builds your internal mesh.** Every page gets a click depth and an inbound / outbound count (menus, archives, cross-sells included). Orphans, weakly linked pages and dead-ends are listed with one-click fixes, suggestions can be inserted in batches or from the post editor, and the automatic mesh links the right pages together — reversible, with a mesh score / 100 and an anchor report.

**8. Keeps your link equity.** 301 / 302 / 410 rules (exact or regex) with a live source tester, automatic redirects when a product, category or article is deleted or renamed, a grouped 404 monitor with one-click fixes, CSV import / export, and an on/off switch per rule.

**9. Runs on autopilot.** New products and categories are optimized when published, declining pages are re-optimized, missing alt texts are written on upload, articles are scheduled from your Search Console data, and a weekly e-mail report sums it up — all within a daily credit cap you control.

**10. AI sales agent on your storefront.** A chatbot that knows your full catalog, handles objections, can issue promo codes within your discount limit, and quotes your delivery / returns / payment policy. Conversations and leads are listed in the admin (CSV export, GDPR retention).

**11. Exposes your catalog to ChatGPT, Perplexity and Claude.** Toggle on and Essiow serves a clean `/llms.txt` at your root. It is an answer-engine signal, not a Google one: Google states it ignores the file, the other assistants read it.

**12. Puts your catalog in the shopping feeds.** One `ProductGroup` markup with an offer per variant makes your variable products eligible for Google free listings, and a product feed file (Google Merchant Center, Meta, Pinterest) is served by your shop and fetched automatically. The file alone needs no connection: you paste its URL in the platform and it fetches daily.

**13. Watches your page experience.** Core Web Vitals from real Chrome users (LCP, INP, CLS) plus a Lighthouse score per page template, a ~40-point technical audit of your server, database, assets and images, and one-click fixes for the safe ones — each reversible.

**14. Finds your off-site opportunities.** Essiow searches for pages that mention your brand without linking to it, lists sites worth contacting, and reports external links that now hit a 404 so you can redirect them in one click. It detects and drafts the e-mail — **it never creates a link for you**, and buying links is not part of the product.

**15. Publishes on a rhythm you set.** The content plan turns keywords, your categories and your Search Console gaps into a 14-day calendar: pick how many articles per day or per week, the days, the hour and the output (draft, scheduled or published). An activity register keeps every long action marked until it finishes — closing the tab or reloading the page changes nothing.

**16. Gets your shop ready for Google Merchant Center.** Most refusals and suspensions are about the *shop*, not the feed: a return policy left as a draft, the WooCommerce template text still in place, guest checkout closed, prices shown excluding tax, a page nobody can reach. Essiow audits 95 of those points the way a Google reviewer reads your site, tells you what is missing in plain words, and fixes what can safely be fixed — every fix reversible. Connect your Merchant Center account and you also get the account and product problems Google reports, in your own language, with the exact attribute in cause, plus an e-mail the day the account gets suspended. **Essiow prepares, diagnoses and fixes; Google decides** — no plugin can promise an approved account, which is why the review request stays locked until every blocking check is green (you only get one real appeal).

= Why it ranks better =

When Search Console is connected, every optimization sees the actual queries the page is already ranking on, the striking-distance keywords just outside page 1, and the CTR alerts when a title is converting poorly. The AI doesn't guess keywords — it gets them from Google itself, and writes around what's already working.

= Made for shop owners, not SEOs =

* No keyword research needed
* No technical setup beyond pasting an API key
* Every action shows its credit cost upfront — no surprise billing
* Bulk optimize, pause, resume, restore original — your content is always recoverable
* 8 languages, 4 writing tones, 3 content lengths
* A persistent SEO score / 100 on every product and category, with a factor-by-factor audit, filters and sorting
* One consistent admin: a dashboard with a site score and a prioritized to-do list, KPI on every page, server-side filters, side panels for previews, a floating bar for bulk actions
* Every long action stays marked until it ends, survives a page reload, and is listed in one activity centre

= Compatible & safe =

* WooCommerce HPOS compatible
* Works alongside Yoast SEO / Rank Math / All in One SEO (writes to all three)
* GDPR compliant (auto-delete chat data after 90 days)
* Original content backed up the first time you optimize — one-click restore

= How credits work =

* **1 credit** per product optimization
* **1 credit** per category optimization
* **3 credits** per blog article
* **2 credits** per AI Vision alt text generation
* All indexation actions, audits, internal-link suggestions, mesh-score, /llms.txt, product feeds, Core Web Vitals, the off-site scan and the whole Merchant Center diagnostic — **free** (no AI involved)
* Inside Merchant Center, only the **assisted** fixes call the AI (a return, shipping or contact page written from your own settings, a product title or description rewritten to the feed rules). Automatic fixes — publishing a draft page, reopening guest checkout, switching prices to tax-included — cost nothing.
* Credits are debited only on success. Failed AI calls don't consume credits.
* Purchased credits never expire (free trial credits expire after 30 days)

= External Service =

This plugin connects to the Essiow API at `https://essiow.com/api/v1` to process AI content generation. Your product data (names, descriptions, prices, categories) is sent to the Essiow servers where it is processed using OpenAI's models. No data is stored beyond what is needed to track your credit usage.

* [Essiow Terms of Service](https://essiow.com/terms)
* [Essiow Privacy Policy](https://essiow.com/privacy)

== Installation ==

1. Upload the `essiow` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. Go to **Essiow > Settings** and enter your API key from [essiow.com](https://essiow.com)
4. Click "Test Connection" to verify
5. Start optimizing from **Essiow > Products** or **Essiow > Categories**

== Frequently Asked Questions ==

= Do I need an Essiow account? =
Yes. Create a free account at [essiow.com](https://essiow.com) to get your API key and 10 free credits.

= Do I need technical skills? =
No. If you can install a WordPress plugin, you can use Essiow. Everything is done in a few clicks.

= Do I need to know SEO? =
No. Essiow does the SEO work : it picks the keywords (from Google Search Console when connected), writes the meta tags, generates the schema, builds the internal links and submits everything to search engines. You just click "Optimize".

= Why does connecting Google Search Console matter? =
With GSC connected, every optimization is fed with the real queries your page already ranks on. Essiow finds queries where you sit at position 11-20 (just outside page 1) and rewrites the matching page targeting that exact query. Without GSC, optimizations are still good — but generic. With GSC, they're surgical.

= Will optimizing break my existing content? =
No. The first time a product or category is optimized, the original content is backed up automatically. One click in the preview modal restores it.

= Which SEO plugins are supported? =
Essiow works with Yoast SEO, Rank Math, and All in One SEO. It writes to all three formats simultaneously, so switching SEO plugin later does not lose your data.

= Is my data safe? =
Your product data is sent to Essiow servers only during optimization. It is processed in real-time and not stored beyond credit-tracking metadata. Chat conversations are auto-deleted after 90 days per GDPR requirements.

= Do credits expire? =
Purchased credits never expire. The 10 free credits expire after 30 days.

= Can I cancel a bulk optimization? =
Yes. Pause / Resume / Cancel buttons appear during a bulk run. Closing the tab also auto-cancels — items already processed remain saved.

= Can I try before buying? =
Yes. Create a free account and get 10 credits to test all features. No credit card required.

= What is the SEO score shown on products and categories? =
A local audit out of 100 (title and meta lengths, focus keyword, description length and structure, image alt texts, FAQ, schema…). It is computed by the plugin, never costs a credit, and is stored so you can filter and sort the catalog by score. Rating a large catalog happens progressively in the background of the Products page.

= Does the plugin slow down my store? =
No. Nothing runs on the storefront except the optional chat widget, the JSON-LD schemas (cached) and the redirect lookup (cached). Bulk jobs, syncs and automations run through WP-Cron or on the Essiow servers.

= What happens when I update the plugin? =
Nothing to do: the plugin migrates its own data at the first request after an update (database tables, new options with safe defaults, expired caches). Your API key, settings, optimizations and redirects are kept.

= What is removed when I delete the plugin? =
All options, cached data, scheduled tasks, tables (agent conversations and leads, redirects, 404 log, automation queue, content plan, speed runs, off-site findings) and Essiow metadata. If "Restore originals on uninstall" is enabled in Settings, the original titles, descriptions, SEO meta and alt texts are put back and the internal links added by Essiow are removed.

= Does Essiow build backlinks for me? =
No, and it never will. The Off-site module searches for pages that already mention your brand without linking to it, lists sites worth contacting and reports external links that now hit a 404. It prepares the outreach e-mail; you send it and the other site decides. Nothing is posted, bought or published anywhere on your behalf.

= What does /llms.txt actually do? =
It exposes a clean summary of your catalog to AI assistants. It is an answer-engine signal, not a Google ranking factor: Google has stated it ignores the file. Keep it on if you care about ChatGPT, Perplexity or Claude citing your shop; it costs nothing either way.

= Do I need a Google API key for the Speed module? =
Only for the Field tab. Core Web Vitals from real users and the Lighthouse score come from the free PageSpeed Insights API. The technical audit and its fixes work without any key.

= Does the product feed need a Google Merchant Center connection? =
No. Essiow serves the feed file at your own domain and you paste that URL in the platform (Google Merchant Center, Meta or Pinterest), which fetches it daily. The Eligibility tab lists what each item is still missing. Connecting your Merchant Center account is a separate, optional step that adds Google's own verdict on top.

= Will Essiow get my Merchant Center account approved? =
No, and no plugin can: Google decides. What Essiow does is remove the reasons for a refusal before you ask — it audits 95 points the way a reviewer reads your shop, explains each failure in plain words, and fixes the ones that can be fixed safely. The review request button stays locked until every blocking check is green, on purpose: you only get one real appeal, and each refusal lengthens the wait.

= What does the Merchant Center diagnostic actually check? =
Your shop, not your feed: HTTPS and reachability, return and shipping policies (published, not the WooCommerce template, complete), contact details and legal identity, the checkout end to end (guest checkout, payment method, terms, order e-mails), prices and tax display, placeholder content, structured data, and your product data (titles, descriptions, images, GTIN, category, variants). The probe reads your shop from your own server, so it cannot see blocking that depends on the visitor's country or IP — those checks say so instead of claiming to be covered.

= Do I need a Google Cloud project to connect Merchant Center? =
No. The authorization goes through essiow.com: you sign in with Google, accept the access, and come back. No WordPress.com account, no Cloud project, no API key to create. Disconnecting removes the link on both sides.

= If I close the tab during a long job, do I lose it? =
No. Optimizations, bulk generations, scans and measurements are registered server-side. The item stays marked until the job finishes, the state comes back after a reload, and the activity centre in the top bar lists everything currently running.

== Screenshots ==

1. Dashboard: site SEO score, KPI, prioritized to-do list and module overview
2. Products: SEO score per product, server-side filters, bulk optimization and audit panel
3. Categories: same workflow for product categories
4. Blog: three-step article generator with SEO research, article list and bulk generation
5. Internal links: overview, suggestions, automatic mesh and link graph
6. Search Console: connection status, performance, indexation, opportunities
7. Redirects, 404 monitor and lost links
8. Automation: automations, content plan, queue and log
9. Speed: Core Web Vitals, Lighthouse per template and technical audit
10. Off-site: brand mentions, outreach targets and recovered visits
11. Feeds: feed URLs per platform and free-listing eligibility
12. Merchant Center: readiness score, the 95-point diagnostic grouped by family with its one-click fixes, the Google connection, the account and product problems reported by Google
13. AI sales agent: settings with live preview, conversations and leads
14. Settings and help

== Changelog ==

= 1.4.1 =
* **One rule for "optimized".** A product scoring 70 or more now counts as optimized. Until now three different rules coexisted: the score badge turned green at 70, the score card counted "good" only from 80, and the optimized status also waited for 80 — so a product at 75 was green, "medium" and "not optimized" at the same time. The badge colour, the score card, the Score filter, its labels and the optimized status now all derive from the single "Optimized score threshold" setting, so they can no longer disagree, whatever value you choose. "Medium" starts at 50 everywhere (the badge used 40).
* **Automation log and content plan in your language.** Results and errors written by a background run ("Optimized — SEO score 30 → 75", "Invalid API key") were stored already translated, frozen in the language of the moment they were written. They are now stored as message keys and written out in the language of whoever reads them, like the Merchant Center diagnostic since 1.4.0. Entries written before the update keep their original wording.
* **Upgrade.** Shops still on the former default of 80 are moved to 70 once, at the first request after the update. A threshold you set yourself is kept.

= 1.4.0 =
* **New — Merchant Center.** A new page that audits your shop the way a Google reviewer reads it: 95 checks in eight families (reachability and indexability, policy pages, identity and contact, checkout, prices and tax, content quality, structured data, product data), each failure explained by what is actually missing — "the return policy does not say who pays the return shipping", not "policy incomplete". Checks run in steps, products in batches, and the run survives a page reload.
* **Merchant Center — fixes.** Automatic fixes publish a policy page left as a draft, leave "coming soon" mode, remove a noindex or a password, attach the terms to the checkout, reopen guest checkout, switch the price display to tax-included, or replace a main image that is too small with a compliant one from the gallery. Assisted fixes write a return, shipping or contact page from your own shop settings, or rewrite a product title, description, category or attributes. Manual fixes ask you only for what you alone know. Everything is logged, reversible, and re-checked right after it is applied. A text that falls back to the WooCommerce template or still carries a `{{…}}` marker is never published — that template text is exactly what Google sanctions.
* **Merchant Center — Google connection.** One click through essiow.com: no WordPress.com account, no Google Cloud project, no API key. Pick or create the account, verify then claim the site, declare the Essiow feed as a data source with its daily fetch hour, run the first fetch. A permanent status banner and a five-step connection check, on the Search Console model. Site claimed by another account, business identity pending, account suspended and revoked token each get a short message and one action.
* **Merchant Center — what Google reports.** Account problems rendered by Google in your own language, with severity, explanation, documentation and the local check that fixes them; per-product serving status with the exact reason and the attribute in cause, filters, search, server-side pagination and bulk re-read. Statuses are read in batches, never product by product. A daily check e-mails you on a suspension or a new blocking issue — and the review request stays locked until every blocking check is green.
* **Feed.** Feed and markup prices now come from the same source as the page (`wc_get_price_to_display`), so the feed can no longer contradict the shop. `sale_price` is only sent while the sale actually runs, with its effective date. The brand is no longer filled with the site name, and `identifier_exists` is only sent when the answer is known. New columns: Google product category, age group, gender, size type and system, custom labels 0-4. A title or description written by the AI is declared to Google as such (`structured_title` / `structured_description` with `digital_source_type`), as Merchant Center requires.
* **Product markup.** Every published product is marked up, not only the ones optimized by Essiow. The duplicate-markup guard now observes whether another plugin really writes a Product block instead of assuming it. No aggregate rating unless reviews actually carry a rating.
* **Consistency.** The Feeds page and the Merchant Center page say what each of their counters measures and link to each other; the eligibility lights can no longer be green where the diagnostic is red (they name the failing checks instead); one date format per screen; the Merchant Center entry sits in the menu, the top bar, the Help modules list and the screenshots in the same order.
* **Upgrade & uninstall.** The audit table and the new options are created at the first request after the update, crons are armed, nothing is lost — no reactivation, no need to open the page. Uninstall removes the new table, options and scheduled tasks; two long-standing causes of leftovers were fixed at the root (the activity register recreating its own option, and WordPress rescheduling a recurring event through `spawn_cron()` seconds after the purge).
* **Honest documentation.** No promise that Merchant Center will accept the account: Essiow prepares, diagnoses and fixes, Google decides. The probe says what it cannot see (it reads your shop from your own server, so no geo-blocking or WAF), and the checks that depend on it are marked partial instead of being presented as covered.
* **Developer.** New AJAX actions `essiow_gmc_*` (22, all covered by the smoke test), the `{prefix}essiow_gmc_audit` table, the `essiow_gmc_audit_weekly` and `essiow_gmc_daily` crons, the `essiow_gmc_tabs` and `essiow_gmc_state` filters for adding a tab to the page without touching it, a Merchant Center API mock with six failure modes, an upgrade scenario from 1.3.0 across the 15 admin pages, and an updated inventory in `CONTRIBUTING.md` and `docs/DESIGN.md`.

= 1.3.0 =
* **New — Speed.** Core Web Vitals from real Chrome users (LCP, INP, CLS, FCP, TTFB) with a 10-month trend, a Lighthouse score per page template (mobile / desktop) with its "what to fix first" list, and a technical audit of about forty checks on your server, database, assets and images. The safe fixes are applied in one click and can be undone; the risky ones stay behind an explicit confirmation. Weekly measurement, optional e-mail alert, free PageSpeed API key needed only for the field data.
* **New — Off-site.** Finds pages that mention your brand without linking to it, lists sites worth contacting, and drafts the outreach e-mail for you. A "Lost links" tab in Redirects shows the external links that now hit a 404 with the visits they used to bring, and turns them into a 301 in one click. **Essiow never creates, buys or posts a link** — it detects and prepares, you decide.
* **New — Product feeds.** One feed file per platform (Google Merchant Center, Meta, Pinterest) served by your own shop and fetched daily — no OAuth, no app review. Built-in validation lists every rejected or incomplete item, and an Eligibility tab explains what is still missing for Google free listings. Optional exclusion of out-of-stock items and a cap on the number of items.
* **New — Content plan.** Turn keywords, your product categories and your Search Console gaps into a publishing rhythm: N articles per day or per week, active days, generation hour, publishing window, and output as draft, scheduled or published. A 14-day calendar shows what comes next, items can be reordered by priority, and the plan shares the same daily credit cap as the rest of the automations. Catch-up after a missed run, pause when credits run low, e-mail when the plan is done.
* **Product schema.** Variable products now emit a single `ProductGroup` with one offer per variant (instead of an aggregate), variant attributes as `variesBy`, product specifications, and shipping details read from your real WooCommerce shipping zones — which is what Google asks for to consider a variable product for free listings.
* **Front-end SEO.** Image sitemap, `hreflang` with `x-default` on multilingual sites (Polylang, WPML), complete sharing tags, and business-profile fields (coordinates, price range, area served, Google Business Profile link, X / Twitter handle) feeding the Organization / LocalBusiness schema.
* **Search Console reliability.** The connection banner now shows a real health state (healthy, degraded, reconnection required) that survives a reload, a "Check the connection" button, and an explicit diagnostic whenever a sync returns nothing. Sync results and quota cooldowns are reported everywhere instead of failing silently.
* **Activity register.** Every long action (optimizations, generations, scans, measurements) is registered server-side: the item stays marked until the job ends, the state comes back after a reload or in another tab, AI generations run one at a time per site, and the top-bar activity centre lists what is running. One loading indicator per zone, everywhere — a toast now announces a result, never the start of an action.
* **Consistency.** The Essiow menu follows the same order as the top navigation, action columns fit on one line at 1280 px, "Top queries" and "Top pages" no longer overflow their card, the dashboard and the Products page count optimized products the same way, and duplicated buttons ("Run queue now", "Bulk generation") appear once per screen. Lighter wording across every page: shorter subtitles, one line of help per block, no repeated explanations.
* **Upgrade & uninstall.** `essiow_run_upgrade()` creates the new tables (speed runs, off-site findings, content plan) and their default options at the first request after the update — no reactivation, no need to open the module's page. Crons for the new modules are armed automatically, and uninstall removes every new table, option and scheduled task.
* **Honest documentation.** No FAQ rich-snippet promise, `/llms.txt` described for what it is (an answer-engine signal Google states it ignores), the IndexNow engine list kept accurate (Bing, Yandex, Naver, Seznam, Amazon, Yep), and no backlink-building claim anywhere.
* **Developer.** New AJAX actions `essiow_speed_*`, `essiow_offsite_*`, `essiow_lost_*`, `essiow_feeds_*`, `essiow_plan_*` and `essiow_activity_*`, all covered by the smoke test; a real upgrade scenario from 1.2.0 (`bin/upgrade-test.sh`) checking data, schema, defaults and crons across the 14 admin pages; `bin/shot.sh` covering every page; inventory of actions, options, tables, crons and storage keys in `CONTRIBUTING.md` and `docs/DESIGN.md`; regenerated translation template.

= 1.2.0 =
* **Design** : the whole admin is rebuilt on one design system — top bar with module navigation, connection / credits / Search Console / IndexNow status and a quick search, a header with primary actions on every page, KPI tiles, server-side filters remembered per page, 44 px tables with sticky headers, a floating bar for bulk actions, side panels for previews and details, styled confirmations (no more browser dialogs), illustrated empty states, skeleton loaders, SVG icons (Lucide) instead of Dashicons, keyboard-friendly panels and tabs, and layouts checked at 1280 / 1024 / 782 px.
* **Dashboard** : site SEO score ring with breakdown and "Fix" links that open the right tab, four KPI (products, articles, Search Console clicks, indexed pages), a second row for the modules (average SEO score, internal mesh score, automation queue, redirects and 404), a prioritized "To do now" list that also reports the week's 404s and failed or paused automations, recent activity and shortcuts. The setup wizard is kept for new sites.
* **Products & Categories** : a persistent SEO score / 100 on every product and category (`_essiow_seo_score`, rated progressively in batches), global KPI with score distribution, server-side filters (name / SKU, status, score, category, sort, page size) kept in the URL and in the browser, a fixed paginated table, "select the page" or "select the whole filter" with a floating bar, a preview panel with a factor-by-factor audit, before / after scores, FAQ and restore, and a restyled bulk job (pause, resume, cancel, failed items). Optimizing or restoring no longer reloads the page.
* **Search Console** : an always-visible connection banner (account, property with selector, last sync, rows stored, diagnostics when a sync returns nothing), a single Overview tab (7 days to 16 months, KPI with deltas, clicks and impressions curve, indexation summary, top queries and pages), plus Indexation, Opportunities, Issues, Submission (sitemap, IndexNow queue with "flush now", llms.txt) and Audit tabs; daily totals kept for 16 months, weighted average position, titles decoded.
* **Internal links** : inventory rules made explicit (published content only, noindex / password-protected / system pages excluded, WordPress pages optional, manual exclusions), click depth per page, server-side metrics and mesh score, a real automesh plan with configurable quotas, batch suggestions with one-way insertion and ignored pairs, a force-directed SVG graph with filters and focus, an anchor report, settings tab, and a repaired editor meta box.
* **Blog** : a three-step generator (topic with SEO research and Search Console ideas, options for length / tone / language / linked products / featured image, progress), preview in a side panel (score, audit, FAQ, sources; publish, schedule or draft), a server-side article list (search, status, source, score, pagination, bulk IndexNow), bulk generation as a wizard with job details, and E-E-A-T settings (default author, default status, publishing window).
* **Automation** : one card per automation with an on / off switch, inline settings saved automatically, next run and last result, a paused-queue notice, and queue / log tables with retry and clear actions.
* **Redirects** : KPI, server-side filters (type, status, sort), an add / edit panel with a live test of the source path, CSV import in a panel, a grouped 404 monitor with suggested targets, a settings tab, and an active / inactive switch per rule (new `active` column, migrated automatically). Duplicate regex patterns are refused.
* **AI Agent** : status and quota KPI (messages per hour), Settings / Conversations / Leads tabs, identity and widget cards with a live preview, knowledge, delivery and returns, behaviour, coupons, bank details and privacy cards, conversation transcripts in a side panel with deletion, leads list with CSV export, and a modernized storefront widget (SVG icons, Escape to close, `#essiow-chat` deep link).
* **Upgrade & uninstall** : `essiow_maybe_upgrade()` runs idempotent migrations at the first request after any update (default options, database tables via dbDelta, expired caches purged, orphan cron hooks removed, `essiow_upgraded` action for extensions) — no reactivation needed. Uninstall now removes every option, transient, table, cron, post / term / user meta added since 1.1.79 and still restores the original content when the option is enabled.
* **Developer** : page styles live in `assets/css/pages/<slug>.css` (loaded automatically), `Essiow_Admin::icon / page_header / kpi / empty_state` and `EssiowUI.icon / confirm / drawer / bulkBar / pagination / setKpi` helpers documented in `docs/DESIGN.md`, a demo environment (`bin/smoke.sh` with `SEED=1`, mock API, `bin/shot.sh` screenshots), an upgrade scenario (`bin/upgrade-test.sh`) and an uninstall check (`UNINSTALL=1`) in the smoke test, the smoke test covering every page, AJAX action, cron and front page, new server filters on the products / categories / articles / redirects lists, and a regenerated translation template.

= 1.1.81 =
* **Langues** : toutes les chaînes sources unifiées en anglais (convention WordPress.org), catalogue régénéré (1 665 chaînes) et traductions complètes livrées en français, espagnol, allemand, italien, portugais et néerlandais (.po + .mo). Sur les sites multilingues (Polylang, WPML), la langue de génération suit celle du produit, de la catégorie ou de l'article ; le chatbot répond dans la langue de la page.
* **Automatisation** (nouveau menu) : file d'attente avec plafond de crédits par jour, optimisation automatique des nouveaux produits et catégories à la publication, blog en pilote automatique (sujets tirés de Search Console ou des catégories sans article, N articles par semaine), ré-optimisation des pages en déclin, texte alternatif à l'upload, rapport hebdomadaire par email, journal et boutons « Exécuter maintenant ».
* **Design** : pages Produits, Catégories, Blog, Search Console, Maillage et Agent alignées sur le tableau de bord (bandeaux KPI, filtres par statut / score / catégorie, tri, sélection des non optimisés, états vides, chargements, dashicons à la place des emoji, générateur d'articles en deux colonnes, cartes de jobs, responsive).
* **Performance et robustesse** : JavaScript découpé par écran (seul le script de la page est chargé), schémas Product et Organization mis en cache, catégorie des articles bulk « produits » retrouvée, lien support du chat corrigé, intégration continue (lint PHP 7.4 / 8.3, syntaxe JS, catalogue à jour, test d'exécution complet dans WordPress + WooCommerce).

= 1.1.80 =
* **SEO technique complet sans plugin SEO** : title, meta description, canonical, OpenGraph et Twitter sur produits, catégories, boutique et accueil (modèles configurables) ; schéma Organization / LocalBusiness / Store avec logo, profils sociaux et horaires ; WebSite + SearchAction.
* **Robots** : noindex des produits en rupture (option), des archives d'étiquettes et de la recherche interne, canonical propre et noindex sur les vues filtrées (`orderby`, `filter_*`, prix, utm…), exclusion des produits noindex du sitemap.
* **Schéma Product enrichi** : marque, SKU, GTIN/MPN, disponibilité (précommande, réassort), `priceValidUntil`, politique de retour, frais et délais de livraison, AggregateOffer pour les variables — sans doublon avec Yoast WooCommerce / Rank Math / AIOSEO.
* **FAQ produit générée par l'IA** : 3 à 5 questions d'acheteur stockées à l'optimisation, affichées en accordéon sur la fiche et balisées FAQPage.
* **Redirections et 404** (nouveau menu) : règles 301 / 302 / 410 avec regex, redirection automatique des produits, catégories et articles supprimés et des changements de slug, journal des 404 avec suggestion de cible et création en un clic, import / export CSV, alerte par email au-delà d'un seuil.
* **Blog E-E-A-T** : encart auteur avec profils sociaux et schéma Person, blocs « À lire aussi » et « Produits liés », publication étalée du bulk sur N jours, rafraîchissement / remplacement d'un article existant sans doublon, liste enrichie (score, intention, temps de lecture, indexation), alertes qualité avant publication.
* **Interface** : design system unifié, barre supérieure avec état de connexion, crédits, Search Console et IndexNow, navigation transversale, tableau de bord avec KPIs, détail du score, actions rapides et activités récentes, assistant de démarrage en 4 étapes, réglages par onglets, page d'aide avec statut système.

= 1.1.79 =
* **Sécurité de l'agent IA** : les coordonnées bancaires ne sont plus envoyées à l'IA ni exposées sans activation explicite, les remises créent un coupon à usage unique réservé au client au lieu de modifier le prix réel, coupons et remises désactivés par défaut, limitation de débit par empreinte client et globale, jeton anti-abus sur l'endpoint public, conversations liées au visiteur (plus de reprise d'une conversation tierce), produits non publiés invisibles, email admin retiré des pages, leads stockés + export/effacement RGPD, mention de confidentialité dans le widget, widget absent du panier/checkout/compte.
* **Sécurité des données catalogue** : plus aucun forçage de stock à l'optimisation, sauvegardes unifiées (titre, nom, descriptions, meta SEO, alt d'images) restaurables depuis la liste produits et catégories, jamais d'écrasement par une valeur vide, renommage des catégories en option, choix des champs à réécrire et respect des meta SEO existantes.
* **Bulk produits/catégories** : verrou anti-doublon, budget de temps, reprise idempotente, items en échec signalés au lieu de bloquer le job, cron actif uniquement pendant un job.
* **Search Console** : IndexNow asynchrone (plus jamais de publication bloquée), sélecteur de propriété rétabli, canonical réversible et liste des canonicals actives (Yoast, Rank Math, AIOSEO, SEOPress), détails par page (requêtes top 10 / striking distance), export CSV des opportunités et de l'indexation, vue « pages en déclin » avec ré-optimisation en un clic, corrections OAuth (state), sous-répertoires, llms.txt en cache sans email.
* **Maillage interne** : injection sûre (jamais dans une balise, un shortcode, un bloc ou un titre), orphelins réels (archives, menus, ventes croisées comptés), plafonds respectés dans toutes les passes, plans déterministes, meta box de suggestions dans l'éditeur, auto-link à la publication (option), rapport d'ancres avec alerte de sur-optimisation, revert permanent et retrait de tous les liens Essiow, plus de révisions ni de dates modifiées en masse.
* **Blog** : planification de publication (date et heure).
* **Connexion et réglages** : déconnexion et rotation de clé enfin effectives, clé testée avant enregistrement, réglages réservés aux administrateurs, valeurs validées, journal des erreurs API, tableau de bord avec détail du score et checklist de lancement, nouvelles options (champs optimisés, IndexNow à la mise à jour, restauration des originaux à la désinstallation, multisite).
* **Cycle de vie** : tous les crons sont retirés à la désactivation, désinstallation propre avec restauration optionnelle des contenus d'origine.
* Nettoyage : une vingtaine d'endpoints morts supprimés, requêtes `ORDER BY RAND()` retirées, pagination des catégories, chaînes dupliquées et non traduites corrigées.

= 1.1.78 =
* **Articles ultra-optimisés SEO — recherche avant rédaction** : avant de générer, Essiow interroge Google (People Also Ask, recherches associées, pages qui rankent, sous-titres des 3 meilleurs concurrents, longueur moyenne) et des sources externes autoritaires RÉELLES. L'IA répond à chaque question PAA, couvre tous les sous-thèmes des concurrents et ne peut citer que des URLs vérifiées. Bouton « Recherche SEO » sur la page Blog pour voir le dossier avant de lancer.
* **Réponse directe en premier paragraphe** (40-60 mots, featured snippet / AI Overviews), chaque H2 ouvre sur la réponse, tableaux comparatifs, étapes numérotées, points « À retenir », sommaire cliquable (ancres H2 conservées dans Gutenberg), FAQ normalisée, section « Sources ».
* **Score SEO /100 et checklist** renvoyés pour chaque article (mots, mot-clé dans titre/intro/H2/meta, longueurs meta, H2/H3, images + alt, liens internes/externes, FAQ). Affichés dans l'onglet SEO avant publication et stockés (`_essiow_seo_score`).
* **Liens inventés supprimés** : toute URL absente du catalogue (produits, articles, catégories, sources autorisées) est transformée en texte — plus jamais de 404 publié. Les liens externes reçoivent `rel="noopener"` (+ `nofollow` hors sources autoritaires).
* **Slug SEO** issu du mot-clé (`post_name`), **FAQPage** depuis les données structurées (plus de regex cassée par les blocs Gutenberg), **BreadcrumbList**, `BlogPosting` enrichi (wordCount, timeRequired, articleSection) — et **plus de doublon** de schéma quand Yoast / Rank Math / AIOSEO sont actifs.
* **Meta title / description / OG / Twitter rendus par Essiow** quand aucun plugin SEO n'est installé ; support SEOPress ajouté ; le focus keyword est toujours écrit (bug de l'ancien early-return).
* **Maillage inverse automatique** : à la publication, 3 articles Essiow plus anciens et proches (tags / catégorie) reçoivent un lien « À lire aussi » vers le nouveau — le nouvel article a des liens entrants dès la première minute (désactivable : option `essiow_auto_backlinks`).
* **Fixes** : le champ « Titre (optionnel) » et la longueur sont enfin envoyés à l'API ; `$keywords` non défini à la publication (focus keyword vide, exclusion GSC cassée) ; auteur 0 en cron bulk ; score dashboard IndexNow lisait la mauvaise option ; la longueur « court / moyen / long » du bulk était ignorée (2 500 mots forcés).

= 1.1.77 =
* **Parité complète bulk ↔ individuel — contexte GSC ajouté au worker bulk** : c'était la dernière différence restante. La génération individuelle d'articles enrichissait son prompt avec les vraies queries Google Search Console matchant le keyword (28 derniers jours, agrégées par impressions), permettant à l'IA de cibler ce que l'audience tape déjà. Le bulk générait "à l'aveugle". Désormais : avant chaque génération bulk, le worker appelle `_build_gsc_context_for_article(site, item.keyword)` et injecte le résultat dans le prompt — exactement comme l'individuel.

= 1.1.76 =
* **CAUSE RACINE TROUVÉE — toutes les images bulk étaient bloquées par un filtre silencieux côté backend** : depuis 1.1.69, le plugin envoyait soigneusement le `wp_context` (produits avec image_id/image_url, articles, catégories, pool d'images vedette) au backend Flask. Mais `BulkArticleService.create_job` reconstruisait le dict de configuration en ne retenant qu'une **whitelist de clés** (`tone`, `length`, `language`, `blog_id`, etc.) — `wp_context` n'était PAS dans la whitelist, donc **silencieusement éliminé**. Conséquences en cascade :
    1. Le worker Celery recevait `cfg.get('wp_context')` → `None` → `products = []`, `blog_posts = []`, `featured_image_pool = []`
    2. L'IA recevait un prompt SANS produits → aucune `<img>` générée (l'IA ne pouvait pas inventer des URLs valides)
    3. Pas de pool d'images vedette → pas d'image vedette attachée au post
    4. Le sanitizer auto-injection ajouté en 1.1.75 s'appuyait sur `article_data.products` (vide aussi) → ne pouvait rien injecter non plus
    5. Conclusion : tous les fixes images depuis 1.1.69 étaient **bloqués en amont**, c'est pour cela que les corrections successives ne donnaient rien visible.
* **Fix** : ajout de `wp_context` et `site_url` dans la whitelist. Les NOUVEAUX jobs bulk recevront enfin le contexte produit complet, et toute la chaîne d'images (génération IA + auto-injection sanitizer + image vedette via attachment_id) fonctionnera bout en bout.
* **Note** : les jobs créés AVANT 1.1.76 ont un `config` immuable en DB sans wp_context — relancer un nouveau bulk job pour bénéficier du fix.

= 1.1.75 =
* **Fix définitif — images vedette + images inline dans les articles bulk** :
    1. **Featured image via attachment ID** : avant, le plugin recevait une URL `https://site.com/wp-content/uploads/2024/10/widget-1024x768.jpg` (taille `large`) et tentait `attachment_url_to_postid` → souvent échec car cette fonction n'accepte que l'URL ORIGINALE sans suffixe `-WxH`. Désormais le plugin envoie l'`image_id` directement dans `featured_image_pool` ; le worker stocke `featured_image_id` dans `generated_payload` ; le plugin attache via `set_post_thumbnail($post_id, $id)` — **zéro HTTP, indestructible**.
    2. **Auto-injection des images inline** : l'IA esquivait parfois les `<img>` même quand on lui listait les produits dans le prompt. Le sanitizer compte maintenant les `<img>` valides après génération. Si moins de 3, il injecte automatiquement les images des produits restants, placées après les premières `<h2>`, wrappées dans des `<a>` vers la page produit pour le SEO. Le plugin reçoit un article qui a TOUJOURS au moins 3 images contextuelles, peu importe ce que l'IA a fait.
    3. **Prompt durci** : section IMAGES déplacée en MANDATORY (non-négociable), exige 3-6 `<img>` minimum, format explicite avec wrap `<a href="PRODUCT_URL">` pour cumuler valeur SEO.
* **Fix HTTP 429 sur ping IndexNow** : avant, un 429 (rate limit) cassait l'opération sans recovery. Désormais le retry honore le header `Retry-After` quand IndexNow l'envoie, sinon backoff plus long. Côté UI, le toast affiche un message clair (« IndexNow rate-limited. Try again in a few minutes. ») au lieu d'un cryptique « HTTP 429 ».
* **Fix bouton « Indexer » qui ouvrait GSC dans une nouvelle fenêtre** : avant, après IndexNow + sitemap submit, le plugin ouvrait automatiquement Google Search Console sur la page d'inspection de l'URL — Google affichait son texte par défaut « URL is not on Google. Couldn't fetch it... » et l'utilisateur croyait à un échec de l'indexation. Désormais aucune ouverture automatique ; le toast affiche un récap clair des étapes effectuées (« ✓ IndexNow · Sitemap re-submitted · Indexation status refreshed »). L'URL GSC reste accessible si besoin via `data-hint-url` sur le bouton (extensible plus tard pour une UI dédiée).
* **Récap d'étapes détaillé** dans `ajax_request_indexing` : chaque sous-action (IndexNow, sitemap, inspection cache) renvoie son statut individuel. Les échecs partiels sont annoncés (`⚠ IndexNow rate-limited`) sans planter l'opération globale.

= 1.1.74 =
* **Audit de vérification 1.1.73** : aucun handler legacy orphelin, lock transient correct, idempotence du pull garantie, sanitizer appelé avant le débit crédit. 1 seul vrai bug remonté, corrigé ici.
* **Fix critique — page-close n'annule plus le job** : avant 1.1.74, le `cancelAllBulksOnUnload` annulait toujours les bulks au refresh / fermeture d'onglet via `navigator.sendBeacon`, ce qui était directement contraire à l'architecture jobs-serveur déployée en 1.1.72-73 (« rien ne s'arrête quand l'utilisateur ferme »). Le handler a été neutralisé : les jobs continuent côté serveur, le polling reprend automatiquement au rechargement.
* **Fix sites HTTP + installs en sous-répertoire** : le sanitizer côté Flask reconstruisait `https://{domain}` en ignorant le protocole et le subpath réels (info perdue côté serveur). Les sites en HTTP ou dans `/shop/` voyaient tous leurs liens internes stripés. Désormais le plugin envoie son `site_url` complet (via `home_url('/')`) dans le wp_context et dans le payload de `/optimize/article` — le sanitizer l'utilise comme base de résolution.
* **IndexNow retry exponentiel** : avant, un seul shot avec timeout 5s. Un blip réseau ou un 503 transitoire perdait définitivement la soumission. Désormais : jusqu'à 3 tentatives avec backoff 0s / 1s / 3s. Les 4xx (clé / URLs invalides) court-circuitent — pas de retry sur erreurs déterministes. Timeout porté à 10s. Le log historique inclut maintenant `attempts` et `error` pour audit.
* **Cleanup auto des cannibalisations dismissed/resolved obsolètes** : à chaque chargement de la page Search Console, on compare les clés stockées en options WP avec les paires (query|primary|secondary) actuellement présentes dans les données GSC live. Toute clé absente du live → entrée stale → supprimée. Évite l'accumulation indéfinie (plusieurs centaines par an sur sites actifs).
* **Helper `apiError(xhr, defaultMsg)` côté JS** : les erreurs AJAX étaient toutes affichées comme `'Network error'` quelle que soit la cause. Désormais détection automatique via le code HTTP et `responseJSON.data.code` :
    * Status 0 → « Cannot reach the server. Check your internet connection. »
    * 401/403 ou code `invalid_api_key` → « API key invalid or expired. Reconfigure in Settings. »
    * 402 ou code `insufficient_credits` → « Plan ran out of credits. Upgrade in Settings. »
    * 429 ou code `rate_limited` → « Too many requests. Try again in a minute. »
    * 503/504 ou code `overloaded` → « Server overloaded. Try again in 30 seconds. »
    * 5xx → « Server error. Try again or contact support. »
* **i18n** : 7 nouvelles strings traduisibles (err_network, err_auth, err_no_credits, err_rate_limit, err_overloaded, err_server, no_urls_selected, sync_ok, rows, property_set, rechecked, pinged) + élimination des strings hardcodées (français durci `'Serveur surchargé · réessayez dans 30s'`, anglais durci `'No URLs selected'`, `'Pinged'`, `'Re-checked'`, etc.).

= 1.1.73 =
* **Audit complet du plugin + backend** — 4 axes audités en parallèle (bulk produits/catégories, génération articles, Search Console, UX). 14 bugs et améliorations livrés en une release.
* **Phase 2 wirée sur l'UI** : les boutons « Optimiser sélection » des pages Produits et Catégories utilisent désormais le nouveau système jobs serveur (`essiow_bulk_opt_create`). Concrètement : vous lancez, vous pouvez fermer l'onglet, vous revenez 1 heure plus tard — le job a continué côté serveur, le WP-Cron a appliqué les optimisations au fur et à mesure, et l'UI affiche l'état final.
* **Auto-resume au chargement de la page** : si un job était en cours quand vous avez quitté, l'UI redémarre automatiquement le polling et affiche la progression (via `sessionStorage` côté navigateur).
* **Sanitizer HTML appliqué aussi à la génération individuelle** (`/optimize/article`) : avant 1.1.73, le post-processing des images placeholder et liens relatifs ne tournait que sur le bulk. Les articles générés un par un héritaient des mêmes bugs. Maintenant la même protection s'applique partout — `<img src="IMAGE_URL">` → strip ou fallback, `<a href="produit/x">` relatif → URL absolue canonique, sinon unwrap.
* **Race condition sync-pull + WP-Cron pull supprimée** : un transient lock par job (`essiow_bulk_pull_lock_{id}`) empêche les deux processus de pull les mêmes items simultanément et d'appeler `wp_insert_post` deux fois → plus de duplicates côté WP.
* **Idempotence du pull renforcée** : chaque post WP est marqué `_essiow_bulk_item_id`. Si Flask renvoie le même item après un retry, on détecte le post existant et on re-confirme à Flask au lieu d'insérer un duplicate.
* **Subdirectory install supporté** dans `resolve_url_to_local` : si WordPress est installé dans `/wp/` (ou autre sous-répertoire), GSC renvoie l'URL avec le préfixe subdir, mais `url_to_postid()` attend le path relatif. Le résolveur retire maintenant le préfixe et retente.
* **Cleanup hourly des state tokens OAuth Google expirés** (Celery beat) — sans ce nettoyage, la table `gsc_oauth_states` accumulait une ligne par démarrage de flow OAuth, même ceux abandonnés en route.
* **Confirms actionnables** : avant `confirm('Confirm?')`, désormais `confirm('Optimize 12 products? Each uses 1 credit and continues running even if you close this page.')`. Pareil pour catégories et annulation de job.
* **Label « Processing: [item] »** affiché dans la barre de progression — vous voyez en direct quel produit/catégorie est en cours d'optimisation.
* **CSS bouton désactivé cohérent** (opacity 0.55 + cursor not-allowed) — fini les thèmes qui rendent les boutons disabled identiques aux activés.
* **Config writing_tone/language/length exposée au JS** pour que les jobs bulk utilisent les préférences globales du site automatiquement.

= 1.1.72 =
* **Phase 2 — Optimisations produits/catégories via jobs serveur** (architecture jumelle des articles bulk). Le plugin POST la liste d'objets WP à optimiser vers Flask, Celery les traite un par un en arrière-plan, le plugin pull les items prêts via WP-Cron (5 min) et les applique localement via `wp_update_post` + `update_post_meta` + `update_term_meta` + métas SEO (Yoast / Rank Math / AIOSEO). Le crédit est débité en transaction atomique côté Flask au moment du confirm.
* **Survit à tout** : fermeture d'onglet, perte de connexion, inactivité prolongée, crash navigateur, redémarrage WordPress. Le state du job est en DB SQL côté Flask — le plugin n'a aucun transient critique à perdre. À la reconnexion, le polling reprend où il en était, et même sans reconnexion, le worker Celery continue et le WP-Cron applique au fil de l'eau.
* **Pause / Reprendre / Annuler** propres, lus entre chaque item par le worker Celery. Annulation immédiate sur job inactif (basculement statut serveur instantané, identique à 1.1.68 pour les articles).
* **Nouveau modèle DB Flask** : `BulkOptimizationJob` + `BulkOptimizationItem`. Endpoints : `/optimize/bulk/create`, `/status`, `/pause`, `/resume`, `/cancel`, `/pending-items`, `/items/<id>/applied`.
* **Nouvelle classe plugin** `Essiow_Bulk_Optimize` : AJAX handlers `essiow_bulk_opt_*` + cron `essiow_cron_bulk_opt_pull` qui pull et applique. Le code legacy (WP-Cron transient-based) reste en place pour rétro-compatibilité — la migration UI vers les nouveaux endpoints se fait progressivement dans les prochaines releases.

= 1.1.71 =
* **Fix critique — image vedette absente sur les articles bulk** : `download_url()` échouait silencieusement sur les hosts mutualisés où le loopback HTTP est bloqué (cas très fréquent : mod_security, reverse-proxy hostile, WAF). Résultat : aucune image vedette n'était jamais attachée. Désormais, quand l'URL est locale au site, on retrouve l'attachment via `attachment_url_to_postid` directement — **aucune requête HTTP** — c'est instantané et 100 % fiable. Le téléchargement reste en fallback pour les images distantes (CDN externe).
* **Fix critique — images cassées dans le corps d'article** : l'IA produisait régulièrement des `<img src="IMAGE_URL">` littéraux (placeholder du prompt non substitué par le vrai URL). Désormais, un post-processing côté Flask scanne chaque `<img>` après génération : ceux qui contiennent un placeholder (`IMAGE_URL`, `PRODUCT_URL`, `example.com`, src vide, etc.) sont soit remplacés par l'image vedette du pool, soit stripés. Le prompt OpenAI a aussi été reformulé pour interdire explicitement les placeholders.
* **Fix critique — liens internes renvoient à l'accueil** : l'IA générait fréquemment des `<a href="produit/widget">` (relatif), qui une fois publiés sur `/mon-article/`, deviennent `/mon-article/produit/widget` → 404 → souvent redirigé vers l'accueil par les plugins SEO. Le post-processing résout maintenant chaque `<a href>` :
    * Si l'URL correspond à un produit / article / catégorie envoyé en contexte → résolu en URL absolue canonique.
    * Si l'URL est relative et inconnue → résolue via `site_url + chemin`.
    * Si l'URL est vide / placeholder / `#` → le `<a>` est unwrappé (le texte reste, le lien disparaît).
* **Nouveau service** `article_html_sanitizer.py` : module autonome de post-processing HTML qui répare tous les artefacts d'IA (placeholders, URLs relatives, ancres vides). Logué avec compteurs (`imgs kept=X stripped=Y / links kept=X stripped=Y`) pour audit a posteriori.
* **set_featured_image() durci** :
    * Timeout porté de 5s à 30s.
    * Détection automatique URL locale vs distante (compare hosts normalisés sans `www.`).
    * Fallback gracieux si le nom de fichier de l'image est sans extension (déduction du type MIME via `getimagesize`).
    * Logs explicites en cas d'échec (avant : silencieux).

**Phase 2 à venir** : migration de la génération individuelle d'articles, de l'optimisation produits (individuel + bulk), et de l'optimisation catégories (individuel + bulk) vers l'architecture jobs-serveur identique aux articles bulk — fermeture de l'onglet, perte de connexion, inactivité : rien ne s'arrête, l'optimisation reprend où elle s'est arrêtée. Travail de refonte sur plusieurs releases.

= 1.1.70 =
* **Audit complet des 4 sources de génération bulk** (liste de mots-clés, catégories WC, produits WC, import CSV SEMrush) + 6 bugs corrigés.
* **Fix critique — état du sélecteur de source** : taper une liste de mots-clés, puis switcher sur « Produits » (sans rien cocher) puis lancer envoyait le mauvais payload au backend (source=products + des keywords textuels libres). Désormais, le changement d'onglet réinitialise la sélection et l'état visuel — l'utilisateur doit re-sélectionner sur le nouveau mode.
* **Source « Catégories produits »** : le worker reçoit maintenant explicitement le nom de la catégorie sous `category_name`. Le prompt active la section « PRODUCT CATEGORY CONTEXT » et rédige l'article comme une page pilier de catégorie au lieu d'un article générique sur la requête.
* **Source « Produits »** : l'URL, l'image et le prix du produit sélectionné sont désormais envoyés en contexte au worker. Le produit cible est injecté en tête de la liste des produits disponibles (l'IA le mentionne en priorité) et son image devient l'image vedette par défaut.
* **Fix dropdown auteur vide sur WP 5.9+** : `get_users(['who' => 'authors'])` est déprécié depuis WordPress 5.9 et renvoyait un tableau vide → le dropdown auteur de la page bulk était vide, impossible de lancer un job. Remplacé par `capability => 'edit_posts'`, avec fallback sur l'utilisateur courant si vide.
* **CPC préservé** dans le pipeline : la valeur Cost-Per-Click parsée depuis les exports SEMrush était parsée puis dropped lors de la sanitisation côté PHP. Maintenant elle remonte jusqu'au worker (disponible pour de futures heuristiques de priorisation des keywords).
* **Sanitisation des keywords élargie** côté plugin : accepte les 3 formats — strings (mode liste/catégories), dict SEMrush (`keyword, volume, kd, intent, cpc`), dict produit (`keyword, product_url, product_image_url, product_price`).

= 1.1.69 =
* **Parité complète entre articles bulk et articles individuels**. Avant 1.1.69, les articles générés en masse étaient pauvres : pas d'images, pas d'image vedette, pas de liens internes vers les produits/catégories, pas de recommandations. Maintenant ils sortent identiques à ceux générés un par un.
* **Contexte WordPress envoyé au worker** : à chaque création de job, le plugin collecte et transmet au backend les 30 produits top-ventes (avec URL/prix/image), les 20 articles récents et les 15 catégories produits actives. L'IA dispose donc du même contexte que pour la génération individuelle — elle peut citer les vrais produits, créer des liens internes pertinents, choisir des images réelles.
* **Image vedette automatique** : chaque article reçoit en featured image une image produit de votre catalogue (rotation par position pour varier entre articles d'un même batch).
* **Conversion en blocs Gutenberg** : le contenu HTML est désormais découpé en blocs `<p>`, `<h2>`, `<ul>`, `<table>` distincts (plus de gros bloc HTML brut difficile à éditer). Identique à la génération individuelle.
* **SEO meta complet** : Yoast SEO, Rank Math et AIOSEO sont tous les trois renseignés (titre, description, focus keyword). Avant, seuls titre + description partiels étaient settés.
* **Excerpt automatique** + nettoyage des doublons (h1/h2 du titre, en-têtes "Introduction" résiduels).
* **Nouvelle section « Articles générés en masse »** en bas de la page Bulk : liste paginée des articles produits par les jobs, avec score SEO (0-100), statut (publié/brouillon), date, nombre de mots, et **boutons Voir / Modifier**. Sélection multiple → ping IndexNow + demande d'indexation Google, comme dans la liste des articles individuels.
* **Auto-refresh de la liste** à chaque tick de polling — les nouveaux articles publiés apparaissent dans la liste sans recharger la page.
* **Fix accents** : les titres de produits et catégories étaient affichés avec leurs entités HTML brutes (`8Sinn eXtraThin HDMI &#8211; Cable` au lieu de `– Cable`). Décodage via `html_entity_decode` partout : sélecteur produits bulk, sélecteur catégories, contexte envoyé au worker.

= 1.1.68 =
* **Refonte UX bulk** suivant retours utilisateurs (5 changements majeurs) :
* 1) **Annulation immédiate** : annuler un job en queued / awaiting_wp_publish / paused bascule maintenant le job à `cancelled` instantanément côté serveur. Avant, seul le flag `cancel_requested` était mis à true, mais comme aucun worker n'était en train de tourner, le status restait inchangé — les boutons Annuler persistaient même après reload. Pareil pour Pause sur un job en attente.
* 2) **Suppression des jauges visuelles** : remplacées par des pourcentages texte clairs et compacts. La modale active affiche `🤖 Génération IA : 60% (3/5) · 📝 Publication WP : 40% (2/5)` sur une seule ligne.
* 3) **Générations en cours déplacées en bas** de la page (sous l'historique), comme une barre de statut discrète. L'utilisateur n'est plus visuellement bloqué par un gros banner en haut quand il configure une nouvelle génération.
* 4) **Dialog Détails enrichi** : nouvelle colonne « Titre de l'article » qui affiche le titre généré par l'IA pour chaque mot-clé, plus deux boutons d'action explicites — 👁 **Voir** (lien public vers l'article) et ✎ **Modifier** (admin WP). Indispensable pour passer en revue les articles générés.
* 5) **Auto-refresh après action** : pause / reprendre / annuler déclenchent immédiatement un re-fetch du status + un refresh de l'historique + un refresh de la modale Détails si elle est ouverte sur le même job. Plus jamais d'état stale dans l'UI.
* Auto-fade-out de la barre active 5s après complétion + toast final « ✅ Génération terminée — N articles ».

= 1.1.67 =
* **Refonte complète UI Bulk generation** suite aux retours utilisateurs.
* **2 jauges de progression distinctes** : une pour la génération IA côté SaaS, une pour la publication WordPress côté local. Avant, le compteur affichait toujours 0 jusqu'à la publication WP — pendant tout le temps de génération (5-10 min sur des dizaines d'articles), l'utilisateur croyait que rien ne se passait. Maintenant la jauge bleue avance dès qu'un article est généré côté SaaS, puis la jauge verte avance quand le post WP est créé.
* **Boutons d'action fonctionnels** avec feedback immédiat : pause / reprendre / annuler envoient maintenant un toast de confirmation, forcent un rafraîchissement immédiat du status et de l'historique. Plus jamais d'action « silencieuse ».
* **Dialog « Détails »** sur chaque ligne d'historique + bouton dans la modale active : affiche tous les paramètres du job (statut, source, dates, config IA, mots-clés un par un avec leur statut individuel et lien direct vers l'article WP créé). Indispensable pour vérifier où en est un job ou consulter ses anciens runs.
* **Bouton « Détails » dans la modale active** également, pour consulter la liste des keywords pendant la génération.
* **Affichage du mot-clé en cours** dans la modale : `⚡ En cours : "comment choisir un tracteur"` — l'utilisateur sait exactement où en est le générateur.
* **Toast final** « Génération terminée — N articles » à la complétion du job (au lieu de la modale qui restait éternellement).
* **Compteurs propres** : on lit maintenant les bons noms de champs (`completed_count`, `total_count`, `generated_count`, `published_count`) avec alias rétro-compat. Plus de « undefined / undefined ».

= 1.1.66 =
* **Fix critical** : sur les sites peu visités, WP-Cron ne tournait pas → les articles générés côté SaaS restaient en attente et n'étaient jamais publiés. Le job passait à `awaiting_wp_publish` mais le plugin ne pullait jamais les articles.
* **Sync-pull dans le polling** : à chaque poll status (toutes les 5s côté JS), si le job est `running` / `awaiting_wp_publish` / `paused`, le plugin déclenche un pull synchrone immédiat — il pull les items prêts, fait `wp_insert_post` localement, et confirme à Flask (qui débite le crédit). Le navigateur du user devient le moteur de cron, exactement comme on a fait pour l'Automesh sur shared hosting.
* **Boutons actions dans l'historique** : nouvelle colonne « Actions » avec ⏸ Pause / ▶ Reprendre / ✕ Annuler pour chaque job actif. Plus besoin d'attendre que le banner du job actif s'affiche pour le piloter.
* **Toast FR direct** : « Génération en masse lancée » au lieu du fallback anglais « Bulk job started » quand les traductions PHP ne sont pas encore chargées (transition de version).

= 1.1.65 =
* **Fix critical (jauge)**: la barre de progression du bulk apparaissait pleine 5 secondes au refresh avant de revenir à 0. Cause : `total_count` passé en string `'…'` au render initial → calcul `(0+0+0)*100/'…'` = NaN → `width: NaN%` CSS invalide → fallback navigateur à 100%. Tous les compteurs sont désormais castés en `parseInt(... 10) || 0`, et le render initial part avec `total_count: 0` (jauge à 0%, plus de flash).
* **Traductions FR** complètes de toute la nouvelle vue Bulk generation. Tous les libellés sont en français dans la vue PHP, et les fallbacks JS également (statuts du job, sources, labels de progression, boutons pause/reprendre/annuler, configuration de génération, etc.).
* **Format date français** dans l'historique (`toLocaleDateString` + heure HH:MM) au lieu de l'ISO brut.
* Labels lisibles pour status (`queued` → « En file », `running` → « En cours », `awaiting_wp_publish` → « Publication WordPress », etc.) et source (`keyword_list` → « Liste de mots-clés », `collections` → « Catégories produits », etc.) — plus de codes techniques affichés à l'utilisateur.

= 1.1.64 =
* **Fix critical (bulk articles)** : audit complet de 1.1.63 — l'implémentation initiale n'aurait pas pu fonctionner. Trois problèmes bloquants corrigés.
* Fix 1 — **API authentication broken**. The bulk handlers used `get_option('essiow_api_key')` which reads an option that doesn't exist in clear (the API key is stored AES-encrypted in `essiow_api_key_enc`). They also missed the HMAC anti-replay signature headers (`X-Timestamp`, `X-Nonce`, `X-Domain`, `X-Signature`). Every call would have failed with 401 Unauthorized. → Refactored all handlers to use the canonical `Essiow_API_Client::instance()` which handles decryption + HMAC signing transparently.
* Fix 2 — **JSON response unwrapping bug**. `Essiow_API_Client::handle_response()` returns the decoded body directly (so `$resp['data']` IS the data), but the bulk code wrapped on a 3rd level (`$resp['data']['data']['jobs']`), which always evaluated to null. Status / list / create / pending-items / confirm-published — all 7 endpoints affected. → Aligned with the canonical client structure.
* Fix 3 — **CSV preview moved to plugin-side parsing**. The original implementation tried to POST a multipart upload to Flask without computing the HMAC signature for binary content. Replaced by a self-contained PHP CSV parser (header sniffing for keyword/volume/KD/intent across English/French/German/Spanish aliases, encoding detection with BOM stripping, delimiter auto-detect via comma/semicolon/tab/pipe count). No more round-trip to Flask for previews — faster and avoids the signing problem entirely.

= 1.1.63 =
* **New: Bulk article generation** for WooCommerce / WordPress. Generate dozens to hundreds of SEO articles in one go from four sources:
  - **Product categories**: one article per selected WooCommerce category
  - **Products**: one article per product (up to 500)
  - **Keyword list**: free-form textarea, comma or line separated
  - **SEMrush CSV**: upload your export (Keyword Magic Tool / Organic Research / Keyword Gap), preview keywords with volume/KD/intent, select which to keep
* Server-side orchestration via the Essiow backend. The browser only triggers the job — generation continues even if you close the tab. A WP-Cron worker pulls ready articles every 5 minutes and inserts them as WordPress posts (or via wp_insert_post triggered immediately on first launch).
* **1 credit = 1 article published**: credits are debited only after the WordPress post is successfully inserted (atomic transaction backend-side). If credits run out mid-job, it auto-pauses; refill and resume.
* **Pause, resume, cancel** any running job at any time. State is persisted server-side.
* **Crash resilient**: if the worker dies mid-generation, the next pull picks up where it left off without re-paying for what was already generated.
* **Drip publishing** option: spread articles over N days (1 every N days) instead of all at once — better for SEO patterns and avoids Google seeing a burst.
* **Per-post config**: author, post category, publish status (immediate or draft), tone, length, language, fuzzy dedup toggle.
* **SEO meta filled out**: Yoast / Rank Math / AIOSEO meta fields are set automatically when the SaaS returns SEO title/description.
* Hard cap of 500 articles per job (anti-blast-radius).
* Job history with status per row, restart polling automatically when reopening the page on an active job.

= 1.1.62 =
* **Performance overhaul** of `_automesh_compute_plan` — targets sub-30s compute even on 10k-page sites, so the planning phase fits inside the tight PHP-FPM timeouts of shared hosts (EazyWP, low-tier hosts) and stops triggering the 1.1.61 "planning_aborted" abort.
* **Optim 1 — Batch WP term cache** : `wp_get_post_terms()` was called once per product (10000 separate SELECTs on a 10k-product site). Now pre-warms the WP term cache via `update_object_term_cache($product_ids, 'product')` — 1-2 SELECTs total, the per-product calls become free cache hits.
* **Optim 2 — Token memoization** : every `_jaccard_titles()` call was re-tokenizing both titles from scratch. On a 5000-page site with 100-sibling avg pool, that's 500k tokenizations. Added an instance-scoped `_token_cache` keyed by title → ~5k tokenizations total. Speedup ~100×.
* **Optim 3 — Faster Jaccard intersection** : replaced `array_intersect` (O(n×m)) with `array_flip + isset()` lookup (O(n)). 5× cumulated gain.
* **Optim 4 — Pool size cap** in `_best_topical_match*()`. When the candidate pool exceeds 1000 items, sample 500 random instead of scanning all. Statistically same top-K quality, but O(N²) explosion neutralized on massive sites.
* **Optim 5 — Skip anchor registry preload entirely above 5000 pages**. The `AUTOMESH_EXACT_RATIO_MAX` (15%) guard rail already prevents over-using exact anchors, so an empty initial registry produces a balanced mesh from run 1 anyway.
* Result on a 10k-page site (measured on a dev VM): compute_plan dropped from ~180s to ~22s. Memory footprint divided by ~3.
* Note: if a 10k+ site still hits `planning_aborted` on your host after this update, the `max_execution_time` is < 30s. Ask your host to set it to 60s+, or split into smaller imports.

= 1.1.61 =
* **Fix critical**: automesh planning phase stuck in infinite loop ("Preparing your link plan… 5000s+"). On hosts where PHP-FPM kills requests at 60-120s, each worker attempt died before saving its progress — and every subsequent status poll relaunched a fresh attempt that died again. No exit, no error, just an ever-growing elapsed counter.
* Three combined fixes:
  1. **Anchor registry sampling for large sites** (> 2000 pages). The biggest CPU/memory bottleneck of `_automesh_compute_plan` is `_preload_anchor_registry`, which fetches and regex-parses the full HTML content of every page. On 10000 pages that's 10000 SQL + 500 MB of regex. Now sampled to 500 random pages on large sites — enough to estimate existing anchor-type ratios without killing the worker.
  2. **Max 5 planning attempts** counter on the task. If the worker keeps dying mid-compute (PHP-FPM timeout, OOM), after 5 retries we mark the task as `failed` with a clear log: "PHP-FPM/host kill shorter than required compute time. Contact your host to raise max_execution_time and memory_limit, or split your site into smaller imports."
  3. **Cancel button** in the in-progress modal + new `ajax_automesh_cancel` endpoint. Lets users escape a stuck task instantly: marks it failed, releases the `essiow_il_automesh_active` lock, clears scheduled WP-Cron events. Confirmation prompt explains that already-injected links remain (backed up, revertable).
* New: when a task ends in `failed` state, the UI fetches the detailed log line from the backend and shows it in the error toast — actionable info instead of "Something went wrong."
* New strings: `automesh_cancel`, `automesh_confirm_cancel`, `automesh_cancelled` (i18n).

= 1.1.60 =
* **Refactor critical**: the Automesh plan computation is now fully deferred to the background worker. On very large sites (10000+ pages), the compute_plan was taking > 3 minutes inside the HTTP request, hitting browser timeouts, Cloudflare 100s upstream limit, and PHP max_execution_time even after our 1.1.59 hardening. No amount of timeout-raising could solve that — the compute had to stop blocking HTTP entirely.
* **Architecture after 1.1.60**:
  1. `ajax_automesh_plan` (preview) returns a fast **heuristic estimate** based only on the cached graph (orphans count, deadends count, hubs count). ≤ 10s on any site size. No compute_plan, no simulate_apply_plan.
  2. `ajax_automesh_start` immediately creates a task with `status='planning'`, schedules the worker, and returns in < 1s. No more "Timed out" on click.
  3. `cron_automesh_run` (worker) detects `status='planning'` on its first tick → runs `build_graph` + `compute_plan` (the heavy work, up to 5 minutes), then switches to `status='pending'` and processes batches as before.
  4. `ajax_automesh_status` distinguishes `planning` vs `running`. The sync-fallback path (shared hosting where WP-Cron is dead) also handles the planning tick — same trick: if no tick in 30s, the status request itself runs the worker.
* **JS UI**: shows "Preparing your link plan…" with an indeterminate progress bar during the planning phase, then switches to "X / Y pages processed" once the plan is ready. Elapsed counter visible (so the user knows things are moving). Failed-status detection added to clear the modal if the worker crashes during planning.
* New strings: `automesh_planning`, `automesh_planning_hint`, `automesh_failed` (i18n).
* Removed: the 1.1.59 transient cache for the plan — no longer needed since the start endpoint doesn't compute the plan anymore.

= 1.1.59 =
* **Fix**: "Erreur réseau" toast on Automesh when the site has many links (1000+ pages, 4000+ planned links). Four compounding causes audited and patched:
* Fix 1 — **`ajax_automesh_plan` was less protected than `ajax_automesh_start`** despite doing MORE work (build_graph + compute_plan + simulate_apply_plan + 2× score). It had `set_time_limit(180)` only — no `ignore_user_abort`, no `wp_raise_memory_limit`, no `try/catch`. Aligned on `ajax_automesh_start` (300s, memory raised, try/catch with clear error detail).
* Fix 2 — **Double-compute bug**: clicking preview then "Run automesh now" recomputed the plan twice (60s + 60s = 120s cumulative — kill on Cloudflare/Nginx at 100s upstream timeout). Now the preview caches its plan for 5 min, and `ajax_automesh_start` reuses it instead of recomputing. One-shot cache (consumed on use).
* Fix 3 — **JS `$.post` had no `timeout` on the preview**. Default Chrome XHR timeout is multi-minute, but Cloudflare/Nginx kill at 100s and the browser sees a network error. Explicit `timeout: 180000` (3 min) added, with status-specific error messages (504 detected separately to point at the host's reverse proxy).
* Fix 4 — **Transient compression**: large plans (> 100 KB serialized) are now `gzcompress`'d before being stored in `wp_options.essiow_il_automesh_plan_cache`. Avoids hitting MySQL `max_allowed_packet` (default 4-8 MB on shared hosting), which would silently truncate the row and break the worker.
* All four fixes also help the original EazyWP / shared-hosting scenarios from 1.1.58 — they apply to the preview stage which 1.1.58 didn't cover.

= 1.1.58 =
* **Fix critical**: Automesh stuck on "0 / undefined pages processed" on shared / low-spec hosts (EazyWP, Hostinger, low-tier OVH, etc).
* Two combined root causes :
  1. **Truncated AJAX response** from `ajax_automesh_start` : on a 1000+ page site, computing the plan takes 30-60s. PHP's default `max_execution_time` (30s) on shared hosting truncates the JSON response mid-write. The plugin received a partial response with `success:true` but `total` undefined → `0 / undefined` displayed.
  2. **WP-Cron broken / disabled** : many shared hosts disable `DISABLE_WP_CRON` without setting up a real cron, or block loopback HTTP (used by `spawn_cron()`). The worker `cron_automesh_run` was scheduled but never executed → 0 pages processed indefinitely.
* Fix 1 (server-side hardening) : `ajax_automesh_start` now wraps the plan computation in `try/catch`, sets `set_time_limit(300)`, raises memory via `wp_raise_memory_limit('admin')`, and returns a clean error with detail if it crashes — instead of a truncated 200 response.
* Fix 2 (worker stall detection + sync fallback) : `ajax_automesh_status` now detects when a task has been pending/running for 20s+ without any tick. When stalled, it (a) re-schedules the WP-Cron event and (b) **executes ONE batch synchronously** in the status request itself — using the user's browser as the cron engine. The polling continues to drive progress. No more silent stalls.
* Fix 3 (JS defensive) : the progress modal handles `total = 0/undefined/NaN` by showing `…` until the first status poll. Stall detection client-side after 60s shows a hint to the user. AJAX timeout extended to 120s for large-site planning.
* Fix 4 (resume-on-reload safety) : if the user resumes an existing task, the response now includes the original `total` (was missing before, which caused the same "undefined" bug on tab reload).

= 1.1.57 =
* **Fix**: Search Console Overview tab stuck on `Clicks: 0 / Impressions: 0` even after a successful sync. The auto-sync trigger logic was buggy.
* Root cause: the auto-sync only fired when BOTH KPIs and indexation were empty. If indexation was already populated (by an earlier cron, manual sync, or background job), the condition `!hasIndex && !hasKpis` evaluated to `false` and the sync never ran — leaving the analytics KPIs forever at 0 even though the database had no analytics rows.
* Fix: auto-sync now triggers based on KPIs alone (`!hasKpis`). Indexation status is independent of analytics rows — they come from different backend tables and pipelines, so we can't infer one from the other. Now any user with empty analytics gets an automatic sync on first page load.

= 1.1.56 =
* **Fix**: "Could not resolve secondary URL to a local page" when clicking "Set canonical" in the Cannibalization tab. The resolver was relying solely on `url_to_postid()`, which silently fails for the WooCommerce Shop page (`/boutique/`, `/shop/`, etc.), the front page (`/`), and pages with custom permalinks.
* New centralized helper `resolve_url_to_local()` with cascading fallbacks: native `url_to_postid` → front page → blog page → WooCommerce Shop/Cart/Checkout/My-Account/Terms via `wc_get_page_id()` → `get_page_by_path()` for slug lookup → `/product-category/<slug>/`, `/category/<slug>/`, `/tag/<slug>/` for terms. Now correctly resolves `/boutique/` → Shop page ID, `/` → front page ID, etc.
* Used by `set_canonical`, `set_canonical_bulk`, and `resolve_url_to_entity` (Indexation tab). The cannibalization "Set canonical" button now works for the homepage and the Shop page, which are the two most common cases of cannibalization on a WooCommerce store.

= 1.1.55 =
* **Fix critical** : after reconnecting Google Search Console, the Overview tab stayed stuck on "Processing…" with empty KPIs (—) even though the server was correctly syncing thousands of rows in the background.
* Root cause: **cache poisoning** in the Search Console overview cache. The cache key included the period (`essiow_gsc_overview_cache_28`, `_90`, etc.), but the invalidation only deleted the unsuffixed key (`essiow_gsc_overview_cache`). On reconnect, the first overview fetch returned KPIs at 0 (sync not finished yet) and got cached for 1 hour — every subsequent fetch served the empty cache, never updating despite the data arriving in the database.
* Fix 1: new `invalidate_overview_cache()` helper that deletes ALL period-suffixed cache keys (`_1`, `_7`, `_28`, `_90`, `_180`, `_365`, `_480`). Called by all 4 places that previously did the broken single-key delete: `ajax_disconnect`, `ajax_sync`, `ajax_select_property`, `cron_nightly_sync`.
* Fix 2: JS `loadScOverview()` now accepts a `force` flag that bypasses the PHP cache. Used after the auto-sync triggered when KPIs are detected as empty, and after the manual "Refresh stats now" button — guarantees the post-sync fetch always sees fresh data, never the old cached zeros.

= 1.1.54 =
* **Fix definitively**: the GSC quota toast that kept appearing during Product / Category optimizations is now suppressed outside the Search Console tab.
* Root cause analysis: the auto-inspection background batch (50 URLs) re-fired on every table re-render after an optimization, hit the exhausted Google quota, and showed the toast — even though the user wasn't on the indexation tab. The 1-hour server cooldown was only set if **100%** of the batch errored; in practice GSC often returns 49 errors + 1 success, leaving the cooldown unset and the next run re-triggering the toast.
* Fix 1: **Toast confined to Search Console tab**. The auto-inspection still runs in background to populate badges, but the error toast only appears when `essiow.current_page === 'essiow-search-console'`. On Products / Categories pages, the cooldown is silently applied without the toast.
* Fix 2: **Cooldown threshold lowered to ≥50% errors**. Catches the realistic case (49/50 errors) — previously needed exactly 100%.
* Fix 3: **Cooldown duration extended to 6h** (vs 1h). Google's quota resets daily; a 1-hour cooldown was too short and caused the toast to come back after 1 hour of work.
* Fix 4: **Cooldown propagated immediately in the AJAX response** (`quota_cooldown: true, cooldown_until: ts`). The JS applies it without waiting for a page reload, so any subsequent inspection batch in the same session is short-circuited.

= 1.1.53 =
* **Major Graph view rewrite**. Visual link graph now shows the actual edges (lines) connecting pages — previously you saw nodes but no relationships.
* New: **Persistent edges** drawn as SVG lines between every pair of internally-linked pages. Subdued gray when idle, bold blue when a node is focused. Auto-redraws on pan/zoom. Capped at 1500 visible non-focus edges to keep large graphs (1000+ pages) responsive.
* New: **Click an edge to delete it**. Wide invisible hitbox (10px) makes 1px lines easy to click. Confirmation prompt, then the `<a>` wrapping is removed from the source page (anchor text preserved).
* New: **Permanently visible drag handle (🔗)**. Top-right of every node, opacity 0.55 by default, fully visible on hover or focus. Drag it onto another node to create a reciprocal link.
* New: **Click a node to focus it**. Highlights all its edges in blue, shows a status panel with title, URL, edit link, in/out counts and instructions. Re-click to defocus.
* New: **Type filters** (Products / Categories / Articles checkboxes) hide entire types — useful on big sites to inspect just the category-product mesh.
* New: **Toggle labels**. When zoomed-out on dense graphs, you can hide titles and keep only colored dots — much more readable.
* New: **Reorganize button** (🌀). Resets every node position to a fresh Vogel-spiral layout (golden-angle distribution = no visual rays). Preserved manually-dragged positions are wiped.
* New: **Visible legend** under the toolbar : 🔴 orphan / 🟢 hub / ⚪ normal + interaction help.
* New backend AJAX endpoint: `essiow_il_unlink_pair` (action). Removes `<a href="target">…</a>` wrappers from a source page's content, keeping the anchor text intact. Direction can be `a_to_b`, `b_to_a` or `both`. Used by the click-edge-to-delete flow.
* New: graph cache now exposes a flat `edges` array `[{from, to}, …]` (was previously dropped before caching). Allows the frontend to render the full mesh.

= 1.1.52 =
* Polish: rescue caps raised after real-world data (1.1.51 reduced orphans from 380 to 39 on a 1156-page store, score 87 → 99/100). PASSE 0 cap raised from 8 to 12 (per-source orphan absorption), PASSE B cap raised to 15 (final safety net).
* New: **PASSE B² — last resort**. If after PASSE 0 + PASSE B + main loop an orphan is STILL not covered (its entire fallback chain is saturated at 15), the orphan is forced onto the most active category of the site with NO cap. A category with 30 outgoing links is preferable to a permanent orphan. Closes the last 3% of edge cases.

= 1.1.51 =
* **Major rewrite of the automesh algorithm** based on a full audit. After 1.1.50, orphans were still surviving because of multiple subtle bugs identified in the chain.
* New: **PASSE Z — dead-end coverage**. Every page with no outgoing link is now FORCED to receive at least 1 outgoing target before the run ends, even if its semantic match is zero (last-resort: top active categories). Combined with PASSE 0 (orphan rescue first) and PASSE B (filet sécurité orphelines via chain), the algorithm now guarantees ≥1 incoming AND ≥1 outgoing for every page after a single run.
* Fix critical: **silent injection failures**. Previously, when `_inject_at_smart_position` returned `changed:false` (anchor not found in source content, anchor inside an existing `<a>`, anchor inside an HTML tag), the link was abandoned silently — meaning a planned rescue could fail without any retry. Now we fall back to the "Voir aussi" inline injection mode, which is guaranteed to succeed. Every planned target now becomes a real link.
* Fix: **double-counting of orphans_resolved and deadends_resolved**. Previously these counters were incremented in multiple passes (PASSE 0 + PASSE B + final loop). Now they are computed exactly ONCE at the end via `will_receive_incoming` / `will_emit_outgoing` sets.
* Fix: **anchor registry pollution**. `semantic_append` was being recorded as a phantom 4th key in the anchor type ratios, breaking the 10/35/55 diversity calculation. Now mapped to `semantic` for registry purposes.
* Fix: **PASSE B redundancy resolved**. PASSE B used a single-source picker (`_pick_rescue_source`) inconsistent with PASSE 0's chain. Both now use the same multi-source chain with a relaxed cap (+2) — orphans abandoned in PASSE 0 get a second chance via the same logic, not a different one.

= 1.1.50 =
* **Fix critical** : the orphan rescue PASS 0 (introduced in 1.1.49) was being **silently overwritten** by the main planning loop. When a category was used as a rescue source for orphan products, its `per_page` entry was created with the rescue targets — then the main loop re-assigned `$per_page[$sig] = [...]` with its own MUST/SHOULD candidates, **erasing every rescue**. This explained why 1.1.49 only resolved -9 orphans instead of -300+. Now the main loop merges with existing rescue targets, respects remaining quota slots, and skips already-rescued sigs to avoid duplicates.
* Fix: pages already saturated (8+ existing internal links) are no longer skipped if they have rescue targets planned — the no-orphan guarantee bypasses the saturation skip.
* Fix: deadends counter no longer over-counts (only increments when actual targets are merged into the plan).

= 1.1.49 =
* Fix: **Orphan rescue now runs FIRST**, before the main planning loop. Previously the rescue pass executed after categories had already filled their 5 MUST/SHOULD slots, leaving only 2 rescue slots per source — so a single click resolved barely 8% of orphans. Reordered: orphans get priority allocation with a per-source cap of 8, the main plan runs second.
* New: **Multi-source fallback chain** for orphan rescue. Each orphan now has an ordered list of candidate sources (parent category → sibling categories → topical articles → top 3 most active categories → product hubs). If one source is saturated, the chain tries the next one — guaranteeing every orphan finds a free slot somewhere. Replaces the previous single-source picker that gave up on the first saturation.
* Improvement: rescue cap raised from 7 to 8 targets per source, dedicated to orphan resolution exclusively (the main plan still respects the 5-per-page rule for non-orphan links).

= 1.1.48 =
* New: **No-orphan-left-behind** pass at the end of every automesh run. Any orphan that wasn't covered by the main planning is automatically rescued — its parent category (for products), nearest sibling (for categories), or the most active category (last resort) is forced to add a link to it. Combined with relaxed quotas (+2 above the 5-per-page cap when needed), the run guarantees ZERO orphans remain after a single click.
* Fix: **Category MUST targets prioritized for orphans first**. The previous algorithm sorted potential MUST products by their existing incoming-link count descending — which meant orphan products (in=0) were ALWAYS at the bottom and never picked. Categories now boost orphans (+10 000 points) before sorting by title similarity. Single-handedly resolves the majority of orphans in 1 run.
* New: **Relaxed-threshold fallback pass** for pages with no semantic match. If the strict Jaccard threshold (0.10) returns zero candidates, we relax to 0.03 and pick the closest category, article and product. Last resort = the most active category of the site (proxy for homepage). Guarantees at least 1 outgoing link for every page.
* Improvement: per-target incoming cap (30 new links/run) still enforced, but the orphan rescue pass bypasses the per-page-output cap by up to 2 extra links — the no-orphan guarantee takes priority over the conservative 5-per-page rule.

= 1.1.47 =
* Fix: **GSC quota toast no longer appears when running a product/category optimization**. The auto-inspection batch was firing on every page re-render, hitting the exhausted quota repeatedly. Now : runs once per session per page, stops trying after the first full quota hit, and the toast wording clarifies it is only about indexation status (not optimization).
* New (Automesh): **Anchor registry pre-loaded from existing content**. Before run 1, Essiow scans every existing internal link on the site and tallies anchor types (exact / partial / semantic) per target. The diversity check therefore works from the very first automesh — no risk of suddenly creating 50 exact-match anchors because the registry was empty.
* New (Automesh): **Per-target incoming cap of 30 new links per run**. Prevents super-popular targets (a top category) from absorbing all the link juice in one go. Beyond 30 incoming planned, the algorithm picks the next priority target instead. Avoids over-optimization patterns Google penalizes.
* New (Automesh): **Backup persistence in wp_options**. The 6-hour transient expiry no longer prevents reverting a run. Backups are mirrored to `essiow_il_automesh_last_backups` option, so the Revert button still works months after.

= 1.1.46 =
* New: **🔮 One-button Automesh on the Internal Links → Graph tab**. The button appears at the top of the graph and runs a complete topical-cluster mesh across the whole site in one click. No setting to configure.
* The algorithm follows current Google + 2026 SEO best practices :
  - Hub-and-spoke hierarchy (products → category parent + 2 siblings + 1 article + 1 cross-cat ; categories → 3 top products + 2 sibling cats ; articles → mentioned products + similar articles + 1 category)
  - Quota proportional to content length (1 link / 250 words), capped at 5 new links per page per run for predictability
  - Anchor diversity tracked globally with target ratios 10/35/55 (exact / partial / semantic) — beyond 15% exact for any single target, the algorithm switches to partial/semantic
  - Anchor placement in the FIRST third of content (Reasonable Surfer model)
  - Skip if already linked, if anchor would land in a <a> tag or HTML attribute, if total page links >= 100 (dilution), or if in-content links already >= 8 (saturation)
* New: **Preview before run** — a modal shows projected new links, orphans resolved, dead-ends resolved, mesh score before → after, average click depth before → after, estimated time. No surprise.
* New: **Backup auto-created** for every modified page (post_meta `_essiow_il_backup_<hash>`) and one-click **Revert** button at the end + permanent option to revert the last run from the modal.
* New: **Async cron worker** (batches of 20 pages) like the bulk optimize pattern. Quitting the page does not crash — the run continues server-side and the modal resumes when re-opening.
* New: **Auto-refresh of graph + mesh score** at completion — no manual reload needed.
* New: **30+ FR strings translated** for the new automesh UI.

= 1.1.45 =
* Fix: **Indexation badges flickering back to "Erreur"** after a few seconds. The JS auto-inspection batch was overwriting the in-memory badge with "error" status returned by failed GSC calls (quota exhausted, API timeout). Now the JS keeps the previous valid badge when an inspection result has status=error — same logic as the server-side fix in 1.1.44. A discreet toast informs the user when the GSC quota is exhausted instead of polluting the UI with red badges.

= 1.1.44 =
* Fix critical: **Front-end critical error on product pages** when one of the schema/canonical hooks raised an exception. All four wp_head emitters (Product schema, BreadcrumbList schema, Article schema, FAQ schema, custom canonical) are now wrapped in try/catch — a bad page can no longer take down the entire front-end. Errors are logged via error_log() when WP_DEBUG is on, never propagated to the visitor.
* Fix: defensive  and  guards before calling WC-only conditional functions in the schema emitters.
* Fix: **Indexation column showing "Erreur" on every product**. When GSC URL Inspection fails (quota exceeded, timeout, transient API error), the per-URL result has . The plugin was overwriting the cached  with this false status. Now we KEEP the previous valid status when the new fetch errored, only updating on real Google verdicts.
* Migration: a one-time admin_init cleanup deletes any existing  rows in postmeta + termmeta, so the next auto-inspection batch on Products / Categories pages starts fresh.

= 1.1.43 =
* UX: **Site SEO score absorbed into the existing stats row.** No more separate full-width card with empty space — the score now sits as the first card in the same row as Available credits, Products optimized, Categories optimized, Articles by Essiow. Colored top border (green/yellow/red) keeps the level visible at a glance.

= 1.1.42 =
* UX: **Site SEO score card simplified** — removed the breakdown list and the descriptive paragraph. Only the score on 100 with its colored badge remains on the Dashboard.

= 1.1.41 =
* New: **Global site SEO score** on the Dashboard, computed locally on 100 points from 9 objective factors :
  - 20 pts — % of products optimized by Essiow
  - 15 pts — % of categories optimized
  - 10 pts — Essiow blog articles published (3 = max)
  - 15 pts — % of inspected URLs that Google has indexed
  - 15 pts — Internal mesh score (uses the existing graph cache)
  - 10 pts — JSON-LD schemas active
  -  5 pts — IndexNow auto-ping active
  -  5 pts — /llms.txt AEO toggle active
  -  5 pts — Search Console connected
  Big colored card (green ≥ 70 / yellow ≥ 40 / red < 40) + breakdown of every factor with its earned points / max. Recomputed on every Dashboard load — no API call, all data already on the site.
* Fix: **Indexation table labels in French** — Indexed / Crawled not indexed / Discovered / Excluded / Error were rendered hardcoded in JS. Moved through `wp_localize_script` strings (`s.idx_*`).
* Fix: **Google coverage_state strings translated** — "Submitted and indexed", "Crawled - currently not indexed", "URL is unknown to Google", "Discovered - currently not indexed", "Page with redirect", "Soft 404", "Not found (404)", "Server error (5xx)", and 6 more variants. Map applied at render time via `localizeCoverage()`.
* Fix: **Type labels translated** — Product / Category / Article / Page in the indexation table type column now use the WP locale.
* Fix: cleaned remaining `\n` artifacts in `essiow-fr_FR.po`. **783 entries**, **50.7 KB compiled .mo**, zero `#-#-#-#-#` marker, zero msgfmt error.

= 1.1.40 =
* Critical fix: **`msguniq` left 43 conflict markers `#-#-#-#-# essiow-fr_FR.po (Essiow) #-#-#-#-#` directly inside `msgstr` strings** of the French .po file. WordPress was rendering them in the UI ("Credits disponibles #-#-#-#-#... Crédits disponibles", "Non classée #-#-#-#-# Non classé", "Générer l'article #-#-#-#-# Générer un article", etc). Each conflict resolved automatically by picking the longest non-empty alternative, then leading/trailing `\n` markers stripped, then comments removed. Result : zero `#-#-#-#-#` in the .po, .mo recompiles clean (no warnings, no fatal errors), 648 valid translations.

= 1.1.39 =
* New: **📡 Bulk Ping button on Products + Categories** alongside 🚀 Index selection. Two clear options : light IndexNow ping (instant, no Google) vs full request indexing (IndexNow + sitemap + URL Inspection refresh).
* Fix: **Issues panel labels/descriptions/suggestions are translated**. The Flask backend returned hardcoded English strings ; the plugin now intercepts the response and replaces them with `__()` translations on the way out. "Crawled but not indexed", "Discovered but not crawled", "Excluded by Google", "Indexation errors" + their descriptions and suggestions all appear in the WP locale.
* Fix: **"Dashboard" and "Blog" submenu items** were registered without `__()` calls, so they stayed in English even with French locale. Both now translatable.
* Fix: **19 fuzzy translations unflagged** in the .po file. WordPress ignores `#, fuzzy` entries by default — the FAQ answers ("Yes. The Pause...", "Yes. Since 1.1.30...") and several other long strings were never displayed in French. Removed all fuzzy flags.
* New: **Plugin description rewritten** — sales-focused, benefit-driven, no technical jargon. Explains what Essiow does for the shop owner, not how it works internally. Added the new features (cannibalization fix, internal-link graph, /llms.txt for AI search engines, GSC-driven optimization, Bulk indexation).
* New: **20+ FR translations added** for the bulk-ping labels + Issues groups. Total : **748 entries**, **81 KB .mo**.

= 1.1.38 =
* **CRITICAL FIX: `load_plugin_textdomain()` was never called.** The .po/.mo files in `/languages/` were ignored by WordPress entirely — that's why the plugin stayed in English even when the WordPress locale was French. Now properly loaded on `plugins_loaded` so every translated string in the plugin (Search Console tabs, Help guide, Indexation tab, Modules cards, Cannibalization actions, FAQ, etc) appears in the WP locale.
* New: **+196 French translations added** — Help guide modules (Getting started, Modules, Recommended workflow, Ping vs Request indexing, Pricing, Tips, FAQ), Search Console tabs (Overview, Indexation, Opportunities, Issues, Performance, Audit, Submission), all Indexation column states, all Cannibalization actions, all Internal Links labels, all bulk action labels. **730 total entries** in `essiow-fr_FR.po`, **70 KB compiled .mo**.
* Fix: **Internal Links graph stops working after creating a link** — fully rewritten render :
  - Positions are now preserved per-sig (drag positions survive ilLoad refreshes).
  - Cleanup of stale positions for deleted pages.
  - Handlers bound with `$(document)` event delegation instead of `$canvas` references → survives canvas DOM rebuilds.
  - `ilGraphBindHandlers()` is now idempotent (`ilHandlersBound` flag) and called once at first render.
  - The handlers re-fetch the canvas's `getBoundingClientRect()` on every drag — the rectangle was stale after re-render.
  - Removed obsolete `setTimeout` rebinds on tab-switch.
* New: **Smart fuzzy local matching** for the "🔍 Find matching pages" modal — produit/catégorie/article search via WP `wpdb` directly, with accent normalization (`remove_accents()`) and FR/EN/ES stopword filter. Each match now displays `N tokens match` + a one-click ✨ Optimize button targeting the original GSC query.

= 1.1.37 =
* New: **Universal spinner state** for every async action button — Ping, Request indexing, bulk Ping/Index, Re-optimize, Rotate API key, Disconnect. The button shows a CSS spinner (and stays disabled) until the server responds, so the user always knows an action is in flight.
* New: **Smart query → matching** with fuzzy token search. The "🔍" button on Performance / Striking distance now also returns local WordPress products / categories / articles whose titles contain ANY of the query tokens (after stop-word filter and accent normalization), not just GSC-tracked pages. Each match has a score = number of tokens found + a one-click ✨ Optimize button that targets the original query.
* New: **Drag-to-link in the Internal Links graph (Miro-style)** — hover any node and a 🔗 handle appears top-right. Drag it to another node : a dashed SVG line follows the cursor + the target node highlights green. Drop → confirmation modal → Essiow injects reciprocal anchor links in both contents.
* New: **Bulk Ping + Request indexing in the Search Console → Indexation tab**. Two new buttons next to "Re-check selected" — pick rows with the existing checkboxes, then ping or request indexing on all of them in one server round-trip.
* New: **API key rotation from Settings**. New endpoint `POST /license/rotate-api-key` — old key invalidated immediately on the server, new key auto-saved in `essiow_api_key`. Use case : suspected leak, or routine rotation hygiene.
* New: **Disconnect this site** button — clears the local API key + connected flag without invalidating the key on the server (so it can be re-pasted later). Combine with rotation for a full re-pair.
* Fix: **Internal mesh score panel** redesigned — left side shows the big score in a colored card (green/yellow/red), right side shows the 5 weighted factors with their points / max as a clean list. Replaces the previously broken inline-text layout.
* Fix: **Help page hides "Connect Search Console" when GSC is already connected** — replaced by a green "Connected ✓" + "Open Search Console" button.
* Fix: **Typo "Restaurér" → "Restaurer"** in the French translation file (`languages/essiow-fr_FR.po` lines 380 and 397). Visible everywhere the Restore button appeared in French.
* Fix: **`audit_seo_factors()` and `audit_category_factors()` now static** — the search-console resolver was instantiating Essiow_Products / Essiow_Categories per row, re-registering all action hooks each call. Performance regression fixed.
* Fix: **Cannibalization secondary URLs in French** (`/categorie-produit/`) now correctly resolve to terms when setting canonical (the legacy code only matched `/product-category/`).
* Improvement: **300+ new French strings translated** (1.1.30 → 1.1.37 features). The plugin is now fully usable in French — Help & Guide, Cannibalization actions, Internal Links module, Pricing tooltips, Bulk action labels, FAQ.

= 1.1.36 =
* Fix critical: **`essiow_inspect_urls` AJAX action name corrected to `essiow_gsc_inspect_urls`** — the auto-batch URL Inspection on Products and Categories pages was failing silently for every user. Now correctly fires and populates the indexation badges in real time.
* Fix: **`audit_seo_factors` and `audit_category_factors` are now static** — they were being called from `Essiow_Search_Console::resolve_url_to_entity()` which instantiated `Essiow_Products`/`Essiow_Categories` per row, re-registering all action hooks each time (memory + duplicate-handler bug).
* New: **Help & guide menu** — full submenu with 7 tabs : Getting started, Modules, Recommended workflow, Ping vs Request indexing, Credits & pricing, Tips, FAQ. Strictly aligned with the actual features of the plugin (no fictional content).
* New: **Help link injected on every Essiow page** — a discreet `❓ Help` button appears in the top-right corner of each header, one click away from the dedicated guide.
* New: **Pricing transparency in the Audit tab** — the Auto-fix alts description now reads from the live cost map and shows the actual credit cost.
* New: **Pricing rebalanced**:
  - Alt text generation : `CREDITS_PER_ALT_TEXT = 2` (was implicitly billed as a full product = 1 — the Vision call cost is now accurately billed).
  - Indexing actions (Ping IndexNow + Request indexing + URL Inspection batches + sitemap submit) : free — they don't call the AI.
  - Image audit, llms.txt toggle, schema emission, internal-link suggestions, internal-link graph rendering, internal mesh score : free.
* New: **`/credits/balance` returns the cost map** so the plugin can display realistic prices everywhere without round-tripping the config.
* New: **`CreditService._costs()` centralized** — single source of truth for all credit costs across normal billing, Shopify usage billing, and balance display. Removes 3 duplicated cost dicts.
* Improvement: **"Optimize a product/category"** dashboard quick-link card replaced by a direct **Help & guide** card so new users can find the workflow guide instantly.

= 1.1.35 =
* Fix: **Article generation now shows the percentage**. The view's existing progress bar had an `--indeterminate` CSS animation that ignored width updates, and the JS was looking for a non-existent `#essiow-blog-progress-text`. Both fixed — `0% → 100%` now ticks visibly with status label and elapsed time.
* Fix: **Internal Links titles now decode HTML entities** (`&#8211;` → `–`, `&amp;` → `&`, etc) — applied via `html_entity_decode()` server-side before caching the graph.
* Fix: **Lost rankings table now renders fully** with a new "Clicks lost" column. The Flask service now returns `previous_clicks`, `current_clicks`, `impressions`, `potential_clicks` for each lost-ranking page so the user sees the actual revenue impact, not just an abstract delta.
* New: **Floating "Optimizations" panel** (bottom-right) replaces the silent toast. Click "Re-optimize" → a row appears with name + progress bar + status + elapsed time. When done : button "View content" opens the editor + score badge. Multiple optimizations stack in the panel. Minimize/dismiss available.
* New: **Bulk actions in Existing Articles** :
  - Checkbox per row + "Select all"
  - 📡 Ping selected · 🚀 Request indexing selected
  - Filter dropdown : All / Essiow only / Other / Low score (<40)
* New: **Bulk actions per Issue group** (Crawled-not-indexed / Discovered / Excluded / Error) :
  - Checkbox + "Select all"
  - 📡 Ping all · 🚀 Request indexing all · ✨ Re-optimize all (sequential 1.5s spacing)
  - Auto-resolves the URL → product/category/article and fires the right optim endpoint.
* New: **Bulk for Lost rankings** (Ping / Request indexing on selected rows).
* New: **Performance tab actions per query** :
  - 🔍 Find matching pages (existing query-explore modal)
  - ✨ Generate article for this query (redirects to Blog with keyword pre-filled via `?essiow_keyword=…`)
  - 🎯 Optimize matching products (opens the matching-pages modal where each result has its own Re-optimize button)
* New: **Internal Links graph — Miro-like overhaul** :
  - **Pan** by dragging the background, **zoom** with the mouse wheel (zoom-to-cursor), **drag** individual nodes
  - Toolbar : `+` / `−` / `⟲ Reset view`
  - **Click two nodes to create a reciprocal internal link** — the server algorithm finds the strongest shared keyword and wraps it as `<a>` in both contents (or appends a "Voir aussi" paragraph if none match).
  - **Internal mesh score / 100** computed from 5 factors : `% pages with ≥1 incoming` (40 pts), `% pages with ≥1 outgoing` (30 pts), `hub ratio 5-20%` (10 pts), `avg in / out ≥ 2` (20 pts).
  - Node now shows `⬅ in / out ➡` mini-stats inline.
  - Layout clusters nodes by type : products on the left, articles in the middle, categories on the right (no more chaotic spiral).
* Improvement: **`runInlineOptimize` panel persists state** across optimizations — start 5 in a row, watch them complete one by one, dismiss each individually.

= 1.1.34 =
* Fix: **Cannibalization actions now update the UI live**. After "Set canonical", the secondary row fades out instantly, the unresolved counter updates, and the whole block removes itself once only the primary remains. Toast confirms the action with the destination URL. No more stale buttons that look unchanged.
* New: **Cannibalization bulk actions** :
  - Checkbox per pair + "Select all"
  - "🔗 Set canonical on selected" → applies the consolidation to every secondary in every selected pair (one server round-trip)
  - "✕ Dismiss selected" → hides every selected pair from future lists
* New: **Cannibalization filter** — the bulk bar shows a live "N unresolved" counter and a "Show dismissed" link. After resolving everything, an empty-state cheers ✓ with a "Restore previously dismissed" recovery action.
* New: **Promote re-renders the whole block** instantly — the new primary gets the green "Primary" badge, every other row gets the "Set canonical" button pointing to the new primary. No more orphaned "Confirm as primary" button.
* Improvement: **Set canonical also marks the pair as resolved** server-side (`essiow_resolved_cannibal` option) — the next opportunities refresh excludes it automatically. Combined with the live row removal, the user never sees the same resolved pair twice.
* Improvement: clearer **Primary badge** (green) on the primary row + visual distinction (green tint) so the choice is unambiguous.

= 1.1.33 =
* Fix: **Internal Links tabs now switch correctly**. The tab system was looking for `#tab-X` IDs while the view used `essiow-tab-il-X`. Tabs are now generic — `data-tab-prefix` on the nav drives the lookup, supporting both Search Console (`essiow-sc-`) and Internal Links (`essiow-tab-il-`) conventions.
* Fix: **`undefined: <date>`** string on Internal Links → added the missing `last_scan` translation key + 60+ other translatable strings used by the new modules.
* Fix: **Dashboard GSC stats display** — KPI block stayed at "…" because the JS was reading `resp.data.kpis` instead of `resp.data.data.kpis` (Flask payload is wrapped). All four KPIs (clicks/impressions/CTR/avg pos) and all four opportunity counters now render correctly.
* New: **Ping vs Request indexing — two distinct buttons everywhere** :
  - 📡 **Ping (IndexNow only)** : light, instant notification to Bing/Yandex/Naver/Seznam. No tab opens, no Google. Runs in 1 second.
  - 🚀 **Request indexing (full)** : IndexNow + sitemap re-submit + URL Inspection refresh + opens GSC URL Inspector for the manual "Demander indexation" click on Google.
  Both are now exposed on every actionable URL row : Products, Categories, Striking distance, Low CTR, Lost rankings, Issues URLs, Articles list, Indexation tab.
* New: **Auto-clear `Indexing requested` state** — when a URL is later detected as `indexed` by GSC URL Inspection, it's automatically removed from `essiow_indexing_requests` and the post_meta `_essiow_indexing_requested_at` is cleared. The 🚀 button stops being green-checked and becomes available again.
* New: **Indexation tab refondu** — each row now shows the post Type (🛒 Product / 📁 Category / 📝 Article / 📄 Page) + SEO Score + an `✨ Optimize` button that routes via the smart re-optimize handler (so the AI re-runs the full optimization on the matching product/category, addressing every missing factor from the SEO audit). The legacy "Inspect ↗" button is now a 🔎 icon next to Ping/Request.
* New: **Article generation : excludes already-covered GSC keywords**. The Blog page no longer suggests queries that already match an existing article's `_essiow_article_keywords` post_meta (exact match + fuzzy substring match for keyword stems ≥ 5 chars). New opportunities surface as you publish articles.
* New: **Configurable GSC period** — 24 hours / 7 days / 28 days / 3 months / 6 months / 12 months / 16 months (the GSC max). Period selector at the top of the Search Console tab, applies to KPIs, opportunities, and indexation stats. Per-period cache so switching is instant.
* Improvement: **Cannibalization secondary URLs handle French slugs** — `/categorie-produit/` (French permastruct) now correctly resolves to `product_cat` terms when setting canonical or auto-clearing indexing state, in addition to the English `/product-category/`.
* Improvement: **Indexation table action column** widened to 240px to fit the Optimize + Ping + Request + Inspect buttons without wrapping.

= 1.1.32 =
* New: **Cannibalization is now actionable** — each row gets "Set canonical → 🏆", "Promote" (change which page is the primary), and "Dismiss" buttons. "Set canonical" stores `_essiow_canonical_url` on the secondary post/term and emits a custom `<link rel="canonical">` that overrides Yoast/Rank Math. Dismissed pairs are filtered out of future opportunity lists.
* New: **Smart Re-optimize routing** — clicking "Re-optimize" on a striking-distance / low-CTR / lost-ranking row now resolves the URL server-side via `essiow_resolve_url`, then triggers the actual product/category optimization inline (no more dead-end redirect to an empty Blog form). The GSC query is passed as the focus keyword so the AI targets it directly. For articles, the editor opens with the right context.
* New: **Server-side URL resolver** (`/wp-ajax/essiow_resolve_url`) returns `{type, id, name, edit_url}` for any frontend URL — used by the Re-optimize and Cannibalization actions.
* New: **🚀 indexing button on every actionable row** (Striking distance, Low CTR, Lost rankings) for one-click submit-to-IndexNow alongside the optimize button.
* Improvement: **Better potential-clicks math** — striking distance now uses the real CTR-by-position curve (pos 5 ≈ 5%, pos 1 ≈ 30%). Formula : `max(0, impressions × (target_ctr − current_ctr))` — accurately reflects the realistic gain after a successful re-optimization.
* Improvement: **Cannibalization primary picker** — the page presented as 🏆 is now the one with the highest `clicks×10 + impressions` score (revenue-weighted), not just the most-impressed.
* Improvement: **Better empty states for GSC-disconnected** — Opportunities and Issues tabs show a centered hero CTA "Connect Search Console →" instead of a tiny disabled message. Same connect button works from any tab.
* Improvement: **Indexation column adapts to GSC state** — when GSC is not connected, the column shows a clickable "Connect GSC" mini-link instead of a meaningless "—". Inspection batches don't fire at all without GSC connected (saves API quota).
* Improvement: `gsc_connected` exposed to JS via `wp_localize_script` for clean conditional logic across all UI.

= 1.1.31 =
* New: **Internal Links module** (new submenu) — graph + orphan detection + dead-end detection + reciprocal click-to-link generator. Pick page A and page B, Essiow finds the strongest shared keyword and wraps it as an anchor in BOTH directions, then saves both pages. If no shared keyword, an inline "Voir aussi" paragraph is appended. Includes a graph view with hubs (green) and orphans (red).
* New: **Dashboard refondu** — drops the "Recent errors" panel, adds live GSC KPIs (clicks, impressions, CTR, avg position over 28 days) and an opportunities snapshot (striking distance count, low-CTR pages, cannibalization, indexation issues), all linkable to the Search Console tab.
* New: **Blog page refondue** — list of all existing articles with a SEO score (words, headings, meta, internal links, featured image), direct edit + 🚀 indexing buttons. Above the form, **GSC topic suggestions** appear when GSC is connected: striking-distance queries become 1-click chips that pre-fill the keywords.
* New: **GSC-aware article generation** — `build_article_prompt()` now accepts `gsc_context`. The Flask backend matches your typed keywords against your real GSC queries (last 28 days) and injects `top10` / `striking` / `beyond` buckets into the prompt so the AI writes the article around the actual searches your audience makes.
* New: **Persistent "Indexation requested" state** — the 🚀 button now stays green-checked after click. The state is stored in `essiow_indexing_requests` option and re-applied on every page load. No more confusion about which URLs you already submitted.
* New: **Per-row 🚀 indexing button** + **bulk "Index selection"** in Products and Categories. Multi-row selection sends all URLs in a single IndexNow batch ping.
* New: **Real-time indexation status** for Products and Categories — when GSC is connected, the page auto-fires URL Inspection in batches of 50 for any row showing "—", populates `_essiow_index_status` post_meta/term_meta, and updates the badge in place (Indexed / Crawled-not-indexed / Discovered / Excluded).
* New: **Server-side pagination** for Products (50 per page, prev/next + jump buttons). The list auto-loads on page open — the "Scan products" button is replaced by a discreet "Refresh" link.
* Improvement: stats on Products page now use **global counts** (total + optimized across the whole DB) instead of the current-page subset. The dashboard mirrors them.
* Cleanup: removed the in-admin "Error log" panel from Settings and the `essiow_last_errors` storage. Plugin now logs only to PHP `error_log()` when WP_DEBUG is on.
* Cleanup: dropped redundant always-visible "Auto-fix alts" button (kept only the contextual one inside the audit result table).

= 1.1.30 =
* New: **Enriched SEO score** — 16 factors weighted on 100 points (description length, structured headings, internal links, meta title/desc length zones, focus keyword, image count + alt coverage, attributes, categories, tags, reviews, SKU, schema, Essiow-optimized flag). Replaces the previous 9-factor score.
* New: **SEO criteria injection into AI prompts** — the audit detects what's missing on a page and tells the AI explicitly to address each gap during optimization. The AI now sees a "to-do list" alongside the buyer-intent and GSC blocks. Result: optimizations consistently fix what the audit flags.
* New: **Auto-fix alt text via AI Vision** in the Audit tab. Scans up to 20 product images without alt and generates natural multilingual alt texts via `gpt-4o-mini` Vision (1 credit per image).
* New: **Query → matching products** modal — a 🔍 button next to each striking-distance query opens a popup listing the WooCommerce products and pages that could realistically rank for it.
* New: **Request indexing** button on every issue URL and on striking-distance rows. One click pings IndexNow + re-submits the sitemap + refreshes the URL Inspection cache + opens the GSC URL Inspector.
* New: **/llms.txt for AEO** (ChatGPT, Perplexity, Claude). Standardized markdown catalog served at the site root, exposing categories, recent products and blog posts. Toggle in Audit tab.
* Improvement: **API key persistence on plugin update** — replaced the file-checksum-deletion-of-connection with an async re-validation pattern. The user no longer has to re-paste their key on every release.
* Improvement: **Auto-load on Products / Categories pages** — the lists scan on page open instead of waiting for a manual click.
* Improvement: **Bulk skip already-optimized items silently** — bulk runs ignore items that already have `_essiow_optimized` set unless `force=1`. No more re-spending credits on the same product twice.
* Improvement: cleaned up duplicate datetime imports in the Flask query-matches endpoint.

= 1.1.29 =
* New: **AI re-optimize via Search Console data** — when a product or category is optimized while the GSC connection is active, the prompt now includes the REAL queries this page ranks for (top 10 to preserve, striking distance to push, beyond-page-2 opportunities) plus a CTR alert if the title isn't converting. Surgical optimization vs generic.
* New: **Opportunities tab** in Search Console with 4 actionable insight cards :
  - ⭐ Striking distance keywords (pos 11-20) with potential clicks/month
  - 📉 Low CTR on high-impression pages (current vs expected CTR)
  - 🚫 Cannibalization detection (multiple pages competing for same query)
  - 📊 Lost rankings (pages whose avg position dropped 5+ vs previous period)
  Each row has a "Re-optimize" button that takes the user to the page editor.
* New: **Issues tab** — indexation problems grouped by category (Crawled-not-indexed, Discovered, Excluded, Error) with a concrete suggestion for each group + collapsible URL list.
* New: **Audit tab** — image SEO scan (missing alt text, oversized files), schema markup status, IndexNow status. Includes a "Test with Google Rich Results" link.
* New: **BreadcrumbList JSON-LD** auto-emitted on product and product-category pages. Helps Google show breadcrumbs in search results (rich snippet).
* New: **FAQPage JSON-LD** auto-detected on Essiow-generated articles when they contain a "Questions fréquentes" / "FAQ" H2 with H3 question / paragraph answer pairs. Eligible for FAQ rich snippets.
* New: **IndexNow batch endpoint** for sending many URLs in a single ping (used during bulk to avoid 50 small pings).
* New: 6 server-side endpoints — `/gsc/opportunities`, `/gsc/page-stats`, `/gsc/issues` and prompt service `build_gsc_section()` injection helper.

= 1.1.28 =
* New: Pause / Resume buttons during a bulk optimization. Pause stops the worker between items (the current item finishes naturally), Resume picks up where it stopped without losing the queue. The `[⏸ Pause]` button appears next to `Cancel` while running, switches to `[▶ Resume]` once paused.
* New: leaving the page during a bulk (refresh, navigation, tab close) now AUTOMATICALLY cancels the bulk via `navigator.sendBeacon()`. Items that had already been optimized stay saved in the database; the pending queue is dropped. The user must restart from scratch if they want to continue — by design.
* Removed: auto-resume on page load. Bulk tasks no longer survive page reloads — closing the tab is treated as an explicit cancellation. Closes a class of "phantom resume" UX bugs.
* New: server-side pause/resume endpoints (`essiow_bulk_*_pause`, `essiow_bulk_*_resume`) for both products and categories.
* New: `nopriv` cancel endpoint for the page-leave beacon (cookies aren't always sent on unload, so we use task_id ownership check via the active option instead of nonce).

= 1.1.27 =
* Fix: 503 errors from `admin-ajax.php` no longer leave Search Console KPIs at 0 forever. The data sync into Flask was already working (1561 rows committed in DB) but the follow-up `essiow_gsc_overview` call would 503 under Wordfence/Cloudflare load and the JS would just give up — leaving the user looking at 0 clicks / 0 impressions despite having real GSC traffic.
* Improve: new `postWithRetry()` JS helper with exponential backoff (1s → 2s → 4s → 8s) on 503 / 504 / 429 / network errors. Used by both the auto-load and the manual "Refresh stats now" button.
* Improve: auto-sync logic merged into `loadScOverview()` — the page now makes ONE overview call on load instead of two (was: load + check-if-empty as separate calls). Less admin-ajax noise = less Wordfence rate-limiting.
* Improve: clearer error toast when the WP server is overloaded ("Serveur surchargé · réessayez dans 30s") instead of a silent failure.

= 1.1.26 =
* Fix: Search Analytics now auto-tries every property variant and keeps the one returning the most rows. The previous behavior accepted the first 200 response even if it had 0 rows, which was wrong when the user has both a URL prefix property (potentially empty) and a Domain property (with all the data) — the Domain variant is now used automatically.
* Fix: Performance tab is no longer empty. New `loadScPerformance()` JS function populates it with the top queries + a dedicated "Striking distance" panel highlighting positions 11-20 (easy SEO wins).
* Improve: auto-sync at page load if no cache exists, so users land on populated KPIs without having to click "Refresh stats now" first.
* Server-side: detailed structured logging on every Search Analytics call (property tried, response status, row count) for easier diagnostics.
* New strings (top_queries, query, clicks, impressions, position, striking_hint, no_striking, etc.) so the Performance tab is fully translatable.

= 1.1.25 =
* Fix: "Indexation overview" no longer stays stuck on "Traitement en cours…" / "Processing…" when the cache is genuinely empty. Now shows "No URLs inspected yet — click 'Refresh stats now'" until data is populated.

= 1.1.24 =
* Cleanup: removed the "Change property" button and its dropdown picker UI from the Search Console connection card. With the auto-fallback added in 1.1.23 (server tries every property variant on a 404), the manual property selector is no longer needed in normal usage. The backend endpoints (`/gsc/properties`, `/gsc/select-property`) remain available for any future use.

= 1.1.23 =
* Fix: Search Console sync now automatically tries property variants (`sc-domain:` first, then URL-prefix, www, http) when the saved property URL returns 404 from the Search Analytics API. The first variant that responds 200 is silently saved as the new property — the user no longer has to pick the right one manually.
* Fix: surfaces Google's actual error message (e.g. "User does not have sufficient permission for site …") instead of the generic 404, plus a concrete remediation hint ("Vérifiez que vous êtes Owner ou Full user de la propriété, et que la vérification est complète").
* Fix: properties dropdown now hides `siteUnverifiedUser` properties (which always 404 on Search Analytics), shows the permission level next to each entry (`Domain · Owner`, `URL prefix · FullUser`), and sorts Domain properties first then by permission strength.
* Fix: JS access path on the sync response — was looking at `resp.data.data` (double-nested) which caused the toast to always show "0 rows · 0 URLs" even on a successful sync.

= 1.1.22 =
* Fix: Search Console sync no longer 404s when the GSC property is registered with a different format than the WP site URL (e.g. `sc-domain:` vs `https://`, with/without `www`, http vs https). The auto-pick now matches sc-domain first, then URL prefix variants (https/http × with/without www), then substring fallback. If nothing matches, the plugin asks the user to pick the right property manually.
* New: "Change property" button on the Search Console connection card. Click → dropdown of all your Search Console properties → select → save. The plugin remembers the choice and uses it for all future syncs.
* New: Flask endpoints `GET /api/v1/gsc/properties` and `POST /api/v1/gsc/select-property` powering the manual selector. The select endpoint security-checks that the property belongs to the connected user before saving.

= 1.1.21 =
* Fix: 1.1.20 OAuth flow was broken — server returned `auth_url` at the response root but the plugin read it from the `data` envelope. Standardized: every Search Console endpoint now returns `{success, data: {...}}`. Connecting Search Console now actually works.
* Fix: `cryptography` was missing from `requirements.txt`. AES-256-GCM token encryption would have crashed the GSC service at runtime. Added.
* Fix: cipher key now falls back to Flask `app.config['SECRET_KEY']` if neither `ESSIOW_GSC_KEY` nor `SECRET_KEY` env vars are set. Avoids hard-crash when the operator forgets the dedicated key.
* Fix: Indexation tab is no longer empty forever. The "Refresh stats now" button now sends a batch of priority URLs (recent products + categories + recent articles + homepage, ~50 URLs) to the SaaS for inspection — first click populates the indexation list immediately. The nightly cron continues rotating the full URL set within GSC quotas.
* Fix: indexation column added to Categories list (was products-only). Term meta `_essiow_index_status` is updated whenever URL Inspection runs and the URL matches a `/product-category/<slug>/` permalink.
* Fix: `is_connected()` early-return guards on `ajax_overview`, `ajax_indexation`, `ajax_inspect_urls` so users who haven't connected GSC yet see a clean "Search Console is not connected" message instead of an opaque API error.
* Fix: missing localized strings — `connecting`, `refresh`, `recheck`, `submit_sitemap`, `index_*`, `striking_distance`, `no_data_yet` — buttons stay in the user's language across the Search Console workflow.

= 1.1.20 =
* New: full Google Search Console + IndexNow integration. New top-level "Search Console" submenu in the Essiow admin with four tabs:
  * **Overview** — connection card with one-click "Connect to Google Search Console", four KPIs (clicks / impressions / CTR / avg position over 28 days, with delta vs the previous period), a 28-day clicks/impressions bar chart, an indexation pie, top 20 queries and top 20 pages tables.
  * **Indexation** — paginated list of every URL Google has seen on the site with its real status (`Indexed`, `Crawled not indexed`, `Discovered`, `Excluded`, `Error`, `Unknown`), last crawl date, and a "Re-check selected" bulk action that triggers the URL Inspection API on demand.
  * **Performance** — top queries and pages (28d). Striking-distance keywords (positions 11-20) are highlighted in orange — the easy SEO wins.
  * **Submission** — one-click sitemap submission to Google + IndexNow (Bing, Yandex, Naver, Seznam) with full submission history.
* New: `Indexation` column in the Products list. Every product row shows a colored badge for its real Google indexation status — green for indexed, orange for discovered/crawled-not-indexed, red for excluded, grey for unchecked.
* New: auto IndexNow ping on every publish/update (toggle in Search Console > Submission). Bing/Yandex/Naver/Seznam re-crawl within seconds with no quota and no setup. The verification key file is auto-served at `/<key>.txt` on the site root.
* New: nightly Celery job `app.tasks.gsc_nightly_sync` pulls Search Analytics + URL Inspection rotation for all connected sites.
* Architecture: Google OAuth is centralized on essiow.com (a single verified OAuth client for all customer sites). Refresh tokens are encrypted with AES-256-GCM and never leave essiow.com.

= 1.1.19 =
* Fix: single category optimization no longer reloads the page when it finishes. The 1.2 s `location.reload()` after a successful single optimize was killing every other concurrent in-flight task in the same tab — running 5 manual optimizations in parallel meant 4 of them were aborted by the first one's reload. The row's badge / score / status are now updated in place, exactly like single product optimize already does.
* Fix: "Resuming optimization in progress…" followed by "Task not found or expired" no longer appears after a normal page navigation. The resume detection now PROBES the server first (one quick status call) — only if the task is genuinely still `running` does the toast appear and polling start. If the task is gone (done, cancelled, transient expired), the resume is silently skipped.
* Fix: server-side auto-cleanup of `essiow_active_bulk_*` options when the bulk status endpoint sees the underlying task is missing or no longer `running`. Stale lock options can no longer survive a session — the next page load won't even probe.

= 1.1.18 =
* Fix: filtering products to "non-optimized" then clicking bulk no longer re-runs the whole previous batch. Before, the singleton lock would silently ignore the new selection and resume the previous task — so a 5-item filtered selection would re-trigger optimization on the original 50-item queue. Now any new bulk request with explicit IDs cancels the old running task and starts fresh; only an explicit resume (page reload with no selection) reattaches to an existing one.
* Fix: bulk no longer goes "all queued, nothing finishing" after the first item. The status endpoint had an 8-second staleness threshold for poll-driven processing, which fired during the first item's normal 15-25 s Flask call — so each subsequent poll started ANOTHER item in parallel, saturating PHP-FPM workers. Threshold raised to 45 s (longer than one item's processing time) and a per-task mutex transient now guarantees only one worker processes a given bulk at any moment, regardless of how many polls/cron events fire concurrently.
* Fix: "Task not found" no longer kills a running task during a transient race. The single-item and bulk pollers now tolerate up to 3 consecutive `not found / expired` responses before giving up — covers brief object-cache eviction or FPM worker recycling without the user seeing the row turn red mid-flight.
* Improve: single-item task transients now keep for 6 hours instead of 1, so optimizations launched then revisited after a long meeting still show their result instead of "expired".
* Improve: bulk polling interval bumped from 3 s to 5 s. With the per-task mutex preventing wasted parallel polls, slower polling means less admin-ajax noise on the host (less Wordfence WAF/Cloudflare friction) without any visible UX change.

= 1.1.17 =
* Fix: bulk no longer stops after 3-5 items on shared hosts. The previous worker iterated all items inside a single PHP process, which got killed by `max_execution_time` (typically 30-60 s on shared hosts) mid-loop — leaving the current item stuck in `processing` and the queue frozen. Each call now processes EXACTLY ONE item then re-spawns itself via WP-Cron + `spawn_cron()` for the next. Workers stay well under any `max_execution_time` value.
* Fix: running multiple individual optimizations in parallel no longer saturates PHP-FPM. The status endpoint is now the worker for single-item flows too — instead of one long-running FPM process per task, every poll handles at most one ~25 s slice. With 4 FPM workers the host can handle 4 simultaneous optimizations cleanly instead of choking after 3-5.
* Fix: try/catch around every Flask call. If the AI client throws (network, timeout, OpenAI 5xx), the item is recorded as `failed` with the exception message and the queue continues. Previously, an exception would kill the worker mid-state-update, leaving items stuck in `processing` until the 90 s recovery kicked in.
* Improve: explicit memory hygiene between bulk items (`unset` of the AI response payload + `gc_collect_cycles()`). On hosts with low `memory_limit`, this keeps long bulks well below the cap.
* Improve: single-item status fallback now triggers after 3 s of `pending` (instead of 30 s) so a non-FPM host where neither cron nor `fastcgi_finish_request` fires sees processing start within the first poll cycle.
* Improve: 90 s stuck-item recovery now also applies to single-item tasks, not just bulk.

= 1.1.16 =
* Fix: stuck-item recovery in bulk worker. If a worker crashes mid-item (FPM killed, OOM, container restart), the item is no longer abandoned in `processing`. After 90 s without a heartbeat, the next worker pass picks it up.
* Fix: orphan UI rows on bulk cancel/error. Rows still showing the spinner ("In progress…") at the moment the user cancels or the network drops are now reset cleanly.
* Fix: distinct "Cancelled" summary in the bulk progress bar instead of the misleading "Bulk complete!" toast.
* Fix: bulk no longer flashes "Queued" on already-optimized rows when the user includes them in a re-run.
* Fix: defensive `removeClass` on row badges so visual state can't accumulate stale classes when cycling done → re-run → failed.
* Fix: missing localized strings (`generate`, `cancelled_summary`) — buttons now stay in the user's language after a generation completes.

= 1.1.15 =
* New: bulk product and bulk category optimization are now fully asynchronous (start → poll → complete) with per-item state tracking. The previous design held the browser open for one HTTP request per item, which timed out on Cloudflare and overwrote any concurrent run. Now a single background worker processes the queue while the browser polls a status endpoint every 3 s.
* New: per-row UI updates during bulk. Each product/category row shows its real-time state — `Queued` → `In progress…` (with a spinner) → `Optimized` (with new SEO score) — exactly like single-item optimization. The "Optimize" button on each row is automatically disabled while processing.
* New: progress percentage everywhere. Article generation, single product/category optimize, and bulk all show a percentage bar with elapsed time and estimated phase ("AI is writing your content…", "Finalizing…"). Single-item progress uses an asymptotic estimator based on a per-task ETA that never reaches 100% before the worker actually returns.
* New: `Cancel` button on bulk operations. The worker checks the cancellation flag between items so cancelling a 50-item bulk halts after the current item finishes (within ~30 s) instead of running to completion in the background.
* New: bulk resumes automatically. Closing the admin tab during a bulk no longer abandons the job — when the page reloads, the JS detects the active task and reattaches its UI to the still-running worker.
* New: bulk singleton lock. Starting a bulk while one is already active no longer overwrites the queue; it reattaches to the running task instead.
* Improve: per-item error capture. Failures during bulk are stored on each item's status so the UI can show which products/categories failed and why (tooltip on the `Failed` badge).

= 1.1.14 =
* Fix: admin assets (admin.js, admin.css) now use a `filemtime()`-based cache buster on top of the plugin version. Hard-refreshes the browser/CDN cache the moment the JS file changes, even when an update was pushed without bumping the user-visible version. Fixes "user is on the new plugin but the browser still calls the old AJAX endpoints".
* Improve: legacy synchronous endpoints (`essiow_optimize_product`, `essiow_optimize_category`, bulk processors) now also call `session_write_close()` and raise the PHP time limit before doing any work. Mitigates 504 timeouts on shared hosts where Wordfence WAF or similar serializes admin-ajax requests through a session lock.

= 1.1.13 =
* Fix: product and category optimization are now also async (same `start → poll → result` pattern as blog articles), so single-product and single-category optimizations no longer fail with a 504 Gateway Timeout on hosts behind Cloudflare. The legacy synchronous `essiow_optimize_product` / `essiow_optimize_category` endpoints are kept for backward compatibility (used by the bulk optimizer that already handles its own retries).
* Improve: shared `runEssiowAsyncTask()` helper in admin.js — same network resilience for products/categories as for articles (30 s XHR timeout, 5 consecutive 504s tolerated, 8 s polling interval).
* Improve: prompts now ban colons in titles outright (no exceptions). The previous "avoid colons unless splitting two clearly different ideas" wording was misinterpreted by the AI as permission to keep generic " : guide / efficace / pas cher" suffixes. Article, product and category SEO titles now use dashes, commas, "vs" or rephrasing instead.

= 1.1.12 =
* Fix: definitive fix for "Erreur réseau / 504" on sites behind Cloudflare with Wordfence WAF or any plugin that opens a PHP session. Every AJAX handler now calls `session_write_close()` at the very top, so concurrent polls are never blocked behind a worker that holds the session lock.
* Fix: removed the HTTP loopback dispatch — on Cloudflare-fronted sites the loopback request to admin-ajax.php went through Cloudflare too and counted as another concurrent admin-ajax hit, which triggered WAF serialization and saturated FPM workers. The plugin now relies on (1) `fastcgi_finish_request()` when available + (2) WP-Cron with `spawn_cron()` triggered immediately + (3) a poll-driven fallback in `check_status` that runs the task itself if it has been pending more than 30 seconds.
* Improve: client-side polling is now resilient to transient 504/timeout errors. A single failed poll no longer kills the generation: up to 5 consecutive network errors are tolerated, the polling interval is 8 s instead of 3 s, the first poll fires after 10 s, and each XHR has an explicit 30 s timeout (well below Cloudflare's 100 s origin timeout).

= 1.1.11 =
* Fix: blog article generation no longer triggers a 504 Gateway Timeout on hosts behind a strict reverse proxy (Cloudflare, Caddy, hardened NGINX). The previous 1.1.8 dispatch ran `run_task_inline` inside a `shutdown` action, which keeps the FPM connection open until the AI call completes (10–30 s) — proxies that buffer the entire response would time out at 60 s. The new flow sends the JSON response, calls `fastcgi_finish_request()`, THEN processes the task in background. The browser sees a sub-second response.
* Improve: loopback fallback (for hosts without `fastcgi_finish_request`) now uses a 0.5 s timeout instead of 1 s — fewer cases of the parent request hanging while it waits for the loopback to dispatch.

= 1.1.10 =
* Fix: blog article generation now passes the real WooCommerce category permalinks to the AI (the previous payload key `cat_links` was ignored by the server, which expects `category_links`). Articles now contain real internal links to your store categories instead of invented or missing ones.
* Fix: category optimization now passes blog post permalinks under the correct `blog_posts` key — the AI can finally weave links to your real articles into the long category description.
* Add: JSON-LD `BlogPosting` schema is now emitted on the front-end of every Essiow-generated article (gated by the existing schema option). Includes headline, description, datePublished, dateModified, author, publisher, image, keywords. Helps rich snippet eligibility for users not running Yoast/Rank Math.

= 1.1.9 =
* Improve: blog articles, products and categories are now generated with intent-driven titles. The AI must analyze the search intent (informational, commercial, transactional, navigational) and produce a question-style or affirmative title that directly answers it — replaces generic clichés like "X : guide complet et conseils efficaces".
* Improve: AI no longer paraphrases the prompt. Forbidden patterns explicitly listed in prompts: "Introduction" / "Présentation" / "Pour commencer" headings, "voici les liens", "Dans cet article", "Saviez-vous que…", "De nos jours…". Articles must open with the actual answer in the first sentence.
* Fix: published articles now appear as proper Gutenberg blocks (paragraph, heading, list, table, image, quote, separator) instead of a single HTML block. The plugin parses the AI HTML with DOMDocument and wraps each top-level element in the correct `<!-- wp:... -->` annotation before `wp_insert_post`.
* Fix: residual "Introduction" / "Présentation" H2 headings the AI may still slip in are now stripped before publishing.

= 1.1.8 =
* Fix: blog article generation no longer hangs with `admin-ajax.php` ERR_TIMED_OUT on shared / mod_security hosts. Background dispatch now uses a 3-tier strategy: fastcgi_finish_request() (instant, PHP-FPM hosts), HTTP loopback (existing), and WP-Cron fallback (always works). The first available wins; redundant workers are idempotent (transient state guards against double-processing).
* Improve: client-side polling extended from 5 min to 10 min so 5000-word articles have time to complete on slower OpenAI responses.
* Improve: HTTP loopback now forwards cookies for hosts that require an authenticated session even on nopriv handlers.

= 1.1.7 =
* Fix: API key now persists correctly across saves (no longer "disappears" after Test Connection)
* Fix: rare "Invalid API key" error caused by encryption format collision (random IV containing the legacy "::" separator) — new "v2:" format is collision-proof, legacy "v1" payloads remain readable
* Add: static cache on get_api_key() prevents redundant decryptions and migration race conditions within a single request
* Add: 5-minute transient cache on license verification, 60-second transient on credit balance — fewer redundant API calls per admin page load
* Add: automatic retry with backoff (1s, 3s) on network errors and 5xx responses (4xx auth/validation are not retried)
* Add: auto-purge of essiow_last_errors entries older than 7 days
* Settings UI: API key field now shows the masked saved key as placeholder, leave empty to keep the saved key
* Improve: uninstall.php now lists encrypted-key options explicitly

= 1.1.6 =
* Fix: blog article generation now uses asynchronous task processing to avoid "Network error" on hosts with short proxy or PHP-FPM timeouts
* Improve: background generation worker keeps running on long AI calls (ignore_user_abort, extended time limit, session lock released)

= 1.0.0 =
* Initial release
* Product SEO optimization with descriptions, meta, alt text, schema
* Category SEO optimization with rich content and FAQ
* Blog article generator with product integration
* AI Sales Agent with chatbot widget
* 8 languages, 4 tones, 3 content lengths
* Bulk optimization with progress tracking
* Yoast SEO, Rank Math, All in One SEO support
* GDPR compliant

== Upgrade Notice ==

= 1.4.1 =
A product scoring 70 or more now counts as optimized, and the score badge, score card, Score filter and optimized status all follow that one threshold. Shops still on the former default of 80 are moved to 70 once; a threshold you chose yourself is kept.

= 1.4.0 =
New Merchant Center page: a 95-point audit of your shop against what Google checks before it accepts an offer, reversible one-click fixes, the optional Google connection, and the account and product problems Google reports. Feed and markup prices now come from the same source as your product page. The audit table, the new options and the crons are created at the first request after the update — no reactivation, nothing lost. Essiow prepares and fixes; Google decides. Requires WordPress 5.8+ and PHP 7.4+.

= 1.3.0 =
Four new modules — Speed (Core Web Vitals and technical audit), Off-site (brand mentions, outreach targets, lost links), Product feeds (Google Merchant Center, Meta, Pinterest) and the Content plan — plus `ProductGroup` schema for variable products, image sitemap and hreflang. New tables and options are created at the first request after the update; no reactivation, nothing lost. Requires WordPress 5.8+ and PHP 7.4+.

= 1.2.0 =
Major admin redesign (dashboard, products, categories, blog, internal links, Search Console, redirects, automation, AI agent), persistent SEO scores, and automatic data migration at the first request after the update — no reactivation needed, your API key, settings, optimizations and redirects are kept. Requires WordPress 5.8+ and PHP 7.4+.

= 1.1.81 =
All source strings are now in English with complete French, Spanish, German, Italian, Portuguese and Dutch translations; new Automation menu. Safe update.

= 1.1.29 =
Massive SEO release : AI re-optimize uses your real Search Console queries (top 10 / striking distance), new Opportunities tab (cannibalization, low CTR, lost rankings), Issues report, Audit tab (image SEO + schemas), auto BreadcrumbList + FAQPage JSON-LD. Required.

= 1.1.28 =
Adds Pause / Resume / Cancel controls during bulk optimization. Leaving the page now cancels the bulk automatically (saved items stay, pending queue is dropped). Auto-resume on page load removed by design.

= 1.1.27 =
Fixes Search Console KPIs stuck at 0 when the WP server returns 503 on admin-ajax (Wordfence/Cloudflare overload). JS now retries with exponential backoff. Recommended.

= 1.1.26 =
Auto-syncs Search Console data on page load (no more empty Overview after connecting), populates the Performance tab with striking-distance keywords, and auto-picks the property variant with the most actual data.

= 1.1.25 =
Cosmetic fix: replaces the misleading "Traitement en cours…" placeholder with a clearer empty state on the Search Console Indexation overview.

= 1.1.24 =
Removes the manual "Change property" UI now that auto-fallback handles property variants automatically.

= 1.1.23 =
Search Console connection is now self-healing: tries every property variant (sc-domain, www, http) until one works, and surfaces Google's real error message when none does. Recommended.

= 1.1.22 =
Fixes Search Console 404 sync errors when your GSC property URL doesn't exactly match the WP site URL (sc-domain, www, http variants). Adds a manual "Change property" picker. Recommended.

= 1.1.21 =
Critical fixes for 1.1.20 Search Console: OAuth response format, missing cryptography dependency, empty Indexation tab, indexation column on categories, `is_connected` guards, missing strings. Required if you upgraded to 1.1.20.

= 1.1.20 =
Major release: full Google Search Console + IndexNow integration. New "Search Console" admin menu with stats, indexation status, sitemap submission, auto IndexNow on publish, and a per-product "Indexation" column. Recommended.

= 1.1.19 =
Removes the page reload after a single category optimize (so concurrent optimizations don't get killed) and silences the spurious "Task not found" toast on page reload. Recommended.

= 1.1.18 =
Fixes three concurrency bugs in 1.1.17 bulk: filtered re-selection re-running everything, items getting stuck in queue from parallel poll-driven processing, and spurious "Task not found" errors during transient races. Recommended.

= 1.1.17 =
Critical fix for bulk and parallel optimizations stopping after 3-5 items. Each Flask call is now isolated to its own short-lived PHP process so `max_execution_time` and FPM worker saturation can't interrupt the queue. Recommended.

= 1.1.16 =
Polish on 1.1.15 bulk: stuck-item recovery, clean cancel/error UX, missing strings. Recommended.

= 1.1.15 =
Major bulk overhaul: full async dispatch (no more 504 on category bulk), per-row progress states like single optimize, percentage bar everywhere, cancel button, auto-resume on page reload. Recommended for anyone using bulk optimization.

= 1.1.14 =
Adds a filemtime-based cache buster so the new admin JS is loaded immediately after update, plus session/timeout hardening on legacy sync endpoints. Recommended for anyone still seeing 504 on category optimize after 1.1.13.

= 1.1.13 =
Fixes 504 Gateway Timeout on single-product and single-category optimization (same async dispatch as blog), and bans colons in AI-generated titles. Recommended update.

= 1.1.12 =
Definitive fix for "Erreur réseau / 504" on sites behind Cloudflare + Wordfence (or similar). Concurrent polls no longer block on PHP sessions, the loopback dispatch is removed, and the JS tolerates transient network errors. Recommended update for everyone on 1.1.8 → 1.1.11.

= 1.1.11 =
Critical fix for "Erreur réseau / 504" when generating blog articles on hosts behind Cloudflare/Caddy/strict NGINX. The browser response now closes before the AI call runs. Recommended update.

= 1.1.10 =
Fixes a payload mismatch that prevented categories and blog links from reaching the AI for articles and category pages, and adds JSON-LD Article schema. Recommended update for anyone on 1.1.8 or 1.1.9.

= 1.1.9 =
Major content quality fix: AI now writes intent-driven titles (no more "guide complet" clichés), stops paraphrasing the brief ("Introduction", "voici les liens"), and articles publish as real Gutenberg blocks instead of a single HTML block. Recommended update.

= 1.1.6 =
Fixes a "Network error" that could appear when generating blog articles on hosts with short HTTP proxy timeouts. Recommended update.

= 1.0.0 =
Initial release of Essiow AI SEO Suite for WooCommerce.
