diff --git a/doc/async-rest-migration.md b/doc/async-rest-migration.md index bebf55c0..4d015d2e 100644 --- a/doc/async-rest-migration.md +++ b/doc/async-rest-migration.md @@ -3,28 +3,45 @@ Goal: keep blocking BeyondWords API requests off the hot paths — page render and the save request. -## Done (this PR) +## How it works ### Read caching -The editor-dropdown reads (`Client::get_languages()`, `get_voices()`, -`get_summarization_settings()`, `get_summarization_settings_templates()`, -`get_video_settings()`, `get_video_settings_templates()`) now cache successful -responses in transients (`Client::cached_get()`, 15-min TTL). These power both -the block-editor REST proxies and the classic-editor render, so both get fewer -and more resilient API calls. +The editor-dropdown reads go through `Client::cached_get()`, which stores the +decoded response in a transient: +[src/api/class-client.php](../src/api/class-client.php). + +- `Client::get_languages()` +- `Client::get_voices()` (and `get_voice()`, which filters that list) +- `Client::get_project()` +- `Client::get_video_settings()` +- `Client::get_summarization_settings_templates()` +- `Client::get_video_settings_templates()` + +These power both the block-editor REST proxies and the classic-editor render, so +both get fewer and more resilient API calls. - The cache key is salted with the project ID + API key, so changing either invalidates implicitly — no flush needed, which matters on object-cache hosts (e.g. VIP) where `_transient_*` rows can't be enumerated. -- Only 2xx array responses are cached; errors retry on the next call. +- Successful 2xx array responses are cached for `Client::CACHE_TTL` (15 + minutes). +- Failures are negative-cached: an empty array is stored for the shorter + `Client::CACHE_TTL_ON_ERROR` (2 minutes). Because `cached_get()` returns any + value it finds (`false !== $cached`), that stored `[]` short-circuits the next + fetch, so an unreachable API is probed at most once per interval rather than + on every render. +- Requests use `Client::DEFAULT_REQUEST_TIMEOUT` (3 seconds), except voices, + which uses `Client::VOICES_REQUEST_TIMEOUT` (8 seconds) — it is the one slow + endpoint, and the default would abandon (and then negative-cache) many + cold-cache fetches. ### Background create/update (VIP only) -The blocking `create_audio`/`update_audio` call on `save_post` made saving slow. -`Sync::on_add_or_update_post()` now defers `generate_audio_for_post()` to a -WP-Cron single event (`Sync::GENERATE_AUDIO_CRON_HOOK`) so the save request -returns immediately. +The blocking `create_audio`/`update_audio` call on `wp_after_insert_post` made +saving slow. `Sync::on_add_or_update_post()` defers `generate_audio_for_post()` +to a WP-Cron single event (`Sync::GENERATE_AUDIO_CRON_HOOK`) so the save request +returns immediately: [src/post/class-sync.php](../src/post/class-sync.php). - **Gated to WordPress VIP only** (`Sync::is_async_generation_enabled()`), detected by VIP-only cron symbols (`Automattic\WP\Cron_Control\Main`, @@ -33,16 +50,51 @@ returns immediately. WP-Cron from a real system cron, so the deferred job fires promptly. - Overridable via the `beyondwords_async_generate_audio` filter. - The cron job re-runs `generate_audio_for_post()`, which re-checks eligibility - and captures the API response → writes `content_id` / `preview_token` / voice - meta exactly as the synchronous path does. No webhook/poll needed. + and passes the API response to `process_response()` → writes + `beyondwords_project_id`, `beyondwords_content_id` and + `beyondwords_preview_token` exactly as the synchronous path does. Language and + voice meta are deliberately not copied back from the response: those keys hold + explicit editor choices. No webhook/poll needed. - Error meta is written when the job runs (≈1 cron tick later) rather than inline, so the editor surfaces an API error on its next load. -## Deferred (follow-up) +### Background delete (VIP only) + +The trash and permanent-delete handlers use the same mechanism. +`Sync::on_trash_post()` and `Sync::on_delete_post()` call +`delete_audio_for_post_or_defer()`, which on VIP schedules +`Sync::DELETE_AUDIO_CRON_HOOK` via `schedule_audio_deletion()` and off VIP +deletes inline. + +- The cron event carries the BeyondWords project + content IDs, not a post ID: + the meta is wiped (trash) or the post row is gone (permanent delete) by the + time the job runs. `Sync::delete_audio_by_ids()` handles the event and calls + `Client::delete_audio_by_ids()`. +- Both handlers first call `unschedule_audio_generation()` to drop any pending + generate event for the post. +- The save-time "delete content" branch in `Sync::on_add_or_update_post()` + (`beyondwords_delete_content` meta) still deletes synchronously. + +### Bulk generation -### Classic-editor async reads +`Sync::bulk_generate_audio_for_posts()` backs the posts-list "Generate audio" +bulk action ([src/posts-list/class-bulk-edit.php](../src/posts-list/class-bulk-edit.php)). +It sets `beyondwords_generate_audio` meta on every selected post, then: -The block editor is already fully async (it populates dropdowns from the +- On VIP, queues one cron event per post and returns immediately. +- Off VIP, generates inline up to `Sync::BULK_GENERATE_SYNC_LIMIT` (10) — each + post is a blocking API call, so the cap keeps the batch inside execution + limits. Posts without a content ID are processed first, so re-running the + action makes forward progress. The remainder is reported as a `deferred` + count and surfaced by `Notices::deferred_notice()` + ([src/posts-list/class-notices.php](../src/posts-list/class-notices.php)); + their generate flag is already set, so re-running the action completes them. + +## Known limitations + +### Classic-editor server-side reads + +The block editor is fully async (it populates dropdowns from the `beyondwords/v1` REST proxies via React). The classic editor still calls the API **server-side during metabox render**: @@ -51,19 +103,21 @@ The block editor is already fully async (it populates dropdowns from the - `SettingsFields::render_format_section()` → `get_video_settings_templates()` + `get_video_settings()` Read caching (above) means these only hit the network on a cold cache, so the -per-render blocking is largely mitigated. Fully removing it means rendering empty -``-driven voice/model UI replaced the old -`select-voice` dropdown. +`display-player` rendered the "Display player" checkbox as a row inside +`preview-panel/`; that row was dropped and the visibility control now lives in +the Embed select in +[settings-panel/player-section.js](../src/editor/components/settings-panel/player-section.js). +`src/editor/components/play-audio/` was **not** removed — it is still the +component that renders the player, imported by +[preview-panel/index.js](../src/editor/components/preview-panel/index.js) and by +[block/document-setting/index.js](../src/editor/block/document-setting/index.js). + +`src/editor/components/select-voice/` was also kept. It was refactored in place +into the model-first Language → Model → Voice selects — see +[class-select-voice.php](../src/editor/components/select-voice/class-select-voice.php) +and [classic-metabox.js](../src/editor/components/select-voice/classic-metabox.js) +— and remains the classic editor's voice UI; it was not replaced by a new +component. ## Downgrade safety DB rows for the deprecated keys are preserved until full uninstall, so a plugin -downgrade still renders posts using the legacy values. New posts written under -v7 use the new keys only; the legacy keys are left untouched. +downgrade still renders posts using the legacy values — with the exception of +the `beyondwords_disabled = '1'` rows, which `migrate_disabled_to_embed_none()` +deletes on upgrade to v7.0.0. A downgrade after that migration will not see the +old opt-out flag on those posts; the equivalent state lives in +`beyondwords_embed = 'none'`, which pre-v7 code ignores. + +New posts written under v7 use the new keys only; the remaining legacy keys are +left untouched. diff --git a/doc/plugin-features.md b/doc/plugin-features.md index db3568e5..370a2e6c 100644 --- a/doc/plugin-features.md +++ b/doc/plugin-features.md @@ -1,22 +1,35 @@ # Features +The settings page has three tabs: Authentication, Integration and +Preferences ([src/settings/class-tabs.php](../src/settings/class-tabs.php)). +Only Authentication is shown until the saved credentials have validated +against the API — Integration and Preferences appear after that. + | Screen | Feature | Description | | --------- | ---------------------------------- | ----------- | -| Settings | ‘API Key’ field | Required field to make BeyondWords API requests. | -| Settings | ‘Project ID’ field | Required field to make BeyondWords API requests. | -| Settings | Process excerpts | Should ‘excerpts’ be read aloud in-between titles and body content? | -| Settings | Preselect ‘Generate audio’ | The ‘Generate audio’ checkbox in the BeyondWords sidebar will be automatically checked for selected post types. | +| Settings | ‘API key’ field | Required field to make BeyondWords API requests. Authentication tab. | +| Settings | ‘Project ID’ field | Required field to make BeyondWords API requests. Authentication tab. | +| Settings | ‘Integration method’ | ‘REST API’ (the default) or ‘Magic Embed’, for sites where a theme or plugin prevents saving content via the REST API. Integration tab. | +| Settings | ‘Excerpt’ | Include the post excerpt at the start of generated audio and video. Preferences tab. | +| Settings | ‘Player UI’ | ‘Enabled’, ‘Headless’ or ‘Disabled’. Preferences tab. | +| Settings | Preselect ‘Generate audio’ | ‘Generate audio’ starts checked for the selected post types, either for every post or only for posts with selected taxonomy terms. See [preselect-generate-audio.md](./preselect-generate-audio.md). Preferences tab. | | Posts | BeyondWords column | A table column to display which posts have players or errors. | -| Posts | Bulk action | ‘Generate audio’ for multiple posts at once. | -| Post Edit | ‘Generate audio’ | Optionally select to ‘Generate audio’ for the current post. | +| Posts | Bulk actions | ‘Generate audio’ or ‘Delete audio’ for multiple posts at once, from the bulk actions dropdown or the inline bulk edit. | +| Post Edit | Generate audio toggle | Turn audio generation on or off for the current post. The caption reflects the state: ‘Generation enabled’ or ‘Generation disabled’. | | Post Edit | Player preview | Display a preview of the audio player, or the processing status if the player is not available. | -| Post Edit | Display player | Show/hide the player for the current post. | -| Post Edit | BeyondWords sidebar | All plugin functionality and support in one place (Block Editor only). | -| Post Edit | Prepublish panel | Confirm ‘Generate audio’ immediately before publishing. | +| Post Edit | ‘Embed’ | Pick which generated asset is shown on the post — ‘None’ shows no player. Replaces the ‘Display player’ checkbox removed in 7.0.0; see [legacy-meta-migration.md](./legacy-meta-migration.md). | +| Post Edit | ‘Source’ and ‘Script template’ | Generate from the ‘Post’, a ‘Script’, or ‘Post + script’. ‘Script template’ is shown only when the source includes a script, and defaults to ‘Project default’. | +| Post Edit | ‘Output’ | Generate ‘Audio’, ‘Video’, or ‘Audio + video’. | +| Post Edit | ‘Video template’ and ‘Video size’ | Per-post video options, shown when the output includes video. Each defaults to ‘Project default’. | +| Post Edit | ‘Voice’ | Toggle ‘Customize’ to override the project defaults with a ‘Language’ and a ‘Voice’. ‘Accent’, ‘Native’ and ‘Model’ narrow the voice list; they are filters only and are not saved — the post stores just the language code and voice id. | +| Post Edit | Per-block generation toggle | Exclude an individual block from the generated audio, via the block toolbar button or the block inspector panel. | +| Post Edit | BeyondWords sidebar / metabox | All plugin functionality and support in one place: a sidebar in the Block Editor, and an equivalent metabox in the Classic Editor. | +| Post Edit | Prepublish panel | Confirm audio generation immediately before publishing. | | Post Edit | ‘Insert BeyondWords player’ button | Customize the audio player location while using the Classic Editor. | | Post Edit | ’BeyondWords’ block | Customize the audio player location while using the Block Editor. | | Post Edit | Inspect panel | View and copy the custom data our plugin adds to each post. | | - | JavaScript SDK (Player) | Automatically embed audio versions using a custom audio player. | | - | Settings notice | Display a notice if the BeyondWords Project ID or API Key is missing. | +| - | Site Health | A BeyondWords section in Site Health with the current settings, plugin version, registered (and deprecated) filters, and a REST API connectivity check. | | - | AMP plugin compatibility | Compatibility with the [official AMP plugin](https://en-gb.wordpress.org/plugins/amp/). | | - | WPGraphQL plugin compatibility | Compatibility with the [GraphQL plugin](https://www.wpgraphql.com/). | diff --git a/doc/preselect-generate-audio.md b/doc/preselect-generate-audio.md index 051b00a5..aeb752fd 100644 --- a/doc/preselect-generate-audio.md +++ b/doc/preselect-generate-audio.md @@ -21,10 +21,29 @@ One entry per compatible post type, each with a `mode`: 'genre' => [ 56 ], ], ], - // a missing post type (or mode 'none') means never preselect + // a missing post type means never preselect (`Preselect::MODE_OFF`) ] ``` +`all` and `terms` are the only valid values of the `mode` key; any other `mode` +resolves to `off`. `Preselect::get_mode()` is also tolerant of the pre-7.0.0 +shapes described below, so a legacy `'1'` still reads as `all` and a bare +taxonomy array as `terms` before the migration has run. Nothing writes an +explicit `mode: off` — `sanitize()` removes the post type's entry instead. + +### Default value + +`Preselect::DEFAULT_VALUE` is `[ 'post' => [ 'mode' => 'all' ] ]`, and +`Preselect::get()` passes it as the `get_option()` fallback. So the setting is +not pure opt-in: until the option has been saved at least once, posts +preselect out of the box (other post types do not). + +`sanitize()` merges into the *raw* stored option rather than into `get()`, so +that default is not silently written into the first save. It also reads only +the post types and hierarchical taxonomies rendered in that request, so saving +the settings page never wipes config belonging to a post type or taxonomy that +is currently unregistered. + ### Legacy (pre-7.0.0) shapes `Updater::run()` migrates the old shapes on upgrade: @@ -39,11 +58,20 @@ 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. +The classic editor's checkbox writes an explicit `'1'`/`'0'` on save. The +server renders the correct initial state; its metabox JS +([classic-metabox.js](../src/editor/components/generate-audio/classic-metabox.js)) +then does two things: + +1. Keeps the caption beside the checkbox in step with the checkbox state, + swapping between its `data-label-enabled` and `data-label-disabled` text. +2. In `terms` mode only, re-checks the checkbox as the watched taxonomy terms + are ticked and unticked (`post_category[]` for categories, `tax_input[…][]` + for other taxonomies). The `change` listener is delegated, so terms added + through the "+ Add New Category" UI are covered too. + +Once the user toggles the checkbox themselves, the term sync stops (the +caption still follows), so a deliberate choice is never clobbered. ## Save-time decision diff --git a/doc/rest-meta-visibility.md b/doc/rest-meta-visibility.md index 1e0254ee..cc5bacdf 100644 --- a/doc/rest-meta-visibility.md +++ b/doc/rest-meta-visibility.md @@ -2,7 +2,8 @@ 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()`). +(`register_meta()`, `register_rest_meta_visibility()`, +`hide_private_meta_from_rest()`). ## Registration model @@ -12,25 +13,50 @@ 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)); +- writes are authorised (`auth_callback` requires `edit_posts`); - 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. +`show_in_rest` is set from the `current` key list in +[src/core/class-utils.php](../src/core/class-utils.php) +(`Utils::get_post_meta_keys()`) plus `Sync::REST_LEGACY_META_KEYS`; every other +deprecated key gets `show_in_rest => false` and never reaches the REST API. +That hidden set covers, among others, the legacy `speechkit_access_key`, the +cached SpeechKit API state (`speechkit_response`, `speechkit_info`, +`speechkit_status`, `speechkit_retries`, `speechkit_error`), the per-post +player/voice keys deprecated in v7 (`beyondwords_player_style`, +`beyondwords_player_content`, `beyondwords_title_voice_id`, +`beyondwords_summary_voice_id`, `beyondwords_disabled`) and the remaining +hash/state bookkeeping keys (`beyondwords_hash`, `speechkit_hash`, +`speechkit_disabled`, `speechkit_updated_at`, `publish_post_to_speechkit`, +`_speechkit_text`). The voice and language keys the v7 editor still uses — +`beyondwords_body_voice_id`, `beyondwords_language_code`, +`beyondwords_language_id` — are `current`, so they *are* exposed. + +Hiding meta from the legacy "Custom Fields" panel is a separate mechanism, not +a side effect of registration: `Sync::init()` filters `is_protected_meta`, and +the callback `Sync::is_protected_meta()` flags every key in +`Utils::get_post_meta_keys( 'all' )` as protected. Unlike registration it is +not scoped per post type — the two only share the key list. The block editor's +panel can break when plugin meta renders there — see +[gutenberg#23078](https://github.com/WordPress/gutenberg/issues/23078). ## 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/)). +On an upgraded site, deprecated keys can hold a post's only BeyondWords data +until audio is regenerated under v7. The allow-list spans both eras: the +legacy SpeechKit keys (`speechkit_generate_audio`, `speechkit_project_id`, +`speechkit_podcast_id`, `speechkit_error_message`, `_speechkit_link`) and the +pre-v7 BeyondWords `beyondwords_podcast_id`. The block editor reads them in the +authenticated `edit` context as a fallback — the error notice, pending notice, +play/preview controls and the "Generate audio" toggle all read them (see the +components under [src/editor/components/](../src/editor/components/)). + +`_speechkit_link` is the odd one out: no editor component renders it. Its real +consumer is server-side — `Meta::get_podcast_id()` in +[src/post/class-meta.php](../src/post/class-meta.php) parses the legacy `/a/`, +`/e/` or `/m/` player URL out of it via `get_post_meta()`, which needs no +`show_in_rest`. ## Hiding private meta from public REST (`Sync::REST_PRIVATE_META_KEYS`) @@ -43,7 +69,9 @@ 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. +`Sync::register_rest_meta_visibility()` adds the filter on `rest_api_init` — +once per compatible post type, as `rest_prepare_{$post_type}` — 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. diff --git a/doc/running-tests.md b/doc/running-tests.md index 4a4b1df8..7d3b8fda 100644 --- a/doc/running-tests.md +++ b/doc/running-tests.md @@ -18,14 +18,34 @@ To run an arbitrary `wp-env` command against a specific environment: npm run env -- run cli wp option get siteurl # tests -npm run env:tests run cli wp option get siteurl +npm run env:tests -- run cli wp option get siteurl ``` ## Prerequisites ### 1. Ensure Mock API is enabled -The tests environment has `BEYONDWORDS_MOCK_API` set to `true` by default in [`.wp-env.tests.json`](../.wp-env.tests.json). To override anything per developer, create a `.wp-env.override.json` file (for example, using the `config` section). Restart with `npm run env:tests:start` after editing. +The tests **site** — the WordPress install Cypress drives — has +`BEYONDWORDS_MOCK_API` set to `true` in +[`.wp-env.tests.json`](../.wp-env.tests.json), so Cypress is mocked out of the +box. To change any of that per developer, add a `config` section to +`.wp-env.tests.override.json` — see +[`.wp-env.tests.override.json.example`](../.wp-env.tests.override.json.example) +— and restart with `npm run env:tests:start`. + +PHPUnit does **not** inherit that setting. It boots against the WordPress test +framework's own `wp-tests-config.php`, which never sees wp-env's `wp-config.php` +defines, so [`tests/phpunit/bootstrap.php`](../tests/phpunit/bootstrap.php) +reads `BEYONDWORDS_MOCK_API` and the `BEYONDWORDS_TESTS_*` secrets from +environment variables (CI), falling back to the `config` section of +`.wp-env.tests.override.json` (local). That is why the example file sets +`BEYONDWORDS_MOCK_API` to `true`, and why you must create the override file +(step 3) — without it PHPUnit hits the real API. The bootstrap re-reads the +JSON on every run, so no restart is needed for PHPUnit. + +`.wp-env.override.json` is the equivalent override for the **development** env, +and does not affect the tests env — see +[`.wp-env.override.json.example`](../.wp-env.override.json.example). ### 2. Create test audio in BeyondWords dashboard @@ -63,18 +83,67 @@ dev env. `/tests/cypress/` -To open the Cypress app: +To open the Cypress app (Chrome): ```bash npm run cypress:open ``` -Or to run all tests in terminal (like we do in CI): +### Run only the affected specs + +The full suite takes 20+ minutes, so the normal workflow is to run just the +specs that exercise the source you changed. Every spec starts with a `@group` +and one or more `@covers` header tags, so grep for them: ```bash -npm run cypress:run +# Specs that cover a file or directory +grep -rl '@covers .*content-id' tests/cypress/e2e/ + +# All specs in a group +grep -rl '@group block-editor' tests/cypress/e2e/ ``` +Then run only those: + +```bash +# One spec +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/block-editor/content-id.cy.js' + +# Multiple (comma-separated, no spaces) +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/block-editor/content-id.cy.js,tests/cypress/e2e/classic-editor/content-id.cy.js' + +# Whole group via glob +npm run cypress:run -- --browser chrome --spec 'tests/cypress/e2e/settings/*.cy.js' +``` + +The groups in use, and the header convention for new specs, are listed in +[AGENTS.md](../AGENTS.md). + +Running `npm run cypress:run` with no arguments runs the whole suite in +Cypress's bundled Electron browser against the tests env on port 8889. CI runs +the suite differently — via the `cypress-io/github-action` with +`browser: chrome` against its own WordPress install — so pass +`--browser chrome` locally if you need to match CI's browser. + +## Jest unit tests + +Jest covers the pure JavaScript helpers and the `@wordpress/data` settings +store — logic that is awkward or unreliable to reach through the editor UI. +Test files live next to the source they cover as `*.test.js`, for example +[src/settings/store/index.test.js](../src/settings/store/index.test.js) and +[src/editor/components/inspect-panel/helpers.test.js](../src/editor/components/inspect-panel/helpers.test.js). + +```bash +npm run test:unit + +# Re-run on change +npm run test:unit:watch +``` + +Both scripts wrap `wp-scripts test-unit-js`, which supplies the Jest config, so +there is no `jest.config.js` in the repo. The tests need no wp-env, database or +built assets. CI runs `npm run test:unit` as its own **Jest** job. + ## PHPUnit tests `/tests/phpunit/` diff --git a/doc/settings-internals.md b/doc/settings-internals.md index 2214120b..5e8fff84 100644 --- a/doc/settings-internals.md +++ b/doc/settings-internals.md @@ -16,15 +16,43 @@ 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()`. +`beyondwords_valid_api_connection` stores an **ISO-8601 UTC datetime string** +(`gmdate( \DateTime::ATOM )`) recording the last successful credential +validation; only its truthiness is ever read. It gates visibility of the +Integration / Preferences tabs via `Tabs::get_visible_tabs()`, and also gates +the admin/editor UI bootstrap in `Core\Plugin::init()` +([src/core/class-plugin.php](../src/core/class-plugin.php)), which returns +early without it — so a missing flag hides the editor sidebar, posts-list +column and bulk-edit UI as well as the settings tabs. -- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT`, so a slow +Validation runs when the Authentication tab loads, and is throttled by the +`beyondwords_api_connection_checked` transient +(`Utils::CONNECTION_CHECK_TRANSIENT`), whose TTL is +`Utils::CONNECTION_CHECK_TTL` — 5 minutes. The transient stores an `md5()` +**fingerprint of the credentials**, not a boolean: + +- Inside the window, a stored fingerprint matching the current project ID and + API key means the last result for *those* credentials is trusted and no + request is made. +- The fingerprint is recorded whatever the outcome, so a failing or down API is + throttled too rather than retried on every page load. +- Saving a changed API key or project ID changes the fingerprint, busting the + throttle and forcing an immediate re-check. The fingerprint is computed from + the stored options, so typing in a field has no effect until the values are + saved. + +How the flag itself is updated: + +- If the project ID or API key is missing, the flag is deleted outright and no + request (or throttle write) happens at all. +- The validation request relies on `Client::DEFAULT_REQUEST_TIMEOUT` — 3 + seconds, the WordPress VIP ceiling for a blocking remote request — 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. +- Once a request is made, only authentication failures clear it: a 401 (in + `Client::call_api()`, [src/api/class-client.php](../src/api/class-client.php)) + or a 403 (in the validation itself). Re-validation then happens on the next + Authentication tab load that is not throttled — immediately if the saved + credentials changed, otherwise once the 5-minute window expires. diff --git a/doc/video-settings-payload.md b/doc/video-settings-payload.md index bd639dc0..8750d904 100644 --- a/doc/video-settings-payload.md +++ b/doc/video-settings-payload.md @@ -14,8 +14,8 @@ carries all of: - 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. +A partial payload carrying only `template.id` and `sizes[].name`/`enabled` +leaves `variants` empty server-side, so no video is produced. ## Approach: mirror the dashboard @@ -28,7 +28,9 @@ video, so the plugin does the same: 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; + - when the post sets a Video size, that size becomes the only enabled + size; when the post leaves Video size at "Project default", every size + keeps the project's own `enabled` flag; - 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 diff --git a/doc/wordpress-vip.md b/doc/wordpress-vip.md index d7093ca0..bfb815b8 100644 --- a/doc/wordpress-vip.md +++ b/doc/wordpress-vip.md @@ -25,8 +25,13 @@ 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 uninstaller's transient cleanup + ([src/core/class-uninstaller.php](../src/core/class-uninstaller.php), + `cleanup_plugin_transients()`) is a direct SQL sweep of the options table + by prefix, deleting both the `_transient_beyondwords_%` value rows and the + `_transient_timeout_beyondwords_%` timeout rows. With an external object + cache there are no such rows to match, so the query deletes nothing and the + cached entries expire via their TTL instead; - 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. diff --git a/doc/wp-config.md b/doc/wp-config.md index f7ae08f4..378e1aa5 100644 --- a/doc/wp-config.md +++ b/doc/wp-config.md @@ -3,20 +3,56 @@ To override default behaviour, the following constants can be defined in `wp-config.php`. -## BEYONDWORDS_AUTO_SYNC_SETTINGS +## Behaviour -Should we auto-sync the settings to/from the BeyondWords dashboard? -Defaults to `true`. +### BEYONDWORDS_AUTOREGENERATE + +Setting this to a falsy value stops the plugin regenerating audio for posts +that already have a BeyondWords content ID. The check runs in +[src/post/class-sync.php](../src/post/class-sync.php) only after an existing +content ID has been found, so initial generation for posts without audio is +unaffected. Defaults to regenerating. ```php -define('BEYONDWORDS_AUTO_SYNC_SETTINGS', false); +define('BEYONDWORDS_AUTOREGENERATE', false); ``` -## BEYONDWORDS_AUTOREGENERATE +The value is reported in the "BeyondWords - Text-to-Speech" section of the Site +Health Info screen, see +[src/site-health/class-site-health.php](../src/site-health/class-site-health.php). + +## URL overrides + +The URLs the plugin uses are defined as class constants in +[src/core/class-urls.php](../src/core/class-urls.php). Each one can be +overridden by defining a constant of the same name in `wp-config.php`. These +are intended for local development and testing against non-production +environments. -Should we autoregenerate the audio when an existing Post is updated? -Defaults to `true`. +An override is only applied if it is a non-empty string — the accessors guard +with `strlen()`, so defining a constant as `''` leaves the built-in default in +place. + +| Constant | Default | +| --- | --- | +| `BEYONDWORDS_API_URL` | `https://api.beyondwords.io/v1` | +| `BEYONDWORDS_BACKEND_URL` | `''` (empty) | +| `BEYONDWORDS_JS_SDK_URL` | `https://proxy.beyondwords.io/npm/@beyondwords/player@latest/dist/umd.js` | +| `BEYONDWORDS_AMP_PLAYER_URL` | `https://audio.beyondwords.io/amp/%d?podcast_id=%s` | +| `BEYONDWORDS_AMP_IMG_URL` | `https://beyondwords-cdn-b7fyckdeejejb6dj.a03.azurefd.net/assets/logo.svg` | +| `BEYONDWORDS_DASHBOARD_URL` | `https://dash.beyondwords.io` | + +`BEYONDWORDS_AMP_PLAYER_URL` is a format string: `%d` is the project ID and +`%s` is the content ID. + +`BEYONDWORDS_BACKEND_URL` is legacy: `Urls::get_backend_url()` has no callers in +`src/`, so overriding it changes nothing at runtime. Note that the build also +reads a `BEYONDWORDS_BACKEND_URL` *environment* variable via `DefinePlugin` in +[webpack.config.js](../webpack.config.js) (defaulting to +`https://audio.beyondwords.io`); that is a separate, build-time value and is +unrelated to the `wp-config.php` constant. ```php -define('BEYONDWORDS_AUTOREGENERATE', false); +define('BEYONDWORDS_API_URL', 'https://api.staging.example.com/v1'); +define('BEYONDWORDS_DASHBOARD_URL', 'https://dash.staging.example.com'); ```