# ContentGecko Connector

ContentGecko Connector packages the custom REST API that the ContentGecko platform expects when merchants cannot enable Application Passwords. It installs like any other WordPress plugin and exposes ContentGecko-specific endpoints under `/wp-json/contentgecko/v1` while gating access behind a shared API key.

![ContentGecko Connector settings](assets/images/settings-page.png)

## Requirements

- WordPress 6.0 or higher
- WooCommerce 6.0 or higher (for catalog endpoints)
- PHP 7.4+ (tested up to 8.2)
- WPML or Polylang (optional, required only for translation linking)

## Installation

1. Download or clone this repository and zip the `contentgecko-connector` folder (or export it directly from your IDE).
2. In WordPress, navigate to **Plugins → Add New → Upload Plugin** and upload the ZIP.
3. Activate **ContentGecko Connector**.
4. Go to **Settings → ContentGecko Connector**.
5. Click **Generate API Key**, copy the value that appears, and save it inside the ContentGecko app. The key is only shown once per generation.
6. (Optional) Enable request logging to retain the last 50 API calls for troubleshooting.

## Admin Console Overview

- **API key panel**: Generate or regenerate keys. Plain value is only shown immediately after generation.
- **Logging panel**: Toggle persistence of the last 50 REST calls and review basic request telemetry.
- **Language defaults**: When WPML or Polylang is active, pick the original content language that seeds new posts.
- **Health check**: Mirrors the `/health` endpoint so you can validate configuration without leaving the WP dashboard.

## REST API Summary

All routes require an `X-ContentGecko-Key` header that matches the generated API key.

| Route | Method | Description |
| --- | --- | --- |
| `/health` | GET | Connectivity + environment details (WP version, WooCommerce active flag, timezone, site URL). |
| `/auth/verify` | POST | Echo test during initial handshake. |
| `/posts` | GET | List posts with optional filters (`page`, `perPage`, `status`, `search`, taxonomy filters) and toggleable content/meta fields. |
| `/posts` | POST | Create or update posts, including featured media handling, SEO meta updates, and language assignment. |
| `/posts/{id}` | GET | Retrieve a single post by WordPress ID with optional content/meta payloads. |
| `/posts/{id}/delete` | DELETE | Move a post to the trash (bin) by WordPress ID. |
| `/posts/translations` | POST | Attach a translated version of an existing post to WPML or Polylang. |
| `/pages` | GET | List WordPress pages with pagination and optional content/meta payloads. |
| `/pages` | POST | Create or update pages, including featured media handling, SEO meta updates, and language assignment. |
| `/pages/{id}` | GET | Retrieve a single page by WordPress ID with optional content/meta payloads. |
| `/pages/{id}/delete` | DELETE | Move a page to the trash (bin) by WordPress ID. |
| `/pages/translations` | POST | Attach a translated version of an existing page to WPML or Polylang. |
| `/categories` | GET | List WordPress blog categories with per-language term data. |
| `/categories` | POST | Create a WordPress blog category. |
| `/categories/{id}` | PUT | Update a WordPress blog category. |
| `/store/categories` | GET | Paginated WooCommerce category listing with language variations. |
| `/store/categories` | POST | Create a WooCommerce product category. |
| `/store/categories/{id}` | PUT | Update a WooCommerce product category. |
| `/store/products` | GET | Paginated product feed with image, category, meta payload, and language variations. |
| `/store/products/{id}` | PUT | Update an existing WooCommerce product. |
| `/store/images/search` | POST | Search and paginate through all WordPress media library images. |
| `/store/images/resolve` | POST | Resolve source image URLs to WordPress media URLs by exact filename reuse or sideload. |
| `/store/catalog` | GET | Convenience bundle returning both categories and products with language variations. |

Use `GET /wp-json/contentgecko/v1/posts` to paginate WordPress posts (`page`, `perPage`, `status`, `category`, `tag`, `search`) and toggle heavy fields via `includeContent` or `includeMeta`. Use `GET /wp-json/contentgecko/v1/pages` for WordPress pages. Each record now includes `linksByLanguage`, mapping language codes to their localized permalinks. To pull a specific article or page, call the matching singular route and optionally suppress large sections with `includeContent=false` or `includeMeta=false`.

### Endpoint Details

All endpoints require the `X-ContentGecko-Key` header and honor the `contentgecko_allowed_ips` filter for allow-listing IP ranges.

