=== Skwirrel PIM sync for WooCommerce ===
Contributors: jkoomen
Tags: woocommerce, sync, pim, skwirrel, product-sync
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.3
Stable tag: 3.8.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Synchronises products from the Skwirrel PIM system to WooCommerce via a JSON-RPC 2.0 API.


== Description ==

Skwirrel PIM sync for WooCommerce connects your WooCommerce webshop to the Skwirrel PIM system. Products, variations, categories, brands, manufacturers, images, and documents are synchronised automatically or on demand.

**Features:**

* Full and delta (incremental) product synchronisation
* Simple and variable product support with ETIM classification for variation axes
* Automatic category tree sync with parent-child hierarchy
* Brand sync via WooCommerce native product_brand taxonomy
* Manufacturer sync with dedicated product_manufacturer taxonomy
* Product image and document import into the WordPress media library
* Custom class attributes (alphanumeric, logical, numeric, range, date, multi)
* Configurable product URL slugs (source field, suffix, update on re-sync)
* GTIN and manufacturer product code search filter on the product list page
* Scheduled synchronisation via WP-Cron or Action Scheduler
* Manual synchronisation from the admin dashboard with live progress tracking
* Date-grouped sync history (last 20 runs)
* Stale product and category purge after full sync
* Delete protection with warnings and automatic full re-sync
* Multilingual support with 7 locales (nl_NL, nl_BE, de_DE, fr_FR, fr_BE, en_US, en_GB)

**Requirements:**

* WordPress 6.0 or higher
* WooCommerce 8.0 or higher (9.6+ recommended for native brand support; tested up to 10.6)
* PHP 8.3 or higher
* An active Skwirrel account with API access

== Installation ==

1. Upload the plugin files to `/wp-content/plugins/skwirrel-pim-sync/`, or install the plugin directly through the WordPress plugin screen.
2. Activate the plugin through the 'Plugins' screen in WordPress.
3. Navigate to WooCommerce > Skwirrel Sync to configure the plugin.
4. Enter your Skwirrel API URL and authentication token.
5. Click 'Sync now' to start the first synchronisation.

== Frequently Asked Questions ==

= Which Skwirrel API version is supported? =

The plugin works with the Skwirrel JSON-RPC 2.0 API.

= How often are products synchronised? =

You can set an automatic schedule (hourly, twice daily, or daily) or synchronise manually from the settings page.

= Are existing products overwritten? =

The plugin uses the Skwirrel external ID as a unique key. Existing products are updated, not duplicated.

= I use a media offload plugin (WP Offload Media, S3 Uploads, …) — will the sync delete my offloaded files? =

No, the sync never invokes `wp_delete_attachment()` on a missing-file event in 3.8.0+. When the local file is gone, the plugin only clears its own Skwirrel-side meta keys from the WP attachment record so the next sync can download fresh; the WP record itself (and any remote copy your offload plugin manages) is left untouched.

If you want to go a step further and have the sync **reuse** the existing WP attachment (no fresh download, no churn) when the local file is gone but the remote copy is fine, hook into the `skwirrel_wc_sync_attachment_is_valid` filter. The simplest implementation as a mu-plugin:

`<?php`
`add_filter( 'skwirrel_wc_sync_attachment_is_valid', function ( $local_present, $att_id ) {`
`    return $local_present || (bool) wp_get_attachment_url( $att_id );`
`}, 10, 2 );`

Returning `true` tells the sync the attachment is still valid even though the local file is missing. The plugin ships a more thorough reference implementation (URL-equals-uploads-baseurl check) you can adapt — see the project's `mu-plugins/skwirrel-offload-compat.php`.

== Changelog ==

= 3.8.2 =

Release-hygiene fixes for WordPress.org Plugin Check:

* Stop shipping dev-only files in the SVN trunk. The 10up deploy action skips `.distignore` whenever a `BUILD_DIR` is configured, so the previous releases inadvertently shipped empty `composer.json`, `phpstan.neon.dist`, `phpunit.xml.dist`, `phpunit-integration.xml.dist`, `.phpcs.xml.dist`, `.gitignore`, and `.distignore` placeholder files. Those placeholders have been removed from the plugin source so the deploy ZIP only contains runtime files.
* Suppress Plugin Check false positives in plugin code: the `active_plugins` activation check is the WordPress core filter (not a plugin-defined hook), the `_skwirrel_category_id` term-meta lookup queries are documented as having no WP API equivalent, and the `'meta_key'` array key inside a logger context is correctly flagged as not a database query argument. No runtime behavior changes.

