peptide-repo-core v0.6.0: peptide schema backfill + Yoast-integrated Drug/MedicalWebPage/FAQ (#19)
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
This commit is contained in:
@@ -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 );
|
||||
|
||||
152
includes/cpt/class-pr-core-schema-sanitizers.php
Normal file
152
includes/cpt/class-pr-core-schema-sanitizers.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sanitizers for v0.6.0 schema-input meta fields.
|
||||
*
|
||||
* What: Static sanitizer methods for _pr_molecular_formula, _pr_molecular_weight,
|
||||
* and _pr_faq_items meta fields introduced in v0.6.0.
|
||||
*
|
||||
* Who calls it: PR_Core_Peptide_CPT::get_meta_fields() registers these as
|
||||
* sanitize_callback values for register_post_meta().
|
||||
*
|
||||
* Dependencies: WordPress sanitize_text_field, sanitize_textarea_field, wp_json_encode.
|
||||
*
|
||||
* @see cpt/class-pr-core-peptide-cpt.php — Meta field registration.
|
||||
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
|
||||
*/
|
||||
class PR_Core_Schema_Sanitizers {
|
||||
|
||||
/**
|
||||
* Regex token for a single element symbol + optional count (e.g., "C10", "Na", "O").
|
||||
*
|
||||
* Matches: one uppercase letter, optionally followed by one lowercase letter,
|
||||
* optionally followed by one or more digits.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const FORMULA_TOKEN = '[A-Z][a-z]?\d*';
|
||||
|
||||
/**
|
||||
* Regex character class for allowed group-separator / structural characters.
|
||||
*
|
||||
* Covers: parentheses, square brackets, interpunct (·, U+00B7), period, digits.
|
||||
* These may appear between element tokens in complex or hydrated formulas.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const FORMULA_SEP = '[().\[\]\xB7\d]';
|
||||
|
||||
/**
|
||||
* Sanitize a molecular formula string (for _pr_molecular_formula).
|
||||
*
|
||||
* Strips HTML tags and control characters, then validates the value against
|
||||
* an element-formula grammar. Returns the longest leading run of valid
|
||||
* formula tokens and separators; returns '' when nothing valid is found.
|
||||
*
|
||||
* Grammar (per token): [A-Z][a-z]?\d* — an element symbol plus optional count.
|
||||
* Separators: ( ) [ ] · . and standalone digits (for hydrated formulas).
|
||||
* Input is treated as UNTRUSTED; downstream JSON escaping is unchanged.
|
||||
*
|
||||
* Examples:
|
||||
* "C62H98N16O22" → "C62H98N16O22" (valid, unchanged)
|
||||
* "C10H12\ninjection" → "C10H12" (injection stripped)
|
||||
* "C2H5(OH)" → "C2H5(OH)" (parenthesised formula preserved)
|
||||
* "<script>alert(1)</script>" → "" (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 ) ?: '[]';
|
||||
}
|
||||
}
|
||||
158
includes/frontend/class-pr-core-jsonld-drug.php
Normal file
158
includes/frontend/class-pr-core-jsonld-drug.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Builds the schema.org Drug graph piece for a peptide page.
|
||||
*
|
||||
* What: Assembles a Drug (+ MolecularEntity) schema node from peptide DTO
|
||||
* and the new _pr_* schema-input meta fields added in v0.6.0.
|
||||
*
|
||||
* Who calls it: PR_Core_Jsonld::build_drug_piece() during Yoast graph assembly
|
||||
* or standalone @graph construction.
|
||||
*
|
||||
* Dependencies: PR_Core_Peptide_DTO, WordPress get_post_meta(), get_permalink().
|
||||
*
|
||||
* @see frontend/class-pr-core-jsonld.php — Orchestrator that calls this builder.
|
||||
* @see ARCHITECTURE.md — §2.7 JSON-LD output.
|
||||
*/
|
||||
class PR_Core_Jsonld_Drug {
|
||||
|
||||
/** @var string Meta key: molecular formula sourced by migration 0004. */
|
||||
private const META_FORMULA = '_pr_molecular_formula';
|
||||
|
||||
/** @var string Meta key: molecular weight (float string) sourced by migration 0004. */
|
||||
private const META_WEIGHT = '_pr_molecular_weight';
|
||||
|
||||
/** @var string Meta key: aliases JSON array sourced by migration 0004. */
|
||||
private const META_ALIASES = '_pr_aliases';
|
||||
|
||||
/** @var string Unit text for molecularWeight QuantitativeValue. */
|
||||
private const UNIT_TEXT = 'g/mol';
|
||||
|
||||
/**
|
||||
* Build a schema.org Drug piece array for a peptide.
|
||||
*
|
||||
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
|
||||
* @return array<string, mixed> 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<string, mixed> $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<string, string>|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' ),
|
||||
];
|
||||
}
|
||||
}
|
||||
100
includes/frontend/class-pr-core-jsonld-faq.php
Normal file
100
includes/frontend/class-pr-core-jsonld-faq.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Builds the schema.org FAQPage graph piece for a peptide page.
|
||||
*
|
||||
* What: Reads _pr_faq_items post-meta (JSON array of {question, answer} objects)
|
||||
* and produces a FAQPage node suitable for injection into Yoast's graph.
|
||||
* Emits nothing when the meta is empty or absent.
|
||||
*
|
||||
* Who calls it: PR_Core_Jsonld::build_faq_piece() during graph assembly.
|
||||
* Dependencies: WordPress get_post_meta(), get_permalink().
|
||||
*
|
||||
* @see frontend/class-pr-core-jsonld.php — Orchestrator that calls this builder.
|
||||
* @see cpt/class-pr-core-schema-sanitizers.php — sanitize_faq_items() used on save.
|
||||
* @see ARCHITECTURE.md — §2.7 JSON-LD output.
|
||||
*/
|
||||
class PR_Core_Jsonld_Faq {
|
||||
|
||||
/** @var string Meta key holding the FAQ items JSON array. */
|
||||
public const META_FAQ_ITEMS = '_pr_faq_items';
|
||||
|
||||
/**
|
||||
* Build a FAQPage node for a post, or return null if no items exist.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $permalink Canonical permalink for @id construction.
|
||||
* @return array<string, mixed>|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<string, mixed> 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<int, array{question: string, answer: string}> 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;
|
||||
}
|
||||
}
|
||||
134
includes/frontend/class-pr-core-jsonld-webpage.php
Normal file
134
includes/frontend/class-pr-core-jsonld-webpage.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Enriches Yoast's WebPage node to MedicalWebPage for peptide single pages.
|
||||
*
|
||||
* What: Hooks into Yoast's wpseo_schema_webpage_type filter to retype the page
|
||||
* node to MedicalWebPage, and into wpseo_schema_graph to inject
|
||||
* lastReviewed, reviewedBy, and audience enrichments.
|
||||
*
|
||||
* Who calls it: PR_Core_Jsonld::register_hooks() registers both filters.
|
||||
* Dependencies: Yoast SEO (wordpress-seo) ≥ 27.6 when active; graceful no-op otherwise.
|
||||
*
|
||||
* @see frontend/class-pr-core-jsonld.php — Parent orchestrator.
|
||||
* @see ARCHITECTURE.md — §2.7 JSON-LD output, Yoast integration contract.
|
||||
*/
|
||||
class PR_Core_Jsonld_Webpage {
|
||||
|
||||
/** @var string Meta key for last editorial review date (ISO date string). */
|
||||
private const META_LAST_REVIEWED = '_pr_last_reviewed';
|
||||
|
||||
/** @var string Meta key for last source verification date (ISO date string). */
|
||||
private const META_LAST_VERIFIED = '_pr_last_source_verified';
|
||||
|
||||
/**
|
||||
* Return 'MedicalWebPage' as the schema type for peptide single pages.
|
||||
*
|
||||
* Hooked on: wpseo_schema_webpage_type (Yoast SEO ≥ 19.x).
|
||||
* Passes through unchanged on non-peptide pages.
|
||||
*
|
||||
* @param string $type Existing Yoast page type (e.g., 'WebPage').
|
||||
* @return string 'MedicalWebPage' on peptide singles, $type otherwise.
|
||||
*/
|
||||
public function retype_to_medical_webpage( string $type ): string {
|
||||
if ( is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
|
||||
return 'MedicalWebPage';
|
||||
}
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich Yoast's graph pieces array for peptide single pages.
|
||||
*
|
||||
* Injects lastReviewed, reviewedBy, and audience into the WebPage/MedicalWebPage
|
||||
* node already in Yoast's graph. Does NOT add new WebPage or BreadcrumbList nodes.
|
||||
*
|
||||
* Hooked on: wpseo_schema_graph (Yoast SEO ≥ 19.x), priority 11.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $graph Yoast schema graph pieces array.
|
||||
* @param \WPSEO_Schema_Context $context Yoast schema context object.
|
||||
* @return array<int, array<string, mixed>> 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<string, mixed> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<int, mixed> $pieces Existing graph pieces from Yoast.
|
||||
* @param \WPSEO_Schema_Context $context Yoast schema context object.
|
||||
* @return array<int, mixed> 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<string, mixed> $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(
|
||||
'<script type="application/ld+json">%s</script>' . "\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<string, mixed> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Migration 0004: Backfill peptide schema meta from PSA psa_* keys.
|
||||
*
|
||||
* What: Copies PSA-stored PubChem data (molecular formula, weight, aliases)
|
||||
* into PR Core's own _pr_molecular_formula, _pr_molecular_weight,
|
||||
* _pr_aliases meta keys on all published peptide posts.
|
||||
* For posts missing PSA data, attempts a PubChem REST lookup via
|
||||
* PR_Core_Pubchem_Client.
|
||||
*
|
||||
* Who calls it: PR_Core_Migration_Runner::run_pending().
|
||||
* Dependencies: WordPress get_post_meta(), update_post_meta(), get_posts().
|
||||
* PR_Core_Pubchem_Client for PubChem fallback.
|
||||
* PR_Core_Schema_Sanitizers for formula sanitization.
|
||||
* PR_Core_Peptide_CPT::POST_TYPE for query.
|
||||
*
|
||||
* Idempotent: skips posts where all three PR Core meta keys are already non-empty.
|
||||
* Re-runnable: safe to run multiple times.
|
||||
*
|
||||
* PSA source keys (all treated as UNTRUSTED — sanitized before writing):
|
||||
* psa_molecular_formula => _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 : [] );
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
208
includes/migrations/class-pr-core-pubchem-client.php
Normal file
208
includes/migrations/class-pr-core-pubchem-client.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Lightweight PubChem REST client for the backfill migration.
|
||||
*
|
||||
* What: Fetches molecular formula, molecular weight, and synonyms from the
|
||||
* PubChem REST API for a given compound CID or name. Used exclusively
|
||||
* by migration 0004 for the 4 peptide posts that lack PSA-sourced data.
|
||||
*
|
||||
* Who calls it: PR_Core_Migration_0004_Backfill_Peptide_Meta::fetch_pubchem().
|
||||
* Dependencies: WordPress wp_remote_get() when available; falls back to
|
||||
* file_get_contents with stream context (WP-CLI context).
|
||||
*
|
||||
* All values from PubChem are returned unsanitized — callers must sanitize
|
||||
* before writing to post_meta.
|
||||
*
|
||||
* @see migrations/class-pr-core-migration-0004-backfill-peptide-meta.php — Caller.
|
||||
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
|
||||
*/
|
||||
class PR_Core_Pubchem_Client {
|
||||
|
||||
/** @var string PubChem REST base URL. */
|
||||
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
|
||||
|
||||
/** @var int HTTP timeout per request (seconds). */
|
||||
private const TIMEOUT = 8;
|
||||
|
||||
/** @var int Maximum retry attempts with exponential backoff. */
|
||||
private const MAX_RETRIES = 3;
|
||||
|
||||
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
|
||||
private const MAX_SYNONYMS = 10;
|
||||
|
||||
/**
|
||||
* Fetch properties and synonyms by PubChem CID.
|
||||
*
|
||||
* @param string $cid Numeric PubChem CID string.
|
||||
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
|
||||
*/
|
||||
public function fetch_by_cid( string $cid ): ?array {
|
||||
if ( '' === $cid || ! ctype_digit( $cid ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$props = $this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user