#### GET `/wp-json/contentgecko/v1/health`
- **Purpose**: Basic connectivity, versions, timezone, WooCommerce active flag, and site URL.
- **Query parameters**: None.
- **Filters / actions**: IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/auth/verify`
- **Purpose**: One-off handshake that echoes a nonce for API key validation.
- **Body fields**: `nonce` (string, optional echo token).
- **Filters / actions**: IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/posts`
- **Purpose**: Paginated post index with optional filters.
- **Query parameters**: `page` (default `1`), `perPage` (default `20`, max `100`), `status` (single CSV of `publish|draft|pending|private|future`), `category` (one ID or CSV), `tag` (slug or CSV), `search` (full-text term), `includeContent` (`true|false`), `includeMeta` (`true|false`).
- **Response fields**: Array of posts with `id`, `wordpressId`, `title`, `slug`, `status`, `type`, `link`, `linksByLanguage` (language code → permalink), ISO8601 `date` / `modified`, `authorId`, sanitized `excerpt`, taxonomy IDs, resolved `language`, `metaTitle`, `metaDescription`, optional `featuredImage`, `contentHtml` / `excerptHtml`, optional `meta` hash, and `clusterId`.
- **Filters / actions**: Metadata payload filtered through `contentgecko_rest_post_meta` before returning; IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/pages`
- **Purpose**: Paginated page index using the same response shape as the posts collection but restricted to the `page` post type.
- **Query parameters**: `page` (default `1`), `perPage` (default `20`, max `100`), `status` (CSV of `publish|draft|pending|private|future`), `search` (full-text term), `includeContent` (`true|false`), `includeMeta` (`true|false`).
- **Response fields**: Array of pages matching the posts collection schema, including `linksByLanguage` for every localized permalink.
- **Filters / actions**: `contentgecko_rest_post_meta` applies when meta is requested; IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/posts/{id}`
- **Purpose**: Fetch a single WordPress post by WordPress ID. Non-`post` objects are rejected.
- **Query parameters**: `includeContent` (`true|false`, default `true`), `includeMeta` (`true|false`, default `true`).
- **Response fields**: Same shape as the collection endpoint but wrapped in a `post` object.
- **Filters / actions**: `contentgecko_rest_post_meta`, `contentgecko_allowed_ips`.

#### DELETE `/wp-json/contentgecko/v1/posts/{id}/delete`
- **Purpose**: Move a WordPress post to the trash (bin) by WordPress ID. Non-`post` objects are rejected.
- **Path parameters**: `id` (int, required) - The WordPress post ID to delete.
- **Query parameters**: None.
- **Response fields**: `{ postId, status, message }` with success envelope. Status will be `trash` on success.
- **Error responses**: `invalid_post_id` (400), `post_not_found` (404), `post_already_trashed` (400), `delete_failed` (500).
- **Filters / actions**: Triggers cache clearing via existing `purge_post_caches()` method; IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/posts`
- **Purpose**: Create or update a WordPress post. When `wordpressId` is provided, the referenced object must be a native `post`.
- **Body fields**:
  - Identifiers: `wordpressId` (int) to update an existing post.
  - Content: `title` (required on create), `contentHtml`, `contentMarkdown`, `excerpt`, `slug`.
  - Publishing: `status` (`draft|publish|pending|private`), `autoPublish` (bool to publish when updating), `authorId` (int, requires `edit_posts`).
  - Taxonomy: `categories` (array of term IDs), `tags` (array of term IDs).
  - Media: `featuredImage` object with `mediaId` (int) or `sourceUrl` (string) and optional `altText`.
  - Metadata: `meta` (associative array), `metaTitle`, `metaDescription`, `clusterId` (string for `_contentgecko_cluster_id`).
  - Language helpers: `language`, `trid`, `sourceLanguage`, `source_language`, `source_language_code` (used when WPML/Polylang present).
- **Response fields**: `{ postId, status, link, linksByLanguage }` plus success envelope.
- **Filters / actions**: `contentgecko_assign_language` fires when neither WPML nor Polylang is active; IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/posts/translations`
- **Purpose**: Create or update a translation tied to an existing WordPress post (WPML or Polylang). The original post and any updated translation must both be `post` objects.
- **Body fields**: Inherits all `/posts` fields plus `originalPostId` or `originalWordpressId` (source post), `translationLanguage` (target locale), optional `language` override.
- **Response fields**: Same structure as `/posts` write endpoint.
- **Filters / actions**: Reuses `/posts` logic, including `contentgecko_assign_language` (when applicable) and IP gating.