= 3.8.1 =

* Fix: grouped-products sync now honours every configured `dynamic_selection_id`. The 3.8.0 multi-selection fix only covered the main fetch loop; the grouped-products prefilter still used `collection_ids[0]` and silently skipped grouped products whose members lived only in selections 2..N. With this patch, a grouped product is kept whenever any of its members appears in any of the configured selections.

= 3.8.0 =

Media — real Skwirrel ↔ WordPress mapping + content-change detection:

* Every imported attachment now stores the Skwirrel `product_attachment_id` as `_skwirrel_attachment_id` post meta. Re-syncs locate existing media by that stable id first; URL-hash matching is the legacy fallback. CDN URL rewrites no longer create duplicate WP attachments.
* The API's `file_sha256_checksum` lands on the WP attachment as `_skwirrel_file_checksum`. A re-sync that sees a different checksum replaces the underlying file in place — same WP attachment id, fresh bytes, sub-sizes regenerated, mime type updated. Failed downloads leave the existing attachment untouched.
* Offload-plugin-safe missing-file guard — when the local file is gone the WP attachment record is preserved (no `wp_delete_attachment()` is ever invoked, which would have triggered offload-plugin hooks that may purge remote storage). Only the Skwirrel-side meta is cleared so the next sync can download fresh. New `skwirrel_wc_sync_attachment_is_valid` filter lets offload-aware site code declare an attachment valid even when the local file is missing.
* Lazy migration: re-syncs of attachments imported before 3.8 silently backfill the new meta keys from the API payload. No re-download on upgrade.

Sync safety — fixes for issues surfaced in code review:

* Mutex on concurrent runs: `run_sync()` refuses to start when another run's heartbeat is still fresh, preventing the queue, per-product synced_at meta and purge step from being raced. A stale heartbeat is taken as "previous run died" and the new run takes over.
* The global queue truncate at sync startup is gone. Each run uses its own `sync_run_id` and only touches its own rows; defends queue contents even if the mutex is bypassed.
* Pagination atomicity: a later-page fetch failure is now a hard abort. Previously the run continued through every phase, advanced `last_sync`, and ran stale-product purge — at worst trashing the products that lived on the un-fetched pages.
* Multi-selection support in the main fetch: `getProductsByFilter` is now called once per configured selection id. The previous code silently used only the first id, dropping every product that lived only in selections 2..N.
* Empty cross-sells / upsells now actually clear. Previously an API payload that returned zero relations left existing WC cross-sells/upsells in place forever.

Plugin Check submission cleanup:

* `.distignore` excludes all dev tooling configs from the deploy ZIP.
* `Tested up to: 6.9.4` → `6.9` (wp.org accepts only major.minor).
* Bulk SQL in the purge handler reworked so `Plugin Check`'s static analyser can verify int-sanitised IDs in the IN-clauses.
* Google Fonts enqueue now passes a version string instead of `null`.

Tests:

* 237 unit + 45 integration tests (up from 169 + 11 in 3.6.x). New `MediaImporterIntegrationTest` (10 cases) and `SyncSafetyIntegrationTest` (8 cases) pin every new behaviour.

= 3.7.0 =
* Bump minimum PHP version to 8.3 (PHP 8.1 reached end-of-life on 2025-12-31; 8.2 is in security-only support until 2026-12-31). `Requires PHP` and `composer.json` runtime constraint both updated.
* Bump minimum Node.js version to 22 LTS for the local dev environment (`package.json` `engines.node`). Node 18 reached end-of-life in April 2025 and Node 20 maintenance ends April 2026.
* CI: bump GitHub Actions PHP version from 8.1 to 8.3 to match the dev tooling lock file (Pest 3 / PHPUnit 11 require PHP 8.2+). Cache key updated accordingly.
* `.wp-env.json`: bump `phpVersion` to 8.3 for parity with CI and the runtime floor.
* Internal: aligned source files with the WordPress coding standards now that CI no longer fails before phpcs runs. Class files renamed to `class-skwirrel-wc-sync-{slug}.php` (full class name in kebab-case); the `Skwirrel_WC_Sync_Plugin` bootstrap class extracted from `skwirrel-pim-sync.php` into its own file. ~125 Yoda condition flips, ~33 `$camelCase` → `$snake_case` local variable renames, removed `$mapper` parameter from `Skwirrel_WC_Sync_Category_Sync::assign_categories()` (was unused), renamed `$object` → `$wc_object` in `Skwirrel_WC_Sync_Variation_Attributes_Fix::fix_rest_response_attributes()` (PHP 8.2+ reserved keyword). No functional changes.

