# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A WordPress plugin ("Yunits for WP") that syncs content from a Yunits Community platform into WordPress
(agenda items, blogs, news as custom post types; themes as a taxonomy) and exposes WordPress content back
to Yunits through the REST API. PHP >= 8.1, WordPress >= 6.0.

## Commands

```sh
composer i          # installs deps AND runs Strauss (see "Strauss" below) — always use this, not `composer install --no-scripts`
npm i

npm run watch       # webpack dev build of assets/ -> dist/
npm run build       # production build (required before the settings page will load)

composer run unit            # PHPUnit "Unit Test Suite"
composer run unit-coverage   # same + HTML coverage in tests/coverage
composer run phpcs           # WordPress coding standards (config in phpcs.xml)
composer run phpcbf          # auto-fix
```

Tests are `10up/wp_mock` unit tests — there is no WordPress runtime, so every WP function is either mocked
or stubbed. `tests/Unit/bootstrap.php` documents its own load order, and the order is load-bearing:
`ABSPATH` must be defined before anything (all 61 files under `src/` `exit` without it), and the hook shims
in `tests/Unit/stubs/wp-hooks.php` must be declared before `WP_Mock::bootstrap()`, which declares its own
`add_action`/`add_filter` behind `function_exists()`.

Two traps worth knowing before adding tests:

- **Anything defined in `tests/Unit/stubs/` can never be mocked.** `WP_Mock::userFunction()` bails when
  `function_exists()` is already true and silently no-ops. Only put functions there whose behaviour no test
  will want to vary.
- **One expectation per stateful function.** Mockery returns the *first* eligible expectation, so a second
  `WP_Mock::userFunction( 'get_option' )` in a test is shadowed by the one `FakesWordPressState` registers in
  `setUp()`. That trait backs options and transients with an in-memory store for exactly this reason — assert
  against `$this->option_writes` / `$this->transient_deletes` rather than registering another expectation.

`composer run unit` works locally; CI calls `vendor/bin/phpunit` directly because the composer script is
prefixed `clear &&` and runners have no `TERM`. Pushing a tag triggers `deploy.yml`, which builds and
publishes to the WordPress.org SVN repo and creates a GitHub release.

## Architecture

### Bootstrap chain

`yunits-for-wp.php` defines the `YUNITS_FOR_WP_*` constants, loads `vendor-prefixed/autoload.php`,
`src/autoload.php`, Action Scheduler, then instantiates `YunitsForWP\Bootstrap`, and finally registers the
WP-CLI commands.

`Bootstrap` builds a PHP-DI container from `config/php-di.php` (with annotations enabled), resolves the list
of provider **interfaces** in `Bootstrap::get_providers()`, then calls `register()` on all of them followed by
`boot()` on all of them.

### Provider / service pattern

Everything is wired through interfaces. Concrete classes are never referenced by the container consumers:

- `src/Interfaces/**` — one interface per Command, Controller, Provider, Service.
- `config/php-di.php` — maps every interface to `autowire( Concrete::class )`.
- `src/Providers/*` extend `ServiceProvider`; `register()` is where `add_action`/`add_filter`/CPT
  registration happens. A provider either overrides `register()` itself (e.g. `AgendaServiceProvider`
  registers the CPT and admin columns) or holds a `$this->services` array that it delegates to
  (e.g. `AppServiceProvider`).
- `src/Services/*` extend `Service` (no-op `register()`/`boot()` by default).

**Adding a new provider or service requires three edits:** the interface under `src/Interfaces/`, the binding
in `config/php-di.php`, and — for providers — an entry in `Bootstrap::get_providers()`.

### Autoloading

Two separate autoloaders are in play at runtime:

1. `src/autoload.php` — a hand-rolled `spl_autoload_register` that maps the `YunitsForWP\` namespace onto
   `src/` by exploding the class name. The Composer PSR-4 autoloader (`vendor/autoload.php`) is **not** loaded
   by the plugin; the `autoload` block in `composer.json` only matters for dev tooling.
2. `vendor-prefixed/autoload.php` — the Strauss output.

### Strauss (vendor prefixing)

Runtime Composer dependencies (php-di, doctrine/annotations) are rewritten into `vendor-prefixed/` under the
namespace prefix `YunitsForWP\Vendor_Prefixed\` via a `post-install-cmd`/`post-update-cmd` hook. So container
code imports e.g. `YunitsForWP\Vendor_Prefixed\DI\ContainerBuilder` and
`YunitsForWP\Vendor_Prefixed\Psr\Container\ContainerInterface`. Action Scheduler is the exception — it is
loaded directly from `vendor/woocommerce/action-scheduler/action-scheduler.php`, so `vendor/` ships in the
release zip (see `.distignore`).

### Import pipeline

All four content types follow an identical shape. Trigger → WP action → importer → API → post/term:

1. **Triggers** (all fan into `do_action( 'yfw_import_<type>', 1 )` where the arg is page 1):
   - WP-Cron events `yfw_import_<type>_cron`, scheduled by `EventService::schedule()` on `init` using the
     custom intervals in `SettingsServiceProvider::get_cron_schedules()` (`yfw_none`, `yfw_1_hour` … `yfw_1_day`).
   - WP-CLI: `wp yfw:get-agendas import`, `yfw:get-blogs`, `yfw:get-news`, `yfw:get-themes` (`src/Commands/`).
   - REST: `GET /wp-json/yfw/v1/trigger-import?type=…` (settings page buttons and dashboard widget).
2. `EventService::register()` hooks each `yfw_import_<type>` action to the matching `src/Importers/*Importer`.
3. The importer calls `YunitsService` (`get_agenda_items`, `get_blog_items`, `get_news_items`,
   `get_all_themes`) against `/partner-api/v1/*` with `page`/`limit` (batch size 100).
4. `Model::to_post_array()` (`src/Models/`) maps the API payload to a `wp_insert_post` array; all meta keys are
   prefixed with the post type, e.g. `yfw_agenda_item_start_date`. Post content is deliberately **not** run
   through `wp_kses_post` — Yunits is treated as a trusted source so base64 data URLs and iframes survive.
5. Upsert/delete is decided by the `Exists` trait, which looks up `<post_type>_item_id` meta. An item with
   `activeCommon === false` is deleted.
6. **Pagination**: if a batch fills `MAX_BATCH_SIZE`, the importer calls `as_schedule_single_action()` for the
   next page in Action Scheduler group `yunits-for-wp`, bounded by `MAX_PAGES`. Single-page results complete
   inline without the scheduler.
7. `Importer::pause_indexers()`/`resume_indexers()` wrap each batch to disable FacetWP/SearchWP indexing and
   defer term/comment counting. `resume_indexers()` runs from a `finally` and no-ops unless this importer
   actually paused; it returns early while a *per-page* import hook is still pending. That query is scoped to
   the four `yfw_import_*` hooks on purpose — the recurring `yfw_import_*_cron` actions share the
   `yunits-for-wp` group and always have a pending instance, so a group-wide query would never resume.

**Failure handling.** `ApiService::make_request()` returns a `WP_Error` on a transport failure, a non-2xx
status, or an unobtainable token; on 401/403 it drops the cached token and retries exactly once. Each importer
treats a `WP_Error` as an abort: nothing imported, no next page queued, watermark untouched. A `WP_Error` is
used rather than `null` precisely because `! empty()` is true for it, so a missed check fails loudly instead of
looking like an empty last page. Failures are logged and recorded in `yfw_api_last_error` for the dashboard
widget.

**Incremental sync.** Two options per post type: `yfw_last_sync_<post_type>` is the committed watermark that
requests filter on, `yfw_sync_pending_<post_type>` is the current run's start time. Page 1 records the pending
value, every page requests using only the committed one (so the window is stable across a paginated run), and
the pending value is promoted only when a run ends on a short page. A run that fails never commits, so the
next one covers a wider window — safe because the API filter is inclusive and the `<post_type>_item_id` upsert
makes reprocessing idempotent. The WP-Cron relay fires `do_action( 'yfw_import_<type>', 1, true )`: the second
argument marks a full run, which deletes the committed watermark on page 1 so no page filters on
`lastModifiedSince`, and commits a fresh one at the end. Manual triggers (WP-CLI, REST) pass no second argument
and stay incremental. Themes have no server-side incremental filter and are always fetched in full.

Post types / taxonomy constants: `Agenda::POST_TYPE = yfw_agenda`, `Blog::POST_TYPE = yfw_blog`,
`News::POST_TYPE = yfw_news`, `Theme::TAXONOMY = yfw_knowledge_base_theme`.

### API client & auth

`ApiService` reads credentials from the `yfw_settings_general` option and bails silently in its constructor if
`apiBaseUrl`/`clientID`/`clientSecret` are missing — nothing else gets registered in that case.
OAuth2 client-credentials tokens are fetched from `/oauth/v2/token`, wrapped in `TokenService`, and cached in
the `ApiService::TOKEN_CACHE_KEY` transient with a TTL 10 minutes shorter than the token's own lifetime, so the
cache expires before `TokenService::is_expired()` starts returning true. Requested scopes are the
`partner_api_*` ones for the enabled content types, plus `partner_api_theme` unconditionally (the CLI theme
import is reachable with Knowledge Bases off). Because the cached token is scope-agnostic, saving settings
deletes the transient. `YunitsService extends ApiService` and holds the endpoint methods.

### Settings

Five options hold all configuration: `yfw_settings_general`, `yfw_settings_agendas`, `yfw_settings_blogs`,
`yfw_settings_news`, `yfw_settings_knowledge_bases`. Enabling/disabling a type here is what decides whether its
CPT is registered, its OAuth scope is requested, and its cron event is scheduled — so most features are
option-gated at `register()` time.

The admin UI is a React app (`assets/js/admin.js` → `assets/js/admin/containers/Settings.js`) rendered into
`#yfw-settings` in `src/Views/admin/settings-page.php`. It talks to the `yfw/v1` REST namespace
(`src/Controllers/ApiController.php`: `/settings` GET+POST, `/trigger-import`, `/assigned-knowledge-bases`) via
`assets/js/admin/utils/fetchWP.js`, with the nonce and base URL injected as `window.yfwSettings` by
`ResourceService`. `ResourceService` throws if `dist/admin.asset.php` is missing — run `npm run build` first.

Capabilities: the settings page requires `yfw_manage_settings` (granted to administrators on `admin_init`);
the CPTs use fully custom caps (`yfw_edit_agendas`, `yfw_read_agenda`, …) with `map_meta_cap`.

`OIDCServiceProvider` hooks `openid-connect-generic-update-user-using-current-claim` to persist Yunits roles
from the OIDC claim into `yfw_user_roles` / `yfw_user_item_roles` user meta.

## Conventions

- Every PHP file opens with a docblock (`@package Yunits_For_WP`, `@author Yard | Digital Agency`, `@since`)
  followed by an `if ( ! defined( 'ABSPATH' )) exit;` guard — including files under `src/`, after the
  `namespace` declaration.
- Method-level docblocks in providers/services often just use `@inheritDoc`; the real documentation lives on
  the interface.
- WordPress coding standards apply (tabs, `array()` long syntax, Yoda-ish comparisons) but `phpcs.xml` excludes
  a long list of sniffs — notably brace-on-new-line for classes/functions is **required** here, and
  `camelCase`/`snake_case` method names are both tolerated. Run `composer run phpcbf` rather than hand-formatting.
- All user-facing strings use the `yunits-for-wp` text domain.
- Hooks, options, meta keys, transients, caps and CLI commands are prefixed `yfw_` / `yfw:`.
- Commit messages: `type: subject` (`feat:`, `fix:`, `chore:`, `release:`). Older history used `(type):`.

## Releasing

Per the README: update the changelog in `readme.txt` (max 10 entries), bump the version in
`yunits-for-wp.php` (both the header and the `YUNITS_FOR_WP_VERSION` constant), `package.json` and
`readme.txt`, run `npm run build`, regenerate translations with
`wp i18n make-pot . languages/yunits-for-wp.pot --include="src,dist,assets"`, then commit as
`release: vX.Y.Z`, tag `vX.Y.Z` and push both the branch and the tag.

A technical wiki lives at https://github.com/yardinternet/plugin-yunits-for-wp/wiki