#### POST `/wp-json/contentgecko/v1/pages`
- **Purpose**: Create or update a WordPress page. When `wordpressId` is provided, the referenced object must be a native `page`.
- **Body fields**: Same payload schema as `/posts`.
- **Response fields**: `{ pageId, status, link, linksByLanguage }` plus success envelope.
- **Filters / actions**: `contentgecko_assign_language` fires when neither WPML nor Polylang is active; IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/pages/{id}`
- **Purpose**: Fetch a single WordPress page by WordPress ID. Non-`page` objects are rejected.
- **Query parameters**: `includeContent` (`true|false`, default `true`), `includeMeta` (`true|false`, default `true`).
- **Response fields**: Same shape as the posts item endpoint but wrapped in a `page` object.
- **Filters / actions**: `contentgecko_rest_post_meta`, `contentgecko_allowed_ips`.

#### DELETE `/wp-json/contentgecko/v1/pages/{id}/delete`
- **Purpose**: Move a WordPress page to the trash (bin) by WordPress ID. Non-`page` objects are rejected.
- **Path parameters**: `id` (int, required) - The WordPress page ID to delete.
- **Query parameters**: None.
- **Response fields**: `{ pageId, status, message }` with success envelope. Status will be `trash` on success.
- **Filters / actions**: Triggers cache clearing via existing `purge_post_caches()` method; IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/pages/translations`
- **Purpose**: Create or update a translation tied to an existing WordPress page (WPML or Polylang). The original page and any updated translation must both be `page` objects.
- **Body fields**: Same payload schema as `/posts/translations`.
- **Response fields**: Same structure as `/pages` write endpoint.
- **Filters / actions**: Reuses `/pages` logic, including `contentgecko_assign_language` (when applicable) and IP gating.