= 3.6.1 =
* Fix: ship files that were missing from the 3.6.0 build — `class-pim-link.php` (the "Open in Skwirrel" deep-link implementation), the updated `class-admin-settings.php` (Settings-saved notice + transient-based test result), the updated `class-product-sync-meta-box.php` (PIM link button), the `assets/s.png` button icon, and the corresponding `.po`/`.mo` translation updates. Without these files an install of 3.6.0 fatalled on activation with "Failed opening required ... class-pim-link.php"

= 3.6.0 =
* "Open in Skwirrel" deep-link — Skwirrel meta box on the product edit screen and each row on the WP Products list now offer a button that jumps straight to the matching product in the Skwirrel PIM web UI. The link uses the host from the JSON-RPC endpoint. Simple products use `/catalogue/products/edit/{product_id}`; variable/grouped product shells use `/catalogue/grouped-products/edit/{grouped_id}`.
* "Settings saved" notice on the settings page — saving settings now shows a proper confirmation. The connection test result moved to a transient so it no longer re-displays after every save (previously a stale `?test=ok` URL parameter caused the test-success notice to fire on save).
* New `skwirrel_wc_sync_after_attributes_fetched` action hook — fires during the attributes phase right after a product's enriched payload (with `_etim` and `_custom_classes`) is fetched and before the WooCommerce attribute table is written. Enables site-specific code to persist the enriched payload as post meta for custom frontend rendering. Hook signature: `do_action( 'skwirrel_wc_sync_after_attributes_fetched', int $wc_id, array $attr_product, ?array $group_info )`. Fires in both the bulk Phase 3 loop and the single grouped product re-sync path
* Fix: variation filter values no longer empty during sync — pre-sync rebuild of the variable product shell wiped the parent's term-options to `[]` for the entire duration of the sync, leaving the frontend variation filter (storage, color, connectivity, etc.) empty until the sync finished. The shell-rebuild now preserves existing term-options for each axis taxonomy
* `flush_parent_attribute_terms()` is now authoritative — at the end of Phase 3 the parent's variation taxonomy term-list is rebuilt as exactly the set of term IDs whose slugs appear in the current children's `attribute_pa_*` post meta. Stale terms from removed variants are dropped (previously merged in indefinitely); new terms from added or updated variants are picked up. Replaces the previous merge-on-merge pattern with a single derived-from-children compute

= 3.4.0 =
* Live sync log viewer on the Debug page — tails the current sync log file with 2-second polling, auto-scroll, pause/clear/download controls
* "View live log" anchor link on the in-progress sync banner jumps straight to the live log
* Active log filename now tracked in a WP option so the viewer follows sync runs across page refreshes
* Dashboard Debug block relabelled to surface the live log alongside variation attribute troubleshooting
* CI actions bumped to Node 24 — checkout/cache/upload-artifact/github-script

= 3.3.0 =
* Log viewer performance — progressive rendering (200 lines/frame), chunked server loading (100 KB/request), and download button
* API Response meta box for grouped products — collapsible JSON display with syntax highlighting
* Lazy-loaded variation API responses via AJAX with individual collapsible sections
* Single grouped product sync — "Sync this product" button now works for variable/grouped products
* Grouped product ID stored as variation meta for direct group membership lookup
* Price fix: "Prices managed outside Skwirrel" now also protects simple product prices (previously only variations)
* Quick-scroll link from Skwirrel sidebar meta box to API response section

= 3.2.2 =
* New "Prices managed outside Skwirrel" setting — when enabled, the PIM sync no longer overwrites existing variation prices with 0 when the PIM payload contains no price. Use this when prices are synced from a separate system (e.g. ERP).

