Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ local development environment.
5. [Plugin features](./doc/plugin-features.md): An overview of the features of
our plugin.
6. [wp-config.php](./doc/wp-config.md): Our *wp-config.php* settings.
7. [Async REST migration](./doc/async-rest-migration.md): API response caching
and background (cron) audio generation on WordPress VIP.
8. [Legacy meta migration](./doc/legacy-meta-migration.md): The v7 post-meta
cleanup and downgrade-safety guarantees.
9. [REST meta visibility](./doc/rest-meta-visibility.md): How BeyondWords post
meta is exposed (or hidden) over the WordPress REST API.
10. [Video settings payload](./doc/video-settings-payload.md): Why the plugin
sends a full `video_settings` object to the content endpoint.
11. [Preselect "Generate audio"](./doc/preselect-generate-audio.md): The
setting's stored format and how the editor and save path honour it.
12. [Settings internals](./doc/settings-internals.md): Settings-error
transport and the API connection check.

## Links

Expand Down
15 changes: 3 additions & 12 deletions cypress.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ const { defineConfig } = require( 'cypress' );
const util = require( 'util' );
const exec = util.promisify( require( 'child_process' ).exec );

// Track if we've done the one-time setup for this test run
let hasSetupDatabase = false;

/**
* Helper function to execute WP-CLI commands
* Automatically handles CI vs local environment differences
* Execute WP-CLI commands, handling CI vs local environment differences.
*
* @param {string|string[]} commands - Single command or array of commands
* @param {Object} options - Optional configuration
Expand All @@ -30,9 +28,8 @@ async function execWp( commands, options = {} ) {
return returnResult ? lastResult : undefined;
}

// CI sets these via env vars; locally cypress.env.json fills in the gaps.
// We read cypress.env.json explicitly so we can keep allowCypressEnv:false
// (Cypress 15 deprecates auto-merging cypress.env.json into Cypress.env()).
// CI sets these via env vars; locally cypress.env.json fills the gaps — read it
// explicitly to keep allowCypressEnv:false (Cypress 15 deprecates auto-merging).
const localEnv = ( () => {
try {
return require( './cypress.env.json' );
Expand Down Expand Up @@ -93,10 +90,8 @@ function setupNodeEvents( on, config ) {
const apiKey = config.env.apiKey || '';
const projectId = ( config.expose && config.expose.projectId ) || '';

// implement node event listeners here
on( 'task', {
async setupDatabase() {
// One-shot per test run; bails if already set up.
if ( hasSetupDatabase ) {
// eslint-disable-next-line no-console
console.log(
Expand All @@ -108,7 +103,6 @@ function setupNodeEvents( on, config ) {
// eslint-disable-next-line no-console
console.log( ' - Running database setup...' );

// Reset database and activate plugins
await execWp( [
'plugin activate wp-reset',
'reset reset --yes',
Expand Down Expand Up @@ -192,7 +186,6 @@ function setupNodeEvents( on, config ) {
postDate = '',
} = options;

// Escape single quotes in title and content
const escapedTitle = title.replace( /'/g, "'\\''" );
const escapedContent = content.replace( /'/g, "'\\''" );

Expand Down Expand Up @@ -286,7 +279,6 @@ function setupNodeEvents( on, config ) {
const { pattern, exclude = [] } = options;

try {
// Get all options matching pattern
const listCmd = `option list --search='${ pattern }' --field=option_name`;
const result = await execWp( listCmd, { returnResult: true } );

Expand All @@ -295,7 +287,6 @@ function setupNodeEvents( on, config ) {
.split( '\n' )
.filter( Boolean );

// Delete each option (except excluded ones)
for ( const optionName of optionNames ) {
if ( ! exclude.includes( optionName ) ) {
await execWp( `option delete ${ optionName }` );
Expand Down
63 changes: 63 additions & 0 deletions doc/preselect-generate-audio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Preselect "Generate audio"

How the **Preselect ‘Generate audio’** setting decides whether the Generate
Audio toggle starts checked, and how that choice is honoured at save time
without dirtying the post. Implemented in
[src/settings/class-preselect.php](../src/settings/class-preselect.php),
[src/editor/components/generate-audio/](../src/editor/components/generate-audio/)
and [src/post/class-sync.php](../src/post/class-sync.php).

## Stored format (`beyondwords_preselect` option)

One entry per compatible post type, each with a `mode`:

```php
[
'post' => [ 'mode' => 'all' ], // preselect for every post
'page' => [
'mode' => 'terms', // gate by taxonomy terms
'terms' => [
'category' => [ 12, 34 ],
'genre' => [ 56 ],
],
],
// a missing post type (or mode 'none') means never preselect
]
```

### Legacy (pre-7.0.0) shapes

`Updater::run()` migrates the old shapes on upgrade:

- `'1'` for a post type meant "preselect the whole post type" → `mode: all`.
- A bare `[ taxonomy => [ term_ids ] ]` array meant term-gating → `mode: terms`.

## Editor behaviour: derive, don't write

The block editor does **not** write `beyondwords_generate_audio` meta to show a
preselected toggle — it *derives* the checked state from the Preselect setting.
An untouched post therefore stays clean (no dirty state, no meta write) until
the user actually changes something.

The classic editor's checkbox writes an explicit `'1'`/`'0'` on save. Its
metabox JS only handles live changes (project/taxonomy edits while the screen
is open); the server renders the correct initial state, and the JS stops
adjusting once the user toggles the checkbox themselves so a deliberate choice
is never clobbered.

## Save-time decision

Because a preselected-but-untouched post has no meta, the generate decision
must come from the setting at save time:

1. An explicit `beyondwords_generate_audio` value always wins
(`'1'` = generate, `'0'` = don't).
2. When unset, `Sync::should_generate_audio_for_post()` falls back to
`Preselect::should_preselect_for_post()` — but **only for editor/REST
saves** (`REST_REQUEST`). Programmatic and imported posts
(`wp_insert_post()`, WXR import, cron) keep the explicit-meta requirement so
a bulk import never unexpectedly generates audio.

`Preselect::get()` applies an explicit default-value fallback rather than
relying on `register_setting` defaults (those only apply where the setting is
registered), keeping REST/cron decisions in step with what the editor displays.
49 changes: 49 additions & 0 deletions doc/rest-meta-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# REST meta visibility

How BeyondWords post meta is exposed (or hidden) over the WordPress REST API.
Implemented in [src/post/class-sync.php](../src/post/class-sync.php)
(`register_meta()`, `hide_private_meta_from_rest()`).

## Registration model

`Sync::register_meta()` registers **every** BeyondWords post-meta key, per
compatible post type, so that:

- all writes are sanitised (`sanitize_text_field`, or the strict
`Meta::sanitize_content_id()` charset check for `beyondwords_content_id`,
whose value is interpolated into API URL paths);
- the legacy "Custom Fields" panel stays hidden via `is_protected_meta()`
(the block editor's panel can break when plugin meta renders there —
see [gutenberg#23078](https://github.com/WordPress/gutenberg/issues/23078));
- only the keys the block editor actually reads/writes over REST get
`show_in_rest => true`.

Secrets and internal data — the legacy `speechkit_access_key`, cached API
responses, player/voice config — are registered `show_in_rest => false` and
never reach the REST API.

## Legacy keys still exposed (`Sync::REST_LEGACY_META_KEYS`)

On a site upgraded from the legacy SpeechKit plugin, deprecated keys
(`speechkit_project_id`, `speechkit_podcast_id`, `_speechkit_link`, …) can hold
a post's only BeyondWords data until audio is regenerated under v7. The block
editor reads them in the authenticated `edit` context as a fallback — the error
notice, pending notice, play/preview controls, the "Generate audio" toggle and
the legacy player link all depend on them (see the components under
[src/editor/components/](../src/editor/components/)).

## Hiding private meta from public REST (`Sync::REST_PRIVATE_META_KEYS`)

A handful of REST-exposed keys hold secrets or internal data: BeyondWords API
error strings, the audio preview token, and the legacy player URL.

`WP_REST_Meta_Fields` returns `show_in_rest` meta in the public `view` context
too, with **no capability check** — so an anonymous `GET /wp/v2/posts/:id`
would disclose them. Requesting the `edit` context is itself permission-gated
by the posts controller, so `Sync::hide_private_meta_from_rest()` keeps those
keys only for `edit` requests and strips them from every other response —
closing the anonymous disclosure while leaving the block editor unaffected.
The filter is registered on `rest_api_init` so it only loads for REST requests.

See also: [legacy-meta-migration.md](./legacy-meta-migration.md) for how keys
moved between the `current` and `deprecated` sets.
25 changes: 25 additions & 0 deletions doc/running-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ To run the coverage gate standalone (without re-running the suite):
npm run composer:tests -- test:coverage-check
```

## PHPUnit lore

- **AJAX handler tests** extend `WP_Ajax_UnitTestCase`, which pretends
`DOING_AJAX` is true, routes `wp_die()` to a handler that captures the JSON
body into `$this->_last_response` and throws a `WPAjaxDie*Exception`, and
suppresses the "headers already sent" warning. See
`tests/phpunit/posts-list/test-bulk-edit-ajax.php`.

## Flaky-test lore

Hard-won details behind some non-obvious patterns in the Cypress suite:

- **`cy.getEditorCanvasBody()`** (`tests/cypress/support/commands.js`) must not
introduce a `.then( cy.wrap )` boundary. Pinning the resolved `<body>`
subject breaks Cypress's retry-ability when the block-editor iframe
re-renders during hydration — it fails as *"subject is no longer attached to
the DOM"*, seen mostly on slower PHP 8.0 CI runs. Whether the editor canvas
is an iframe is stable for a given WordPress version, so it is detected once
synchronously via `Cypress.$` instead.
- **Stubbing voices in the block editor doesn't work**: `cy.intercept` on the
REST voices route stubs fine in the classic editor, but the block editor's
`wp.data` store ends up empty. Branches that depend on voice-list contents
(e.g. the single-bucket Model/Voice case) are covered by the classic-editor
spec plus Jest unit tests instead.

## Further reading

* [Xdebug IDE support](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#xdebug-ide-support).
30 changes: 30 additions & 0 deletions doc/settings-internals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Settings internals

Non-obvious plumbing behind the plugin settings screens, implemented in
[src/settings/class-utils.php](../src/settings/class-utils.php) and
[src/settings/class-tabs.php](../src/settings/class-tabs.php).

## Settings-error transport (`Utils::add_settings_error_message()`)

Error notices raised while saving settings must survive the Settings API's
`wp_redirect()` back to the settings page. They are carried in a **transient**,
not `wp_cache_*`: WordPress's default object cache is request-scoped, so on
hosts without a persistent Redis/Memcached drop-in a cache entry would be empty
after the redirect. Transients fall back to the options table, which does
persist. The 30-second TTL deliberately matches core's own `settings_errors`
transient.

## API connection check (`Utils::validate_api_connection()`)

`beyondwords_valid_api_connection` stores a **timestamp** recording the last
successful credential validation. It gates visibility of the Integration /
Preferences tabs via `Tabs::get_visible_tabs()`.

- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT`, so a slow
or unreachable API cannot block admin rendering.
- Transient failures (timeout, DNS, 5xx, `WP_Error`) intentionally leave the
last known-good flag in place — a blip should not lock the operator out of
the settings tabs.
- Only authentication failures clear it: a 401 (in `Client::call_api()`) or a
403 (in the validation itself), after which the settings page re-runs
validation.
36 changes: 36 additions & 0 deletions doc/video-settings-payload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Video settings payload

Why `Content::get_video_settings_params()` sends a full `video_settings` object
instead of just a template ID.

## Backend requirements

Choosing "Video" (or "Audio + video") output opts a post into video generation,
overriding the project's default `video_settings.enabled`. However, the
BeyondWords backend **silently skips video generation** unless the payload
carries all of:

- `enabled: true`
- a non-empty `variants` array
- a non-empty `sizes` array whose entries include `width`/`height`

Earlier plugin versions sent only `template.id` and `sizes[].name`/`enabled`,
which left `variants` empty server-side — so no video was ever produced.

## Approach: mirror the dashboard

The BeyondWords dashboard sends the full object whenever a user customises
video, so the plugin does the same:

1. Seed the payload from the project's default video settings
(`Client::get_video_settings()`, fetched once and cached). The defaults are
fetched for the project the post publishes to — which may be a per-post
override of the global project — so variants and size dimensions match the
project the content POST actually targets.
2. Layer the post's own choices on top:
- the chosen size becomes the only enabled size;
- the chosen video template overrides the project default (omitted when the
post has none, deferring to the project default).
3. Anything the post doesn't customise (`variants`, and the size dimensions) is
echoed straight from the project defaults. The plugin exposes no per-post
variant control, so the project default is the correct value to send.
15 changes: 15 additions & 0 deletions doc/wordpress-vip.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,18 @@ PHP, custom JavaScript, and SVG files. We do not review HTML, CSS, SASS, many
popular third-party JavaScript libraries, or built JavaScript files.

See [Developing with VIP](https://wpvip.com/documentation/developing-with-vip)

## Object-cache notes

On hosts with an external object cache (VIP uses Memcached), transients are not
stored as `_transient_*` rows in the options table, so they cannot be
enumerated or bulk-deleted. This is why:

- the uninstaller's transient cleanup only deletes known keys — anything else
holds a TTL and self-expires;
- the API client salts its cache keys with the project ID + API key
(`Client::cache_key()`), so changing credentials invalidates the cache
implicitly with no flush step.

See also [async-rest-migration.md](./async-rest-migration.md) for the
VIP-gated background (cron) audio generation.
Loading
Loading