#### GET `/wp-json/contentgecko/v1/categories`
- **Purpose**: Paginated WordPress blog category listing.
- **Query parameters**: `page` (default `1`), `perPage` (default `50`, max `100`).
- **Response fields**: `categories` array containing `id`, `name`, `slug`, `permalink`, `categoriesByLanguage` (language code → `{ id, name, slug }`), `language` (current language code), `description`, `metaTitle`, `metaDescription`, and `parent`; also surfaces `total`, `page`, `perPage`.
- **Filters / actions**: Category language map filtered through `contentgecko_category_terms_by_language`; IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/categories`
- **Purpose**: Create a WordPress blog category.
- **Body fields**: `name` (string, required), optional `slug`, `description`, `parent` (int), `metaTitle`, `metaDescription`, and language helpers (`language`, `trid`, `sourceLanguage`, `source_language`, `source_language_code`).
- **Response fields**: `category` object matching the GET schema.
- **Filters / actions**: `contentgecko_assign_term_language` fires when neither WPML nor Polylang is active; IP gating via `contentgecko_allowed_ips`.

#### PUT `/wp-json/contentgecko/v1/categories/{id}`
- **Purpose**: Update a WordPress blog category.
- **Path parameters**: `id` (int, required) - The WordPress category term ID.
- **Body fields**: Any of `name`, `slug`, `description`, `parent`, `metaTitle`, `metaDescription`, and language helpers (`language`, `trid`, `sourceLanguage`, `source_language`, `source_language_code`).
- **Response fields**: `category` object matching the GET schema.
- **Filters / actions**: `contentgecko_assign_term_language` fires when neither WPML nor Polylang is active; IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/store/categories`
- **Purpose**: Paginated WooCommerce product category listing.
- **Query parameters**: `page` (default `1`), `perPage` (default `50`, max `100`).
- **Response fields**: `categories` array containing `id`, `name`, `slug`, `permalink`, `linksByLanguage` (language code → category permalink), `localizedByLanguage` (language code → localized category payload including localized `description`), `language` (current language code), `description` (always present; empty string when unset), `metaTitle`, `metaDescription`, breadcrumb `path`, and `parent`; also surfaces `total`, `page`, `perPage`.
- **Filters / actions**: Language links filtered through `contentgecko_category_language_links`; IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/store/categories`
- **Purpose**: Create a WooCommerce product category.
- **Body fields**: `name` (string, required), optional `slug`, `description`, `parent` (int), `metaTitle`, and `metaDescription`.
- **Response fields**: `category` object matching the GET `/store/categories` item schema.
- **Error responses**: `missing_category_name` (400), `category_create_failed` (500), `woocommerce_inactive` (400).

#### PUT `/wp-json/contentgecko/v1/store/categories/{id}`
- **Purpose**: Update a WooCommerce product category.
- **Path parameters**: `id` (int, required) - The WooCommerce category term ID.
- **Body fields**: Any of `name`, `slug`, `description`, `parent`, `metaTitle`, and `metaDescription`.
- **Response fields**: `category` object matching the GET `/store/categories` item schema.
- **Error responses**: `invalid_category_id` (400), `category_not_found` (404), `invalid_category_parent` (400), `missing_category_updates` (400), `category_update_failed` (500), `woocommerce_inactive` (400).

#### GET `/wp-json/contentgecko/v1/store/products`
- **Purpose**: Paginated WooCommerce product feed.
- **Query parameters**: `page` (default `1`), `perPage` (default `20`, max `100`), `category` (product_cat ID or slug), `search` (term), `ids` (array or comma-separated product IDs).
- **Response fields**: `products` array with `id`, `sku`, `name`, `permalink`, `linksByLanguage` (language code → product permalink), `localizedByLanguage` (language code → localized product payload including localized `description`), `language` (current language code), `stockStatus`, `description` (always present; empty string when unset), `shortDescription`, `metaTitle`, `metaDescription`, category objects (`id`, `name`, `permalink`, `path`), `images` (`id`, `src`, `alt`), and `meta.raw` (raw WooCommerce meta). Includes `total`, `maxPages`, `page`, `perPage`.
- **Filters / actions**: Language links filtered through `contentgecko_product_language_links`; IP gating via `contentgecko_allowed_ips`.

#### PUT `/wp-json/contentgecko/v1/store/products/{id}`
- **Purpose**: Update an existing WooCommerce product.
- **Path parameters**: `id` (int, required) - The WooCommerce product ID.
- **Body fields**: Any of `name`, `slug`, `description`, `category` (single ID/slug, CSV, or array), `categories` (array/CSV of IDs or slugs), `metaTitle`, and `metaDescription`.
- **Response fields**: `product` object matching the GET `/store/products` item schema.
- **Error responses**: `invalid_product_id` (400), `product_not_found` (404), `missing_product_updates` (400), `invalid_product_category` (400), `product_update_failed` (500), `product_category_update_failed` (500), `woocommerce_inactive` (400).

#### POST `/wp-json/contentgecko/v1/store/images/search`
- **Purpose**: Search and paginate through all WordPress media library images.
- **Body fields**: `term` (string search phrase matched against filenames and alt text; optional - if empty returns all images; legacy `terms` array or `search` string still accepted), `page` (int, default `1`), `perPage` (int, 1–100, default `20`), `limit` (int, legacy parameter for backward compatibility).
- **Response fields**: `images` array with comprehensive image data including `id`, `src`/`url`, `alt`/`altText`, `title`, `caption`, `description`, `filename`, `filesize`, `width`, `height`, `uploadDate`, `mimeType`, `thumbnail`, `medium`, and optional `attachedTo` (parent post info). Also includes pagination: `total`, `maxPages`, `page`, `perPage`.
- **Filters / actions**: IP gating via `contentgecko_allowed_ips`.

#### POST `/wp-json/contentgecko/v1/store/images/resolve`
- **Purpose**: Resolve ContentGecko source image URLs to WordPress-hosted media URLs for in-article image rewriting.
- **Mode**: Intended for ContentGecko `wp-plugin` mode integrations.
- **Body fields**: `images` (required array). Each item requires `sourceUrl` (absolute URL) and `fileName` (deterministic filename). Optional per-item fields: `altText`, `preferredMimeType`, `preferredExtension`, and `originalFileName`.
- **Behavior**: Checks for existing attachments whose basename exactly matches `fileName`. If not found, downloads `sourceUrl` server-side and sideloads into the media library using the provided filename. When `preferredMimeType: image/webp` or `preferredExtension: webp` is supplied, uploads are forced to WebP and saved with a `.webp` filename.
- **Response fields**: Always returns `data.images` with one result per input item (`sourceUrl`, `mediaId` or `null`, `wordpressUrl`, `action`, final `fileName`, final `mimeType`). On partial failures, `data.errors` includes per-item errors without failing the entire request.
- **Error responses**: `invalid_payload` (400) when `images` is missing or not an array; auth/IP errors (401/403) reuse the standard connector auth pipeline.
- **Filters / actions**: IP gating via `contentgecko_allowed_ips`.

#### GET `/wp-json/contentgecko/v1/store/catalog`
- **Purpose**: Convenience endpoint returning categories and products in one payload.
- **Query parameters**: Supports the union of `/store/categories` (`page`, `perPage`) and `/store/products` (`category`, `search`, `ids`) settings.
- **Response fields**: `categories` array and `products` array mirroring their standalone counterparts (including the `sku` field).
- **Filters / actions**: IP gating via `contentgecko_allowed_ips`.

Refer to `docs/curl-examples.md` for ready-to-run command samples.

### Multilingual workflows

The connector links translations when either WPML or Polylang is active.

1. In **Settings → ContentGecko Connector**, pick the “Original content language”. This value seeds the language assigned to new posts when a payload omits `language`.
2. Publish the source article via `POST /wp-json/contentgecko/v1/posts`. Include `language` when you want to override the configured default.
3. Publish translations via `POST /wp-json/contentgecko/v1/posts/translations` with:
   - `originalPostId` (or legacy `originalWordpressId`) pointing to the source article.
   - `translationLanguage` set to the destination locale code (e.g. `fr`, `et`).
   - The usual post payload (title, contentHtml/contentMarkdown, excerpt, slug, media, meta, etc.).
4. The connector writes the translated post and links it back to the original:
   - **WPML**: resolves the original TRID, sets `language_code`, `source_language_code`, and reuses the existing translation group.
   - **Polylang**: assigns the translation language, mirrors the original language mapping, and saves the translation set via `pll_save_post_translations()`.

#### Example translation payload

```bash
curl -X POST \
     -H 'Content-Type: application/json' \
     -H 'X-ContentGecko-Key: <api-key>' \
     -d '{
           "originalPostId": 123,
           "translationLanguage": "fr",
           "title": "French headline",
           "contentHtml": "<p>French body...</p>",
           "status": "publish",
           "meta": {
             "contentgecko_internal_id": "abc-123"
           }
         }' \
     https://example.com/wp-json/contentgecko/v1/posts/translations