= 3.2.1 =
* Single-variant groups synced as simple products instead of variable products
* Custom features as variation axes alongside ETIM features
* Custom feature matching by ID with translated labels
* Attribute label auto-update for numeric and cc_ prefixed labels
* No duplicate attributes for custom feature variation axes
* Phase 3 custom class fetch when grouped products enabled

= 3.2.0 =
* Custom class collection ID — new required setting for product fetching
* Custom classes included in bulk product fetch
* Text features (type T) now stored as product attributes
* GTIN / Variant visibility toggles (default: hidden)
* Sync aborts with a clear error when custom class collection ID is not configured

= 3.0.0 =
* Virtual product content — variable products inherit name, descriptions, categories from virtual products
* Variation slugs — deterministic URL slugs generated from attribute values during sync
* Variation permalinks — optional clean URLs: /product/{product-slug}/{variation-slug}/
* Enhanced Skwirrel meta box — navigation links between parent and variation products
* Theme API — helper functions for theme developers (variation URLs, thumbnails, default variations)
* Example theme snippets in snippets/ directory

= 2.6.2 =
* Fix false "not managed by Skwirrel" message on variable products

= 2.6.1 =
* Fix related products API flag and add smart relation type mapping (auto/cross-sells/upsells/both)

= 2.6.0 =
* Related products sync — sync Skwirrel related products as WooCommerce cross-sells, upsells, or both
* New settings for related products mapping type

= 2.5.0 =
* Variant label setting — choose which field shows in the variant dropdown (SKU, ERP description, or product name)
* Custom class attribute visibility filter — control which custom class attributes are visible on the product page

= 2.4.4 =
* Fix "Stop sync" button — now checks during phases, not just between them
* Show failed sync timestamp and error in status card

= 2.4.3 =
* Flush WooCommerce object cache after every product in all processing phases

= 2.4.2 =
* Flush WordPress object cache between sync phases to reclaim memory

= 2.4.1 =
* Fix OOM in grouped products pre-sync phase — flush wpdb memory between API pages

= 2.4.0 =
* Deferred attribute fetch — ETIM and custom classes fetched per-product in attribute phase instead of bulk fetch

= 2.3.5 =
* Aggressive wpdb memory cleanup after every product in all sync phases

= 2.3.4 =
* Fix OOM during fetch — flush wpdb between batches, default batch size 10

= 2.3.3 =
* Fix OOM during fetch phase — smaller API batches + wpdb memory flush between pages

= 2.3.2 =
* Fix unexpected output during plugin activation

= 2.3.1 =
* Use WordPress timezone for log filenames instead of UTC

= 2.3.0 =
* Database-backed sync queue — product data stored in temporary DB table instead of memory, preventing OOM crashes on low-memory servers
* Products processed one at a time per phase via cursor pattern (O(1) memory usage)

= 2.2.9 =
* Convert existing simple products to variations when a grouped product sync encounters a duplicate SKU
* Reduce memory usage during phased sync by freeing heavy product data after each phase

= 2.2.8 =
* Simplify per-product category assignment — use resolved map from tree sync instead of recursive per-product resolution

= 2.2.7 =
* Add "Stop sync" button to progress banner — abort a running sync from the dashboard
* Log timestamps now respect the WordPress timezone setting

= 2.2.6 =
* Add include_contexts to category API call, improve category sync diagnostics

= 2.2.5 =
* Fix approved download directory: enable existing disabled directories during sync

= 2.2.4 =
* Fix category sync — use correct API parameter name for fetching categories
* Fix per-product category assignment when API returns ID-only category data

= 2.2.3 =
* Dark terminal-style log viewer with syntax highlighting for log levels
* JSON objects and sync separators styled for readability

= 2.2.2 =
* Raise PHP memory limit at sync start to prevent OOM crashes on large API responses
* Detect fatal errors (OOM) during sync and record them as failed results
* Fixes silent sync failures showing stale success status

= 2.2.1 =
* Separate "Sync Logs" settings section with per-trigger log mode (per sync or per day)
* Add "Manual (no auto-delete)" option to log retention
* Fix super category ID field width to match selection IDs field

