From e9d97b51e21c4987454b34192387239f486372f0 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:09:56 +0700 Subject: [PATCH] peptide-repo-core v0.6.0: peptide schema backfill + Yoast-integrated Drug/MedicalWebPage/FAQ (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backfill psa_*->_pr_* meta (migration 0004 + PubChem fallback); enrich Drug node with {permalink}#drug @id + molecularFormula/Weight/alternateName; rewrite emitter into Yoast schema graph (MedicalWebPage retype + FAQPage, no duplicate nodes). QA: APPROVE (independent) — qa-reviews/peptide-repo-core/2026-06-13-817690c1b6ec2118ddcf3b8cfe8bc5ea7f3933b3.md Agent-Session: peptide-schema-backfill v0.6.0 merge --- ARCHITECTURE.md | 33 +- CHANGELOG.md | 38 +++ CONTEXT.md | 115 +++++++ CONVENTIONS.md | 13 + includes/cpt/class-pr-core-peptide-cpt.php | 55 ++- .../cpt/class-pr-core-schema-sanitizers.php | 152 +++++++++ .../frontend/class-pr-core-jsonld-drug.php | 158 +++++++++ .../frontend/class-pr-core-jsonld-faq.php | 100 ++++++ .../frontend/class-pr-core-jsonld-webpage.php | 134 ++++++++ includes/frontend/class-pr-core-jsonld.php | 188 +++++----- ...e-migration-0004-backfill-peptide-meta.php | 270 +++++++++++++++ .../class-pr-core-migration-runner.php | 1 + .../class-pr-core-pubchem-client.php | 208 +++++++++++ peptide-repo-core.php | 6 +- tests/bootstrap.php | 6 +- tests/unit/test-jsonld.php | 299 ++++++++++++++++ tests/unit/test-migration-0004.php | 322 ++++++++++++++++++ uninstall.php | 25 ++ 18 files changed, 2004 insertions(+), 119 deletions(-) create mode 100644 CONTEXT.md create mode 100644 includes/cpt/class-pr-core-schema-sanitizers.php create mode 100644 includes/frontend/class-pr-core-jsonld-drug.php create mode 100644 includes/frontend/class-pr-core-jsonld-faq.php create mode 100644 includes/frontend/class-pr-core-jsonld-webpage.php create mode 100644 includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php create mode 100644 includes/migrations/class-pr-core-pubchem-client.php create mode 100644 tests/unit/test-jsonld.php create mode 100644 tests/unit/test-migration-0004.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c52fbf0..e6f5dbe 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,6 +70,7 @@ peptide-repo-core/ │ │ │ ├── cpt/ │ │ ├── class-pr-core-peptide-cpt.php # CPT + peptide_category registration (guarded), meta fields, sanitizers +│ │ └── class-pr-core-schema-sanitizers.php # Sanitizers for v0.6.0 schema-input meta (formula, weight, aliases, FAQ) │ │ └── class-pr-core-repo-daily-cpt.php # Repo Daily CPT registration for editorial content │ │ │ ├── taxonomies/ @@ -79,7 +80,9 @@ peptide-repo-core/ │ │ ├── class-pr-core-migration-runner.php # Sequential migration engine │ │ ├── class-pr-core-migration-0001-dosing-rows.php # pr_dosing_rows table │ │ ├── class-pr-core-migration-0002-legal-cells.php # pr_legal_cells table + unique constraint -│ │ └── class-pr-core-migration-0003-candidate-queue.php # pr_ai_candidate_queue table +│ │ ├── class-pr-core-migration-0003-candidate-queue.php # pr_ai_candidate_queue table +│ │ ├── class-pr-core-migration-0004-backfill-peptide-meta.php # Backfill _pr_* schema meta from PSA psa_* keys +│ │ └── class-pr-core-pubchem-client.php # PubChem REST client (used by migration 0004) │ │ │ ├── dto/ │ │ ├── class-pr-core-peptide-dto.php # Typed peptide value object @@ -101,8 +104,11 @@ peptide-repo-core/ │ │ └── class-pr-core-candidate-queue-page.php # AI candidate review admin screen │ │ │ ├── frontend/ -│ │ ├── class-pr-core-disclaimer.php # Shortcode + static API for surface disclaimers -│ │ └── class-pr-core-jsonld.php # schema.org Drug JSON-LD on single peptide pages +│ │ ├── class-pr-core-disclaimer.php # Shortcode + static API for surface disclaimers +│ │ ├── class-pr-core-jsonld.php # Orchestrator: Yoast-integrated Drug/FAQ/MedicalWebPage emission +│ │ ├── class-pr-core-jsonld-drug.php # Drug (+ MolecularEntity) schema piece builder +│ │ ├── class-pr-core-jsonld-webpage.php # MedicalWebPage retype + lastReviewed/reviewedBy enrichment +│ │ └── class-pr-core-jsonld-faq.php # FAQPage schema piece (emits only when _pr_faq_items populated) │ │ │ └── api/ │ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal @@ -197,9 +203,27 @@ Automated extraction populates the queue; humans approve/reject. Approved rows c ### #5: Disclaimer component owned by core Single editorial review point. All consumer plugins render the same versioned disclaimer text. Surface-specific copy (dosing, legal, reconstitution, AI answer) stored in one wp_option. -### #6: JSON-LD from day one +### #6: JSON-LD from day one (v0.6.0: Yoast-integrated) Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object. +**v0.6.0 Yoast integration (ratified jsonld-contract-v1 2026-06-11):** + +- **Integrated path (Yoast active):** PR Core hooks into `wpseo_schema_graph_pieces` (priority 11) to inject Drug and FAQPage pieces. Yoast owns `WebPage`/`BreadcrumbList` — we never emit those. `wpseo_schema_webpage_type` filter retypes the page node to `MedicalWebPage`. `wpseo_schema_graph` (priority 11) enriches the existing WebPage piece with `lastReviewed`, `reviewedBy`, and `audience`. +- **Standalone fallback (no Yoast):** `wp_head` action (priority 99) emits a complete `@graph` (Drug + FAQPage) only. Suppressed automatically when `function_exists('YoastSEO')` is true. +- **Drug `@id`:** `{permalink}#drug` — stable for cross-plugin `about` linkage from PRAutoBlogger articles. +- **Molecular data source:** `_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases` (written by migration 0004, sourced from PSA `psa_*` keys or PubChem REST API). +- **FAQ emission:** `_pr_faq_items` post-meta (JSON array of `{question, answer}` objects). Node emitted only when items exist. +- **Dosing omitted from schema:** YMYL constraint — no dosing data in JSON-LD. + +JSON-LD class tree (v0.6.0): + +| Class | File | Responsibility | +|---|---|---| +| `PR_Core_Jsonld` | `frontend/class-pr-core-jsonld.php` | Orchestrator: registers hooks, Yoast vs. standalone routing | +| `PR_Core_Jsonld_Drug` | `frontend/class-pr-core-jsonld-drug.php` | Builds Drug (+ MolecularEntity) schema node | +| `PR_Core_Jsonld_Webpage` | `frontend/class-pr-core-jsonld-webpage.php` | MedicalWebPage retype + lastReviewed/reviewedBy enrichment | +| `PR_Core_Jsonld_Faq` | `frontend/class-pr-core-jsonld-faq.php` | FAQPage node (emit-only-when-populated) | + ### #7: PR Core owns the `peptide` CPT and `peptide_category` taxonomy (v0.2.0) Prior to v0.2.0, PR Core registered `pr_peptide` while Peptide Search AI registered `peptide` — both claimed the public rewrite slug `peptides`, and WP's rewrite resolver picked PR Core's empty CPT, 404'ing all 89 production peptide detail pages. v0.2.0 consolidates both registrations onto a single `peptide` CPT, owned by PR Core. PSA v4.5.0 drops its CPT/taxonomy registration; its meta boxes (`psa_peptide_data`, `psa_extended_data`), directory shortcode, KB article renderer, and search widget continue operating on the shared `peptide` CPT regardless of who registers it. Registration on both sides is guarded with `post_type_exists()` / `taxonomy_exists()` so deploy order is forgiving. @@ -212,3 +236,4 @@ PR Core `uninstall.php` removes plugin-owned data only: 3. **Does not delete `peptide_category` terms.** Shared-ownership taxonomy; term metadata the site relies on outlasts this plugin. 4. **Deletes `pr_core_*` options.** 5. **Removes `manage_peptide_content` capability from all roles.** +6. **Deletes v0.6.0 schema-input meta keys** (`_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items`) from all peptide posts. These are PR Core-authored values (sourced from PubChem / PSA) and are safe to remove on uninstall. They do not overlap with PSA's `psa_*` namespace — PSA's own meta is unaffected. diff --git a/CHANGELOG.md b/CHANGELOG.md index 48789b7..7c433ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ All notable changes to Peptide Repo Core are documented here. Format: [Semantic Versioning](https://semver.org/). +## [0.6.0] — 2026-06-13 + +### Added +- **Backfill migration 0004** (`class-pr-core-migration-0004-backfill-peptide-meta.php`): Copies PSA-stored PubChem data into new `_pr_*` schema-input meta keys across all published peptide posts. Source: `psa_molecular_formula` → `_pr_molecular_formula`, `psa_molecular_weight` → `_pr_molecular_weight` (strip "Da", floatval), `psa_aliases` → `_pr_aliases` (comma-split, sanitize, JSON array). For 4 posts without PSA data (igf-1-lr3, cagrilintide, ghk-cu, epitalon), falls back to PubChem REST API via new `PR_Core_Pubchem_Client`. Migration is idempotent + re-runnable; skips posts where all three keys are already populated. +- **Schema-input meta keys** (registered in CPT, v0.6.0): + - `_pr_molecular_formula` — sanitized molecular formula string. + - `_pr_molecular_weight` — float as string (g/mol); "Da" suffix normalized out. + - `_pr_aliases` — JSON array of sanitized alternate names. + - `_pr_faq_items` — JSON array of `{question, answer}` objects for FAQPage emission. +- **`PR_Core_Schema_Sanitizers`** (`cpt/class-pr-core-schema-sanitizers.php`): Static sanitizers for the four schema-input meta keys, split from `PR_Core_Peptide_CPT` to keep the CPT file under 300 lines. +- **`PR_Core_Pubchem_Client`** (`migrations/class-pr-core-pubchem-client.php`): Lightweight PubChem REST client with CID/name lookup, synonym fetch, and MAX_RETRIES=3 exponential backoff. Used exclusively by migration 0004. +- **Yoast-integrated JSON-LD emission** (replaces standalone `wp_head` script): + - `PR_Core_Jsonld_Drug` — builds Drug (+ MolecularEntity) schema node with stable `@id = {permalink}#drug`, reads `_pr_*` schema-input meta, `alternateName`/`molecularFormula`/`molecularWeight` (g/mol) emitted only when non-empty. + - `PR_Core_Jsonld_Webpage` — retypes Yoast's WebPage to `MedicalWebPage` via `wpseo_schema_webpage_type`; enriches it with `lastReviewed`, `reviewedBy` (Person or Organization), and `audience` via `wpseo_schema_graph`. + - `PR_Core_Jsonld_Faq` — builds FAQPage node from `_pr_faq_items`; emitted only when items exist. + - `PR_Core_Jsonld` — orchestrator hooking all three builders into Yoast's graph; maintains standalone `@graph` fallback when Yoast is inactive. +- **`CONTEXT.md`** — new domain glossary covering all new terms (`_pr_*` keys, Drug node, MedicalWebPage, FAQPage, Yoast integration contract, PubChem CID). + +### Changed +- `PR_Core_Jsonld` rewritten: replaces standalone `wp_head` script-tag emission with Yoast graph-filter integration. Standalone `@graph` retained as fallback only. **No duplicate WebPage/BreadcrumbList** — Yoast owns those. +- `PR_Core_Peptide_CPT::get_meta_fields()` — adds 4 new `_pr_*` fields; existing `aliases`/`molecular_formula`/`molecular_weight` fields unchanged. +- `PR_Core_Migration_Runner::MIGRATIONS` — adds `PR_Core_Migration_0004_Backfill_Peptide_Meta` at index 3. +- `PR_CORE_TARGET_SCHEMA_VERSION` bumped from 3 to 4. +- `uninstall.php` — purges `_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items` from all peptide posts on uninstall. +- `ARCHITECTURE.md` — §#6 JSON-LD rewritten; §2.9 uninstall spec updated; file tree updated for new classes. +- `CONVENTIONS.md` — schema-input meta convention added; Yoast JSON-LD extension pattern added. + +### Fixed +- `tests/unit/test-migration-0004.php`: `error_log()` stub now guarded with `function_exists()` to prevent PHP fatal redeclaration error in CI PHP Lint (8.1/8.2/8.3). +- `tests/bootstrap.php`: `wp_json_encode` stub updated to accept `int $flags = 0` second argument, matching production call signature; guarded with `function_exists()`. +- `cpt/class-pr-core-schema-sanitizers.php`: `sanitize_molecular_formula()` rewritten from character-allowlist to element-formula grammar validation (Option B). Now strips control characters first, then extracts the maximal leading run of valid `[A-Z][a-z]?\d*` element tokens and group separators. Inputs containing trailing English words (e.g., `"C10H12\ninjection"`) correctly return the formula prefix only (`"C10H12"`); fully invalid inputs (e.g., `""`) return `""`. Parenthesised formulas (e.g., `"C2H5(OH)"`) preserved unchanged. Seven-case unit test coverage added. + +### Technical +- Schema version: `PR_CORE_TARGET_SCHEMA_VERSION` = 4. +- New test files: `tests/unit/test-migration-0004.php`, `tests/unit/test-jsonld.php`. +- Yoast filter names verified against Yoast 27.6: `wpseo_schema_graph_pieces`, `wpseo_schema_webpage_type`, `wpseo_schema_graph`. +- No CAS/DrugBank emission in v0.6.0 (no reliable source — documented in ARCHITECTURE.md for future enrichment sprint). + ## [0.5.0] — 2026-04-27 ### Added diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..41c326c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,115 @@ +# Peptide Repo Core — Domain Glossary (CONTEXT.md) + +This file defines domain terms introduced or used in the peptide-repo-core codebase. +Per AGENT-OPERATING-STANDARD v1.2.0, update this file in the same PR as any change +that introduces, renames, or redefines a domain term. + +--- + +## Peptide + +A short chain of amino acids studied for biological activity. In this codebase, a +"peptide" refers to one of the 93 canonical monograph posts (`post_type = 'peptide'`) +on peptiderepo.com. Each monograph covers identity, mechanism, evidence, and regulatory +status. **No dosing data appears in schema output** (YMYL constraint). + +## Monograph + +A single-peptide page (`/peptides/{slug}/`) containing the canonical research profile +for that compound. Populated from the `peptide` CPT plus its associated meta fields, +dosing rows (`pr_dosing_rows` table), and legal cells (`pr_legal_cells` table). + +## Drug node + +The `schema.org/Drug` structured-data entity emitted on each monograph page. Stable +`@id` is `{permalink}#drug` — this URI is the cross-plugin linking target used by +PRAutoBlogger article `about` references (per `jsonld-contract-v1`, ratified 2026-06-11). + +## MedicalWebPage + +A `schema.org/MedicalWebPage` subtype of `WebPage`. Applied to peptide monograph pages +via the `wpseo_schema_webpage_type` Yoast filter. Signals to search engines and LLMs +that the page carries medical/scientific content reviewed by an organization. + +## FAQPage + +A `schema.org/FAQPage` node emitted when `_pr_faq_items` post-meta is populated. +Contains `Question`/`Answer` pairs curated by the CMO content sprint (v0.6.0 ships +the meta key and emitter; population is a separate sprint). Emitted only when items +exist — never an empty node. + +## _pr_molecular_formula + +Post-meta key (string). Stores the molecular formula of a peptide compound +(e.g., `C62H98N16O22`). Written by migration 0004 from PSA's `psa_molecular_formula` +key. Sanitized: only `[A-Za-z0-9().\[\]{}]` characters allowed. + +## _pr_molecular_weight + +Post-meta key (string). Stores the molecular weight of a peptide compound as a +float string (e.g., `"1419.5"`). Unit: g/mol (equivalent to Da, but g/mol is the +standard SI label used in schema.org `QuantitativeValue`). Written by migration 0004 +from PSA's `psa_molecular_weight` — "Da" suffix stripped, `floatval()` applied. + +## _pr_aliases + +Post-meta key (string, JSON array). Stores alternate names / synonyms for a peptide +(e.g., `'["Body Protection Compound-157","PL 14736","Bepecin"]'`). Written by +migration 0004 from PSA's comma-separated `psa_aliases`. Each alias sanitized via +`sanitize_text_field`. Maps to `schema.org/alternateName` array on the Drug node. + +## _pr_faq_items + +Post-meta key (string, JSON array of objects). Stores curated FAQ entries for a +peptide monograph, each as `{question: string, answer: string}`. Registered in v0.6.0; +population is a CMO content sprint. Maps to `schema.org/FAQPage` when non-empty. +Sanitized by `PR_Core_Schema_Sanitizers::sanitize_faq_items()` on save. +Purged on uninstall. + +## Schema-input meta + +The collective term for the four `_pr_*` meta keys (`_pr_molecular_formula`, +`_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items`) introduced in v0.6.0. These +are the data-layer inputs to the JSON-LD emitter. The emitter reads only these keys +at render time — it never reads PSA's `psa_*` keys directly (no cross-plugin read +coupling). Sourced from PSA via migration 0004 (one-time backfill). + +## psa_* meta + +Post-meta keys written by Peptide Search AI (PSA) plugin using its PubChem integration. +Includes `psa_molecular_formula`, `psa_molecular_weight`, `psa_aliases`, +`psa_pubchem_cid`, `psa_cas_number`, `psa_drugbank_id`. Migration 0004 treats all +`psa_*` values as UNTRUSTED and sanitizes them before writing to PR Core's `_pr_*` keys. +PR Core never reads `psa_*` keys at render time. + +## PubChem CID + +A numeric compound identifier in the NCBI PubChem database (e.g., `9941957` for +BPC-157). Used by migration 0004 as a fallback lookup for the 4 peptide posts that +lack `psa_molecular_formula`. Fetched via `PR_Core_Pubchem_Client`. + +## Yoast integration contract + +The agreed schema architecture ratified 2026-06-11 (`convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`): +PR Core integrates into Yoast's schema graph via `wpseo_schema_*` filters; never +emits duplicate `WebPage` or `BreadcrumbList` nodes; maintains a standalone `@graph` +fallback for when Yoast is inactive. The Drug `@id` convention (`{permalink}#drug`) +is part of this contract. + +## Evidence strength + +Ordered enum: `preclinical`, `case-series`, `observational`, `rct-small`, `rct-large`, +`meta-analysis`. Stored in the `evidence_strength` post-meta key. Does **not** appear +in schema.org output (dosing/evidence YMYL constraint). + +## Verdict + +One of five editorial states assigned to a monograph: `effective`, `promising`, +`mixed`, `insufficient_evidence`, `avoid`. Stored in post content via a verdict card +Gutenberg block. Not surfaced in JSON-LD (dosing YMYL constraint). + +## Migration runner + +`PR_Core_Migration_Runner` — runs numbered migrations (0001–N) sequentially on every +`plugins_loaded`. Compares `pr_core_schema_version` wp_option against +`PR_CORE_TARGET_SCHEMA_VERSION` constant. All migrations are idempotent and re-runnable. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 7defd34..625083c 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -21,6 +21,8 @@ Files live in `includes/` or one level of subdirectory under `includes/`. 3. Add the field to `PR_Core_Peptide_Repository::post_to_dto()` in `repositories/class-pr-core-peptide-repository.php`. 4. Add a form input in `PR_Core_Peptide_Metaboxes::render_identifiers_box()` in `admin/class-pr-core-peptide-metaboxes.php`. 5. The `save_meta()` method reads from the same field list — no changes needed unless custom save logic applies. +6. **Schema-input meta fields** (`_pr_*` keys introduced in v0.6.0) use `PR_Core_Schema_Sanitizers` as the sanitize_callback. Add new sanitizers to `cpt/class-pr-core-schema-sanitizers.php`, not to `PR_Core_Peptide_CPT` (keeps CPT under 300 lines). +7. If the field is purged on uninstall, add it to the `$schema_meta_keys` array in `uninstall.php`. ## How To: Add a New Post-Meta Field to Repo Daily Posts @@ -36,6 +38,7 @@ Files live in `includes/` or one level of subdirectory under `includes/`. 3. Add the class name to `PR_Core_Migration_Runner::MIGRATIONS` array (index = version - 1). 4. Bump `PR_CORE_TARGET_SCHEMA_VERSION` in `peptide-repo-core.php`. 5. All migrations must be idempotent (safe to run twice). +6. **Idempotency caveat (all-or-nothing skip):** migration 0004's skip guard requires *all* populated keys; a post with legitimately empty aliases will re-run on manual re-invoke — this is safe (write_meta only overwrites with non-empty values) but expected. ## How To: Add a New REST Endpoint @@ -50,6 +53,16 @@ Files live in `includes/` or one level of subdirectory under `includes/`. 2. Use `[pr_disclaimer surface="new-surface"]` in templates. 3. Custom copy can be set via `PR_Core_Disclaimer::update_disclaimer()`. +## How To: Extend the JSON-LD Drug Schema + +v0.6.0 uses four focused classes (see ARCHITECTURE.md §#6). To add a new field to the Drug node: + +1. Add the logic to `PR_Core_Jsonld_Drug::build()` in `includes/frontend/class-pr-core-jsonld-drug.php`. +2. Read data from the appropriate `_pr_*` post-meta key (not from PSA `psa_*` keys directly). +3. **Always omit the field when the meta is empty** — never emit blank/null/zero values. +4. To add a new schema piece (not Drug), create a new `PR_Core_Jsonld_` class in `frontend/` and inject it from `PR_Core_Jsonld::inject_graph_pieces()`. +5. The `pr_core_jsonld_peptide` filter on the Drug node array is preserved for consumer plugins. + ## Error Handling - Repository methods return `null` or `0` on failure, never throw. diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index 9af655b..d06b97a 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -68,7 +68,7 @@ class PR_Core_Peptide_CPT { 'aliases' => [ 'type' => 'string', 'default' => '[]', - 'sanitize' => [ __CLASS__, 'sanitize_json_array' ], + 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ], ], 'molecular_formula' => [ 'type' => 'string', @@ -145,6 +145,28 @@ class PR_Core_Peptide_CPT { 'default' => 'current', 'sanitize' => [ PR_Core_Verification_Sanitizers::class, 'sanitize_status' ], ], + // Schema-input meta (v0.6.0): molecular data sourced from PSA psa_* keys via migration 0004. + '_pr_molecular_formula' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_formula' ], + ], + '_pr_molecular_weight' => [ + 'type' => 'string', + 'default' => '', + 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_weight_string' ], + ], + '_pr_aliases' => [ + 'type' => 'string', + 'default' => '[]', + 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ], + ], + // FAQ items (v0.6.0): [{question, answer}] JSON for FAQPage schema. Populated by CMO content sprint. + '_pr_faq_items' => [ + 'type' => 'string', + 'default' => '[]', + 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_faq_items' ], + ], ]; } @@ -160,12 +182,7 @@ class PR_Core_Peptide_CPT { } /** - * Register the `peptide` custom post type. - * - * Guarded with `post_type_exists()`: if another plugin (historically PSA) - * has already registered the `peptide` post type, this call no-ops. This - * makes deploy order between PR Core and PSA irrelevant during the - * PSA v4.5.0 consolidation transition. + * Register the `peptide` CPT. Guarded with post_type_exists() so deploy order is irrelevant. * * Side effects: registers CPT with WordPress. * @@ -218,15 +235,7 @@ class PR_Core_Peptide_CPT { } /** - * Register the `peptide_category` taxonomy. - * - * Guarded with `taxonomy_exists()` for the same reason CPT registration - * is guarded. The 8 existing terms + term_relationships stay intact — - * they key on taxonomy name `peptide_category` in wp_term_taxonomy, - * which is exactly what we register here. - * - * v0.2.0: `pr_peptide_family` taxonomy removed — never populated, never - * surfaced in UI. + * Register the `peptide_category` taxonomy. Guarded with taxonomy_exists(). * * Side effects: registers taxonomy with WordPress. * @@ -272,20 +281,6 @@ class PR_Core_Peptide_CPT { } } - /** Sanitize a JSON array string (e.g., aliases field). */ - public static function sanitize_json_array( $value ): string { - if ( is_array( $value ) ) { - $value = wp_json_encode( array_map( 'sanitize_text_field', $value ) ); - } - - $decoded = json_decode( (string) $value, true ); - if ( ! is_array( $decoded ) ) { - return '[]'; - } - - return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) ); - } - /** Sanitize evidence_strength to allowed enum values. */ public static function sanitize_evidence_strength( $value ): string { $value = sanitize_text_field( (string) $value ); diff --git a/includes/cpt/class-pr-core-schema-sanitizers.php b/includes/cpt/class-pr-core-schema-sanitizers.php new file mode 100644 index 0000000..8d9b24f --- /dev/null +++ b/includes/cpt/class-pr-core-schema-sanitizers.php @@ -0,0 +1,152 @@ +alert(1)" → "" (no valid leading token) + * "" → "" (empty input) + * + * @param mixed $value Raw input value. + * @return string Sanitized formula string, or '' when no valid formula found. + */ + public static function sanitize_molecular_formula( $value ): string { + // 1. Strip control characters (including newlines, tabs, null bytes). + $value = preg_replace( '/[\x00-\x1F\x7F]/', '', (string) $value ) ?? ''; + // 2. Strip HTML tags; then strip remaining unsafe text characters. + $value = sanitize_text_field( wp_strip_all_tags( $value ) ); + if ( '' === $value ) { + return ''; + } + // 3. Extract the maximal leading run of valid element tokens and separators. + // A valid run must start with an element token ([A-Z][a-z]?\d*), not a separator. + $pattern = '/^((?:' . self::FORMULA_TOKEN . '|' . self::FORMULA_SEP . ')+)/u'; + if ( preg_match( $pattern, $value, $m ) ) { + return $m[1]; + } + return ''; + } + + /** + * Sanitize a molecular weight stored as a float string (for _pr_molecular_weight). + * + * Strips any trailing "Da" unit suffix, returns a float string. + * Stored as string in post_meta to avoid float precision issues. + * Returns empty string for invalid/zero inputs. + * + * @param mixed $value Raw input value (e.g., "1419.5" or "1419.5 Da"). + * @return string Float string (e.g., "1419.5"), or "" on invalid input. + */ + public static function sanitize_molecular_weight_string( $value ): string { + $clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( (string) $value ) ) ?? '' ); + $f = (float) $clean; + return $f > 0.0 ? (string) $f : ''; + } + + /** + * Sanitize a JSON array string (e.g., for legacy aliases field or _pr_aliases). + * + * Accepts a JSON string or PHP array. Sanitizes each element individually. + * Input is UNTRUSTED. + * + * @param mixed $value Raw input (JSON string or array). + * @return string JSON array of sanitized strings, or '[]'. + */ + public static function sanitize_json_array( $value ): string { + if ( is_array( $value ) ) { + $value = wp_json_encode( array_map( 'sanitize_text_field', $value ) ); + } + + $decoded = json_decode( (string) $value, true ); + if ( ! is_array( $decoded ) ) { + return '[]'; + } + + return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) ); + } + + /** + * Sanitize FAQ items JSON (for _pr_faq_items). + * + * Expects a JSON array of {question: string, answer: string} objects. + * Sanitizes each question and answer individually. + * Input is UNTRUSTED — malformed entries are silently dropped. + * + * @param mixed $value Raw input (JSON string or PHP array). + * @return string JSON array of sanitized FAQ item objects, or '[]'. + */ + public static function sanitize_faq_items( $value ): string { + if ( is_string( $value ) ) { + $value = json_decode( $value, true ); + } + + if ( ! is_array( $value ) ) { + return '[]'; + } + + $clean = []; + foreach ( $value as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + $question = sanitize_text_field( (string) ( $item['question'] ?? '' ) ); + $answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) ); + if ( '' !== $question && '' !== $answer ) { + $clean[] = [ + 'question' => $question, + 'answer' => $answer, + ]; + } + } + + return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; + } +} diff --git a/includes/frontend/class-pr-core-jsonld-drug.php b/includes/frontend/class-pr-core-jsonld-drug.php new file mode 100644 index 0000000..aad78d4 --- /dev/null +++ b/includes/frontend/class-pr-core-jsonld-drug.php @@ -0,0 +1,158 @@ + Schema.org Drug node. + */ + public function build( PR_Core_Peptide_DTO $peptide ): array { + $permalink = get_permalink( $peptide->id ); + + $node = [ + '@type' => [ 'Drug', 'MolecularEntity' ], + '@id' => $permalink . '#drug', + 'name' => $peptide->display_name ?: $peptide->title, + 'url' => $permalink, + ]; + + if ( '' !== $peptide->excerpt ) { + $node['description'] = $peptide->excerpt; + } + + // alternateName: prefer _pr_aliases (v0.6.0), fall back to DTO aliases field. + $aliases = $this->get_aliases( $peptide ); + if ( ! empty( $aliases ) ) { + $node['alternateName'] = $aliases; + } + + // molecularFormula: prefer _pr_molecular_formula, fall back to DTO field. + $formula = $this->get_formula( $peptide ); + if ( '' !== $formula ) { + $node['molecularFormula'] = $formula; + } + + // molecularWeight: prefer _pr_molecular_weight, fall back to DTO field. + $weight = $this->get_weight( $peptide ); + if ( $weight > 0.0 ) { + $node['molecularWeight'] = [ + '@type' => 'QuantitativeValue', + 'value' => $weight, + 'unitText' => self::UNIT_TEXT, + ]; + } + + // External identifiers: CAS and DrugBank omitted in v0.6.0 (no source). + // @see ARCHITECTURE.md §v0.6.0 schema sprint — future enrichment. + $legal_status = $this->build_legal_status(); + if ( null !== $legal_status ) { + $node['legalStatus'] = $legal_status; + } + + /** + * Filter the Drug schema node for a peptide. + * + * Known listeners: none. + * Registered at: PR_Core::register_public_filters() — carried forward from v0.5.0. + * + * @param array $node Drug schema node. + * @param PR_Core_Peptide_DTO $peptide Peptide DTO. + */ + return apply_filters( 'pr_core_jsonld_peptide', $node, $peptide ); + } + + /** + * Get aliases array from _pr_aliases meta, falling back to DTO aliases. + * + * @param PR_Core_Peptide_DTO $peptide Peptide DTO. + * @return string[] Sanitized alias strings. + */ + private function get_aliases( PR_Core_Peptide_DTO $peptide ): array { + $raw = get_post_meta( $peptide->id, self::META_ALIASES, true ); + + if ( is_string( $raw ) && '' !== $raw ) { + $decoded = json_decode( $raw, true ); + if ( is_array( $decoded ) && ! empty( $decoded ) ) { + return array_values( array_filter( array_map( 'sanitize_text_field', $decoded ) ) ); + } + } + + // Fall back to DTO aliases (legacy bare-key meta). + return $peptide->aliases; + } + + /** + * Get molecular formula from _pr_molecular_formula meta, falling back to DTO. + * + * @param PR_Core_Peptide_DTO $peptide Peptide DTO. + * @return string Sanitized formula string, or '' if not available. + */ + private function get_formula( PR_Core_Peptide_DTO $peptide ): string { + $raw = (string) get_post_meta( $peptide->id, self::META_FORMULA, true ); + if ( '' !== $raw ) { + return sanitize_text_field( $raw ); + } + return $peptide->molecular_formula; + } + + /** + * Get molecular weight float from _pr_molecular_weight meta, falling back to DTO. + * + * @param PR_Core_Peptide_DTO $peptide Peptide DTO. + * @return float Weight in g/mol, or 0.0 if not available. + */ + private function get_weight( PR_Core_Peptide_DTO $peptide ): float { + $raw = (string) get_post_meta( $peptide->id, self::META_WEIGHT, true ); + if ( '' !== $raw ) { + $weight = (float) $raw; + if ( $weight > 0.0 ) { + return $weight; + } + } + return $peptide->molecular_weight; + } + + /** + * Build a standard legalStatus node for research compounds. + * + * Returns null when legalStatus should be omitted (future: when per-peptide + * status is available from legal cells). + * + * @return array|null + */ + private function build_legal_status(): ?array { + return [ + '@type' => 'DrugLegalStatus', + 'description' => esc_html__( 'Not approved for human therapeutic use; supplied for laboratory research purposes only.', 'peptide-repo-core' ), + ]; + } +} diff --git a/includes/frontend/class-pr-core-jsonld-faq.php b/includes/frontend/class-pr-core-jsonld-faq.php new file mode 100644 index 0000000..baba40d --- /dev/null +++ b/includes/frontend/class-pr-core-jsonld-faq.php @@ -0,0 +1,100 @@ +|null FAQPage schema node, or null when no items. + */ + public function build( int $post_id, string $permalink ): ?array { + $items = $this->get_validated_items( $post_id ); + + if ( empty( $items ) ) { + return null; + } + + return [ + '@type' => 'FAQPage', + '@id' => $permalink . '#faq', + 'mainEntity' => array_map( [ $this, 'build_question' ], $items ), + ]; + } + + /** + * Build a single Question node from a FAQ item array. + * + * @param array{question: string, answer: string} $item Sanitized FAQ item. + * @return array Schema.org Question node. + */ + private function build_question( array $item ): array { + return [ + '@type' => 'Question', + 'name' => esc_html( $item['question'] ), + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => esc_html( $item['answer'] ), + ], + ]; + } + + /** + * Read and validate _pr_faq_items meta, returning only well-formed items. + * + * Returns empty array when meta is absent, empty, or malformed. + * Does not trust stored values — re-validates each item at read time. + * + * @param int $post_id WordPress post ID. + * @return array Validated items. + */ + private function get_validated_items( int $post_id ): array { + $raw = get_post_meta( $post_id, self::META_FAQ_ITEMS, true ); + + if ( ! is_string( $raw ) || '' === $raw ) { + return []; + } + + $decoded = json_decode( $raw, true ); + + if ( ! is_array( $decoded ) || empty( $decoded ) ) { + return []; + } + + $valid = []; + foreach ( $decoded as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + $question = sanitize_text_field( (string) ( $item['question'] ?? '' ) ); + $answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) ); + if ( '' !== $question && '' !== $answer ) { + $valid[] = [ + 'question' => $question, + 'answer' => $answer, + ]; + } + } + + return $valid; + } +} diff --git a/includes/frontend/class-pr-core-jsonld-webpage.php b/includes/frontend/class-pr-core-jsonld-webpage.php new file mode 100644 index 0000000..4fd85d7 --- /dev/null +++ b/includes/frontend/class-pr-core-jsonld-webpage.php @@ -0,0 +1,134 @@ +> $graph Yoast schema graph pieces array. + * @param \WPSEO_Schema_Context $context Yoast schema context object. + * @return array> Modified graph. + */ + public function enrich_webpage_piece( array $graph, $context ): array { + if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) { + return $graph; + } + + $post_id = get_the_ID(); + $enrichments = $this->build_webpage_enrichments( (int) $post_id ); + + if ( empty( $enrichments ) ) { + return $graph; + } + + foreach ( $graph as &$piece ) { + if ( ! is_array( $piece ) ) { + continue; + } + + $type = $piece['@type'] ?? ''; + if ( 'WebPage' === $type || 'MedicalWebPage' === $type ) { + foreach ( $enrichments as $key => $value ) { + $piece[ $key ] = $value; + } + break; + } + } + + return $graph; + } + + /** + * Build MedicalWebPage enrichment fields for a peptide post. + * + * @param int $post_id WordPress post ID. + * @return array Enrichment fields to merge into the WebPage node. + */ + private function build_webpage_enrichments( int $post_id ): array { + $enrichments = []; + + // lastReviewed: prefer _pr_last_reviewed, fall back to _pr_last_source_verified, + // then post modified date. + $last_reviewed = (string) get_post_meta( $post_id, self::META_LAST_REVIEWED, true ); + if ( '' === $last_reviewed ) { + $last_reviewed = (string) get_post_meta( $post_id, self::META_LAST_VERIFIED, true ); + } + if ( '' === $last_reviewed ) { + $last_reviewed = get_post_modified_time( 'Y-m-d', false, $post_id ) ?: ''; + } + + if ( '' !== $last_reviewed ) { + $enrichments['lastReviewed'] = sanitize_text_field( $last_reviewed ); + } + + // reviewedBy: Person only when a medical editor is assigned; otherwise Organization. + $editor_id = (int) get_post_meta( $post_id, 'medical_editor_id', true ); + if ( $editor_id > 0 ) { + $editor = get_userdata( $editor_id ); + if ( $editor ) { + $enrichments['reviewedBy'] = [ + '@type' => 'Person', + 'name' => sanitize_text_field( $editor->display_name ), + 'url' => get_author_posts_url( $editor_id ), + ]; + } + } + + if ( ! isset( $enrichments['reviewedBy'] ) ) { + $enrichments['reviewedBy'] = [ + '@type' => 'Organization', + 'name' => 'Peptide Repo', + 'url' => home_url(), + ]; + } + + // audience: MedicalAudience for researchers. + $enrichments['audience'] = [ + '@type' => 'MedicalAudience', + 'audienceType' => 'Researcher', + ]; + + return $enrichments; + } +} diff --git a/includes/frontend/class-pr-core-jsonld.php b/includes/frontend/class-pr-core-jsonld.php index febc3a9..d1f904d 100644 --- a/includes/frontend/class-pr-core-jsonld.php +++ b/includes/frontend/class-pr-core-jsonld.php @@ -4,33 +4,122 @@ declare(strict_types=1); /** * JSON-LD / schema.org structured data emission for peptide pages. * - * What: Emits Drug schema on single peptide pages, Dataset schema on archives. - * Who calls it: PR_Core::init() registers wp_head hook. - * Dependencies: PR_Core_Peptide_Repository for data lookup. + * What: Integrates Drug, MedicalWebPage, and FAQPage schema into Yoast's graph + * on single peptide pages. When Yoast is inactive, emits a standalone + * @graph block via wp_head as a fallback. * - * Consumer plugins can override via pr_core_jsonld_peptide filter. + * Who calls it: PR_Core::init() calls register_hooks(). Yoast hooks fire on + * every frontend page that Yoast processes. * - * @see ARCHITECTURE.md — Section 2.7 JSON-LD output. + * Dependencies: + * - Yoast SEO (wordpress-seo) ≥ 27.6 for integrated emission path. + * - PR_Core_Jsonld_Drug, PR_Core_Jsonld_Webpage, PR_Core_Jsonld_Faq (builders). + * - PR_Core_Peptide_Repository, PR_Core_Peptide_CPT (data). + * + * Contract (ratified 2026-06-11, jsonld-contract-v1.md): + * - Integrated path: Yoast owns WebPage and BreadcrumbList — we never duplicate. + * - Standalone fallback: emits a complete @graph (Drug + FAQPage) only. + * - Drug @id: {permalink}#drug (stable for cross-plugin linking). + * + * @see frontend/class-pr-core-jsonld-drug.php — Drug piece builder. + * @see frontend/class-pr-core-jsonld-webpage.php — MedicalWebPage enrichment. + * @see frontend/class-pr-core-jsonld-faq.php — FAQPage piece builder. + * @see ARCHITECTURE.md — §2.7 JSON-LD output. */ class PR_Core_Jsonld { + /** @var PR_Core_Jsonld_Drug Drug piece builder. */ + private PR_Core_Jsonld_Drug $drug_builder; + + /** @var PR_Core_Jsonld_Webpage MedicalWebPage enricher. */ + private PR_Core_Jsonld_Webpage $webpage_enricher; + + /** @var PR_Core_Jsonld_Faq FAQ piece builder. */ + private PR_Core_Jsonld_Faq $faq_builder; + + /** + * Construct the orchestrator with its piece builders. + */ + public function __construct() { + $this->drug_builder = new PR_Core_Jsonld_Drug(); + $this->webpage_enricher = new PR_Core_Jsonld_Webpage(); + $this->faq_builder = new PR_Core_Jsonld_Faq(); + } + /** * Register hooks for JSON-LD output. * + * Registers both the Yoast-integrated path (active when Yoast is loaded) + * and the standalone wp_head fallback (active only when Yoast is absent). + * + * Side effects: calls add_filter(), add_action(). + * * @return void */ public function register_hooks(): void { - add_action( 'wp_head', [ $this, 'emit_jsonld' ], 99 ); + // Integrated path: hook into Yoast's schema graph. + // wpseo_schema_graph_pieces: filter on the array of schema piece objects. + // wpseo_schema_webpage_type: filter returning the WebPage @type string. + add_filter( 'wpseo_schema_graph_pieces', [ $this, 'inject_graph_pieces' ], 11, 2 ); + add_filter( 'wpseo_schema_webpage_type', [ $this->webpage_enricher, 'retype_to_medical_webpage' ] ); + add_filter( 'wpseo_schema_graph', [ $this->webpage_enricher, 'enrich_webpage_piece' ], 11, 2 ); + + // Standalone fallback: emits only when Yoast is not producing output. + add_action( 'wp_head', [ $this, 'emit_standalone_fallback' ], 99 ); } /** - * Emit JSON-LD on peptide single pages. + * Inject Drug and FAQPage pieces into Yoast's schema graph. * - * Side effects: outputs script tag in wp_head. + * Hooked on: wpseo_schema_graph_pieces (priority 11, after Yoast's own pieces). + * This filter receives Yoast's piece objects array. We append plain arrays; + * Yoast 27.6 accepts both objects and plain arrays in this filter. + * + * @param array $pieces Existing graph pieces from Yoast. + * @param \WPSEO_Schema_Context $context Yoast schema context object. + * @return array Pieces array with Drug and FAQPage appended. + */ + public function inject_graph_pieces( array $pieces, $context ): array { + if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) { + return $pieces; + } + + $post_id = get_the_ID(); + if ( ! $post_id ) { + return $pieces; + } + + $peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id ); + if ( ! $peptide ) { + return $pieces; + } + + $pieces[] = $this->drug_builder->build( $peptide ); + + $faq_piece = $this->faq_builder->build( (int) $post_id, get_permalink( (int) $post_id ) ); + if ( null !== $faq_piece ) { + $pieces[] = $faq_piece; + } + + return $pieces; + } + + /** + * Emit a standalone @graph block via wp_head when Yoast is not active. + * + * Guard: suppressed if Yoast is loaded (function_exists check). This is the + * no-Yoast fallback required by the jsonld-contract. When Yoast is present, + * the integrated path (inject_graph_pieces) handles emission. + * + * Side effects: outputs a script tag in wp_head. * * @return void */ - public function emit_jsonld(): void { + public function emit_standalone_fallback(): void { + if ( function_exists( 'YoastSEO' ) ) { + return; + } + if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) { return; } @@ -40,86 +129,25 @@ class PR_Core_Jsonld { return; } - $repo = new PR_Core_Peptide_Repository(); - $peptide = $repo->find_by_id( $post_id ); - + $peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id ); if ( ! $peptide ) { return; } - $schema = $this->build_drug_schema( $peptide ); + $permalink = get_permalink( (int) $post_id ); + $graph = [ $this->drug_builder->build( $peptide ) ]; - /** - * Filter the JSON-LD schema for a peptide page. - * - * @param array $schema Schema.org data array. - * @param PR_Core_Peptide_DTO $peptide The peptide DTO. - */ - $schema = apply_filters( 'pr_core_jsonld_peptide', $schema, $peptide ); - - if ( empty( $schema ) ) { - return; + $faq_piece = $this->faq_builder->build( (int) $post_id, $permalink ); + if ( null !== $faq_piece ) { + $graph[] = $faq_piece; } printf( '' . "\n", - wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ) + wp_json_encode( + [ '@context' => 'https://schema.org', '@graph' => $graph ], + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT + ) ); } - - /** - * Build a schema.org Drug object for a peptide. - * - * @param PR_Core_Peptide_DTO $peptide Peptide data. - * @return array Schema.org JSON-LD structure. - */ - private function build_drug_schema( PR_Core_Peptide_DTO $peptide ): array { - $schema = [ - '@context' => 'https://schema.org', - '@type' => 'Drug', - 'name' => $peptide->display_name ?: $peptide->title, - 'url' => get_permalink( $peptide->id ), - 'description' => $peptide->excerpt, - ]; - - if ( ! empty( $peptide->aliases ) ) { - $schema['alternateName'] = $peptide->aliases; - } - - if ( '' !== $peptide->molecular_formula ) { - $schema['molecularFormula'] = $peptide->molecular_formula; - } - - if ( $peptide->molecular_weight > 0 ) { - $schema['molecularWeight'] = [ - '@type' => 'QuantitativeValue', - 'value' => $peptide->molecular_weight, - 'unitText' => 'Da', - ]; - } - - // External identifiers as codes. - $codes = []; - if ( '' !== $peptide->cas_number ) { - $codes[] = [ - '@type' => 'MedicalCode', - 'codeValue' => $peptide->cas_number, - 'codingSystem' => 'CAS', - ]; - } - - if ( '' !== $peptide->drugbank_id ) { - $codes[] = [ - '@type' => 'MedicalCode', - 'codeValue' => $peptide->drugbank_id, - 'codingSystem' => 'DrugBank', - ]; - } - - if ( ! empty( $codes ) ) { - $schema['code'] = $codes; - } - - return $schema; - } } diff --git a/includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php b/includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php new file mode 100644 index 0000000..fc8c7d9 --- /dev/null +++ b/includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php @@ -0,0 +1,270 @@ + _pr_molecular_formula (string) + * psa_molecular_weight => _pr_molecular_weight (float string, strip "Da") + * psa_aliases => _pr_aliases (JSON array string) + * + * @see ARCHITECTURE.md — §v0.6.0 schema sprint. + * @see CONVENTIONS.md — How To: Add a New Migration. + * @see class-pr-core-pubchem-client.php — HTTP client for PubChem REST. + */ +class PR_Core_Migration_0004_Backfill_Peptide_Meta { + + /** @var string Meta key: molecular formula (PR Core namespace). */ + public const META_FORMULA = '_pr_molecular_formula'; + + /** @var string Meta key: molecular weight as float string (PR Core namespace). */ + public const META_WEIGHT = '_pr_molecular_weight'; + + /** @var string Meta key: aliases JSON array (PR Core namespace). */ + public const META_ALIASES = '_pr_aliases'; + + /** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */ + private PR_Core_Pubchem_Client $pubchem; + + /** + * Construct migration with optional PubChem client (injectable for testing). + * + * @param PR_Core_Pubchem_Client|null $pubchem PubChem client; creates default if null. + */ + public function __construct( ?PR_Core_Pubchem_Client $pubchem = null ) { + $this->pubchem = $pubchem ?? new PR_Core_Pubchem_Client(); + } + + /** + * Run the backfill migration. + * + * Side effects: reads post meta, writes post meta, logs via error_log. + * + * @return void + */ + public function up(): void { + $peptide_ids = $this->get_all_peptide_ids(); + + if ( empty( $peptide_ids ) ) { + error_log( '[PR Core Migration 0004] No peptide posts found — nothing to backfill.' ); + return; + } + + $counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ]; + + foreach ( $peptide_ids as $post_id ) { + $result = $this->backfill_post( $post_id ); + $counts[ $result ] += 1; + } + + error_log( sprintf( + '[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d', + $counts['skipped'], + $counts['copied_psa'], + $counts['pubchem_ok'], + $counts['pubchem_skip'] + ) ); + } + + /** + * Backfill meta for a single peptide post. + * + * @param int $post_id WordPress post ID. + * @return string Result: skipped|copied_psa|pubchem_ok|pubchem_skip. + */ + private function backfill_post( int $post_id ): string { + $existing_formula = (string) get_post_meta( $post_id, self::META_FORMULA, true ); + $existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true ); + $existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true ); + + // Idempotency limitation: this guard is all-or-nothing. A peptide that legitimately + // has no aliases (e.g., a novel compound) will never satisfy all three conditions and + // the migration will re-run on every manual re-invoke. This is safe because + // write_meta_from_psa() / write_meta_from_pubchem() only overwrite with non-empty + // values, but callers should be aware the 'skipped' result requires all three keys set. + if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) { + return 'skipped'; + } + + $psa_formula = (string) get_post_meta( $post_id, 'psa_molecular_formula', true ); + + if ( '' !== $psa_formula ) { + $psa_weight = (string) get_post_meta( $post_id, 'psa_molecular_weight', true ); + $psa_aliases = (string) get_post_meta( $post_id, 'psa_aliases', true ); + $this->write_meta_from_psa( $post_id, $psa_formula, $psa_weight, $psa_aliases ); + return 'copied_psa'; + } + + // No PSA data — attempt PubChem REST lookup. + $psa_cid = (string) get_post_meta( $post_id, 'psa_pubchem_cid', true ); + $post = get_post( $post_id ); + $name = $post ? sanitize_text_field( $post->post_title ) : ''; + + $data = ( '' !== $psa_cid && ctype_digit( $psa_cid ) ) + ? $this->pubchem->fetch_by_cid( $psa_cid ) + : null; + + if ( null === $data && '' !== $name ) { + $data = $this->pubchem->fetch_by_name( $name ); + } + + if ( null === $data ) { + error_log( sprintf( + '[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.', + $post_id, + esc_html( $name ) + ) ); + return 'pubchem_skip'; + } + + $formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] ); + $weight = (float) $data['weight']; + $aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? [] ); + $this->write_pr_meta( $post_id, $formula, $weight, $aliases_json ); + + error_log( sprintf( + '[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s', + $post_id, + esc_html( $name ), + $formula, + (string) $weight + ) ); + + return 'pubchem_ok'; + } + + /** + * Write PR Core meta from PSA source values. + * + * Input is UNTRUSTED — sanitizes before writing. + * + * @param int $post_id WordPress post ID. + * @param string $formula Raw psa_molecular_formula value. + * @param string $weight Raw psa_molecular_weight value (may include " Da"). + * @param string $aliases Raw psa_aliases value (comma-separated). + * @return void + */ + private function write_meta_from_psa( int $post_id, string $formula, string $weight, string $aliases ): void { + $clean_formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $formula ); + $clean_weight = $this->parse_weight( $weight ); + $clean_aliases = $this->parse_aliases_string( $aliases ); + $this->write_pr_meta( $post_id, $clean_formula, $clean_weight, $clean_aliases ); + } + + /** + * Write the three PR Core meta keys for a post. + * + * Only writes a field when it has a usable value to avoid overwriting + * existing data with empty placeholders. + * + * @param int $post_id WordPress post ID. + * @param string $formula Sanitized formula (empty = skip). + * @param float $weight Parsed weight in g/mol (0 = skip). + * @param string $aliases JSON array string (empty array = skip). + * @return void + */ + private function write_pr_meta( int $post_id, string $formula, float $weight, string $aliases ): void { + if ( '' !== $formula ) { + update_post_meta( $post_id, self::META_FORMULA, $formula ); + } + + if ( $weight > 0.0 ) { + update_post_meta( $post_id, self::META_WEIGHT, (string) $weight ); + } elseif ( '' !== $formula ) { + error_log( '[PR Core Migration 0004] Post ID ' . $post_id . ': weight parsed to 0 — check source.' ); + } + + $decoded = json_decode( $aliases, true ); + if ( is_array( $decoded ) && count( $decoded ) > 0 ) { + update_post_meta( $post_id, self::META_ALIASES, $aliases ); + } + } + + /** + * Parse a molecular weight string to a float. + * + * Strips trailing " Da" suffix (case-insensitive) and whitespace. + * Returns 0.0 on parse failure. + * + * @param string $value Raw weight value (e.g., "1419.5 Da"). + * @return float Parsed weight in g/mol, 0.0 on failure. + */ + public function parse_weight( string $value ): float { + $clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( $value ) ) ?? '' ); + return (float) $clean; + } + + /** + * Parse a comma-separated alias string into a sanitized JSON array string. + * + * Input is UNTRUSTED — each alias is sanitized individually. + * + * @param string $value Comma-separated aliases. + * @return string JSON array of sanitized strings, or '[]' if empty. + */ + public function parse_aliases_string( string $value ): string { + if ( '' === trim( $value ) ) { + return '[]'; + } + + $clean = []; + foreach ( explode( ',', $value ) as $part ) { + $alias = sanitize_text_field( trim( $part ) ); + if ( '' !== $alias ) { + $clean[] = $alias; + } + } + + return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; + } + + /** + * Convert a raw synonyms array from PubChem to a JSON string. + * + * Sanitizes each synonym. Returns '[]' on empty input. + * + * @param string[] $synonyms Raw synonym strings from PubChem. + * @return string JSON array of sanitized strings. + */ + private function synonyms_to_json( array $synonyms ): string { + $clean = []; + foreach ( $synonyms as $syn ) { + $alias = sanitize_text_field( (string) $syn ); + if ( '' !== $alias ) { + $clean[] = $alias; + } + } + return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; + } + + /** + * Get IDs of all published peptide posts. + * + * @return int[] + */ + private function get_all_peptide_ids(): array { + $posts = get_posts( [ + 'post_type' => PR_Core_Peptide_CPT::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] ); + return array_map( 'intval', is_array( $posts ) ? $posts : [] ); + } +} diff --git a/includes/migrations/class-pr-core-migration-runner.php b/includes/migrations/class-pr-core-migration-runner.php index 55c536a..b53e2e1 100644 --- a/includes/migrations/class-pr-core-migration-runner.php +++ b/includes/migrations/class-pr-core-migration-runner.php @@ -29,6 +29,7 @@ class PR_Core_Migration_Runner { 'PR_Core_Migration_0001_Dosing_Rows', 'PR_Core_Migration_0002_Legal_Cells', 'PR_Core_Migration_0003_Candidate_Queue', + 'PR_Core_Migration_0004_Backfill_Peptide_Meta', ]; /** diff --git a/includes/migrations/class-pr-core-pubchem-client.php b/includes/migrations/class-pr-core-pubchem-client.php new file mode 100644 index 0000000..994bac0 --- /dev/null +++ b/includes/migrations/class-pr-core-pubchem-client.php @@ -0,0 +1,208 @@ +fetch_properties( 'cid', $cid ); + if ( null === $props ) { + return null; + } + + $props['synonyms'] = $this->fetch_synonyms( 'cid', $cid ); + return $props; + } + + /** + * Fetch properties and synonyms by compound name. + * + * @param string $name Compound name (e.g., peptide post title). + * @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure. + */ + public function fetch_by_name( string $name ): ?array { + if ( '' === trim( $name ) ) { + return null; + } + + $props = $this->fetch_properties( 'name', $name ); + if ( null === $props ) { + return null; + } + + // If a CID was returned in the property response, use it for synonyms (more reliable). + $cid = $props['cid'] ?? ''; + $type = ( '' !== $cid ) ? 'cid' : 'name'; + $ref = ( '' !== $cid ) ? $cid : $name; + + $props['synonyms'] = $this->fetch_synonyms( $type, $ref ); + unset( $props['cid'] ); + return $props; + } + + /** + * Fetch MolecularFormula and MolecularWeight from PubChem. + * + * @param string $lookup_type 'cid' or 'name'. + * @param string $identifier CID number or compound name. + * @return array{formula: string, weight: float, cid: string}|null + */ + private function fetch_properties( string $lookup_type, string $identifier ): ?array { + $url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier ) + . '/property/MolecularFormula,MolecularWeight/JSON'; + $body = $this->get_with_retry( $url ); + + if ( null === $body ) { + return null; + } + + $data = json_decode( $body, true ); + $props = $data['PropertyTable']['Properties'][0] ?? null; + + if ( ! is_array( $props ) ) { + return null; + } + + $formula = (string) ( $props['MolecularFormula'] ?? '' ); + $weight = (float) ( $props['MolecularWeight'] ?? 0 ); + $cid = (string) ( $props['CID'] ?? '' ); + + if ( '' === $formula || $weight <= 0.0 ) { + return null; + } + + return [ + 'formula' => $formula, + 'weight' => $weight, + 'cid' => $cid, + ]; + } + + /** + * Fetch synonyms for a compound, limited to MAX_SYNONYMS. + * + * Returns an empty array on failure — callers treat this gracefully. + * + * @param string $lookup_type 'cid' or 'name'. + * @param string $identifier CID or name value. + * @return string[] Raw synonym strings (unsanitized). + */ + private function fetch_synonyms( string $lookup_type, string $identifier ): array { + $url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier ) . '/synonyms/JSON'; + $body = $this->get_with_retry( $url ); + + if ( null === $body ) { + return []; + } + + $data = json_decode( $body, true ); + $synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? []; + + if ( ! is_array( $synonyms ) ) { + return []; + } + + return array_slice( $synonyms, 0, self::MAX_SYNONYMS ); + } + + /** + * GET request with exponential-backoff retry (up to MAX_RETRIES). + * + * @param string $url URL to fetch. + * @return string|null Response body on HTTP 200, null after all retries fail. + */ + private function get_with_retry( string $url ): ?string { + for ( $attempt = 0; $attempt < self::MAX_RETRIES; $attempt++ ) { + if ( $attempt > 0 ) { + sleep( (int) pow( 2, $attempt - 1 ) ); // 1s, 2s. + } + + $body = $this->get_once( $url ); + if ( null !== $body ) { + return $body; + } + } + + error_log( '[PR Core PubChem] Request failed after ' . self::MAX_RETRIES . ' attempts: ' . $url ); + return null; + } + + /** + * Single HTTP GET attempt. + * + * Uses wp_remote_get() when available; falls back to file_get_contents + * for WP-CLI contexts where the HTTP API may not be bootstrapped. + * + * @param string $url URL to fetch. + * @return string|null Body on HTTP 200, null on error or non-200. + */ + private function get_once( string $url ): ?string { + if ( function_exists( 'wp_remote_get' ) ) { + $response = wp_remote_get( $url, [ + 'timeout' => self::TIMEOUT, + 'headers' => [ 'Accept' => 'application/json' ], + ] ); + + if ( is_wp_error( $response ) ) { + return null; + } + + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + $body = wp_remote_retrieve_body( $response ); + return is_string( $body ) ? $body : null; + } + + // WP-CLI / plain PHP fallback. + $context = stream_context_create( [ + 'http' => [ + 'timeout' => self::TIMEOUT, + 'header' => "Accept: application/json\r\n", + 'method' => 'GET', + ], + ] ); + + $body = @file_get_contents( $url, false, $context ); + return ( false === $body ) ? null : $body; + } +} diff --git a/peptide-repo-core.php b/peptide-repo-core.php index 8b77a7a..1b95b45 100644 --- a/peptide-repo-core.php +++ b/peptide-repo-core.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide Repo Core * Plugin URI: https://peptiderepo.com * Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output. - * Version: 0.5.0 + * Version: 0.6.0 * Author: peptiderepo * Author URI: https://peptiderepo.com * License: GPL-2.0-or-later @@ -22,11 +22,11 @@ if ( ! defined( 'ABSPATH' ) ) { /* ── Constants ────────────────────────────────────────────────────────── */ -define( 'PR_CORE_VERSION', '0.5.0' ); +define( 'PR_CORE_VERSION', '0.6.0' ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); -define( 'PR_CORE_TARGET_SCHEMA_VERSION', 3 ); +define( 'PR_CORE_TARGET_SCHEMA_VERSION', 4 ); /* ── Autoloader ───────────────────────────────────────────────────────── */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c028d53..c3f1913 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -113,8 +113,10 @@ function sanitize_text_field( $value ): string { return is_scalar( $value ) ? trim( (string) $value ) : ''; } -function wp_json_encode( $data ): string { - return json_encode( $data ) ?: '[]'; +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, int $flags = 0 ): string { + return json_encode( $data, $flags ) ?: '[]'; + } } function absint( $value ): int { diff --git a/tests/unit/test-jsonld.php b/tests/unit/test-jsonld.php new file mode 100644 index 0000000..978d7dd --- /dev/null +++ b/tests/unit/test-jsonld.php @@ -0,0 +1,299 @@ + $overrides Field overrides. + * @return PR_Core_Peptide_DTO + */ +function make_peptide_dto( array $overrides = [] ): PR_Core_Peptide_DTO { + return new PR_Core_Peptide_DTO( array_merge( [ + 'id' => 1, + 'title' => 'BPC-157', + 'slug' => 'bpc-157', + 'content' => '', + 'excerpt' => 'A synthetic peptide.', + 'status' => 'publish', + 'display_name' => 'BPC-157', + 'aliases' => [], + 'molecular_formula' => '', + 'molecular_weight' => 0.0, + 'cas_number' => '', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'preclinical', + 'editorial_review_status' => 'published', + 'last_editorial_review_at' => '', + 'medical_editor_id' => 0, + 'categories' => [], + 'families' => [], + ], $overrides ) ); +} + +echo "== JSON-LD v0.6.0 unit tests ==\n\n"; + +// ── Drug @id ───────────────────────────────────────────────────────────── + +echo "Drug @id:\n"; + +$GLOBALS['pr_test_postmeta'][1] = []; +$peptide = make_peptide_dto(); +$drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + +pr_assert( isset( $drug_node['@id'] ), 'Drug node has @id' ); +pr_assert_equals( + 'https://peptiderepo.com/peptides/bpc-157/#drug', + $drug_node['@id'], + 'Drug @id = {permalink}#drug' +); + +// ── Drug @type ─────────────────────────────────────────────────────────── + +echo "\nDrug @type:\n"; +pr_assert( is_array( $drug_node['@type'] ), 'Drug @type is an array' ); +pr_assert( in_array( 'Drug', $drug_node['@type'], true ), '@type includes "Drug"' ); +pr_assert( in_array( 'MolecularEntity', $drug_node['@type'], true ), '@type includes "MolecularEntity"' ); + +// ── Fields present when meta set ───────────────────────────────────────── + +echo "\nFields present when _pr_* meta set:\n"; + +$GLOBALS['pr_test_postmeta'][1] = [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound-157","PL 14736"]', +]; + +$drug_node_populated = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + +pr_assert( isset( $drug_node_populated['molecularFormula'] ), 'molecularFormula present when meta set' ); +pr_assert_equals( 'C62H98N16O22', $drug_node_populated['molecularFormula'], 'molecularFormula value correct' ); + +pr_assert( isset( $drug_node_populated['molecularWeight'] ), 'molecularWeight present when meta set' ); +pr_assert_equals( 'QuantitativeValue', $drug_node_populated['molecularWeight']['@type'], 'molecularWeight @type is QuantitativeValue' ); +pr_assert_equals( 1419.5, $drug_node_populated['molecularWeight']['value'], 'molecularWeight value correct' ); +pr_assert_equals( 'g/mol', $drug_node_populated['molecularWeight']['unitText'], 'molecularWeight unitText = g/mol' ); + +pr_assert( isset( $drug_node_populated['alternateName'] ), 'alternateName present when _pr_aliases set' ); +pr_assert_equals( 2, count( $drug_node_populated['alternateName'] ), 'alternateName has correct count' ); + +// ── Fields omitted when meta empty ─────────────────────────────────────── + +echo "\nFields OMITTED when meta empty:\n"; + +$GLOBALS['pr_test_postmeta'][1] = []; +$drug_node_empty = ( new PR_Core_Jsonld_Drug() )->build( $peptide ); + +pr_assert( ! isset( $drug_node_empty['molecularFormula'] ), 'molecularFormula OMITTED when meta empty' ); +pr_assert( ! isset( $drug_node_empty['molecularWeight'] ), 'molecularWeight OMITTED when meta empty' ); +pr_assert( ! isset( $drug_node_empty['alternateName'] ), 'alternateName OMITTED when _pr_aliases empty' ); + +// ── FAQPage present when _pr_faq_items has items ───────────────────────── + +echo "\nFAQPage emission:\n"; + +$faq_builder = new PR_Core_Jsonld_Faq(); +$permalink = 'https://peptiderepo.com/peptides/bpc-157/'; + +$GLOBALS['pr_test_postmeta'][1] = [ + '_pr_faq_items' => wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic peptide.' ], + [ 'question' => 'Is it legal?', 'answer' => 'Research use only.' ], + ] ), +]; + +$faq_piece = $faq_builder->build( 1, $permalink ); +pr_assert( null !== $faq_piece, 'FAQPage piece not null when items present' ); +pr_assert_equals( 'FAQPage', $faq_piece['@type'], 'FAQPage @type correct' ); +pr_assert_equals( $permalink . '#faq', $faq_piece['@id'], 'FAQPage @id = {permalink}#faq' ); +pr_assert( isset( $faq_piece['mainEntity'] ), 'FAQPage has mainEntity' ); +pr_assert_equals( 2, count( $faq_piece['mainEntity'] ), 'FAQPage has 2 questions' ); +pr_assert_equals( 'Question', $faq_piece['mainEntity'][0]['@type'], 'Question @type correct' ); +pr_assert( isset( $faq_piece['mainEntity'][0]['acceptedAnswer'] ), 'Question has acceptedAnswer' ); + +// ── FAQPage absent when _pr_faq_items empty ────────────────────────────── + +$GLOBALS['pr_test_postmeta'][1] = [ '_pr_faq_items' => '[]' ]; +$faq_empty = $faq_builder->build( 1, $permalink ); +pr_assert( null === $faq_empty, 'FAQPage piece IS null when _pr_faq_items is "[]"' ); + +$GLOBALS['pr_test_postmeta'][1] = []; +$faq_absent = $faq_builder->build( 1, $permalink ); +pr_assert( null === $faq_absent, 'FAQPage piece IS null when _pr_faq_items absent' ); + +// ── MedicalWebPage retype ──────────────────────────────────────────────── + +echo "\nMedicalWebPage retype:\n"; + +$webpage_enricher = new PR_Core_Jsonld_Webpage(); + +$GLOBALS['pr_test_is_singular'] = true; +$GLOBALS['pr_test_singular_type'] = 'peptide'; +$result = $webpage_enricher->retype_to_medical_webpage( 'WebPage' ); +pr_assert_equals( 'MedicalWebPage', $result, 'returns MedicalWebPage on peptide single' ); + +$GLOBALS['pr_test_is_singular'] = false; +$GLOBALS['pr_test_singular_type'] = ''; +$result = $webpage_enricher->retype_to_medical_webpage( 'WebPage' ); +pr_assert_equals( 'WebPage', $result, 'passes through unchanged on non-peptide pages' ); + +// ── No duplicate WebPage / BreadcrumbList nodes ─────────────────────────── + +echo "\nNo duplicate WebPage/BreadcrumbList nodes:\n"; + +// Verify our builders only produce Drug and FAQPage — never WebPage or BreadcrumbList. +$GLOBALS['pr_test_postmeta'][1] = [ '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q', 'answer' => 'A' ] ] ) ]; +$types_from_builders = []; +foreach ( [ $drug_node_populated, $faq_piece ] as $piece ) { + if ( null === $piece ) { continue; } + $t = $piece['@type'] ?? ''; + foreach ( ( is_array( $t ) ? $t : [ $t ] ) as $type ) { + $types_from_builders[] = $type; + } +} +pr_assert( ! in_array( 'WebPage', $types_from_builders, true ), 'WebPage NOT in our emitted pieces' ); +pr_assert( ! in_array( 'MedicalWebPage', $types_from_builders, true ), 'MedicalWebPage NOT in injected pieces (Yoast owns node)' ); +pr_assert( ! in_array( 'BreadcrumbList', $types_from_builders, true ), 'BreadcrumbList NOT in our emitted pieces' ); +pr_assert( in_array( 'Drug', $types_from_builders, true ), 'Drug IS in our emitted pieces' ); +pr_assert( in_array( 'FAQPage', $types_from_builders, true ), 'FAQPage IS in our emitted pieces when items present' ); + +// ── PR_Core_Schema_Sanitizers::sanitize_faq_items() ────────────────────── + +echo "\nPR_Core_Schema_Sanitizers::sanitize_faq_items():\n"; + +$valid_input = wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A peptide.' ], +] ); +$result = PR_Core_Schema_Sanitizers::sanitize_faq_items( $valid_input ); +$decoded = json_decode( $result, true ); +pr_assert( is_array( $decoded ), 'sanitize_faq_items returns JSON-decodable string' ); +pr_assert_equals( 1, count( $decoded ), 'correct count after sanitize' ); + +$result_empty = PR_Core_Schema_Sanitizers::sanitize_faq_items( '[]' ); +pr_assert_equals( '[]', $result_empty, 'empty JSON array returns "[]"' ); + +$result_malformed = PR_Core_Schema_Sanitizers::sanitize_faq_items( 'not-json' ); +pr_assert_equals( '[]', $result_malformed, 'malformed JSON returns "[]"' ); + +// Item missing 'answer' key: should be dropped. +$incomplete_input = wp_json_encode( [ [ 'question' => 'No answer here?' ] ] ); +$result_incomplete = PR_Core_Schema_Sanitizers::sanitize_faq_items( $incomplete_input ); +$decoded_incomplete = json_decode( $result_incomplete, true ); +pr_assert_equals( 0, count( $decoded_incomplete ), 'item without answer field dropped' ); + +// ── Summary ────────────────────────────────────────────────────────────── + +exit( pr_test_summary() ); diff --git a/tests/unit/test-migration-0004.php b/tests/unit/test-migration-0004.php new file mode 100644 index 0000000..82562ee --- /dev/null +++ b/tests/unit/test-migration-0004.php @@ -0,0 +1,322 @@ + $post_id, 'key' => $key, 'value' => $value ]; + return true; +} + +function get_post( int $id ) { + if ( isset( $GLOBALS['pr_test_posts'][ $id ] ) ) { + return $GLOBALS['pr_test_posts'][ $id ]; + } + return null; +} + +function get_posts( array $args = [] ): array { + return $GLOBALS['pr_test_all_post_ids'] ?? []; +} + +function is_wp_error( $value ): bool { + return false; +} + +function wp_strip_all_tags( $value ): string { + return strip_tags( (string) $value ); +} + +function esc_html( string $val ): string { + return htmlspecialchars( $val, ENT_QUOTES, 'UTF-8' ); +} + +if ( ! function_exists( 'error_log' ) ) { + function error_log( string $msg ): bool { + // Suppress during tests. + return true; + } +} + +// Load dependencies. +require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php'; +require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; +require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-pubchem-client.php'; +// Load migration class under test. +require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php'; + +// ── Expose private methods via reflection ──────────────────────────────── + +/** + * Call a private/protected method on an object via reflection. + * + * @param object $obj Instance. + * @param string $method Method name. + * @param array $args Arguments. + * @return mixed Return value. + */ +function call_private( object $obj, string $method, array $args = [] ) { + $ref = new ReflectionMethod( $obj, $method ); + $ref->setAccessible( true ); + return $ref->invokeArgs( $obj, $args ); +} + +$migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta(); + +echo "== PR_Core_Migration_0004 unit tests ==\n\n"; + +// ── parse_weight() ─────────────────────────────────────────────────────── + +echo "parse_weight():\n"; + +pr_assert_equals( + 1419.5, + $migration->parse_weight( '1419.5 Da' ), + 'strips " Da" suffix and parses float' +); + +pr_assert_equals( + 1419.5, + $migration->parse_weight( '1419.5 DA' ), + 'strips uppercase " DA" suffix' +); + +pr_assert_equals( + 1419.5, + $migration->parse_weight( '1419.5' ), + 'parses plain float string without suffix' +); + +pr_assert_equals( + 0.0, + $migration->parse_weight( '' ), + 'returns 0.0 for empty string' +); + +pr_assert_equals( + 0.0, + $migration->parse_weight( ' Da ' ), + 'returns 0.0 for suffix-only string' +); + +pr_assert_equals( + 800.123, + $migration->parse_weight( ' 800.123 da ' ), + 'strips whitespace and lowercase "da"' +); + +// ── parse_aliases_string() ─────────────────────────────────────────────── + +echo "\nparse_aliases_string():\n"; + +$result = json_decode( + $migration->parse_aliases_string( 'Body Protection Compound-157, PL 14736, PL-10, Bepecin' ), + true +); +pr_assert( is_array( $result ), 'returns a JSON-decodable array' ); +pr_assert_equals( 4, count( $result ), 'correct count: 4 aliases' ); +pr_assert_equals( 'Body Protection Compound-157', $result[0], 'first alias trimmed correctly' ); +pr_assert_equals( 'PL 14736', $result[1], 'second alias trimmed correctly' ); + +$empty_result = $migration->parse_aliases_string( '' ); +pr_assert_equals( '[]', $empty_result, 'empty string returns "[]"' ); + +$whitespace_result = $migration->parse_aliases_string( ' , , ' ); +pr_assert_equals( '[]', $whitespace_result, 'whitespace-only segments dropped, returns "[]"' ); + +$single = json_decode( $migration->parse_aliases_string( 'Only One' ), true ); +pr_assert_equals( 1, count( $single ), 'single alias produces array of 1' ); + +// ── sanitize_formula() ─────────────────────────────────────────────────── +// Five required cases (Option B — validate, don't allowlist): +// 1. Valid formula unchanged. +// 2. Parenthesised formula preserved. +// 3. Newline-injected payload — "injection" stripped, formula kept. +// 4. Fully invalid string (script tag contents) → empty. +// 5. Empty / whitespace-only → empty. + +echo "\nPR_Core_Schema_Sanitizers::sanitize_molecular_formula():\n"; + +pr_assert_equals( + 'C62H98N16O22', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C62H98N16O22' ), + 'valid formula passes unchanged' +); + +pr_assert_equals( + 'C62H98N16O22', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C62H98N16O22' ), + 'HTML tags stripped from formula' +); + +pr_assert_equals( + 'C2H5(OH)', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C2H5(OH)' ), + 'parenthesised formula preserved unchanged' +); + +pr_assert_equals( + 'C10H12', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( "C10H12\ninjection" ), + 'non-formula characters removed — newline+word stripped, formula kept' +); + +pr_assert_equals( + '', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '' ), + 'fully invalid string returns empty string' +); + +pr_assert_equals( + '', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '' ), + 'empty string returns empty string' +); + +pr_assert_equals( + '', + PR_Core_Schema_Sanitizers::sanitize_molecular_formula( ' ' ), + 'whitespace-only returns empty string' +); + +// ── Idempotency: skips when all three PR Core keys populated ───────────── + +echo "\nIdempotency (all _pr_* keys populated):\n"; + +$GLOBALS['pr_test_postmeta'] = []; +$GLOBALS['pr_test_updated_meta'] = []; +$GLOBALS['pr_test_postmeta'][1] = [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Alias One"]', +]; + +$result = call_private( $migration, 'backfill_post', [ 1 ] ); +pr_assert_equals( 'skipped', $result, 'returns "skipped" when all three keys non-empty' ); +pr_assert( empty( $GLOBALS['pr_test_updated_meta'] ), 'no update_post_meta calls when skipped' ); + +// ── PSA source path ────────────────────────────────────────────────────── + +echo "\nPSA source path:\n"; + +$GLOBALS['pr_test_postmeta'] = []; +$GLOBALS['pr_test_updated_meta'] = []; +$GLOBALS['pr_test_postmeta'][2] = [ + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157, PL 14736', +]; +$GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ]; + +$result = call_private( $migration, 'backfill_post', [ 2 ] ); +pr_assert_equals( 'copied_psa', $result, 'returns "copied_psa" when PSA data present' ); + +$updated = $GLOBALS['pr_test_updated_meta']; +$formula_written = false; +$weight_written = false; +$aliases_written = false; + +foreach ( $updated as $entry ) { + if ( '_pr_molecular_formula' === $entry['key'] ) { + $formula_written = true; + pr_assert_equals( 'C62H98N16O22', $entry['value'], 'formula written correctly' ); + } + if ( '_pr_molecular_weight' === $entry['key'] ) { + $weight_written = true; + pr_assert_equals( '1419.5', $entry['value'], 'weight written as float string without Da' ); + } + if ( '_pr_aliases' === $entry['key'] ) { + $aliases_written = true; + $aliases_arr = json_decode( $entry['value'], true ); + pr_assert( is_array( $aliases_arr ), 'aliases stored as JSON array' ); + pr_assert_equals( 2, count( $aliases_arr ), 'correct number of aliases' ); + } +} + +pr_assert( $formula_written, 'molecular formula meta was written' ); +pr_assert( $weight_written, 'molecular weight meta was written' ); +pr_assert( $aliases_written, 'aliases meta was written' ); + +// ── Partial re-run: existing values preserved ──────────────────────────── + +echo "\nIdempotency: only some keys populated (partial re-run):\n"; + +$GLOBALS['pr_test_postmeta'] = []; +$GLOBALS['pr_test_updated_meta'] = []; +// Only formula already set; weight and aliases are empty. +$GLOBALS['pr_test_postmeta'][3] = [ + '_pr_molecular_formula' => 'C10H12', + '_pr_molecular_weight' => '', + '_pr_aliases' => '', + 'psa_molecular_formula' => 'C62H98N16O22', + 'psa_molecular_weight' => '1419.5 Da', + 'psa_aliases' => 'Body Protection Compound-157', +]; +$GLOBALS['pr_test_posts'][3] = (object) [ 'post_title' => 'BPC-157', 'ID' => 3 ]; + +$result = call_private( $migration, 'backfill_post', [ 3 ] ); +pr_assert_equals( 'copied_psa', $result, 'runs PSA copy even when only some keys populated' ); + +// ── PubChem skip path: no PSA data, no network ────────────────────────── + +echo "\nPubChem skip path (no PSA data, no network in unit tests):\n"; + +// Inject a null-returning PubChem client mock to simulate network failure. +$mock_pubchem = new class extends PR_Core_Pubchem_Client { + public function fetch_by_cid( string $cid ): ?array { return null; } + public function fetch_by_name( string $name ): ?array { return null; } +}; +$mock_migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta( $mock_pubchem ); + +$GLOBALS['pr_test_postmeta'] = []; +$GLOBALS['pr_test_updated_meta'] = []; +$GLOBALS['pr_test_postmeta'][10] = []; // No PSA data, no PR Core data. +$GLOBALS['pr_test_posts'][10] = (object) [ 'post_title' => 'IGF-1 LR3', 'ID' => 10 ]; + +$result = call_private( $mock_migration, 'backfill_post', [ 10 ] ); +pr_assert_equals( 'pubchem_skip', $result, 'returns "pubchem_skip" when PubChem network fails' ); +pr_assert( empty( $GLOBALS['pr_test_updated_meta'] ), 'no meta written on pubchem_skip' ); + +// ── Summary ────────────────────────────────────────────────────────────── + +exit( pr_test_summary() ); diff --git a/uninstall.php b/uninstall.php index e222ee3..2a313fb 100644 --- a/uninstall.php +++ b/uninstall.php @@ -76,6 +76,31 @@ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'" ); +/* ── 4b. Delete v0.6.0 schema-input meta keys from all peptide posts ──── */ + +/* + * Migration 0004 writes _pr_molecular_formula, _pr_molecular_weight, _pr_aliases, + * and _pr_faq_items to the shared peptide posts. These are PR Core-authored values + * (derived from PubChem / PSA) and are safe to remove on uninstall. + * + * @see ARCHITECTURE.md — §2.9 Uninstall specification. + */ +$schema_meta_keys = [ + '_pr_molecular_formula', + '_pr_molecular_weight', + '_pr_aliases', + '_pr_faq_items', +]; + +foreach ( $schema_meta_keys as $meta_key ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->delete( + $wpdb->postmeta, + [ 'meta_key' => $meta_key ], + [ '%s' ] + ); +} + /* ── 5. Remove manage_peptide_content capability from all roles ───────── */ $editable_roles = wp_roles()->roles;