```

The response mirrors the matching write endpoint (`/posts/translations` returns `postId`; `/pages/translations` returns `pageId`). Error responses carry explicit codes such as `missing_original_post`, `invalid_translation_language`, or `translation_plugin_inactive` to guide retry logic on the ContentGecko side.

### Language Variations for Posts, Products & Categories

Starting in version **1.2.0**, all post, page, product, and category endpoints automatically include language variations when WPML or Polylang is active:

- Each post/page now includes `linksByLanguage` (mapping language codes to permalinks) and `language` (current language code).
- Each product now includes `linksByLanguage` and `language` fields.
- Each category now includes `linksByLanguage` and `language` fields.
- Language detection is automatic and requires no additional configuration.
- On sites without translation plugins, these fields return empty values (`language: ""`, `linksByLanguage: {}`).

**Example post response with language variations:**

```json
{
  "id": 456,
  "title": "Getting Started Guide",
  "permalink": "https://example.com/getting-started-guide/",
  "linksByLanguage": {
    "en": "https://example.com/getting-started-guide/",
    "de": "https://example.com/de/erste-schritte/",
    "fr": "https://example.com/fr/guide-demarrage/"
  },
  "language": "en"
}
```

**Example product response with language variations:**

```json
{
  "id": 123,
  "sku": "PROD-001",
  "name": "Wireless Headphones",
  "permalink": "https://example.com/product/wireless-headphones/",
  "linksByLanguage": {
    "en": "https://example.com/product/wireless-headphones/",
    "de": "https://example.com/de/produkt/kabellose-kopfhoerer/",
    "fr": "https://example.com/fr/produit/casque-sans-fil/"
  },
  "language": "en",
  "categories": [...]
}
```

This enables ContentGecko to:
- Map content across all language versions
- Generate proper hreflang tags for SEO
- Synchronize updates across translations
- Provide language-specific recommendations

## Shortcodes

Use the `[contentgecko_products]` shortcode on any post, page, or block editor shortcode embed to render a responsive grid of WooCommerce products with the connector's default styling.

- `ids="1,2,3"` fetches specific products by WordPress ID.
- `skus="SKU-123,SKU-456"` fetches products by SKU; variations are supported when the SKU resolves to a variation.
- You can combine both attributes (`[contentgecko_products ids="10" skus="SKU-123"]`) and the connector will merge and de-duplicate the results.
- All other filters remain available (`category`, `tag`, `limit`, `orderby`, `order`).

When `ids` or `skus` are provided, the products are returned in the order supplied. If neither attribute is defined, the shortcode falls back to the standard WooCommerce query arguments (e.g. categories, tags, or ordering) while keeping the plugin's product card template and CSS.

## Data Stored

- Options: `contentgecko_api_key` (hashed), `contentgecko_settings` (feature flags such as logging).
- Post meta: `_contentgecko_cluster_id`, `_contentgecko_last_payload`, `_contentgecko_last_synced`.
- Media meta: `_contentgecko_imported` for assets sideloaded by ContentGecko.
- Transient: `contentgecko_recent_logs` for the optional request log.

## Changelog

### 1.2.6

- Locked `/wp-json/contentgecko/v1/posts` write, item, delete, and translation routes to native WordPress posts only.
- Added explicit `/wp-json/contentgecko/v1/pages` write, item, delete, and translation routes for native WordPress pages.
- Fixed post/page updates so omitted `status` and content fields preserve the existing stored values instead of forcing `draft` or blank content.

### 1.2.5

- Added `metaTitle` and `metaDescription` support to blog post writes and WordPress blog category create/update requests.
- Blog category, store category, and store product writes now also use camelCase SEO fields: `metaTitle` and `metaDescription`.
- `GET /wp-json/contentgecko/v1/posts`, `GET /wp-json/contentgecko/v1/pages`, and `GET /wp-json/contentgecko/v1/categories` now return normalized `metaTitle` and `metaDescription` fields.

### 1.2.4

- Added `POST /wp-json/contentgecko/v1/store/categories` to create WooCommerce product categories.
- Added `PUT /wp-json/contentgecko/v1/store/categories/{id}` to update WooCommerce category name, description, slug, `metaTitle`, and `metaDescription`.
- Added `PUT /wp-json/contentgecko/v1/store/products/{id}` to update WooCommerce product name, description, slug, category assignment, `metaTitle`, and `metaDescription`.
- WooCommerce category and product payloads now include `metaTitle` and `metaDescription` fields.

### 1.2.3

- `GET /wp-json/contentgecko/v1/store/categories` and `GET /wp-json/contentgecko/v1/store/products` now always return `description` as a string (empty descriptions are returned as `""`).
- Description values now use native WooCommerce visible description fields (not SEO meta fields).
- Added `localizedByLanguage` payload maps for categories and products, including localized `description` for each language.
- Added `ids` filtering to `GET /wp-json/contentgecko/v1/store/products` (array or comma-separated IDs).

### 1.2.2

- Added authenticated `POST /wp-json/contentgecko/v1/store/images/resolve` for in-article image URL resolution.
- Added exact filename media reuse checks using `fileName` before any upload is attempted.
- Added server-side sideload fallback that uploads missing images and returns WordPress-hosted URLs.
- Added partial failure reporting through `data.errors` while keeping successful mappings in `data.images`.
- Added per-image `altText`, `preferredMimeType`, and `preferredExtension` support for image resolve requests.
- Added `fileName` and `mimeType` to each resolve result and now apply per-image alt text updates on reused/uploaded attachments.

## Uninstall

Removing the plugin via the WordPress Plugins screen invokes the uninstall hook which deletes the API key, connector settings, and request log transient.

## Development Notes

- REST requests validate the API key, optionally honor allow-listed IPs via the `contentgecko_allowed_ips` filter, and return camelCase JSON with a per-response `requestId`.
- Markdown content is converted with Parsedown (safe mode) when HTML is not provided.
- WooCommerce access gracefully errors if WooCommerce is inactive, mirroring the API error contract the ContentGecko backend expects.
- Errors are mapped to HTTP status codes and logged with a `ContentGecko:` prefix in the PHP error log for faster triage.

For testing guidance and manual QA steps see `docs/qa-notes.md`.