= 2.2.0 =
* Per-sync log files — each sync run writes to its own log file for easy debugging
* Manual syncs get a unique log file; scheduled syncs share a daily log file (appended)
* Log viewer modal in sync history — click "View" to read log contents inline
* Configurable log retention (12h, 1d, 2d, 7d, 30d) — old files cleaned up automatically

= 2.1.5 =
* Recursively fetch full category tree from API — all depth levels are now synced, not just direct children of the super category

= 2.1.4 =
* Auto-register WP uploads directory as WooCommerce approved download directory during sync
* Fixes "downloadable file not in approved folder" errors for imported PDFs

= 2.1.3 =
* Store Skwirrel API response for all product types: variations and variable product shells now also save _skwirrel_api_response

= 2.1.2 =
* Fix grouped products ignoring dynamic selection ID — post-filter groups against selection product list
* Fetch allowed product IDs from selection before processing groups, skip groups with no matching members

= 2.1.1 =
* Fix grouped products ignoring selection ID filter — pass dynamic_selection_id to getGroupedProducts
* Store raw Skwirrel API response as _skwirrel_api_response post meta during sync
* Add dedicated "Skwirrel API Response" meta box on the product edit screen showing the stored JSON

= 2.1.0 =
* Selection ID is now required — sync aborts if no selection ID is configured
* Add "Show API response" button in the Skwirrel product meta box to view raw JSON from the API
* Reduce batch size maximum from 500 to 50, default from 100 to 10
* Fix translation: selection ID hint incorrectly said "category IDs" in all locales

= 2.0.8 =
* Add raw API response logging for getCategories and per-product _categories data (verbose mode)

= 2.0.7 =
* Fix category tree sync failing when API returns single root category object instead of array
* Categories from getCategories are now correctly extracted from root _children when super category ID is used

= 2.0.6 =
* Add diagnostic logging for category-to-product assignment to trace resolution failures
* Log each category resolve step (meta lookup, name fallback, creation) when verbose logging is enabled
* Warn when categories are extracted from API but no WooCommerce term IDs could be resolved
* Check and log wp_set_object_terms errors instead of silently ignoring failures

= 2.0.5 =
* Update README and plugin description to reflect current feature set
* Replace "ERP/PIM" references with "PIM" throughout
* Update WooCommerce minimum to 8.0 (9.6+ recommended for native brand support)
* Update WooCommerce tested up to 10.6
* Update WordPress tested up to 6.9.4

= 2.0.4 =
* Inline "Update on re-sync" toggle in Permalinks section — saves instantly via AJAX
* Slug warning only shown when "Update on re-sync" is enabled and settings have changed
* Persistent hint when re-sync is enabled warning about URL overwrite and SEO impact
* Add batch size hint text (1–500)

= 2.0.3 =
* Add Permalinks section in Settings showing current slug configuration with link to Permalinks page
* Show warning when slug settings change, advising a full resync and potential link breakage
* Add Selection IDs hint link to Skwirrel selections page (dynamic subdomain URL)

= 2.0.2 =
* Add GTIN / Manufacturer product code search filter on product list page
* Store product GTIN and manufacturer product code as dedicated meta during sync
* Add subtitles to Debug and Danger Zone dashboard blocks

= 2.0.1 =
* Rename "Collection IDs" to "Selection IDs"
* Add API token creation link with dynamic subdomain URL
* Add category finder link on Super category ID field
* Move WordPress admin notices below the Skwirrel header

= 2.0.0 =
* New admin dashboard with block-grid layout replacing the tab-based UI
* Sync progress banner with 6-phase checklist and live counters
* Date-grouped sync history table (Today, Yesterday, day name, or date)
* Settings page redesigned with grouped fieldsets and Tailwind-inspired styling
* Simplified API connection: subdomain-only input with visual prefix/suffix
* Remove auth type selector (always uses static token)
* Sync Logs block links directly to WooCommerce logs
* Debug and Danger Zone inline in the dashboard grid
* Full translation update for all 7 locales (nl_NL, nl_BE, de_DE, fr_FR, fr_BE, en_US, en_GB)

= 1.10.1 =
* Add Domain Path header for automatic translation loading on WordPress 6.7+
* Add load_plugin_textdomain() fallback for older WordPress versions
* Add nl.mo/nl.po locale files for sites using "nl" instead of "nl_NL"
* Fix Danger Zone purge not removing all product attribute taxonomies — now cleans up all orphaned attributes, not just etim_* and skwirrel_variant

= 1.10.0 =
* Phased sync architecture — sync now runs in 5 sequential phases (fetch, products, taxonomy, attributes, media) instead of processing everything per product
* Live progress checklist on the sync tab — shows current phase, status icon, and counter (e.g. "247 / 500")
* Performance fix: restore getProducts API call for full sync (faster than getProductsByFilter with empty filter)
* Auto-refresh now only fires on the sync tab, not on other admin pages

= 1.9.9 =
* Fix Danger Zone purge silently timing out on large datasets — add set_time_limit(0) to prevent PHP timeout
* Rewrite Danger Zone purge to use bulk SQL — orders of magnitude faster on large stores

= 1.9.8 =
* Add "Skwirrel" meta box on product edit screen with single-product sync button

= 1.9.7 =
* Add configurable "Product manufacturer base" slug on Settings → Permalinks page

= 1.9.6 =
* Fix product sync failing when downloadable files are not in WooCommerce's approved directory
* Downloads/documents errors no longer block category, brand and manufacturer assignment

= 1.9.5 =
* Brand sync always active (uses WooCommerce native product_brand taxonomy)
* Add "Sync manufacturers" setting with product_manufacturer taxonomy
* Default product list columns: hide Tags, show Manufacturers
* Add "Filter by manufacturer" dropdown on product list page
* Manufacturers column ordered after Brands, before Date

= 1.9.3 =
* Fix variable product variation attributes: recover parent attribute options from child variation post meta when deferred terms are empty
* Convert non-variation parent attributes to global WooCommerce taxonomy-based attributes
* Fix brand not assigned to variable products: propagate brand from child variations to parent
* Fix categories not assigned to variable products: propagate categories from child variations to parent

= 1.9.2 =
* Remove legacy pa_variant migration code (no live installs to migrate)
* Fix simple product attributes: save as global WooCommerce taxonomy-based attributes instead of custom text attributes, so they appear in layered navigation and product filters

= 1.9.1 =
* Remove legacy pre-1.8.0 Action Scheduler cleanup code (old slug reference)

= 1.9.0 =
* Move remaining inline event handlers (onchange, onclick) to enqueued inline script for WordPress.org compliance
* Fix stale debug log path in admin help text

= 1.8.4 =
* Add non-variation ETIM and custom class attributes to parent variable products during sync

= 1.8.3 =
* Fix empty variation attribute dropdowns on variable products by deferring parent attribute term updates to a single batch flush after all variations are processed

= 1.8.2 =
* Replace all inline `<script>` and `<style>` tags with proper wp_enqueue_script/wp_add_inline_script/wp_add_inline_style calls
* Rename plugin display name to "Skwirrel PIM sync for WooCommerce"

= 1.8.1 =
* Fix variation attribute labels showing raw ETIM codes (e.g. "EF002671") instead of human-readable names
* Add missing `include_etim_translations` and `include_languages` to `getGroupedProducts` API call

= 1.8.0 =
* Rename plugin slug from `skwirrel-pim-wp-sync` to `skwirrel-pim-sync` (WordPress.org restricts "wp" in plugin slugs)
* Update text domain, Action Scheduler group, logger source, and admin page slug
* Rename main plugin file and all language files to match new slug
* Add activation cleanup for old Action Scheduler group from pre-1.8.0
* Existing settings, synced products, and translations are fully preserved

= 1.7.1 =
* Remove deprecated load_plugin_textdomain() call (WordPress 4.6+ auto-loads translations)
* Fix unescaped SQL parameters in purge handler with proper $wpdb->prepare() placeholders
* Fix direct database query caching warning in taxonomy manager
* WordPress Plugin Check compliance improvements

= 1.7.0 =
* Slug settings moved to Settings → Permalinks page (alongside WooCommerce product permalinks)
* New "Update slug on re-sync" option: update existing product slugs during sync
* Sync history: new "Trigger" column showing Manual, Scheduled, or Purge
* Purge (delete all) now adds an entry to sync history and preserves last sync status
* Backward compatible slug settings migration from plugin settings to Permalinks page
* New unit tests for slug resolver (16 tests)
* Updated translations (all languages)

= 1.6.0 =
* Product slug configuration: choose slug source field (product name, SKU, manufacturer code, external ID, Skwirrel ID)
* Slug suffix on duplicate: configurable fallback field appended when slug already exists
* New class: Slug Resolver for deterministic product URL slugs

= 1.5.0 =
* Major refactoring: SyncService split from ~2200 lines into focused sub-classes
* New classes: ProductUpserter, ProductLookup, SyncHistory, PurgeHandler, CategorySync, BrandSync, TaxonomyManager, EtimExtractor, CustomClassExtractor, AttachmentHandler
* SyncService reduced to ~480 lines (pure orchestrator)
* ProductMapper reduced to ~460 lines (delegates to focused sub-classes)
* All existing public APIs preserved — no breaking changes

= 1.4.0 =
* Brand sync: Skwirrel brands synced into WooCommerce product_brand taxonomy
* Category tree sync: sync full category tree from a configurable super category ID
* Sync progress indicator: spinning icon on menu item, blue status bar with auto-refresh
* Sync button disabled while sync is in progress
* Heartbeat mechanism: sync status auto-expires after 60s without activity
* Purge: danger zone now also deletes product brands
* Settings save clears sync-in-progress state
* i18n: all UI strings switched to English source text
* Updated translation files (POT + nl_NL, nl_BE, de_DE, fr_FR, fr_BE, en_US, en_GB)

= 1.3.2 =
* i18n: all UI strings switched to English source text
* Updated translation files (POT + nl_NL, nl_BE, de_DE, fr_FR, fr_BE, en_US, en_GB)
* Added new translation entries for tabbed UI, custom classes, danger zone and delete protection
* Recompiled all .mo binary translation files

= 1.3.1 =
* Deep category tree sync: full ancestor chain from nested _parent_category (unlimited depth)
* Custom Class sync: product-level and trade-item-level custom classes as WooCommerce attributes
* Custom Class feature types: A (alphanumeric), M (multi), L (logical), N (numeric), R (range), D (date), I (internationalized)
* Custom Class text types T and B stored as product meta (_skwirrel_cc_* prefix)
* Whitelist/blacklist filtering on custom class ID or code
* New settings: sync_custom_classes, sync_trade_item_custom_classes, custom_class_filter_mode, custom_class_filter_ids

= 1.3.0 =
* Admin UI: tabbed layout (Sync Products, Instellingen, Logs)
* Sync status and history now shown on the default Sync Products tab
* Sync button moved to page title, visible on all tabs
* Logs and variation debug instructions on dedicated Logs tab
* Fixed GitHub release workflow: version is read from plugin file, no more auto-incrementing

= 1.2.3 =
* WordPress Plugin Check compliance: translators comments, ordered placeholders, escape output
* WordPress Plugin Check compliance: phpcs:ignore for direct DB queries, non-prefixed WooCommerce globals, nonce verification
* Use WordPress alternative functions (wp_parse_url, wp_delete_file, wp_is_writable)
* Translate readme.txt to English

= 1.2.2 =
* Version bump in preparation for release

= 1.2.1 =
* Update text domain and constants for Skwirrel PIM Sync rebranding

= 1.2.0 =
* Rebranded to Skwirrel PIM Sync
* Added unit tests for MediaImporter, ProductMapper, and related components
* Added WordPress.org auto-deploy workflow
* Added automated versioning, tagging, and release workflow

= 1.1.2 =
* Version bump
* Fix duplicate products during sync: 3-step lookup chain + SKU conflict prevention

= 1.1.1 =
* Delete protection: warning banners on Skwirrel-managed products and categories
* Purge stale products and categories after full sync
* Category sync with parent-child hierarchy support
* Collection ID filter for selective synchronisation
* Translation files (POT + nl_NL, nl_BE, en_US, en_GB, de_DE, fr_FR, fr_BE)
* New settings: purge_stale_products, show_delete_warning, collection_ids, sync_categories, include_languages, image_language
* PHPStan, PHP_CodeSniffer, and Pest PHP test framework
* WooCommerce 10.5 compatibility

= 1.0.0 =
* Initial release
* Full product synchronisation
* Variable products with ETIM variation axes
* Image and document import
* Delta synchronisation support
