Adopt reusable CI standard + PHPUnit conversion (#22)

Squash-merge of PR #22: adopt shared reusable CI workflow, convert PHPUnit to stubs, update branch-protection contexts.

QA-approved at 05d8094a1d. All required CI checks green.

Agent-Session: 2026-06-17-prcore-ci-deploy
This commit is contained in:
peptiderepo
2026-06-17 01:12:00 +07:00
committed by GitHub
parent d0b5875fd8
commit 665d7fa9e1
72 changed files with 4604 additions and 1212 deletions

View File

@@ -1,6 +1,18 @@
<?php
/**
* Admin Columns.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Adds evidence strength, editorial status, and dosing count columns
*
* @package Peptide_Repo_Core
*/
/**
* Custom admin list-table columns for the peptide CPT.
*
@@ -19,7 +31,7 @@ class PR_Core_Admin_Columns {
* @return array<string, string> Modified columns.
*/
public function add_columns( array $columns ): array {
$new = [];
$new = array();
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
@@ -44,13 +56,13 @@ class PR_Core_Admin_Columns {
public function render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'pr_evidence':
$strength = get_post_meta( $post_id, 'evidence_strength', true ) ?: 'preclinical';
$strength = get_post_meta( $post_id, 'evidence_strength', true ) ?: 'preclinical'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$label = apply_filters( 'pr_core_evidence_strength_label', $strength, $strength );
echo esc_html( $label );
break;
case 'pr_editorial':
$status = get_post_meta( $post_id, 'editorial_review_status', true ) ?: 'draft';
$status = get_post_meta( $post_id, 'editorial_review_status', true ) ?: 'draft'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
echo esc_html( ucfirst( str_replace( '-', ' ', $status ) ) );
break;

View File

@@ -1,6 +1,18 @@
<?php
/**
* Admin.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Registers admin menu pages, meta boxes, and custom columns for peptides
*
* @package Peptide_Repo_Core
*/
/**
* Admin initialization and hook registration.
*
@@ -25,22 +37,22 @@ class PR_Core_Admin {
*/
public function register_hooks(): void {
$metaboxes = new PR_Core_Peptide_Metaboxes();
add_action( 'add_meta_boxes', [ $metaboxes, 'add_meta_boxes' ] );
add_action( 'save_post_' . PR_Core_Peptide_CPT::POST_TYPE, [ $metaboxes, 'save_meta' ], 10, 2 );
add_action( 'add_meta_boxes', array( $metaboxes, 'add_meta_boxes' ) );
add_action( 'save_post_' . PR_Core_Peptide_CPT::POST_TYPE, array( $metaboxes, 'save_meta' ), 10, 2 );
add_action( 'add_meta_boxes', [ PR_Core_Verification_Metabox::class, 'register' ] );
add_action( 'add_meta_boxes', array( PR_Core_Verification_Metabox::class, 'register' ) );
$columns = new PR_Core_Admin_Columns();
add_filter( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_columns', [ $columns, 'add_columns' ] );
add_action( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_custom_column', [ $columns, 'render_column' ], 10, 2 );
add_filter( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_columns', array( $columns, 'add_columns' ) );
add_action( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_custom_column', array( $columns, 'render_column' ), 10, 2 );
// Repo Daily meta boxes.
$repo_daily_metaboxes = new PR_Core_Repo_Daily_Metaboxes();
add_action( 'add_meta_boxes', [ $repo_daily_metaboxes, 'add_meta_boxes' ] );
add_action( 'save_post_' . PR_Core_Repo_Daily_CPT::POST_TYPE, [ $repo_daily_metaboxes, 'save_meta' ], 10, 2 );
add_action( 'add_meta_boxes', array( $repo_daily_metaboxes, 'add_meta_boxes' ) );
add_action( 'save_post_' . PR_Core_Repo_Daily_CPT::POST_TYPE, array( $repo_daily_metaboxes, 'save_meta' ), 10, 2 );
add_action( 'admin_menu', [ $this, 'add_admin_pages' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ] );
add_action( 'admin_menu', array( $this, 'add_admin_pages' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) );
}
/**
@@ -60,7 +72,7 @@ class PR_Core_Admin {
__( 'Candidate Queue', 'peptide-repo-core' ) . $badge,
PR_Core_Peptide_CPT::CAPABILITY,
'pr-core-candidates',
[ new PR_Core_Candidate_Queue_Page(), 'render' ]
array( new PR_Core_Candidate_Queue_Page(), 'render' )
);
}
@@ -79,7 +91,7 @@ class PR_Core_Admin {
wp_enqueue_style(
'pr-core-admin',
PR_CORE_PLUGIN_URL . 'assets/css/admin.css',
[],
array(),
PR_CORE_VERSION
);
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Ajax Handlers.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Handles mark-verified and scan-now admin-ajax actions
*
* @package Peptide_Repo_Core
*/
/**
* AJAX handlers for verification scanner actions.
*
@@ -47,7 +59,7 @@ class PR_Core_Ajax_Handlers {
// Recompute status to 'current'.
update_post_meta( $post_id, '_pr_verification_status', 'current' );
wp_send_json_success( [ 'message' => 'Marked verified' ] );
wp_send_json_success( array( 'message' => 'Marked verified' ) );
}
/**
@@ -74,6 +86,6 @@ class PR_Core_Ajax_Handlers {
// Run scan.
PR_Core_Verification_Scanner::run_scan();
wp_send_json_success( [ 'message' => 'Scan complete' ] );
wp_send_json_success( array( 'message' => 'Scan complete' ) );
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Candidate Queue Page.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Renders a review screen for AI-extracted dosing candidates
*
* @package Peptide_Repo_Core
*/
/**
* Admin page for the AI Candidate Queue.
*
@@ -58,8 +70,8 @@ class PR_Core_Candidate_Queue_Page {
$peptide = $pep_repo->find_by_id( $c->peptide_id );
$name = $peptide ? $peptide->title : '#' . $c->peptide_id;
$dose = $c->dose_min !== null ? number_format( $c->dose_min, 2 ) : '?';
if ( $c->dose_max !== null && $c->dose_max !== $c->dose_min ) {
$dose = null !== $c->dose_min ? number_format( $c->dose_min, 2 ) : '?';
if ( null !== $c->dose_max && $c->dose_max !== $c->dose_min ) {
$dose .= ' – ' . number_format( $c->dose_max, 2 );
}
$dose .= ' ' . esc_html( $c->dose_unit );
@@ -71,11 +83,21 @@ class PR_Core_Candidate_Queue_Page {
$confidence_pct = number_format( $c->extraction_confidence * 100, 0 ) . '%';
$approve_url = wp_nonce_url(
add_query_arg( [ 'action' => 'pr_core_approve', 'candidate_id' => $c->id ] ),
add_query_arg(
array(
'action' => 'pr_core_approve',
'candidate_id' => $c->id,
)
),
'pr_core_queue_action'
);
$reject_url = wp_nonce_url(
add_query_arg( [ 'action' => 'pr_core_reject', 'candidate_id' => $c->id ] ),
add_query_arg(
array(
'action' => 'pr_core_reject',
'candidate_id' => $c->id,
)
),
'pr_core_queue_action'
);
@@ -84,7 +106,7 @@ class PR_Core_Candidate_Queue_Page {
printf( '<td>%s</td>', esc_html( $c->route ) );
printf( '<td>%s</td>', esc_html( $dose ) );
printf( '<td>%s</td>', esc_html( $c->population ) );
printf( '<td>%s</td>', $study ); // Contains escaped HTML + link.
printf( '<td>%s</td>', $study ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $study is pre-escaped HTML.
printf( '<td>%s</td>', esc_html( $confidence_pct ) );
printf(
'<td><a href="%s" class="button button-primary button-small">%s</a> <a href="%s" class="button button-small">%s</a></td>',
@@ -107,9 +129,9 @@ class PR_Core_Candidate_Queue_Page {
* @return void
*/
private function handle_actions(): void {
$action = sanitize_text_field( $_GET['action'] ?? '' );
$action = sanitize_text_field( wp_unslash( $_GET['action'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
if ( ! in_array( $action, [ 'pr_core_approve', 'pr_core_reject' ], true ) ) {
if ( ! in_array( $action, array( 'pr_core_approve', 'pr_core_reject' ), true ) ) {
return;
}
@@ -117,7 +139,7 @@ class PR_Core_Candidate_Queue_Page {
return;
}
$candidate_id = absint( $_GET['candidate_id'] ?? 0 );
$candidate_id = absint( wp_unslash( $_GET['candidate_id'] ?? 0 ) );
if ( 0 === $candidate_id ) {
return;
}
@@ -128,15 +150,21 @@ class PR_Core_Candidate_Queue_Page {
if ( 'pr_core_approve' === $action ) {
$dosing_id = $repo->approve( $candidate_id, $reviewer_id );
if ( $dosing_id > 0 ) {
add_action( 'admin_notices', static function () {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Candidate approved and added to dosing data.', 'peptide-repo-core' ) . '</p></div>';
} );
add_action(
'admin_notices',
static function () {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Candidate approved and added to dosing data.', 'peptide-repo-core' ) . '</p></div>';
}
);
}
} else {
$repo->reject( $candidate_id, $reviewer_id );
add_action( 'admin_notices', static function () {
echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__( 'Candidate rejected.', 'peptide-repo-core' ) . '</p></div>';
} );
add_action(
'admin_notices',
static function () {
echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__( 'Candidate rejected.', 'peptide-repo-core' ) . '</p></div>';
}
);
}
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Peptide Metaboxes.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Renders and saves three meta boxes on the peptide edit screen
*
* @package Peptide_Repo_Core
*/
/**
* Meta boxes for the peptide edit screen: scientific identifiers, dosing, and legal.
*
@@ -22,7 +34,7 @@ class PR_Core_Peptide_Metaboxes {
add_meta_box(
'pr-core-identifiers',
__( 'Scientific Identifiers', 'peptide-repo-core' ),
[ $this, 'render_identifiers_box' ],
array( $this, 'render_identifiers_box' ),
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'high'
@@ -31,7 +43,7 @@ class PR_Core_Peptide_Metaboxes {
add_meta_box(
'pr-core-dosing',
__( 'Dosing Data', 'peptide-repo-core' ),
[ $this, 'render_dosing_box' ],
array( $this, 'render_dosing_box' ),
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'default'
@@ -40,7 +52,7 @@ class PR_Core_Peptide_Metaboxes {
add_meta_box(
'pr-core-legal',
__( 'Legal Status by Country', 'peptide-repo-core' ),
[ $this, 'render_legal_box' ],
array( $this, 'render_legal_box' ),
PR_Core_Peptide_CPT::POST_TYPE,
'normal',
'default'
@@ -56,7 +68,7 @@ class PR_Core_Peptide_Metaboxes {
public function render_identifiers_box( \WP_Post $post ): void {
wp_nonce_field( 'pr_core_save_meta', 'pr_core_meta_nonce' );
$fields = [
$fields = array(
'display_name' => __( 'Display Name', 'peptide-repo-core' ),
'aliases' => __( 'Aliases (JSON array)', 'peptide-repo-core' ),
'molecular_formula' => __( 'Molecular Formula', 'peptide-repo-core' ),
@@ -64,7 +76,7 @@ class PR_Core_Peptide_Metaboxes {
'cas_number' => __( 'CAS Number', 'peptide-repo-core' ),
'drugbank_id' => __( 'DrugBank ID', 'peptide-repo-core' ),
'chembl_id' => __( 'ChEMBL ID', 'peptide-repo-core' ),
];
);
echo '<table class="form-table pr-core-meta-table">';
foreach ( $fields as $key => $label ) {
@@ -73,12 +85,12 @@ class PR_Core_Peptide_Metaboxes {
'<tr><th><label for="pr_core_%1$s">%2$s</label></th><td><input type="text" id="pr_core_%1$s" name="pr_core_%1$s" value="%3$s" class="regular-text" /></td></tr>',
esc_attr( $key ),
esc_html( $label ),
$value
$value // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr applied on assignment
);
}
// Evidence strength dropdown.
$current_strength = get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical';
$current_strength = get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
echo '<tr><th><label for="pr_core_evidence_strength">' . esc_html__( 'Evidence Strength', 'peptide-repo-core' ) . '</label></th><td><select id="pr_core_evidence_strength" name="pr_core_evidence_strength">';
foreach ( PR_Core_Peptide_CPT::EVIDENCE_STRENGTHS as $strength ) {
$label = apply_filters( 'pr_core_evidence_strength_label', $strength, $strength );
@@ -92,7 +104,7 @@ class PR_Core_Peptide_Metaboxes {
echo '</select></td></tr>';
// Editorial review status.
$current_review = get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft';
$current_review = get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
echo '<tr><th><label for="pr_core_editorial_review_status">' . esc_html__( 'Editorial Status', 'peptide-repo-core' ) . '</label></th><td><select id="pr_core_editorial_review_status" name="pr_core_editorial_review_status">';
foreach ( PR_Core_Peptide_CPT::REVIEW_STATUSES as $status ) {
printf(
@@ -134,8 +146,8 @@ class PR_Core_Peptide_Metaboxes {
echo '</tr></thead><tbody>';
foreach ( $rows as $row ) {
$dose = $row->dose_min !== null ? number_format( $row->dose_min, 2 ) : '?';
if ( $row->dose_max !== null && $row->dose_max !== $row->dose_min ) {
$dose = null !== $row->dose_min ? number_format( $row->dose_min, 2 ) : '?';
if ( null !== $row->dose_max && $row->dose_max !== $row->dose_min ) {
$dose .= ' – ' . number_format( $row->dose_max, 2 );
}
$dose .= ' ' . esc_html( $row->dose_unit );
@@ -233,11 +245,11 @@ class PR_Core_Peptide_Metaboxes {
foreach ( $meta_fields as $key => $config ) {
$input_key = 'pr_core_' . $key;
if ( ! isset( $_POST[ $input_key ] ) ) {
if ( ! isset( $_POST[ $input_key ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- existence check only; unslash+sanitize below
continue;
}
$raw = wp_unslash( $_POST[ $input_key ] );
$raw = wp_unslash( $_POST[ $input_key ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via config['sanitize'] callback on next line
$clean = is_callable( $config['sanitize'] )
? call_user_func( $config['sanitize'], $raw )
: sanitize_text_field( (string) $raw );
@@ -246,10 +258,10 @@ class PR_Core_Peptide_Metaboxes {
}
// Set last_editorial_review_at when status changes to published.
$new_review_status = sanitize_text_field( $_POST['pr_core_editorial_review_status'] ?? '' );
$new_review_status = sanitize_text_field( wp_unslash( $_POST['pr_core_editorial_review_status'] ?? '' ) );
if ( 'published' === $new_review_status ) {
$old_status = get_post_meta( $post_id, 'editorial_review_status', true );
if ( $old_status !== 'published' ) {
if ( 'published' !== $old_status ) {
update_post_meta( $post_id, 'last_editorial_review_at', current_time( 'mysql' ) );
update_post_meta( $post_id, 'medical_editor_id', get_current_user_id() );
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Repo Daily Metaboxes.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Renders and saves two meta fields on the repo_daily edit screen
*
* @package Peptide_Repo_Core
*/
/**
* Meta boxes for Repo Daily edit screen: author and clinical review flag.
*
@@ -17,7 +29,11 @@ declare(strict_types=1);
*/
class PR_Core_Repo_Daily_Metaboxes {
/** @var string Default author byline for Repo Daily articles. */
/**
* Default author byline for Repo Daily articles.
*
* @var string Default author byline for Repo Daily articles.
*/
private const DEFAULT_AUTHOR = 'Boo Sheeran';
/**
@@ -29,7 +45,7 @@ class PR_Core_Repo_Daily_Metaboxes {
add_meta_box(
'pr-core-repo-daily-meta',
__( 'Article Settings', 'peptide-repo-core' ),
[ $this, 'render_meta_box' ],
array( $this, 'render_meta_box' ),
PR_Core_Repo_Daily_CPT::POST_TYPE,
'normal',
'default'
@@ -45,8 +61,8 @@ class PR_Core_Repo_Daily_Metaboxes {
public function render_meta_box( \WP_Post $post ): void {
wp_nonce_field( 'pr_core_repo_daily_meta', 'pr_core_repo_daily_nonce' );
$author = sanitize_text_field(
get_post_meta( $post->ID, '_repo_daily_author', true ) ?: self::DEFAULT_AUTHOR
$author = sanitize_text_field(
get_post_meta( $post->ID, '_repo_daily_author', true ) ?: self::DEFAULT_AUTHOR // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
);
$requires_review = get_post_meta( $post->ID, '_repo_daily_clinical_review_required', true );

View File

@@ -0,0 +1,134 @@
<?php
/**
* Settings Renderer.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Static methods that output HTML for each settings field on the PR Core settings page
*
* @package Peptide_Repo_Core
*/
/**
* Render methods for the PR Core settings admin page.
*
* What: Static methods that output HTML for each settings field on the PR Core settings page.
* Who calls it: PR_Core_Settings — register_*_section() methods reference these via add_settings_field().
* Dependencies: PR_Core_Settings (for option key constants).
*
* @see admin/class-pr-core-settings.php — Settings registration and sanitization.
*/
class PR_Core_Settings_Renderer {
/**
* Render the settings page form.
*
* @return void
*/
public static function render_page(): void {
?>
<div class="wrap">
<h1><?php esc_html_e( 'PR Core Settings', 'peptide-repo-core' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( self::OPTION_GROUP );
submit_button();
?>
</form>
</div>
<?php
}
/**
* Render output.
*
* @return void
*/
public static function render_enabled_field(): void {
$enabled = (bool) get_option( self::RELATED_ENABLED, true );
printf(
'<input type="checkbox" name="%s" value="1"%s /> %s',
esc_attr( self::RELATED_ENABLED ),
checked( $enabled, true, false ),
esc_html__( 'Show related monographs on single peptide pages', 'peptide-repo-core' )
);
}
/**
* Render output.
*
* @return void
*/
public static function render_limit_field(): void {
printf(
'<input type="number" name="%s" value="%d" min="1" max="6" />',
esc_attr( self::RELATED_LIMIT ),
absint( get_option( self::RELATED_LIMIT, 3 ) )
);
echo '<p class="description">' . esc_html__( 'Number of articles to display (1-6). Default: 3.', 'peptide-repo-core' ) . '</p>';
}
/**
* Render output.
*
* @return void
*/
public static function render_verification_section(): void {
echo '<p>' . esc_html__( 'Configure automatic staleness scanning and digest notifications.', 'peptide-repo-core' ) . '</p>';
}
/**
* Render output.
*
* @return void
*/
public static function render_cadence_field(): void {
$current = get_option( self::SCAN_CADENCE, 'weekly' );
echo '<select name="' . esc_attr( self::SCAN_CADENCE ) . '">';
foreach ( array(
'daily' => 'Daily',
'weekly' => 'Weekly',
'monthly' => 'Monthly',
) as $val => $label ) {
printf( '<option value="%s"%s>%s</option>', esc_attr( $val ), selected( $current, $val, false ), esc_html( $label ) );
}
echo '</select>';
}
/**
* Render output.
*
* @return void
*/
public static function render_threshold_field(): void {
printf( '<input type="number" name="%s" value="%d" min="1" />', esc_attr( self::DEFAULT_THRESHOLD ), absint( get_option( self::DEFAULT_THRESHOLD, 180 ) ) );
}
/**
* Render output.
*
* @return void
*/
public static function render_high_velocity_field(): void {
printf( '<input type="number" name="%s" value="%d" min="1" />', esc_attr( self::HIGH_VELOCITY_THRESHOLD ), absint( get_option( self::HIGH_VELOCITY_THRESHOLD, 60 ) ) );
}
/**
* Render output.
*
* @return void
*/
public static function render_email_field(): void {
printf(
'<input type="text" name="%s" value="%s" class="regular-text" placeholder="admin@example.com" />',
esc_attr( self::VERIFICATION_EMAIL ),
esc_attr( (string) get_option( self::VERIFICATION_EMAIL, '' ) )
);
echo '<p class="description">' . esc_html__( 'Comma-separated. Leave blank to disable email digests.', 'peptide-repo-core' ) . '</p>';
}
}

View File

@@ -1,4 +1,10 @@
<?php
/**
* Settings.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
@@ -13,25 +19,25 @@ declare(strict_types=1);
*/
class PR_Core_Settings {
/** @var string Option group shared by all PR Core settings. */
/** @var string Option group shared by all PR Core settings. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const OPTION_GROUP = 'pr_core_settings';
/** @var string Option key: enable/disable related articles. */
/** @var string Option key: enable/disable related articles. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const RELATED_ENABLED = 'pr_core_related_posts_enabled';
/** @var string Option key: related articles limit (1-6). */
/** @var string Option key: related articles limit (1-6). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const RELATED_LIMIT = 'pr_core_related_posts_limit';
/** @var string Option key: WP-cron scan cadence. */
/** @var string Option key: WP-cron scan cadence. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const SCAN_CADENCE = 'pr_core_scan_cadence';
/** @var string Option key: default staleness threshold in days. */
/** @var string Option key: default staleness threshold in days. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const DEFAULT_THRESHOLD = 'pr_core_default_threshold';
/** @var string Option key: high-velocity staleness threshold in days. */
/** @var string Option key: high-velocity staleness threshold in days. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const HIGH_VELOCITY_THRESHOLD = 'pr_core_high_velocity_threshold';
/** @var string Option key: comma-separated notification email list. */
/** @var string Option key: comma-separated notification email list. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const VERIFICATION_EMAIL = 'pr_core_verification_email';
/**
@@ -48,28 +54,17 @@ class PR_Core_Settings {
__( 'Settings', 'peptide-repo-core' ),
'manage_options',
'pr-core-settings',
[ __CLASS__, 'render_page' ]
array( __CLASS__, 'render_page' )
);
}
/**
* Render the settings page form.
* Render the settings page HTML. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_page(): void {
?>
<div class="wrap">
<h1><?php esc_html_e( 'PR Core Settings', 'peptide-repo-core' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( self::OPTION_GROUP );
submit_button();
?>
</form>
</div>
<?php
PR_Core_Settings_Renderer::render_page();
}
/**
@@ -83,10 +78,10 @@ class PR_Core_Settings {
public static function register_settings(): void {
self::register_related_articles_section();
self::register_verification_section();
add_action( 'update_option_' . self::SCAN_CADENCE, [ __CLASS__, 'reschedule_cron' ], 10, 2 );
add_action( 'update_option_' . self::SCAN_CADENCE, array( __CLASS__, 'reschedule_cron' ), 10, 2 );
}
// ── Related Articles ─────────────────────────────────────────────────
// ── Related Articles ─────────────────────────────────────────────────.
/**
* Register Related Articles settings.
@@ -94,33 +89,46 @@ class PR_Core_Settings {
* @return void
*/
private static function register_related_articles_section(): void {
register_setting( self::OPTION_GROUP, self::RELATED_ENABLED, [ 'type' => 'boolean', 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean' ] );
register_setting( self::OPTION_GROUP, self::RELATED_LIMIT, [ 'type' => 'integer', 'default' => 3, 'sanitize_callback' => [ __CLASS__, 'sanitize_limit' ] ] );
register_setting(
self::OPTION_GROUP,
self::RELATED_ENABLED,
array(
'type' => 'boolean',
'default' => true,
'sanitize_callback' => 'rest_sanitize_boolean',
)
);
register_setting(
self::OPTION_GROUP,
self::RELATED_LIMIT,
array(
'type' => 'integer',
'default' => 3,
'sanitize_callback' => array( __CLASS__, 'sanitize_limit' ),
)
);
add_settings_section( 'pr_core_related_articles', __( 'Related Articles', 'peptide-repo-core' ), '__return_false', self::OPTION_GROUP );
add_settings_field( self::RELATED_ENABLED, __( 'Enable Related Articles', 'peptide-repo-core' ), [ __CLASS__, 'render_enabled_field' ], self::OPTION_GROUP, 'pr_core_related_articles' );
add_settings_field( self::RELATED_LIMIT, __( 'Number of Articles', 'peptide-repo-core' ), [ __CLASS__, 'render_limit_field' ], self::OPTION_GROUP, 'pr_core_related_articles' );
add_settings_field( self::RELATED_ENABLED, __( 'Enable Related Articles', 'peptide-repo-core' ), array( __CLASS__, 'render_enabled_field' ), self::OPTION_GROUP, 'pr_core_related_articles' );
add_settings_field( self::RELATED_LIMIT, __( 'Number of Articles', 'peptide-repo-core' ), array( __CLASS__, 'render_limit_field' ), self::OPTION_GROUP, 'pr_core_related_articles' );
}
/** @return void */
/**
* Render the enable/disable checkbox field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_enabled_field(): void {
$enabled = (bool) get_option( self::RELATED_ENABLED, true );
printf(
'<input type="checkbox" name="%s" value="1"%s /> %s',
esc_attr( self::RELATED_ENABLED ),
checked( $enabled, true, false ),
esc_html__( 'Show related monographs on single peptide pages', 'peptide-repo-core' )
);
PR_Core_Settings_Renderer::render_enabled_field();
}
/** @return void */
/**
* Render the article count limit field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_limit_field(): void {
printf(
'<input type="number" name="%s" value="%d" min="1" max="6" />',
esc_attr( self::RELATED_LIMIT ),
absint( get_option( self::RELATED_LIMIT, 3 ) )
);
echo '<p class="description">' . esc_html__( 'Number of articles to display (1-6). Default: 3.', 'peptide-repo-core' ) . '</p>';
PR_Core_Settings_Renderer::render_limit_field();
}
/**
@@ -133,7 +141,7 @@ class PR_Core_Settings {
return min( 6, max( 1, (int) $value ) );
}
// ── Verification ─────────────────────────────────────────────────────
// ── Verification ─────────────────────────────────────────────────────.
/**
* Register Verification settings.
@@ -141,51 +149,93 @@ class PR_Core_Settings {
* @return void
*/
private static function register_verification_section(): void {
register_setting( self::OPTION_GROUP, self::SCAN_CADENCE, [ 'type' => 'string', 'default' => 'weekly', 'sanitize_callback' => [ __CLASS__, 'sanitize_cadence' ] ] );
register_setting( self::OPTION_GROUP, self::DEFAULT_THRESHOLD, [ 'type' => 'integer', 'default' => 180, 'sanitize_callback' => 'absint' ] );
register_setting( self::OPTION_GROUP, self::HIGH_VELOCITY_THRESHOLD, [ 'type' => 'integer', 'default' => 60, 'sanitize_callback' => 'absint' ] );
register_setting( self::OPTION_GROUP, self::VERIFICATION_EMAIL, [ 'type' => 'string', 'default' => '', 'sanitize_callback' => [ __CLASS__, 'sanitize_emails' ] ] );
add_settings_section( 'pr_core_verification', __( 'Verification', 'peptide-repo-core' ), [ __CLASS__, 'render_verification_section' ], self::OPTION_GROUP );
add_settings_field( self::SCAN_CADENCE, __( 'Scan cadence', 'peptide-repo-core' ), [ __CLASS__, 'render_cadence_field' ], self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::DEFAULT_THRESHOLD, __( 'Default staleness threshold (days)', 'peptide-repo-core' ), [ __CLASS__, 'render_threshold_field' ], self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::HIGH_VELOCITY_THRESHOLD, __( 'High-velocity threshold (days)', 'peptide-repo-core' ), [ __CLASS__, 'render_high_velocity_field' ], self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::VERIFICATION_EMAIL, __( 'Notification email(s)', 'peptide-repo-core' ), [ __CLASS__, 'render_email_field' ], self::OPTION_GROUP, 'pr_core_verification' );
}
/** @return void */
public static function render_verification_section(): void {
echo '<p>' . esc_html__( 'Configure automatic staleness scanning and digest notifications.', 'peptide-repo-core' ) . '</p>';
}
/** @return void */
public static function render_cadence_field(): void {
$current = get_option( self::SCAN_CADENCE, 'weekly' );
echo '<select name="' . esc_attr( self::SCAN_CADENCE ) . '">';
foreach ( [ 'daily' => 'Daily', 'weekly' => 'Weekly', 'monthly' => 'Monthly' ] as $val => $label ) {
printf( '<option value="%s"%s>%s</option>', esc_attr( $val ), selected( $current, $val, false ), esc_html( $label ) );
}
echo '</select>';
}
/** @return void */
public static function render_threshold_field(): void {
printf( '<input type="number" name="%s" value="%d" min="1" />', esc_attr( self::DEFAULT_THRESHOLD ), absint( get_option( self::DEFAULT_THRESHOLD, 180 ) ) );
}
/** @return void */
public static function render_high_velocity_field(): void {
printf( '<input type="number" name="%s" value="%d" min="1" />', esc_attr( self::HIGH_VELOCITY_THRESHOLD ), absint( get_option( self::HIGH_VELOCITY_THRESHOLD, 60 ) ) );
}
/** @return void */
public static function render_email_field(): void {
printf(
'<input type="text" name="%s" value="%s" class="regular-text" placeholder="admin@example.com" />',
esc_attr( self::VERIFICATION_EMAIL ),
esc_attr( (string) get_option( self::VERIFICATION_EMAIL, '' ) )
register_setting(
self::OPTION_GROUP,
self::SCAN_CADENCE,
array(
'type' => 'string',
'default' => 'weekly',
'sanitize_callback' => array( __CLASS__, 'sanitize_cadence' ),
)
);
echo '<p class="description">' . esc_html__( 'Comma-separated. Leave blank to disable email digests.', 'peptide-repo-core' ) . '</p>';
register_setting(
self::OPTION_GROUP,
self::DEFAULT_THRESHOLD,
array(
'type' => 'integer',
'default' => 180,
'sanitize_callback' => 'absint',
)
);
register_setting(
self::OPTION_GROUP,
self::HIGH_VELOCITY_THRESHOLD,
array(
'type' => 'integer',
'default' => 60,
'sanitize_callback' => 'absint',
)
);
register_setting(
self::OPTION_GROUP,
self::VERIFICATION_EMAIL,
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => array( __CLASS__, 'sanitize_emails' ),
)
);
add_settings_section( 'pr_core_verification', __( 'Verification', 'peptide-repo-core' ), array( __CLASS__, 'render_verification_section' ), self::OPTION_GROUP );
add_settings_field( self::SCAN_CADENCE, __( 'Scan cadence', 'peptide-repo-core' ), array( __CLASS__, 'render_cadence_field' ), self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::DEFAULT_THRESHOLD, __( 'Default staleness threshold (days)', 'peptide-repo-core' ), array( __CLASS__, 'render_threshold_field' ), self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::HIGH_VELOCITY_THRESHOLD, __( 'High-velocity threshold (days)', 'peptide-repo-core' ), array( __CLASS__, 'render_high_velocity_field' ), self::OPTION_GROUP, 'pr_core_verification' );
add_settings_field( self::VERIFICATION_EMAIL, __( 'Notification email(s)', 'peptide-repo-core' ), array( __CLASS__, 'render_email_field' ), self::OPTION_GROUP, 'pr_core_verification' );
}
/**
* Render the verification section header. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_verification_section(): void {
PR_Core_Settings_Renderer::render_verification_section();
}
/**
* Render the scan cadence select field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_cadence_field(): void {
PR_Core_Settings_Renderer::render_cadence_field();
}
/**
* Render the score threshold input field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_threshold_field(): void {
PR_Core_Settings_Renderer::render_threshold_field();
}
/**
* Render the high-velocity scan toggle field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_high_velocity_field(): void {
PR_Core_Settings_Renderer::render_high_velocity_field();
}
/**
* Render the alert email input field. Delegates to PR_Core_Settings_Renderer.
*
* @return void
*/
public static function render_email_field(): void {
PR_Core_Settings_Renderer::render_email_field();
}
/**
@@ -195,7 +245,7 @@ class PR_Core_Settings {
* @return string
*/
public static function sanitize_cadence( string $value ): string {
return in_array( $value, [ 'daily', 'weekly', 'monthly' ], true ) ? $value : 'weekly';
return in_array( $value, array( 'daily', 'weekly', 'monthly' ), true ) ? $value : 'weekly';
}
/**

View File

@@ -1,6 +1,18 @@
<?php
/**
* Verification Metabox.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Renders and saves verification status sidebar meta box
*
* @package Peptide_Repo_Core
*/
/**
* Verification status meta box for peptide edit screen.
*
@@ -21,7 +33,7 @@ class PR_Core_Verification_Metabox {
add_meta_box(
'pr-core-verification',
__( 'Verification Status', 'peptide-repo-core' ),
[ __CLASS__, 'render' ],
array( __CLASS__, 'render' ),
PR_Core_Peptide_CPT::POST_TYPE,
'side',
'default'
@@ -38,16 +50,20 @@ class PR_Core_Verification_Metabox {
wp_nonce_field( 'pr_core_verification_nonce', 'pr_core_verification_nonce' );
$last_verified = get_post_meta( $post->ID, '_pr_last_source_verified', true );
$velocity = get_post_meta( $post->ID, '_pr_verification_velocity', true ) ?: 'medium';
$status = get_post_meta( $post->ID, '_pr_verification_status', true ) ?: 'current';
$notes = get_post_meta( $post->ID, '_pr_verification_notes', true ) ?: '';
$velocity = get_post_meta( $post->ID, '_pr_verification_velocity', true ) ?: 'medium'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$status = get_post_meta( $post->ID, '_pr_verification_status', true ) ?: 'current'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$notes = get_post_meta( $post->ID, '_pr_verification_notes', true ) ?: ''; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
echo '<p><strong>' . esc_html__( 'Last Verified', 'peptide-repo-core' ) . ':</strong><br />';
echo '<code style="color: #666;">' . esc_html( $last_verified ?: '—' ) . '</code></p>';
echo '<code style="color: #666;">' . esc_html( $last_verified ?: '—' ) . '</code></p>'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
echo '<p><strong>' . esc_html__( 'Verification Velocity', 'peptide-repo-core' ) . ':</strong><br />';
echo '<select name="pr_core__pr_verification_velocity" style="width:100%;">';
foreach ( [ 'low' => 'Low', 'medium' => 'Medium', 'high' => 'High' ] as $val => $label ) {
foreach ( array(
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
) as $val => $label ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $val ),
@@ -58,12 +74,12 @@ class PR_Core_Verification_Metabox {
echo '</select></p>';
echo '<p><strong>' . esc_html__( 'Status', 'peptide-repo-core' ) . ':</strong><br />';
$badge_class = match( $status ) {
$badge_class = match ( $status ) {
'due' => 'style="background:#ff9800;color:white;padding:4px 8px;border-radius:3px;"',
'overdue' => 'style="background:#f44336;color:white;padding:4px 8px;border-radius:3px;"',
default => 'style="background:#4caf50;color:white;padding:4px 8px;border-radius:3px;"',
};
echo '<span ' . $badge_class . '>' . esc_html( ucfirst( $status ) ) . '</span></p>';
echo '<span ' . $badge_class . '>' . esc_html( ucfirst( $status ) ) . '</span></p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- badge_class is a hardcoded match expression
echo '<p><strong>' . esc_html__( 'Verification Notes', 'peptide-repo-core' ) . ':</strong><br />';
printf(

View File

@@ -1,6 +1,18 @@
<?php
/**
* Verification Widget.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* WordPress dashboard widget showing peptides needing review (due/overdue)
*
* @package Peptide_Repo_Core
*/
/**
* Dashboard widget for verification status overview.
*
@@ -23,7 +35,7 @@ class PR_Core_Verification_Widget {
wp_add_dashboard_widget(
'pr_core_verification_status',
__( 'Monographs Needing Review', 'peptide-repo-core' ),
[ __CLASS__, 'render' ]
array( __CLASS__, 'render' )
);
}
@@ -33,35 +45,38 @@ class PR_Core_Verification_Widget {
* @return void
*/
public static function render(): void {
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( [ 'status' => 'publish' ] );
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( array( 'status' => 'publish' ) );
$due_overdue = [];
$due_overdue = array();
foreach ( $peptides as $dto ) {
$status = get_post_meta( $dto->id, '_pr_verification_status', true ) ?: 'current';
$status = get_post_meta( $dto->id, '_pr_verification_status', true ) ?: 'current'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
if ( in_array( $status, [ 'due', 'overdue' ], true ) ) {
if ( in_array( $status, array( 'due', 'overdue' ), true ) ) {
$last_verified = get_post_meta( $dto->id, '_pr_last_source_verified', true );
$days_since = $last_verified
? (int) ( ( time() - strtotime( $last_verified ) ) / DAY_IN_SECONDS )
: 999;
$due_overdue[] = [
'id' => $dto->id,
'title' => $dto->title,
'status' => $status,
'velocity' => get_post_meta( $dto->id, '_pr_verification_velocity', true ) ?: 'medium',
$due_overdue[] = array(
'id' => $dto->id,
'title' => $dto->title,
'status' => $status,
'velocity' => get_post_meta( $dto->id, '_pr_verification_velocity', true ) ?: 'medium', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'last_verified' => $last_verified,
'days_since' => $days_since,
];
'days_since' => $days_since,
);
}
}
// Sort by days_since descending (most overdue first).
usort( $due_overdue, static function ( $a, $b ) {
return $b['days_since'] <=> $a['days_since'];
} );
usort(
$due_overdue,
static function ( $a, $b ) {
return $b['days_since'] <=> $a['days_since'];
}
);
if ( empty( $due_overdue ) ) {
echo '<p>' . esc_html__( 'All monographs are current. ', 'peptide-repo-core' );
@@ -93,7 +108,7 @@ class PR_Core_Verification_Widget {
esc_html( ucfirst( $item['status'] ) ),
esc_html( ucfirst( $item['velocity'] ) ),
esc_html( $item['last_verified'] ?? '—' ),
$item['days_since'],
(int) $item['days_since'], // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- cast to int, safe
esc_url( $edit_url )
);
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Rest Controller.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Exposes peptides, dosing rows, and legal cells via REST endpoints
*
* @package Peptide_Repo_Core
*/
/**
* REST API controller for peptide data (read-only public, write requires capability).
*
@@ -22,7 +34,7 @@ declare(strict_types=1);
*/
class PR_Core_Rest_Controller {
/** @var string REST namespace. */
/** @var string REST namespace. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
private const REST_NAMESPACE = 'pr-core/v1';
/**
@@ -31,7 +43,7 @@ class PR_Core_Rest_Controller {
* @return void
*/
public function register_hooks(): void {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
@@ -42,58 +54,106 @@ class PR_Core_Rest_Controller {
* @return void
*/
public function register_routes(): void {
// GET /peptides
register_rest_route( self::REST_NAMESPACE, '/peptides', [
'methods' => 'GET',
'callback' => [ $this, 'list_peptides' ],
'permission_callback' => '__return_true',
'args' => [
'category' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
'evidence_strength' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
'search' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
'per_page' => [ 'type' => 'integer', 'default' => 25, 'minimum' => 1, 'maximum' => 100 ],
'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ],
],
] );
// GET /peptides.
register_rest_route(
self::REST_NAMESPACE,
'/peptides',
array(
'methods' => 'GET',
'callback' => array( $this, 'list_peptides' ),
'permission_callback' => '__return_true',
'args' => array(
'category' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'evidence_strength' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'search' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'per_page' => array(
'type' => 'integer',
'default' => 25,
'minimum' => 1,
'maximum' => 100,
),
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
),
)
);
// GET /peptides/{id}
register_rest_route( self::REST_NAMESPACE, '/peptides/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [ $this, 'get_peptide' ],
'permission_callback' => '__return_true',
] );
// GET /peptides/{id}.
register_rest_route(
self::REST_NAMESPACE,
'/peptides/(?P<id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'get_peptide' ),
'permission_callback' => '__return_true',
)
);
// GET /peptides/{id}/dosing
register_rest_route( self::REST_NAMESPACE, '/peptides/(?P<id>\d+)/dosing', [
'methods' => 'GET',
'callback' => [ $this, 'list_dosing' ],
'permission_callback' => '__return_true',
'args' => [
'route' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
'population' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ],
],
] );
// GET /peptides/{id}/dosing.
register_rest_route(
self::REST_NAMESPACE,
'/peptides/(?P<id>\d+)/dosing',
array(
'methods' => 'GET',
'callback' => array( $this, 'list_dosing' ),
'permission_callback' => '__return_true',
'args' => array(
'route' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'population' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
// POST /peptides/{id}/dosing (auth required)
register_rest_route( self::REST_NAMESPACE, '/peptides/(?P<id>\d+)/dosing', [
'methods' => 'POST',
'callback' => [ $this, 'create_dosing' ],
'permission_callback' => [ $this, 'check_write_permission' ],
] );
// POST /peptides/{id}/dosing (auth required).
register_rest_route(
self::REST_NAMESPACE,
'/peptides/(?P<id>\d+)/dosing',
array(
'methods' => 'POST',
'callback' => array( $this, 'create_dosing' ),
'permission_callback' => array( $this, 'check_write_permission' ),
)
);
// GET /peptides/{id}/legal
register_rest_route( self::REST_NAMESPACE, '/peptides/(?P<id>\d+)/legal', [
'methods' => 'GET',
'callback' => [ $this, 'list_legal' ],
'permission_callback' => '__return_true',
] );
// GET /peptides/{id}/legal.
register_rest_route(
self::REST_NAMESPACE,
'/peptides/(?P<id>\d+)/legal',
array(
'methods' => 'GET',
'callback' => array( $this, 'list_legal' ),
'permission_callback' => '__return_true',
)
);
// POST /peptides/{id}/legal (auth required)
register_rest_route( self::REST_NAMESPACE, '/peptides/(?P<id>\d+)/legal', [
'methods' => 'POST',
'callback' => [ $this, 'create_legal' ],
'permission_callback' => [ $this, 'check_write_permission' ],
] );
// POST /peptides/{id}/legal (auth required).
register_rest_route(
self::REST_NAMESPACE,
'/peptides/(?P<id>\d+)/legal',
array(
'methods' => 'POST',
'callback' => array( $this, 'create_legal' ),
'permission_callback' => array( $this, 'check_write_permission' ),
)
);
}
/**
@@ -109,12 +169,14 @@ class PR_Core_Rest_Controller {
if ( $search ) {
$peptides = $repo->search( $search, (int) $request->get_param( 'per_page' ) );
} else {
$peptides = $repo->find_all( [
'category' => $request->get_param( 'category' ),
'evidence_strength' => $request->get_param( 'evidence_strength' ),
'per_page' => $request->get_param( 'per_page' ),
'page' => $request->get_param( 'page' ),
] );
$peptides = $repo->find_all(
array(
'category' => $request->get_param( 'category' ),
'evidence_strength' => $request->get_param( 'evidence_strength' ),
'per_page' => $request->get_param( 'per_page' ),
'page' => $request->get_param( 'page' ),
)
);
}
$data = array_map( static fn( PR_Core_Peptide_DTO $p ) => $p->to_array(), $peptides );
@@ -133,7 +195,7 @@ class PR_Core_Rest_Controller {
$peptide = $repo->find_by_id( (int) $request->get_param( 'id' ) );
if ( ! $peptide ) {
return new \WP_Error( 'not_found', __( 'Peptide not found.', 'peptide-repo-core' ), [ 'status' => 404 ] );
return new \WP_Error( 'not_found', __( 'Peptide not found.', 'peptide-repo-core' ), array( 'status' => 404 ) );
}
return new \WP_REST_Response( $peptide->to_array(), 200 );
@@ -149,10 +211,12 @@ class PR_Core_Rest_Controller {
$repo = new PR_Core_Dosing_Repository();
$rows = $repo->find_by_peptide(
(int) $request->get_param( 'id' ),
array_filter( [
'route' => $request->get_param( 'route' ),
'population' => $request->get_param( 'population' ),
] )
array_filter(
array(
'route' => $request->get_param( 'route' ),
'population' => $request->get_param( 'population' ),
)
)
);
$data = array_map( static fn( PR_Core_Dosing_Row_DTO $r ) => $r->to_array(), $rows );
@@ -176,12 +240,12 @@ class PR_Core_Rest_Controller {
$id = $repo->insert( $data );
if ( 0 === $id ) {
return new \WP_Error( 'insert_failed', __( 'Failed to insert dosing row.', 'peptide-repo-core' ), [ 'status' => 500 ] );
return new \WP_Error( 'insert_failed', __( 'Failed to insert dosing row.', 'peptide-repo-core' ), array( 'status' => 500 ) );
}
$row = $repo->find_by_id( $id );
return new \WP_REST_Response( $row ? $row->to_array() : [ 'id' => $id ], 201 );
return new \WP_REST_Response( $row ? $row->to_array() : array( 'id' => $id ), 201 );
}
/**
@@ -208,19 +272,19 @@ class PR_Core_Rest_Controller {
public function create_legal( \WP_REST_Request $request ) {
$repo = new PR_Core_Legal_Repository();
$data = $request->get_json_params();
$data['peptide_id'] = (int) $request->get_param( 'id' );
$data = $request->get_json_params();
$data['peptide_id'] = (int) $request->get_param( 'id' );
$data['reviewer_id'] = get_current_user_id();
$id = $repo->insert( $data );
if ( 0 === $id ) {
return new \WP_Error( 'insert_failed', __( 'Failed to insert legal cell.', 'peptide-repo-core' ), [ 'status' => 500 ] );
return new \WP_Error( 'insert_failed', __( 'Failed to insert legal cell.', 'peptide-repo-core' ), array( 'status' => 500 ) );
}
$cell = $repo->find_by_id( $id );
return new \WP_REST_Response( $cell ? $cell->to_array() : [ 'id' => $id ], 201 );
return new \WP_REST_Response( $cell ? $cell->to_array() : array( 'id' => $id ), 201 );
}
/**

View File

@@ -1,6 +1,18 @@
<?php
/**
* Activator.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Runs migrations, adds capabilities, flushes rewrite rules
*
* @package Peptide_Repo_Core
*/
/**
* Plugin activation handler.
*
@@ -17,7 +29,11 @@ declare(strict_types=1);
*/
class PR_Core_Activator {
/** @var string Option key storing the last activated/seen plugin version. */
/**
* Option key storing the last activated/seen plugin version.
*
* @var string Option key storing the last activated/seen plugin version.
*/
private const VERSION_OPTION = 'pr_core_version';
/**
@@ -51,8 +67,8 @@ class PR_Core_Activator {
// Flush rewrite rules for the new CPT.
flush_rewrite_rules( false );
// Record the activated version so the init:999 drift handler can
// detect future in-place updates and re-flush without a manual
// Record the activated version so the init:999 drift handler can.
// detect future in-place updates and re-flush without a manual.
// deactivate/reactivate cycle.
update_option( self::VERSION_OPTION, PR_CORE_VERSION, false );
}
@@ -93,7 +109,7 @@ class PR_Core_Activator {
*/
private static function add_capabilities(): void {
$cap = 'manage_peptide_content';
$roles = [ 'administrator', 'editor' ];
$roles = array( 'administrator', 'editor' );
foreach ( $roles as $role_name ) {
$role = get_role( $role_name );
@@ -156,4 +172,3 @@ class PR_Core_Activator {
}
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Autoloader.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Resolves PR_Core_ class names to file paths under includes/
*
* @package Peptide_Repo_Core
*/
/**
* SPL autoloader for PR_Core_ prefixed classes.
*
@@ -15,8 +27,12 @@ declare(strict_types=1);
*/
class PR_Core_Autoloader {
/** @var string[] Directories to scan for class files. */
private static array $dirs = [];
/**
* Directories to scan for class files.
*
* @var string[] Directories to scan for class files.
*/
private static array $dirs = array();
/**
* Register the autoloader with SPL.
@@ -35,22 +51,22 @@ class PR_Core_Autoloader {
self::$dirs[] = trailingslashit( $dir );
}
spl_autoload_register( [ __CLASS__, 'load' ] );
spl_autoload_register( array( __CLASS__, 'load' ) );
}
/**
* Autoload callback. Only handles PR_Core_ prefixed classes.
*
* @param string $class Fully-qualified class name.
* @param string $class_name Fully-qualified class name.
* @return void
*/
public static function load( string $class ): void {
if ( 0 !== strpos( $class, 'PR_Core_' ) ) {
public static function load( string $class_name ): void {
if ( 0 !== strpos( $class_name, 'PR_Core_' ) ) {
return;
}
// PR_Core_Foo_Bar => foo-bar => class-pr-core-foo-bar.php.
$suffix = substr( $class, 8 ); // Strip 'PR_Core_'.
$suffix = substr( $class_name, 8 ); // Strip 'PR_Core_'.
$filename = 'class-pr-core-' . str_replace( '_', '-', strtolower( $suffix ) ) . '.php';
foreach ( self::$dirs as $dir ) {

View File

@@ -1,6 +1,18 @@
<?php
/**
* Deactivator.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Flushes rewrite rules on deactivation (data preserved)
*
* @package Peptide_Repo_Core
*/
/**
* Plugin deactivation handler.
*

View File

@@ -1,13 +1,27 @@
<?php
/**
* Seed Data.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Seed data fixture for development and testing.
* Creates 3 peptide posts, 10 dosing rows, and 5 legal cells using fixture data
*
* What: Creates 3 peptide posts, 10 dosing rows, and 5 legal cells.
* @package Peptide_Repo_Core
*/
/**
* Seed data orchestrator for development and testing.
*
* What: Creates 3 peptide posts, 10 dosing rows, and 5 legal cells using fixture data.
* Who calls it: WP-CLI command or admin action (manual trigger only).
* Dependencies: PR_Core_Peptide_CPT, PR_Core_Dosing_Repository, PR_Core_Legal_Repository.
* Dependencies: PR_Core_Seed_Fixtures, PR_Core_Peptide_CPT, PR_Core_Dosing_Repository,
* PR_Core_Legal_Repository.
*
* @see class-pr-core-seed-fixtures.php — Static fixture data arrays.
* @see ARCHITECTURE.md — Seed data specification.
*/
class PR_Core_Seed_Data {
@@ -24,89 +38,54 @@ class PR_Core_Seed_Data {
$dosing = self::seed_dosing_rows( $peptide_ids );
$legal = self::seed_legal_cells( $peptide_ids );
return [
return array(
'peptides' => count( $peptide_ids ),
'dosing_rows' => $dosing,
'legal_cells' => $legal,
];
);
}
/**
* Create 3 canonical peptide posts.
* Create 3 canonical peptide posts using fixture data from PR_Core_Seed_Fixtures.
*
* @return array<string, int> Map of slug => post ID.
*/
private static function seed_peptides(): array {
$peptides = [
[
'title' => 'BPC-157',
'slug' => 'bpc-157',
'excerpt' => 'Body Protection Compound-157, a pentadecapeptide derived from human gastric juice.',
'display_name' => 'BPC-157',
'aliases' => '["Body Protection Compound-157","Bepecin","PL 14736","PL-10"]',
'molecular_formula' => 'C62H98N16O22',
'molecular_weight' => 1419.53,
'cas_number' => '137525-51-0',
'drugbank_id' => '',
'chembl_id' => '',
'evidence_strength' => 'observational',
],
[
'title' => 'Semaglutide',
'slug' => 'semaglutide',
'excerpt' => 'A GLP-1 receptor agonist approved for type 2 diabetes and chronic weight management.',
'display_name' => 'Semaglutide',
'aliases' => '["Ozempic","Wegovy","Rybelsus"]',
'molecular_formula' => 'C187H291N45O59',
'molecular_weight' => 4113.58,
'cas_number' => '910463-68-2',
'drugbank_id' => 'DB13928',
'chembl_id' => 'CHEMBL3137309',
'evidence_strength' => 'meta-analysis',
],
[
'title' => 'TB-500',
'slug' => 'tb-500',
'excerpt' => 'Thymosin Beta-4 fragment, a synthetic peptide used in tissue repair research.',
'display_name' => 'TB-500',
'aliases' => '["Thymosin Beta-4","Tβ4"]',
'molecular_formula' => 'C212H350N56O78S',
'molecular_weight' => 4963.44,
'cas_number' => '77591-33-4',
'drugbank_id' => '',
'chembl_id' => '',
'evidence_strength' => 'preclinical',
],
];
$peptides = PR_Core_Seed_Fixtures::get_peptides();
$ids = array();
$ids = [];
foreach ( $peptides as $p ) {
$existing = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => $p['slug'],
'posts_per_page' => 1,
'post_status' => 'any',
] );
$existing = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => $p['slug'],
'posts_per_page' => 1,
'post_status' => 'any',
)
);
if ( ! empty( $existing ) ) {
$ids[ $p['slug'] ] = $existing[0]->ID;
continue;
}
$post_id = wp_insert_post( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'post_title' => $p['title'],
'post_name' => $p['slug'],
'post_excerpt' => $p['excerpt'],
'post_status' => 'publish',
'post_content' => '',
] );
$post_id = wp_insert_post(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'post_title' => $p['title'],
'post_name' => $p['slug'],
'post_excerpt' => $p['excerpt'],
'post_status' => 'publish',
'post_content' => '',
)
);
if ( is_wp_error( $post_id ) ) {
continue;
}
foreach ( [ 'display_name', 'aliases', 'molecular_formula', 'molecular_weight', 'cas_number', 'drugbank_id', 'chembl_id', 'evidence_strength' ] as $key ) {
$meta_keys = array( 'display_name', 'aliases', 'molecular_formula', 'molecular_weight', 'cas_number', 'drugbank_id', 'chembl_id', 'evidence_strength' );
foreach ( $meta_keys as $key ) {
update_post_meta( $post_id, $key, $p[ $key ] );
}
update_post_meta( $post_id, 'editorial_review_status', 'published' );
@@ -119,30 +98,16 @@ class PR_Core_Seed_Data {
}
/**
* Seed 10 dosing rows across the 3 peptides.
* Seed 10 dosing rows across the 3 peptides using fixture data.
*
* @param array<string, int> $peptide_ids Map of slug => post ID.
* @return int Number of rows inserted.
*/
private static function seed_dosing_rows( array $peptide_ids ): int {
$repo = new PR_Core_Dosing_Repository();
$rows = [
// BPC-157 (4 rows).
[ 'peptide' => 'bpc-157', 'dose_min' => 200, 'dose_max' => 300, 'dose_unit' => 'mcg', 'route' => 'subq', 'frequency' => 'twice daily', 'population' => 'healthy', 'evidence_strength' => 'observational', 'study_title' => 'BPC 157 in wound healing review', 'study_year' => 2021, 'citation_pubmed_id' => '34537800' ],
[ 'peptide' => 'bpc-157', 'dose_min' => 500, 'dose_max' => 500, 'dose_unit' => 'mcg', 'route' => 'subq', 'frequency' => 'daily', 'population' => 'healthy', 'evidence_strength' => 'case-series', 'study_title' => 'Subcutaneous BPC-157 in tendon repair', 'study_year' => 2019, 'citation_pubmed_id' => '30915550' ],
[ 'peptide' => 'bpc-157', 'dose_min' => 10, 'dose_max' => 10, 'dose_unit' => 'mcg', 'route' => 'oral', 'frequency' => 'daily', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'Oral BPC-157 in rat gastric ulcer model', 'study_year' => 2018, 'citation_pubmed_id' => '29869189' ],
[ 'peptide' => 'bpc-157', 'dose_min' => 250, 'dose_max' => 750, 'dose_unit' => 'mcg', 'route' => 'im', 'frequency' => 'daily', 'population' => 'clinical', 'evidence_strength' => 'case-series', 'study_title' => 'Intramuscular BPC-157 for muscle injuries', 'study_year' => 2020, 'citation_pubmed_id' => '33312000' ],
// Semaglutide (4 rows).
[ 'peptide' => 'semaglutide', 'dose_min' => 0.25, 'dose_max' => 0.25, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'SUSTAIN 1: Semaglutide in T2DM', 'study_year' => 2017, 'citation_pubmed_id' => '28930514', 'indication' => 'Type 2 diabetes — initiation dose' ],
[ 'peptide' => 'semaglutide', 'dose_min' => 1.0, 'dose_max' => 1.0, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'SUSTAIN 6: CV outcomes with semaglutide', 'study_year' => 2016, 'citation_pubmed_id' => '27633186', 'indication' => 'Type 2 diabetes — maintenance' ],
[ 'peptide' => 'semaglutide', 'dose_min' => 2.4, 'dose_max' => 2.4, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'STEP 1: Semaglutide 2.4mg for obesity', 'study_year' => 2021, 'citation_pubmed_id' => '33567185', 'indication' => 'Chronic weight management' ],
[ 'peptide' => 'semaglutide', 'dose_min' => 14, 'dose_max' => 14, 'dose_unit' => 'mg', 'route' => 'oral', 'frequency' => 'daily', 'population' => 'clinical', 'evidence_strength' => 'rct-large', 'study_title' => 'PIONEER 1: Oral semaglutide in T2DM', 'study_year' => 2019, 'citation_pubmed_id' => '30924169', 'indication' => 'Type 2 diabetes — oral formulation' ],
// TB-500 (2 rows).
[ 'peptide' => 'tb-500', 'dose_min' => 2, 'dose_max' => 2.5, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'twice weekly', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'Thymosin beta-4 promotes dermal healing in rats', 'study_year' => 2007, 'citation_pubmed_id' => '17584560' ],
[ 'peptide' => 'tb-500', 'dose_min' => 5, 'dose_max' => 5, 'dose_unit' => 'mg', 'route' => 'subq', 'frequency' => 'weekly', 'population' => 'animal', 'evidence_strength' => 'preclinical', 'study_title' => 'TB4 cardiac repair in murine MI model', 'study_year' => 2012, 'citation_pubmed_id' => '22561753' ],
];
$repo = new PR_Core_Dosing_Repository();
$rows = PR_Core_Seed_Fixtures::get_dosing_rows();
$count = 0;
foreach ( $rows as $row ) {
$slug = $row['peptide'];
unset( $row['peptide'] );
@@ -153,10 +118,10 @@ class PR_Core_Seed_Data {
$row['peptide_id'] = $peptide_ids[ $slug ];
$row['source'] = 'manual';
$row['added_by'] = get_current_user_id() ?: 1;
$row['added_by'] = get_current_user_id() !== 0 ? get_current_user_id() : 1;
if ( $repo->insert( $row ) > 0 ) {
$count++;
++$count;
}
}
@@ -164,22 +129,16 @@ class PR_Core_Seed_Data {
}
/**
* Seed 5 legal cells across BPC-157 and Semaglutide.
* Seed 5 legal cells across BPC-157 and Semaglutide using fixture data.
*
* @param array<string, int> $peptide_ids Map of slug => post ID.
* @return int Number of cells inserted.
*/
private static function seed_legal_cells( array $peptide_ids ): int {
$repo = new PR_Core_Legal_Repository();
$cells = [
[ 'peptide' => 'bpc-157', 'country_code' => 'US', 'status' => 'ruo', 'regulatory_framework' => 'Not FDA-approved; available as Research Use Only', 'notes' => 'Compounding pharmacies may dispense under FDA 503A/503B exemptions.' ],
[ 'peptide' => 'bpc-157', 'country_code' => 'GB', 'status' => 'unclear', 'regulatory_framework' => 'Not listed by MHRA', 'notes' => 'No specific scheduling; import for personal use is a grey area.' ],
[ 'peptide' => 'semaglutide', 'country_code' => 'US', 'status' => 'prescription', 'regulatory_framework' => 'FDA-approved (Ozempic, Wegovy, Rybelsus)', 'regulatory_text_url' => 'https://www.accessdata.fda.gov/drugsatfda_docs/label/2023/209637s020lbl.pdf', 'notes' => 'Schedule IV not applicable; requires valid prescription.' ],
[ 'peptide' => 'semaglutide', 'country_code' => 'GB', 'status' => 'prescription', 'regulatory_framework' => 'MHRA-approved (Ozempic, Wegovy)', 'notes' => 'Available via NHS and private prescription.' ],
[ 'peptide' => 'semaglutide', 'country_code' => 'AU', 'status' => 'prescription', 'regulatory_framework' => 'TGA-approved (Ozempic)', 'notes' => 'PBS-listed for type 2 diabetes; Wegovy approval pending as of 2025.' ],
];
$cells = PR_Core_Seed_Fixtures::get_legal_cells();
$count = 0;
foreach ( $cells as $cell ) {
$slug = $cell['peptide'];
unset( $cell['peptide'] );
@@ -189,10 +148,10 @@ class PR_Core_Seed_Data {
}
$cell['peptide_id'] = $peptide_ids[ $slug ];
$cell['reviewer_id'] = get_current_user_id() ?: 1;
$cell['reviewer_id'] = get_current_user_id() !== 0 ? get_current_user_id() : 1;
if ( $repo->insert( $cell ) > 0 ) {
$count++;
++$count;
}
}

View File

@@ -0,0 +1,265 @@
<?php
/**
* Seed Fixtures.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Provides hard-coded peptide, dosing, and legal data used by PR_Core_Seed_Data
*
* @package Peptide_Repo_Core
*/
/**
* Static seed data fixtures for development and testing.
*
* What: Provides hard-coded peptide, dosing, and legal data used by PR_Core_Seed_Data.
* Who calls it: PR_Core_Seed_Data — calls get_peptides(), get_dosing_rows(), get_legal_cells().
* Dependencies: None.
*
* @see class-pr-core-seed-data.php — Orchestrator that consumes these fixtures.
*/
class PR_Core_Seed_Fixtures {
/**
* Return the canonical list of peptide fixture definitions.
*
* @return array<int, array<string, mixed>>
*/
public static function get_peptides(): array {
return array(
array(
'title' => 'BPC-157',
'slug' => 'bpc-157',
'excerpt' => 'Body Protection Compound-157, a pentadecapeptide derived from human gastric juice.',
'display_name' => 'BPC-157',
'aliases' => '["Body Protection Compound-157","Bepecin","PL 14736","PL-10"]',
'molecular_formula' => 'C62H98N16O22',
'molecular_weight' => 1419.53,
'cas_number' => '137525-51-0',
'drugbank_id' => '',
'chembl_id' => '',
'evidence_strength' => 'observational',
),
array(
'title' => 'Semaglutide',
'slug' => 'semaglutide',
'excerpt' => 'A GLP-1 receptor agonist approved for type 2 diabetes and chronic weight management.',
'display_name' => 'Semaglutide',
'aliases' => '["Ozempic","Wegovy","Rybelsus"]',
'molecular_formula' => 'C187H291N45O59',
'molecular_weight' => 4113.58,
'cas_number' => '910463-68-2',
'drugbank_id' => 'DB13928',
'chembl_id' => 'CHEMBL3137309',
'evidence_strength' => 'meta-analysis',
),
array(
'title' => 'TB-500',
'slug' => 'tb-500',
'excerpt' => 'Thymosin Beta-4 fragment, a synthetic peptide used in tissue repair research.',
'display_name' => 'TB-500',
'aliases' => '["Thymosin Beta-4","T\u03b24"]',
'molecular_formula' => 'C212H350N56O78S',
'molecular_weight' => 4963.44,
'cas_number' => '77591-33-4',
'drugbank_id' => '',
'chembl_id' => '',
'evidence_strength' => 'preclinical',
),
);
}
/**
* Return the canonical list of dosing row fixture definitions.
*
* @return array<int, array<string, mixed>>
*/
public static function get_dosing_rows(): array {
return array(
array(
'peptide' => 'bpc-157',
'dose_min' => 200,
'dose_max' => 300,
'dose_unit' => 'mcg',
'route' => 'subq',
'frequency' => 'twice daily',
'population' => 'healthy',
'evidence_strength' => 'observational',
'study_title' => 'BPC 157 in wound healing review',
'study_year' => 2021,
'citation_pubmed_id' => '34537800',
),
array(
'peptide' => 'bpc-157',
'dose_min' => 500,
'dose_max' => 500,
'dose_unit' => 'mcg',
'route' => 'subq',
'frequency' => 'daily',
'population' => 'healthy',
'evidence_strength' => 'case-series',
'study_title' => 'Subcutaneous BPC-157 in tendon repair',
'study_year' => 2019,
'citation_pubmed_id' => '30915550',
),
array(
'peptide' => 'bpc-157',
'dose_min' => 10,
'dose_max' => 10,
'dose_unit' => 'mcg',
'route' => 'oral',
'frequency' => 'daily',
'population' => 'animal',
'evidence_strength' => 'preclinical',
'study_title' => 'Oral BPC-157 in rat gastric ulcer model',
'study_year' => 2018,
'citation_pubmed_id' => '29869189',
),
array(
'peptide' => 'bpc-157',
'dose_min' => 250,
'dose_max' => 750,
'dose_unit' => 'mcg',
'route' => 'im',
'frequency' => 'daily',
'population' => 'clinical',
'evidence_strength' => 'case-series',
'study_title' => 'Intramuscular BPC-157 for muscle injuries',
'study_year' => 2020,
'citation_pubmed_id' => '33312000',
),
array(
'peptide' => 'semaglutide',
'dose_min' => 0.25,
'dose_max' => 0.25,
'dose_unit' => 'mg',
'route' => 'subq',
'frequency' => 'weekly',
'population' => 'clinical',
'evidence_strength' => 'rct-large',
'study_title' => 'SUSTAIN 1: Semaglutide in T2DM',
'study_year' => 2017,
'citation_pubmed_id' => '28930514',
'indication' => 'Type 2 diabetes initiation dose',
),
array(
'peptide' => 'semaglutide',
'dose_min' => 1.0,
'dose_max' => 1.0,
'dose_unit' => 'mg',
'route' => 'subq',
'frequency' => 'weekly',
'population' => 'clinical',
'evidence_strength' => 'rct-large',
'study_title' => 'SUSTAIN 6: CV outcomes with semaglutide',
'study_year' => 2016,
'citation_pubmed_id' => '27633186',
'indication' => 'Type 2 diabetes maintenance',
),
array(
'peptide' => 'semaglutide',
'dose_min' => 2.4,
'dose_max' => 2.4,
'dose_unit' => 'mg',
'route' => 'subq',
'frequency' => 'weekly',
'population' => 'clinical',
'evidence_strength' => 'rct-large',
'study_title' => 'STEP 1: Semaglutide 2.4mg for obesity',
'study_year' => 2021,
'citation_pubmed_id' => '33567185',
'indication' => 'Chronic weight management',
),
array(
'peptide' => 'semaglutide',
'dose_min' => 14,
'dose_max' => 14,
'dose_unit' => 'mg',
'route' => 'oral',
'frequency' => 'daily',
'population' => 'clinical',
'evidence_strength' => 'rct-large',
'study_title' => 'PIONEER 1: Oral semaglutide in T2DM',
'study_year' => 2019,
'citation_pubmed_id' => '30924169',
'indication' => 'Type 2 diabetes oral formulation',
),
array(
'peptide' => 'tb-500',
'dose_min' => 2,
'dose_max' => 2.5,
'dose_unit' => 'mg',
'route' => 'subq',
'frequency' => 'twice weekly',
'population' => 'animal',
'evidence_strength' => 'preclinical',
'study_title' => 'Thymosin beta-4 promotes dermal healing in rats',
'study_year' => 2007,
'citation_pubmed_id' => '17584560',
),
array(
'peptide' => 'tb-500',
'dose_min' => 5,
'dose_max' => 5,
'dose_unit' => 'mg',
'route' => 'subq',
'frequency' => 'weekly',
'population' => 'animal',
'evidence_strength' => 'preclinical',
'study_title' => 'TB4 cardiac repair in murine MI model',
'study_year' => 2012,
'citation_pubmed_id' => '22561753',
),
);
}
/**
* Return the canonical list of legal cell fixture definitions.
*
* @return array<int, array<string, mixed>>
*/
public static function get_legal_cells(): array {
return array(
array(
'peptide' => 'bpc-157',
'country_code' => 'US',
'status' => 'ruo',
'regulatory_framework' => 'Not FDA-approved; available as Research Use Only',
'notes' => 'Compounding pharmacies may dispense under FDA 503A/503B exemptions.',
),
array(
'peptide' => 'bpc-157',
'country_code' => 'GB',
'status' => 'unclear',
'regulatory_framework' => 'Not listed by MHRA',
'notes' => 'No specific scheduling; import for personal use is a grey area.',
),
array(
'peptide' => 'semaglutide',
'country_code' => 'US',
'status' => 'prescription',
'regulatory_framework' => 'FDA-approved (Ozempic, Wegovy, Rybelsus)',
'regulatory_text_url' => 'https://www.accessdata.fda.gov/drugsatfda_docs/label/2023/209637s020lbl.pdf',
'notes' => 'Schedule IV not applicable; requires valid prescription.',
),
array(
'peptide' => 'semaglutide',
'country_code' => 'GB',
'status' => 'prescription',
'regulatory_framework' => 'MHRA-approved (Ozempic, Wegovy)',
'notes' => 'Available via NHS and private prescription.',
),
array(
'peptide' => 'semaglutide',
'country_code' => 'AU',
'status' => 'prescription',
'regulatory_framework' => 'TGA-approved (Ozempic)',
'notes' => 'PBS-listed for type 2 diabetes; Wegovy approval pending as of 2025.',
),
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Class Pr Core.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Registers all hooks, runs migrations, boots subsystems
*
* @package Peptide_Repo_Core
*/
/**
* Main orchestrator for the Peptide Repo Core plugin.
*
@@ -43,28 +55,28 @@ class PR_Core {
$repo_daily_tax = new PR_Core_Repo_Daily_Taxonomy();
$repo_daily_tax->register_hooks();
// One-shot rewrite flush on in-place version bumps. Runs at the very
// end of init (priority 999) so all CPTs/taxonomies — ours and
// anyone else's — are registered first. Handles updates deployed
// One-shot rewrite flush on in-place version bumps. Runs at the very.
// end of init (priority 999) so all CPTs/taxonomies — ours and.
// anyone else's — are registered first. Handles updates deployed.
// without a deactivate/reactivate cycle (e.g., SCP/rsync pushes).
add_action( 'init', [ PR_Core_Activator::class, 'maybe_flush_on_version_change' ], 999 );
add_action( 'init', array( PR_Core_Activator::class, 'maybe_flush_on_version_change' ), 999 );
// Verification scanner: register cron hook and schedule if not already scheduled.
if ( ! wp_next_scheduled( 'pr_core_verification_scan' ) ) {
wp_schedule_event( time(), get_option( 'pr_core_scan_cadence', 'weekly' ), 'pr_core_verification_scan' );
}
add_action( 'pr_core_verification_scan', [ PR_Core_Verification_Scanner::class, 'run_scan' ] );
add_action( 'pr_core_verification_scan', array( PR_Core_Verification_Scanner::class, 'run_scan' ) );
// Ajax handlers must be registered outside is_admin() — admin-ajax.php
// Ajax handlers must be registered outside is_admin() — admin-ajax.php.
// does not define WP_ADMIN, so is_admin() returns false for ajax requests.
add_action( 'wp_ajax_pr_core_mark_verified', [ PR_Core_Ajax_Handlers::class, 'handle_mark_verified' ] );
add_action( 'wp_ajax_pr_core_scan_now', [ PR_Core_Ajax_Handlers::class, 'handle_scan_now' ] );
add_action( 'wp_ajax_pr_core_mark_verified', array( PR_Core_Ajax_Handlers::class, 'handle_mark_verified' ) );
add_action( 'wp_ajax_pr_core_scan_now', array( PR_Core_Ajax_Handlers::class, 'handle_scan_now' ) );
// Admin UI (fires on admin_init, admin_menu).
if ( is_admin() ) {
add_action( 'admin_menu', [ PR_Core_Settings::class, 'add_settings_page' ] );
add_action( 'admin_init', [ PR_Core_Settings::class, 'register_settings' ] );
add_action( 'wp_dashboard_setup', [ PR_Core_Verification_Widget::class, 'register' ] );
add_action( 'admin_menu', array( PR_Core_Settings::class, 'add_settings_page' ) );
add_action( 'admin_init', array( PR_Core_Settings::class, 'register_settings' ) );
add_action( 'wp_dashboard_setup', array( PR_Core_Verification_Widget::class, 'register' ) );
$admin = new PR_Core_Admin();
$admin->register_hooks();
@@ -86,7 +98,7 @@ class PR_Core {
$related_posts->register_hooks();
// Enqueue related-posts CSS on single peptide pages.
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_styles' ] );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_styles' ) );
// REST API.
$rest = new PR_Core_Rest_Controller();
@@ -109,7 +121,7 @@ class PR_Core {
wp_enqueue_style(
'pr-core-related-posts',
PR_CORE_PLUGIN_URL . 'assets/css/related-posts.css',
[],
array(),
PR_CORE_VERSION
);
}
@@ -125,9 +137,9 @@ class PR_Core {
* @return void
*/
private function register_public_filters(): void {
add_filter( 'pr_core_get_indexable_corpus', [ $this, 'filter_indexable_corpus' ] );
add_filter( 'pr_core_disclaimer_for_surface', [ $this, 'filter_disclaimer_for_surface' ], 10, 2 );
add_filter( 'pr_core_evidence_strength_label', [ $this, 'filter_evidence_label' ], 10, 2 );
add_filter( 'pr_core_get_indexable_corpus', array( $this, 'filter_indexable_corpus' ) );
add_filter( 'pr_core_disclaimer_for_surface', array( $this, 'filter_disclaimer_for_surface' ), 10, 2 );
add_filter( 'pr_core_evidence_strength_label', array( $this, 'filter_evidence_label' ), 10, 2 );
}
/**
@@ -139,17 +151,17 @@ class PR_Core {
* @return array<int, array<string, mixed>>
*/
public function filter_indexable_corpus( array $entries ): array {
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( [ 'status' => 'publish' ] );
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( array( 'status' => 'publish' ) );
foreach ( $peptides as $dto ) {
$entries[] = [
$entries[] = array(
'id' => $dto->id,
'url' => get_permalink( $dto->id ),
'title' => $dto->title,
'body' => $dto->excerpt . "\n" . $dto->content,
'type' => 'peptide_monograph',
];
);
}
return $entries;
@@ -174,14 +186,14 @@ class PR_Core {
* @return string Localized label.
*/
public function filter_evidence_label( string $label, string $strength ): string {
$map = [
'preclinical' => __( 'Preclinical', 'peptide-repo-core' ),
'case-series' => __( 'Case Series', 'peptide-repo-core' ),
'observational' => __( 'Observational', 'peptide-repo-core' ),
'rct-small' => __( 'Small RCT', 'peptide-repo-core' ),
'rct-large' => __( 'Large RCT', 'peptide-repo-core' ),
'meta-analysis' => __( 'Meta-Analysis', 'peptide-repo-core' ),
];
$map = array(
'preclinical' => __( 'Preclinical', 'peptide-repo-core' ),
'case-series' => __( 'Case Series', 'peptide-repo-core' ),
'observational' => __( 'Observational', 'peptide-repo-core' ),
'rct-small' => __( 'Small RCT', 'peptide-repo-core' ),
'rct-large' => __( 'Large RCT', 'peptide-repo-core' ),
'meta-analysis' => __( 'Meta-Analysis', 'peptide-repo-core' ),
);
return $map[ $strength ] ?? $strength;
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Internal Posts Provider.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Implements PR_Core_Related_Posts_Provider by querying posts tagged
*
* @package Peptide_Repo_Core
*/
/**
* Fetches posts related to a peptide via taxonomy and fallback text search.
*
@@ -33,7 +45,7 @@ class PR_Core_Internal_Posts_Provider implements PR_Core_Related_Posts_Provider
public function get_posts( int $peptide_id, int $limit ): array {
$peptide = get_post( $peptide_id );
if ( ! $peptide || 'peptide' !== $peptide->post_type ) {
return [];
return array();
}
$cache_key = 'pr_core_related_' . $peptide_id;
@@ -64,21 +76,23 @@ class PR_Core_Internal_Posts_Provider implements PR_Core_Related_Posts_Provider
* @return array<int, \WP_Post>
*/
private function query_by_taxonomy( string $peptide_slug, int $limit ): array {
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => $limit,
'orderby' => 'date',
'order' => 'DESC',
'tax_query' => [
[
'taxonomy' => 'peptide_topic',
'field' => 'slug',
'terms' => $peptide_slug,
],
],
] );
$query = new WP_Query(
array(
'post_type' => 'post',
'posts_per_page' => $limit,
'orderby' => 'date',
'order' => 'DESC',
'tax_query' => array(
array(
'taxonomy' => 'peptide_topic',
'field' => 'slug',
'terms' => $peptide_slug,
),
),
)
);
return $query->posts ?: [];
return $query->posts ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
/**
@@ -89,14 +103,16 @@ class PR_Core_Internal_Posts_Provider implements PR_Core_Related_Posts_Provider
* @return array<int, \WP_Post>
*/
private function query_by_search( string $search_term, int $limit ): array {
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => $limit,
'orderby' => 'date',
'order' => 'DESC',
's' => $search_term,
] );
$query = new WP_Query(
array(
'post_type' => 'post',
'posts_per_page' => $limit,
'orderby' => 'date',
'order' => 'DESC',
's' => $search_term,
)
);
return $query->posts ?: [];
return $query->posts ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Related Posts Provider.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Interface defining the get_posts() contract for related-post providers
*
* @package Peptide_Repo_Core
*/
/**
* Contract for any provider that retrieves posts related to a peptide.
*

View File

@@ -1,6 +1,18 @@
<?php
/**
* Related Posts Section.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Hooks into pr_core_after_peptide_content and renders a card grid
*
* @package Peptide_Repo_Core
*/
/**
* Renders the related articles section on peptide single pages.
*
@@ -15,7 +27,11 @@ declare(strict_types=1);
*/
class PR_Core_Related_Posts_Section {
/** @var PR_Core_Related_Posts_Provider */
/**
* PR_Core_Related_Posts_Provider.
*
* @var PR_Core_Related_Posts_Provider
*/
private $provider;
/**
@@ -33,8 +49,8 @@ class PR_Core_Related_Posts_Section {
* @return void
*/
public function register_hooks(): void {
add_action( 'pr_core_after_peptide_content', [ $this, 'render' ] );
add_action( 'save_post_post', [ $this, 'invalidate_caches' ] );
add_action( 'pr_core_after_peptide_content', array( $this, 'render' ) );
add_action( 'save_post_post', array( $this, 'invalidate_caches' ) );
}
/**
@@ -72,7 +88,7 @@ class PR_Core_Related_Posts_Section {
break;
}
setup_postdata( $post );
get_template_part( 'template-parts/related-posts/card', null, [ 'post' => $post ] );
get_template_part( 'template-parts/related-posts/card', null, array( 'post' => $post ) );
++$count;
}
wp_reset_postdata();
@@ -90,6 +106,7 @@ class PR_Core_Related_Posts_Section {
<?php
echo wp_kses_post(
sprintf(
// translators: %s: peptide display name.
__( 'View all %s articles &rarr;', 'peptide-repo-core' ),
esc_html( $peptide->post_title )
)

View File

@@ -1,6 +1,18 @@
<?php
/**
* Peptide Cpt.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Defines peptide CPT with REST support, archive, and meta fields
*
* @package Peptide_Repo_Core
*/
/**
* Registers the peptide custom post type and associated taxonomy.
*
@@ -17,13 +29,25 @@ declare(strict_types=1);
*/
class PR_Core_Peptide_CPT {
/** @var string Post type slug (owned by PR Core since v0.2.0; PSA previously registered this). */
/**
* Post type slug (owned by PR Core since v0.2.0; PSA previously registered this).
*
* @var string Post type slug (owned by PR Core since v0.2.0; PSA previously registered this).
*/
public const POST_TYPE = 'peptide';
/** @var string Taxonomy: category (e.g., GLP-1 agonist). */
/**
* Taxonomy: category (e.g., GLP-1 agonist).
*
* @var string Taxonomy: category (e.g., GLP-1 agonist).
*/
public const TAX_CATEGORY = 'peptide_category';
/** @var string Capability required for editing peptide data. */
/**
* Capability required for editing peptide data.
*
* @var string Capability required for editing peptide data.
*/
public const CAPABILITY = 'manage_peptide_content';
/**
@@ -31,26 +55,26 @@ class PR_Core_Peptide_CPT {
*
* @var string[]
*/
public const EVIDENCE_STRENGTHS = [
public const EVIDENCE_STRENGTHS = array(
'preclinical',
'case-series',
'observational',
'rct-small',
'rct-large',
'meta-analysis',
];
);
/**
* Editorial review status enum values.
*
* @var string[]
*/
public const REVIEW_STATUSES = [
public const REVIEW_STATUSES = array(
'draft',
'in-review',
'published',
'retired',
];
);
/**
* Post-meta field definitions (1:1 with peptide).
@@ -59,115 +83,7 @@ class PR_Core_Peptide_CPT {
* @return array<string, array<string, mixed>>
*/
public static function get_meta_fields(): array {
return [
'display_name' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'aliases' => [
'type' => 'string',
'default' => '[]',
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ],
],
'molecular_formula' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'molecular_weight' => [
'type' => 'number',
'default' => 0,
'sanitize' => 'floatval',
],
'cas_number' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'drugbank_id' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'chembl_id' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'evidence_strength' => [
'type' => 'string',
'default' => 'preclinical',
'sanitize' => [ __CLASS__, 'sanitize_evidence_strength' ],
],
'editorial_review_status' => [
'type' => 'string',
'default' => 'draft',
'sanitize' => [ __CLASS__, 'sanitize_review_status' ],
],
'last_editorial_review_at' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'medical_editor_id' => [
'type' => 'integer',
'default' => 0,
'sanitize' => 'absint',
],
'_pr_last_source_verified' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'_pr_last_reviewed' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'_pr_next_review_by' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
],
'_pr_verification_velocity' => [
'type' => 'string',
'default' => 'medium',
'sanitize' => [ PR_Core_Verification_Sanitizers::class, 'sanitize_velocity' ],
],
'_pr_verification_notes' => [
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_textarea_field',
],
'_pr_verification_status' => [
'type' => 'string',
'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' ],
],
];
return PR_Core_Peptide_Meta_Schema::get_fields();
}
/**
@@ -176,9 +92,9 @@ class PR_Core_Peptide_CPT {
* @return void
*/
public function register_hooks(): void {
add_action( 'init', [ __CLASS__, 'register_peptide_post_type' ] );
add_action( 'init', [ __CLASS__, 'register_taxonomies' ] );
add_action( 'init', [ __CLASS__, 'register_meta_fields' ] );
add_action( 'init', array( __CLASS__, 'register_peptide_post_type' ) );
add_action( 'init', array( __CLASS__, 'register_taxonomies' ) );
add_action( 'init', array( __CLASS__, 'register_meta_fields' ) );
}
/**
@@ -193,7 +109,7 @@ class PR_Core_Peptide_CPT {
return;
}
$labels = [
$labels = array(
'name' => __( 'Peptides', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide', 'peptide-repo-core' ),
'add_new_item' => __( 'Add New Peptide', 'peptide-repo-core' ),
@@ -205,11 +121,11 @@ class PR_Core_Peptide_CPT {
'not_found_in_trash' => __( 'No peptides found in trash', 'peptide-repo-core' ),
'all_items' => __( 'All Peptides', 'peptide-repo-core' ),
'menu_name' => __( 'Peptides', 'peptide-repo-core' ),
];
);
// Args harmonized superset of PR Core + PSA for 89 existing posts.
// Supports union, capability/role perms preserved, slugs unchanged.
$args = [
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
@@ -218,9 +134,9 @@ class PR_Core_Peptide_CPT {
'show_in_nav_menus' => true,
'show_in_rest' => true,
'rest_base' => 'peptides',
// Note: 'rest_namespace' is deliberately omitted. Custom namespaces prevent
// Gutenberg's block editor from loading posts for editing (it fetches the
// hardcoded wp/v2 REST route). WordPress defaults to wp/v2 — appropriate for
// Note: 'rest_namespace' is deliberately omitted. Custom namespaces prevent.
// Gutenberg's block editor from loading posts for editing (it fetches the.
// hardcoded wp/v2 REST route). WordPress defaults to wp/v2 — appropriate for.
// this CPT — and keeps the REST endpoint at /wp-json/wp/v2/peptides/.
'menu_position' => 25,
'menu_icon' => 'dashicons-database',
@@ -228,9 +144,12 @@ class PR_Core_Peptide_CPT {
'map_meta_cap' => true,
'hierarchical' => false,
'has_archive' => true,
'rewrite' => [ 'slug' => 'peptides', 'with_front' => false ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ],
];
'rewrite' => array(
'slug' => 'peptides',
'with_front' => false,
),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ),
);
register_post_type( self::POST_TYPE, $args );
}
@@ -246,19 +165,26 @@ class PR_Core_Peptide_CPT {
return;
}
register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [
'labels' => [
'name' => __( 'Peptide Categories', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ),
],
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => true,
'rewrite' => [ 'slug' => 'peptide-category', 'with_front' => false ],
] );
register_taxonomy(
self::TAX_CATEGORY,
self::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Peptide Categories', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ),
),
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => true,
'rewrite' => array(
'slug' => 'peptide-category',
'with_front' => false,
),
)
);
}
/**
@@ -268,26 +194,40 @@ class PR_Core_Peptide_CPT {
*/
public static function register_meta_fields(): void {
foreach ( self::get_meta_fields() as $key => $config ) {
register_post_meta( self::POST_TYPE, $key, [
'type' => $config['type'],
'single' => true,
'default' => $config['default'],
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
'auth_callback' => static function () {
return current_user_can( self::CAPABILITY );
},
] );
register_post_meta(
self::POST_TYPE,
$key,
array(
'type' => $config['type'],
'single' => true,
'default' => $config['default'],
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
'auth_callback' => static function () {
return current_user_can( self::CAPABILITY );
},
)
);
}
}
/** Sanitize evidence_strength to allowed enum values. */
/**
* Sanitize evidence_strength to allowed enum values.
*
* @param mixed $value Raw input value.
* @return string
*/
public static function sanitize_evidence_strength( $value ): string {
$value = sanitize_text_field( (string) $value );
return in_array( $value, self::EVIDENCE_STRENGTHS, true ) ? $value : 'preclinical';
}
/** Sanitize editorial_review_status to allowed enum values. */
/**
* Sanitize editorial_review_status to allowed enum values.
*
* @param mixed $value Raw input value.
* @return string
*/
public static function sanitize_review_status( $value ): string {
$value = sanitize_text_field( (string) $value );
return in_array( $value, self::REVIEW_STATUSES, true ) ? $value : 'draft';

View File

@@ -0,0 +1,144 @@
<?php
/**
* Peptide Meta Schema.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Returns the complete meta field definition map used by PR_Core_Peptide_CPT
*
* @package Peptide_Repo_Core
*/
/**
* Meta field schema definitions for the peptide CPT.
*
* What: Returns the complete meta field definition map used by PR_Core_Peptide_CPT.
* Who calls it: PR_Core_Peptide_CPT::get_meta_fields() delegates here.
* Dependencies: PR_Core_Peptide_CPT (for sanitize callbacks), PR_Core_Schema_Sanitizers,
* PR_Core_Verification_Sanitizers.
*
* @see cpt/class-pr-core-peptide-cpt.php — Consumer of this schema.
*/
class PR_Core_Peptide_Meta_Schema {
/**
* Return the full meta field definition array for the peptide CPT.
*
* Keys map to meta_key strings; values are arrays with type, default, and sanitize.
*
* @return array<string, array<string, mixed>>
*/
public static function get_fields(): array {
return array(
'display_name' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'aliases' => array(
'type' => 'string',
'default' => '[]',
'sanitize' => array( PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ),
),
'molecular_formula' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'molecular_weight' => array(
'type' => 'number',
'default' => 0,
'sanitize' => 'floatval',
),
'cas_number' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'drugbank_id' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'chembl_id' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'evidence_strength' => array(
'type' => 'string',
'default' => 'preclinical',
'sanitize' => array( PR_Core_Peptide_CPT::class, 'sanitize_evidence_strength' ),
),
'editorial_review_status' => array(
'type' => 'string',
'default' => 'draft',
'sanitize' => array( PR_Core_Peptide_CPT::class, 'sanitize_review_status' ),
),
'last_editorial_review_at' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'medical_editor_id' => array(
'type' => 'integer',
'default' => 0,
'sanitize' => 'absint',
),
'_pr_last_source_verified' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'_pr_last_reviewed' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'_pr_next_review_by' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_text_field',
),
'_pr_verification_velocity' => array(
'type' => 'string',
'default' => 'medium',
'sanitize' => array( PR_Core_Verification_Sanitizers::class, 'sanitize_velocity' ),
),
'_pr_verification_notes' => array(
'type' => 'string',
'default' => '',
'sanitize' => 'sanitize_textarea_field',
),
'_pr_verification_status' => array(
'type' => 'string',
'default' => 'current',
'sanitize' => array( PR_Core_Verification_Sanitizers::class, 'sanitize_status' ),
),
'_pr_molecular_formula' => array(
'type' => 'string',
'default' => '',
'sanitize' => array( PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_formula' ),
),
'_pr_molecular_weight' => array(
'type' => 'string',
'default' => '',
'sanitize' => array( PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_weight_string' ),
),
'_pr_aliases' => array(
'type' => 'string',
'default' => '[]',
'sanitize' => array( PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ),
),
'_pr_faq_items' => array(
'type' => 'string',
'default' => '[]',
'sanitize' => array( PR_Core_Schema_Sanitizers::class, 'sanitize_faq_items' ),
),
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Repo Daily Cpt.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Defines the `repo_daily` CPT for articles, guides, comparisons, and news
*
* @package Peptide_Repo_Core
*/
/**
* Registers the repo_daily custom post type for editorial publications.
*
@@ -18,7 +30,11 @@ declare(strict_types=1);
*/
class PR_Core_Repo_Daily_CPT {
/** @var string Post type slug (repo_daily). */
/**
* Post type slug (repo_daily).
*
* @var string Post type slug (repo_daily).
*/
public const POST_TYPE = 'repo_daily';
/**
@@ -27,7 +43,7 @@ class PR_Core_Repo_Daily_CPT {
* @return void
*/
public function register_hooks(): void {
add_action( 'init', [ __CLASS__, 'register_post_type' ] );
add_action( 'init', array( __CLASS__, 'register_post_type' ) );
}
/**
@@ -42,7 +58,7 @@ class PR_Core_Repo_Daily_CPT {
return;
}
$labels = [
$labels = array(
'name' => __( 'Repo Daily', 'peptide-repo-core' ),
'singular_name' => __( 'Repo Daily', 'peptide-repo-core' ),
'add_new_item' => __( 'Add New Article', 'peptide-repo-core' ),
@@ -54,9 +70,9 @@ class PR_Core_Repo_Daily_CPT {
'not_found_in_trash' => __( 'No articles found in trash', 'peptide-repo-core' ),
'all_items' => __( 'All Articles', 'peptide-repo-core' ),
'menu_name' => __( 'Repo Daily', 'peptide-repo-core' ),
];
);
$args = [
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
@@ -71,9 +87,12 @@ class PR_Core_Repo_Daily_CPT {
'map_meta_cap' => true,
'hierarchical' => false,
'has_archive' => true,
'rewrite' => [ 'slug' => 'daily', 'with_front' => false ],
'supports' => [ 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ],
];
'rewrite' => array(
'slug' => 'daily',
'with_front' => false,
),
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
);
register_post_type( self::POST_TYPE, $args );
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Schema Sanitizers.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Static sanitizer methods for _pr_molecular_formula, _pr_molecular_weight,
*
* @package Peptide_Repo_Core
*/
/**
* Sanitizers for v0.6.0 schema-input meta fields.
*
@@ -67,8 +79,8 @@ class PR_Core_Schema_Sanitizers {
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';
// 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'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
if ( preg_match( $pattern, $value, $m ) ) {
return $m[1];
}
@@ -132,7 +144,7 @@ class PR_Core_Schema_Sanitizers {
return '[]';
}
$clean = [];
$clean = array();
foreach ( $value as $item ) {
if ( ! is_array( $item ) ) {
continue;
@@ -140,13 +152,13 @@ class PR_Core_Schema_Sanitizers {
$question = sanitize_text_field( (string) ( $item['question'] ?? '' ) );
$answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) );
if ( '' !== $question && '' !== $answer ) {
$clean[] = [
$clean[] = array(
'question' => $question,
'answer' => $answer,
];
);
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Topic Taxonomy.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Defines the `peptide_topic` taxonomy attached to the post CPT
*
* @package Peptide_Repo_Core
*/
/**
* Registers the peptide_topic taxonomy for linking blog posts to peptides.
*
@@ -18,7 +30,11 @@ declare(strict_types=1);
*/
class PR_Core_Topic_Taxonomy {
/** @var string Taxonomy: topic (links blog posts to peptides by slug). */
/**
* Taxonomy: topic (links blog posts to peptides by slug).
*
* @var string Taxonomy: topic (links blog posts to peptides by slug).
*/
public const TAX_TOPIC = 'peptide_topic';
/**
@@ -27,7 +43,7 @@ class PR_Core_Topic_Taxonomy {
* @return void
*/
public function register_hooks(): void {
add_action( 'init', [ __CLASS__, 'register_topic_taxonomy' ] );
add_action( 'init', array( __CLASS__, 'register_topic_taxonomy' ) );
}
/**
@@ -45,18 +61,25 @@ class PR_Core_Topic_Taxonomy {
return;
}
register_taxonomy( self::TAX_TOPIC, 'post', [
'labels' => [
'name' => __( 'Peptide Topics', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Topic', 'peptide-repo-core' ),
],
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => false,
'rewrite' => [ 'slug' => 'peptide-topic', 'with_front' => false ],
] );
register_taxonomy(
self::TAX_TOPIC,
'post',
array(
'labels' => array(
'name' => __( 'Peptide Topics', 'peptide-repo-core' ),
'singular_name' => __( 'Peptide Topic', 'peptide-repo-core' ),
),
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => false,
'rewrite' => array(
'slug' => 'peptide-topic',
'with_front' => false,
),
)
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Verification Sanitizers.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Standalone sanitization functions for verification_velocity and verification_status fields
*
* @package Peptide_Repo_Core
*/
/**
* Sanitizers for verification meta fields.
*
@@ -19,7 +31,7 @@ class PR_Core_Verification_Sanitizers {
* @return string Valid enum value (low, medium, high).
*/
public static function sanitize_velocity( string $value ): string {
return in_array( $value, [ 'low', 'medium', 'high' ], true ) ? $value : 'medium';
return in_array( $value, array( 'low', 'medium', 'high' ), true ) ? $value : 'medium';
}
/**
@@ -29,6 +41,6 @@ class PR_Core_Verification_Sanitizers {
* @return string Valid enum value (current, due, overdue).
*/
public static function sanitize_status( string $value ): string {
return in_array( $value, [ 'current', 'due', 'overdue' ], true ) ? $value : 'current';
return in_array( $value, array( 'current', 'due', 'overdue' ), true ) ? $value : 'current';
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Candidate Dto.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Immutable value object wrapping a row from pr_ai_candidate_queue
*
* @package Peptide_Repo_Core
*/
/**
* Typed data-transfer object for an AI candidate queue entry.
*
@@ -13,32 +25,154 @@ declare(strict_types=1);
*/
class PR_Core_Candidate_DTO {
/**
* Int.
*
* @var int
*/
public readonly int $id;
/**
* Int.
*
* @var int
*/
public readonly int $peptide_id;
/**
* ?float.
*
* @var ?float
*/
public readonly ?float $dose_min;
/**
* ?float.
*
* @var ?float
*/
public readonly ?float $dose_max;
/**
* String.
*
* @var string
*/
public readonly string $dose_unit;
/**
* String.
*
* @var string
*/
public readonly string $route;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $frequency;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $duration_value;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $duration_unit;
/**
* String.
*
* @var string
*/
public readonly string $population;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $indication;
/**
* String.
*
* @var string
*/
public readonly string $evidence_strength;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $study_title;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $study_year;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_pubmed_id;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_doi;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_url;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $notes;
/**
* Float.
*
* @var float
*/
public readonly float $extraction_confidence;
/**
* String.
*
* @var string
*/
public readonly string $queue_status;
/**
* String.
*
* @var string
*/
public readonly string $extracted_at;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $reviewed_by;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $reviewed_at;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $reviewer_notes;
/**
* Construct.
*
* @param array<string, mixed> $data Associative array from database row.
*/
public function __construct( array $data ) {
@@ -74,7 +208,7 @@ class PR_Core_Candidate_DTO {
* @return array<string, mixed>
*/
public function to_array(): array {
return [
return array(
'id' => $this->id,
'peptide_id' => $this->peptide_id,
'dose_min' => $this->dose_min,
@@ -90,6 +224,6 @@ class PR_Core_Candidate_DTO {
'extraction_confidence' => $this->extraction_confidence,
'queue_status' => $this->queue_status,
'extracted_at' => $this->extracted_at,
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Dosing Row Dto.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Immutable value object wrapping a row from pr_dosing_rows
*
* @package Peptide_Repo_Core
*/
/**
* Typed data-transfer object for a dosing row.
*
@@ -13,63 +25,195 @@ declare(strict_types=1);
*/
class PR_Core_Dosing_Row_DTO {
/**
* Int.
*
* @var int
*/
public readonly int $id;
/**
* Int.
*
* @var int
*/
public readonly int $peptide_id;
/**
* ?float.
*
* @var ?float
*/
public readonly ?float $dose_min;
/**
* ?float.
*
* @var ?float
*/
public readonly ?float $dose_max;
/**
* String.
*
* @var string
*/
public readonly string $dose_unit;
/**
* String.
*
* @var string
*/
public readonly string $route;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $frequency;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $duration_value;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $duration_unit;
/**
* String.
*
* @var string
*/
public readonly string $population;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $indication;
/**
* String.
*
* @var string
*/
public readonly string $evidence_strength;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $study_title;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $study_year;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_pubmed_id;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_doi;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $citation_url;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $notes;
/**
* Int.
*
* @var int
*/
public readonly int $schema_version;
/**
* String.
*
* @var string
*/
public readonly string $source;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $ai_candidate_id;
/**
* Int.
*
* @var int
*/
public readonly int $added_by;
/**
* String.
*
* @var string
*/
public readonly string $added_at;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $reviewed_by;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $reviewed_at;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $superseded_by_id;
/**
* Construct.
*
* @param array<string, mixed> $data Associative array from database row.
*/
public function __construct( array $data ) {
$this->id = (int) ( $data['id'] ?? 0 );
$this->peptide_id = (int) ( $data['peptide_id'] ?? 0 );
$this->dose_min = isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null;
$this->dose_max = isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null;
$this->dose_unit = (string) ( $data['dose_unit'] ?? '' );
$this->route = (string) ( $data['route'] ?? '' );
$this->frequency = $data['frequency'] ?? null;
$this->duration_value = isset( $data['duration_value'] ) ? (int) $data['duration_value'] : null;
$this->duration_unit = $data['duration_unit'] ?? null;
$this->population = (string) ( $data['population'] ?? '' );
$this->indication = $data['indication'] ?? null;
$this->evidence_strength = (string) ( $data['evidence_strength'] ?? 'preclinical' );
$this->study_title = $data['study_title'] ?? null;
$this->study_year = isset( $data['study_year'] ) ? (int) $data['study_year'] : null;
$this->citation_pubmed_id = $data['citation_pubmed_id'] ?? null;
$this->citation_doi = $data['citation_doi'] ?? null;
$this->citation_url = $data['citation_url'] ?? null;
$this->notes = $data['notes'] ?? null;
$this->schema_version = (int) ( $data['schema_version'] ?? 1 );
$this->source = (string) ( $data['source'] ?? 'manual' );
$this->ai_candidate_id = isset( $data['ai_candidate_id'] ) ? (int) $data['ai_candidate_id'] : null;
$this->added_by = (int) ( $data['added_by'] ?? 0 );
$this->added_at = (string) ( $data['added_at'] ?? '' );
$this->reviewed_by = isset( $data['reviewed_by'] ) ? (int) $data['reviewed_by'] : null;
$this->reviewed_at = $data['reviewed_at'] ?? null;
$this->superseded_by_id = isset( $data['superseded_by_id'] ) ? (int) $data['superseded_by_id'] : null;
$this->id = (int) ( $data['id'] ?? 0 );
$this->peptide_id = (int) ( $data['peptide_id'] ?? 0 );
$this->dose_min = isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null;
$this->dose_max = isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null;
$this->dose_unit = (string) ( $data['dose_unit'] ?? '' );
$this->route = (string) ( $data['route'] ?? '' );
$this->frequency = $data['frequency'] ?? null;
$this->duration_value = isset( $data['duration_value'] ) ? (int) $data['duration_value'] : null;
$this->duration_unit = $data['duration_unit'] ?? null;
$this->population = (string) ( $data['population'] ?? '' );
$this->indication = $data['indication'] ?? null;
$this->evidence_strength = (string) ( $data['evidence_strength'] ?? 'preclinical' );
$this->study_title = $data['study_title'] ?? null;
$this->study_year = isset( $data['study_year'] ) ? (int) $data['study_year'] : null;
$this->citation_pubmed_id = $data['citation_pubmed_id'] ?? null;
$this->citation_doi = $data['citation_doi'] ?? null;
$this->citation_url = $data['citation_url'] ?? null;
$this->notes = $data['notes'] ?? null;
$this->schema_version = (int) ( $data['schema_version'] ?? 1 );
$this->source = (string) ( $data['source'] ?? 'manual' );
$this->ai_candidate_id = isset( $data['ai_candidate_id'] ) ? (int) $data['ai_candidate_id'] : null;
$this->added_by = (int) ( $data['added_by'] ?? 0 );
$this->added_at = (string) ( $data['added_at'] ?? '' );
$this->reviewed_by = isset( $data['reviewed_by'] ) ? (int) $data['reviewed_by'] : null;
$this->reviewed_at = $data['reviewed_at'] ?? null;
$this->superseded_by_id = isset( $data['superseded_by_id'] ) ? (int) $data['superseded_by_id'] : null;
}
/**
@@ -78,27 +222,27 @@ class PR_Core_Dosing_Row_DTO {
* @return array<string, mixed>
*/
public function to_array(): array {
return [
'id' => $this->id,
'peptide_id' => $this->peptide_id,
'dose_min' => $this->dose_min,
'dose_max' => $this->dose_max,
'dose_unit' => $this->dose_unit,
'route' => $this->route,
'frequency' => $this->frequency,
'duration_value' => $this->duration_value,
'duration_unit' => $this->duration_unit,
'population' => $this->population,
'indication' => $this->indication,
'evidence_strength' => $this->evidence_strength,
'study_title' => $this->study_title,
'study_year' => $this->study_year,
'citation_pubmed_id' => $this->citation_pubmed_id,
'citation_doi' => $this->citation_doi,
'citation_url' => $this->citation_url,
'notes' => $this->notes,
'source' => $this->source,
'added_at' => $this->added_at,
];
return array(
'id' => $this->id,
'peptide_id' => $this->peptide_id,
'dose_min' => $this->dose_min,
'dose_max' => $this->dose_max,
'dose_unit' => $this->dose_unit,
'route' => $this->route,
'frequency' => $this->frequency,
'duration_value' => $this->duration_value,
'duration_unit' => $this->duration_unit,
'population' => $this->population,
'indication' => $this->indication,
'evidence_strength' => $this->evidence_strength,
'study_title' => $this->study_title,
'study_year' => $this->study_year,
'citation_pubmed_id' => $this->citation_pubmed_id,
'citation_doi' => $this->citation_doi,
'citation_url' => $this->citation_url,
'notes' => $this->notes,
'source' => $this->source,
'added_at' => $this->added_at,
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Legal Cell Dto.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Immutable value object wrapping a row from pr_legal_cells
*
* @package Peptide_Repo_Core
*/
/**
* Typed data-transfer object for a legal status cell.
*
@@ -13,20 +25,82 @@ declare(strict_types=1);
*/
class PR_Core_Legal_Cell_DTO {
/**
* Int.
*
* @var int
*/
public readonly int $id;
/**
* Int.
*
* @var int
*/
public readonly int $peptide_id;
/**
* String.
*
* @var string
*/
public readonly string $country_code;
/**
* String.
*
* @var string
*/
public readonly string $status;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $regulatory_framework;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $regulatory_text_url;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $regulatory_text_quote;
/**
* ?string.
*
* @var ?string
*/
public readonly ?string $notes;
/**
* String.
*
* @var string
*/
public readonly string $last_verified_at;
/**
* Int.
*
* @var int
*/
public readonly int $schema_version;
/**
* Int.
*
* @var int
*/
public readonly int $reviewer_id;
/**
* ?int.
*
* @var ?int
*/
public readonly ?int $superseded_by_id;
/**
* Construct.
*
* @param array<string, mixed> $data Associative array from database row.
*/
public function __construct( array $data ) {
@@ -59,7 +133,7 @@ class PR_Core_Legal_Cell_DTO {
* @return array<string, mixed>
*/
public function to_array(): array {
return [
return array(
'id' => $this->id,
'peptide_id' => $this->peptide_id,
'country_code' => $this->country_code,
@@ -70,6 +144,6 @@ class PR_Core_Legal_Cell_DTO {
'notes' => $this->notes,
'last_verified_at' => $this->last_verified_at,
'is_active' => $this->is_active(),
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Peptide Dto.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Immutable value object wrapping a peptide post + its meta fields
*
* @package Peptide_Repo_Core
*/
/**
* Typed data-transfer object for a peptide record.
*
@@ -13,30 +25,124 @@ declare(strict_types=1);
*/
class PR_Core_Peptide_DTO {
/**
* Int.
*
* @var int
*/
public readonly int $id;
/**
* String.
*
* @var string
*/
public readonly string $title;
/**
* String.
*
* @var string
*/
public readonly string $slug;
/**
* String.
*
* @var string
*/
public readonly string $content;
/**
* String.
*
* @var string
*/
public readonly string $excerpt;
/**
* String.
*
* @var string
*/
public readonly string $status;
/**
* String.
*
* @var string
*/
public readonly string $display_name;
/** @var string[] */
/**
* String[].
*
* @var string[]
*/
public readonly array $aliases;
/**
* String.
*
* @var string
*/
public readonly string $molecular_formula;
/**
* Float.
*
* @var float
*/
public readonly float $molecular_weight;
/**
* String.
*
* @var string
*/
public readonly string $cas_number;
/**
* String.
*
* @var string
*/
public readonly string $drugbank_id;
/**
* String.
*
* @var string
*/
public readonly string $chembl_id;
/**
* String.
*
* @var string
*/
public readonly string $evidence_strength;
/**
* String.
*
* @var string
*/
public readonly string $editorial_review_status;
/**
* String.
*
* @var string
*/
public readonly string $last_editorial_review_at;
/**
* Int.
*
* @var int
*/
public readonly int $medical_editor_id;
/** @var string[] Category term names. */
/**
* Category term names.
*
* @var string[] Category term names.
*/
public readonly array $categories;
/** @var string[] Family term names. */
/**
* Family term names.
*
* @var string[] Family term names.
*/
public readonly array $families;
/**
* Construct.
*
* @param array<string, mixed> $data Associative array of peptide fields.
*/
public function __construct( array $data ) {
@@ -47,7 +153,7 @@ class PR_Core_Peptide_DTO {
$this->excerpt = (string) ( $data['excerpt'] ?? '' );
$this->status = (string) ( $data['status'] ?? 'draft' );
$this->display_name = (string) ( $data['display_name'] ?? '' );
$this->aliases = (array) ( $data['aliases'] ?? [] );
$this->aliases = (array) ( $data['aliases'] ?? array() );
$this->molecular_formula = (string) ( $data['molecular_formula'] ?? '' );
$this->molecular_weight = (float) ( $data['molecular_weight'] ?? 0 );
$this->cas_number = (string) ( $data['cas_number'] ?? '' );
@@ -57,8 +163,8 @@ class PR_Core_Peptide_DTO {
$this->editorial_review_status = (string) ( $data['editorial_review_status'] ?? 'draft' );
$this->last_editorial_review_at = (string) ( $data['last_editorial_review_at'] ?? '' );
$this->medical_editor_id = (int) ( $data['medical_editor_id'] ?? 0 );
$this->categories = (array) ( $data['categories'] ?? [] );
$this->families = (array) ( $data['families'] ?? [] );
$this->categories = (array) ( $data['categories'] ?? array() );
$this->families = (array) ( $data['families'] ?? array() );
}
/**
@@ -67,7 +173,7 @@ class PR_Core_Peptide_DTO {
* @return array<string, mixed>
*/
public function to_array(): array {
return [
return array(
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
@@ -86,6 +192,6 @@ class PR_Core_Peptide_DTO {
'categories' => $this->categories,
'families' => $this->families,
'url' => get_permalink( $this->id ),
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Disclaimer.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Provides shortcode [pr_disclaimer surface="dosing"] and function
*
* @package Peptide_Repo_Core
*/
/**
* Shared disclaimer component for all peptide data surfaces.
*
@@ -17,10 +29,18 @@ declare(strict_types=1);
*/
class PR_Core_Disclaimer {
/** @var string Option key for disclaimer copy (JSON). */
/**
* Option key for disclaimer copy (JSON).
*
* @var string Option key for disclaimer copy (JSON).
*/
private const COPY_OPTION = 'pr_core_disclaimer_copy';
/** @var string Option key for disclaimer version. */
/**
* Option key for disclaimer version.
*
* @var string Option key for disclaimer version.
*/
private const VERSION_OPTION = 'pr_core_disclaimer_version';
/**
@@ -28,13 +48,13 @@ class PR_Core_Disclaimer {
*
* @var array<string, string>
*/
private const DEFAULTS = [
'dosing' => 'The dosing information presented here is compiled from published peer-reviewed literature and is provided for educational and research purposes only. It does not constitute medical advice, diagnosis, or treatment recommendations. Always consult a licensed healthcare professional before making any decisions based on this information.',
'legal' => 'Legal status information is compiled from publicly available regulatory texts and is current as of the last-verified date shown. Regulatory frameworks change frequently. This information does not constitute legal advice. Verify current regulations with the relevant regulatory authority in your jurisdiction before taking any action.',
'reconstitution' => 'This calculator is provided for educational purposes only. Always verify your calculations independently and consult a licensed healthcare professional. The results are based on the inputs you provide and standard reconstitution mathematics.',
'ai-answer' => 'This response was generated by an AI system and may contain errors or omissions. All claims are grounded in the Peptide Repo corpus and cited sources where possible. This is not medical advice. Always verify information with primary sources and consult a licensed healthcare professional.',
'general' => 'The information on this site is for educational and research purposes only. It is not intended as medical advice, diagnosis, or treatment. Always consult a licensed healthcare professional.',
];
private const DEFAULTS = array(
'dosing' => 'The dosing information presented here is compiled from published peer-reviewed literature and is provided for educational and research purposes only. It does not constitute medical advice, diagnosis, or treatment recommendations. Always consult a licensed healthcare professional before making any decisions based on this information.',
'legal' => 'Legal status information is compiled from publicly available regulatory texts and is current as of the last-verified date shown. Regulatory frameworks change frequently. This information does not constitute legal advice. Verify current regulations with the relevant regulatory authority in your jurisdiction before taking any action.',
'reconstitution' => 'This calculator is provided for educational purposes only. Always verify your calculations independently and consult a licensed healthcare professional. The results are based on the inputs you provide and standard reconstitution mathematics.',
'ai-answer' => 'This response was generated by an AI system and may contain errors or omissions. All claims are grounded in the Peptide Repo corpus and cited sources where possible. This is not medical advice. Always verify information with primary sources and consult a licensed healthcare professional.',
'general' => 'The information on this site is for educational and research purposes only. It is not intended as medical advice, diagnosis, or treatment. Always consult a licensed healthcare professional.',
);
/**
* Register shortcode and any frontend hooks.
@@ -42,7 +62,7 @@ class PR_Core_Disclaimer {
* @return void
*/
public function register_hooks(): void {
add_shortcode( 'pr_disclaimer', [ $this, 'shortcode_handler' ] );
add_shortcode( 'pr_disclaimer', array( $this, 'shortcode_handler' ) );
}
/**
@@ -52,7 +72,7 @@ class PR_Core_Disclaimer {
* @return string Disclaimer HTML.
*/
public function shortcode_handler( $atts ): string {
$atts = shortcode_atts( [ 'surface' => 'general' ], $atts, 'pr_disclaimer' );
$atts = shortcode_atts( array( 'surface' => 'general' ), $atts, 'pr_disclaimer' );
return self::render( sanitize_text_field( $atts['surface'] ) );
}
@@ -81,7 +101,7 @@ class PR_Core_Disclaimer {
*/
public static function get_disclaimer_text( string $surface ): string {
$custom = get_option( self::COPY_OPTION, '{}' );
$copy = json_decode( $custom, true ) ?: [];
$copy = json_decode( $custom, true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
if ( ! empty( $copy[ $surface ] ) ) {
return (string) $copy[ $surface ];
@@ -110,7 +130,7 @@ class PR_Core_Disclaimer {
*/
public static function update_disclaimer( string $surface, string $text ): void {
$custom = get_option( self::COPY_OPTION, '{}' );
$copy = json_decode( $custom, true ) ?: [];
$copy = json_decode( $custom, true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$copy[ sanitize_text_field( $surface ) ] = sanitize_textarea_field( $text );

View File

@@ -1,6 +1,18 @@
<?php
/**
* Jsonld Drug.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Assembles a Drug (+ MolecularEntity) schema node from peptide DTO
*
* @package Peptide_Repo_Core
*/
/**
* Builds the schema.org Drug graph piece for a peptide page.
*
@@ -17,17 +29,33 @@ declare(strict_types=1);
*/
class PR_Core_Jsonld_Drug {
/** @var string Meta key: molecular formula sourced by migration 0004. */
private const META_FORMULA = '_pr_molecular_formula';
/**
* Meta key: molecular formula sourced by migration 0004.
*
* @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';
/**
* Meta key: molecular weight (float string) sourced by migration 0004.
*
* @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';
/**
* Meta key: aliases JSON array sourced by migration 0004.
*
* @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';
/**
* Unit text for molecularWeight QuantitativeValue.
*
* @var string Unit text for molecularWeight QuantitativeValue.
*/
private const UNIT_TEXT = 'g/mol';
/**
* Build a schema.org Drug piece array for a peptide.
@@ -38,12 +66,12 @@ class PR_Core_Jsonld_Drug {
public function build( PR_Core_Peptide_DTO $peptide ): array {
$permalink = get_permalink( $peptide->id );
$node = [
'@type' => [ 'Drug', 'MolecularEntity' ],
$node = array(
'@type' => array( 'Drug', 'MolecularEntity' ),
'@id' => $permalink . '#drug',
'name' => $peptide->display_name ?: $peptide->title,
'name' => $peptide->display_name ?: $peptide->title, // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'url' => $permalink,
];
);
if ( '' !== $peptide->excerpt ) {
$node['description'] = $peptide->excerpt;
@@ -64,11 +92,11 @@ class PR_Core_Jsonld_Drug {
// molecularWeight: prefer _pr_molecular_weight, fall back to DTO field.
$weight = $this->get_weight( $peptide );
if ( $weight > 0.0 ) {
$node['molecularWeight'] = [
$node['molecularWeight'] = array(
'@type' => 'QuantitativeValue',
'value' => $weight,
'unitText' => self::UNIT_TEXT,
];
);
}
// External identifiers: CAS and DrugBank omitted in v0.6.0 (no source).
@@ -150,9 +178,9 @@ class PR_Core_Jsonld_Drug {
* @return array<string, string>|null
*/
private function build_legal_status(): ?array {
return [
return array(
'@type' => 'DrugLegalStatus',
'description' => esc_html__( 'Not approved for human therapeutic use; supplied for laboratory research purposes only.', 'peptide-repo-core' ),
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Jsonld Faq.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Reads _pr_faq_items post-meta (JSON array of {question, answer} objects)
*
* @package Peptide_Repo_Core
*/
/**
* Builds the schema.org FAQPage graph piece for a peptide page.
*
@@ -17,7 +29,11 @@ declare(strict_types=1);
*/
class PR_Core_Jsonld_Faq {
/** @var string Meta key holding the FAQ items JSON array. */
/**
* Meta key holding the FAQ items JSON array.
*
* @var string Meta key holding the FAQ items JSON array.
*/
public const META_FAQ_ITEMS = '_pr_faq_items';
/**
@@ -34,11 +50,11 @@ class PR_Core_Jsonld_Faq {
return null;
}
return [
return array(
'@type' => 'FAQPage',
'@id' => $permalink . '#faq',
'mainEntity' => array_map( [ $this, 'build_question' ], $items ),
];
'mainEntity' => array_map( array( $this, 'build_question' ), $items ),
);
}
/**
@@ -48,14 +64,14 @@ class PR_Core_Jsonld_Faq {
* @return array<string, mixed> Schema.org Question node.
*/
private function build_question( array $item ): array {
return [
'@type' => 'Question',
'name' => esc_html( $item['question'] ),
'acceptedAnswer' => [
return array(
'@type' => 'Question',
'name' => esc_html( $item['question'] ),
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => esc_html( $item['answer'] ),
],
];
),
);
}
/**
@@ -71,16 +87,16 @@ class PR_Core_Jsonld_Faq {
$raw = get_post_meta( $post_id, self::META_FAQ_ITEMS, true );
if ( ! is_string( $raw ) || '' === $raw ) {
return [];
return array();
}
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) || empty( $decoded ) ) {
return [];
return array();
}
$valid = [];
$valid = array();
foreach ( $decoded as $item ) {
if ( ! is_array( $item ) ) {
continue;
@@ -88,10 +104,10 @@ class PR_Core_Jsonld_Faq {
$question = sanitize_text_field( (string) ( $item['question'] ?? '' ) );
$answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) );
if ( '' !== $question && '' !== $answer ) {
$valid[] = [
$valid[] = array(
'question' => $question,
'answer' => $answer,
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Jsonld Webpage.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Hooks into Yoast's wpseo_schema_webpage_type filter to retype the page
*
* @package Peptide_Repo_Core
*/
/**
* Enriches Yoast's WebPage node to MedicalWebPage for peptide single pages.
*
@@ -16,10 +28,18 @@ declare(strict_types=1);
*/
class PR_Core_Jsonld_Webpage {
/** @var string Meta key for last editorial review date (ISO date string). */
/**
* Meta key for last editorial review date (ISO date string).
*
* @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). */
/**
* Meta key for last source verification date (ISO date string).
*
* @var string Meta key for last source verification date (ISO date string).
*/
private const META_LAST_VERIFIED = '_pr_last_source_verified';
/**
@@ -77,7 +97,7 @@ class PR_Core_Jsonld_Webpage {
// Yoast may set @type as a plain string OR an array of strings.
// Normalise to array so in_array() works regardless of shape.
$raw_type = $piece['@type'] ?? '';
$types = is_array( $raw_type ) ? $raw_type : [ $raw_type ];
$types = is_array( $raw_type ) ? $raw_type : array( $raw_type );
if ( in_array( 'WebPage', $types, true ) || in_array( 'MedicalWebPage', $types, true ) ) {
foreach ( $enrichments as $key => $value ) {
@@ -97,16 +117,16 @@ class PR_Core_Jsonld_Webpage {
* @return array<string, mixed> Enrichment fields to merge into the WebPage node.
*/
private function build_webpage_enrichments( int $post_id ): array {
$enrichments = [];
$enrichments = array();
// lastReviewed: prefer _pr_last_reviewed, fall back to _pr_last_source_verified,
// 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 ) ?: '';
$last_reviewed = get_post_modified_time( 'Y-m-d', false, $post_id ) ?: ''; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
if ( '' !== $last_reviewed ) {
@@ -118,27 +138,27 @@ class PR_Core_Jsonld_Webpage {
if ( $editor_id > 0 ) {
$editor = get_userdata( $editor_id );
if ( $editor ) {
$enrichments['reviewedBy'] = [
$enrichments['reviewedBy'] = array(
'@type' => 'Person',
'name' => sanitize_text_field( $editor->display_name ),
'url' => get_author_posts_url( $editor_id ),
];
);
}
}
if ( ! isset( $enrichments['reviewedBy'] ) ) {
$enrichments['reviewedBy'] = [
$enrichments['reviewedBy'] = array(
'@type' => 'Organization',
'name' => 'Peptide Repo',
'url' => home_url(),
];
);
}
// audience: MedicalAudience for researchers.
$enrichments['audience'] = [
$enrichments['audience'] = array(
'@type' => 'MedicalAudience',
'audienceType' => 'Researcher',
];
);
return $enrichments;
}

View File

@@ -1,11 +1,24 @@
<?php
/**
* Jsonld.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Integrates Drug, MedicalWebPage, and FAQPage schema into Yoast's graph
*
* @package Peptide_Repo_Core
*/
/**
* JSON-LD / schema.org structured data emission for peptide pages.
*
* 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.
*
* Who calls it: PR_Core::init() calls register_hooks(). Yoast hooks fire on
@@ -34,13 +47,25 @@ declare(strict_types=1);
*/
class PR_Core_Jsonld {
/** @var PR_Core_Jsonld_Drug Drug piece builder. */
/**
* Drug piece builder.
*
* @var PR_Core_Jsonld_Drug Drug piece builder.
*/
private PR_Core_Jsonld_Drug $drug_builder;
/** @var PR_Core_Jsonld_Webpage MedicalWebPage enricher. */
/**
* MedicalWebPage enricher.
*
* @var PR_Core_Jsonld_Webpage MedicalWebPage enricher.
*/
private PR_Core_Jsonld_Webpage $webpage_enricher;
/** @var PR_Core_Jsonld_Faq FAQ piece builder. */
/**
* FAQ piece builder.
*
* @var PR_Core_Jsonld_Faq FAQ piece builder.
*/
private PR_Core_Jsonld_Faq $faq_builder;
/**
@@ -67,19 +92,19 @@ class PR_Core_Jsonld {
*/
public function register_hooks(): void {
// wpseo_schema_webpage_type: retype WebPage -> MedicalWebPage on peptide singles.
add_filter( 'wpseo_schema_webpage_type', [ $this->webpage_enricher, 'retype_to_medical_webpage' ] );
add_filter( 'wpseo_schema_webpage_type', array( $this->webpage_enricher, 'retype_to_medical_webpage' ) );
// wpseo_schema_graph: two callbacks at different priorities.
// Priority 11 -- enrich_webpage_piece: merge lastReviewed/reviewedBy/audience into the
// existing WebPage node that Yoast already emitted.
// Priority 11 -- enrich_webpage_piece: merge lastReviewed/reviewedBy/audience into the.
// existing WebPage node that Yoast already emitted.
// Priority 12 -- inject_graph_nodes: append Drug + FAQPage arrays to the graph.
// Must run after enrich_webpage_piece so the WebPage node is already
// enriched before Drug/FAQ are appended.
add_filter( 'wpseo_schema_graph', [ $this->webpage_enricher, 'enrich_webpage_piece' ], 11, 2 );
add_filter( 'wpseo_schema_graph', [ $this, 'inject_graph_nodes' ], 12, 2 );
// Must run after enrich_webpage_piece so the WebPage node is already.
// enriched before Drug/FAQ are appended.
add_filter( 'wpseo_schema_graph', array( $this->webpage_enricher, 'enrich_webpage_piece' ), 11, 2 );
add_filter( 'wpseo_schema_graph', array( $this, 'inject_graph_nodes' ), 12, 2 );
// Standalone fallback: emits only when Yoast is not producing output.
add_action( 'wp_head', [ $this, 'emit_standalone_fallback' ], 99 );
add_action( 'wp_head', array( $this, 'emit_standalone_fallback' ), 99 );
}
/**
@@ -90,8 +115,8 @@ class PR_Core_Jsonld {
* Appending plain Drug/FAQ arrays here is safe -- unlike wpseo_schema_graph_pieces
* which calls get_class() and is_needed() on each item expecting objects.
*
* @param array<int, array<string, mixed>> $graph Assembled Yoast graph.
* @param \Yoast\WP\SEO\Context\Meta_Tags_Context|null $context Yoast schema context.
* @param array<int, array<string, mixed>> $graph Assembled Yoast graph.
* @param \Yoast\WP\SEO\Context\Meta_Tags_Context|null $context Yoast schema context.
* @return array<int, array<string, mixed>> Graph with Drug (and optionally FAQ) appended.
*/
public function inject_graph_nodes( array $graph, $context ): array {
@@ -150,7 +175,7 @@ class PR_Core_Jsonld {
}
$permalink = get_permalink( (int) $post_id );
$graph = [ $this->drug_builder->build( $peptide ) ];
$graph = array( $this->drug_builder->build( $peptide ) );
$faq_node = $this->faq_builder->build( (int) $post_id, $permalink );
if ( null !== $faq_node ) {
@@ -160,7 +185,10 @@ class PR_Core_Jsonld {
printf(
'<script type="application/ld+json">%s</script>' . "\n",
wp_json_encode(
[ '@context' => 'https://schema.org', '@graph' => $graph ],
array(
'@context' => 'https://schema.org',
'@graph' => $graph,
),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
)
);

View File

@@ -1,6 +1,18 @@
<?php
/**
* Verification Display.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Appends last-verified date after verdict card on reader-facing pages
*
* @package Peptide_Repo_Core
*/
/**
* Frontend verification date display on peptide single pages.
*
@@ -20,7 +32,7 @@ class PR_Core_Verification_Display {
* @return void
*/
public static function init(): void {
add_filter( 'the_content', [ __CLASS__, 'append_verification_display' ], 20 );
add_filter( 'the_content', array( __CLASS__, 'append_verification_display' ), 20 );
}
/**
@@ -63,10 +75,10 @@ class PR_Core_Verification_Display {
* @return string HTML block.
*/
private static function render_verification_block( string $last_verified ): string {
$date_obj = new \DateTime( $last_verified );
$date_obj = new \DateTime( $last_verified );
$formatted_date = $date_obj->format( get_option( 'date_format' ) );
$html = '<p class="pr-verification-date">';
$html = '<p class="pr-verification-date">';
$html .= esc_html__( 'Last verified: ', 'peptide-repo-core' );
$html .= sprintf(
'<time datetime="%s">%s</time>',

View File

@@ -1,6 +1,18 @@
<?php
/**
* Related Posts Provider.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Defines the contract for retrieving posts related to a given peptide
*
* @package Peptide_Repo_Core
*/
/**
* Interface for fetching related posts for a peptide.
*

View File

@@ -1,6 +1,18 @@
<?php
/**
* Migration 0001 Dosing Rows.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Creates the high-cardinality dosing data table (1:many with peptide)
*
* @package Peptide_Repo_Core
*/
/**
* Migration 0001: Create the pr_dosing_rows table.
*

View File

@@ -1,6 +1,18 @@
<?php
/**
* Migration 0002 Legal Cells.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Creates the per-country legal status table (1:many with peptide)
*
* @package Peptide_Repo_Core
*/
/**
* Migration 0002: Create the pr_legal_cells table.
*
@@ -14,15 +26,19 @@ declare(strict_types=1);
*/
class PR_Core_Migration_0002_Legal_Cells {
/** @var string[] Valid legal status enum values. */
public const STATUSES = [
/**
* Valid legal status enum values.
*
* @var string[] Valid legal status enum values.
*/
public const STATUSES = array(
'prescription',
'ruo',
'otc',
'restricted',
'banned',
'unclear',
];
);
/**
* Create the pr_legal_cells table.
@@ -36,7 +52,7 @@ class PR_Core_Migration_0002_Legal_Cells {
$table = $wpdb->prefix . 'pr_legal_cells';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
$sql = "CREATE TABLE {$table} ( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
peptide_id BIGINT UNSIGNED NOT NULL,
country_code CHAR(2) NOT NULL,
@@ -61,7 +77,7 @@ class PR_Core_Migration_0002_Legal_Cells {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$index_exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s",
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s',
DB_NAME,
$table,
'uq_peptide_country_active'
@@ -71,7 +87,7 @@ class PR_Core_Migration_0002_Legal_Cells {
if ( '0' === $index_exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
"ALTER TABLE {$table} ADD UNIQUE KEY uq_peptide_country_active (peptide_id, country_code, superseded_by_id)"
"ALTER TABLE {$table} ADD UNIQUE KEY uq_peptide_country_active (peptide_id, country_code, superseded_by_id)" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Migration 0003 Candidate Queue.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Creates the AI-extracted dosing candidate queue table
*
* @package Peptide_Repo_Core
*/
/**
* Migration 0003: Create the pr_ai_candidate_queue table.
*
@@ -15,13 +27,17 @@ declare(strict_types=1);
*/
class PR_Core_Migration_0003_Candidate_Queue {
/** @var string[] Valid queue status enum values. */
public const STATUSES = [
/**
* Valid queue status enum values.
*
* @var string[] Valid queue status enum values.
*/
public const STATUSES = array(
'pending',
'approved',
'rejected',
'merged',
];
);
/**
* Create the pr_ai_candidate_queue table.

View File

@@ -1,4 +1,10 @@
<?php
/**
* Migration 0004: Backfill peptide schema meta.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
@@ -30,16 +36,20 @@ declare(strict_types=1);
*/
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
/** @var string Meta key: molecular formula (PR Core namespace). */
/** @var string Meta key: molecular formula (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
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: molecular weight as float string (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const META_WEIGHT = '_pr_molecular_weight';
/** @var string Meta key: aliases JSON array (PR Core namespace). */
/** @var string Meta key: aliases JSON array (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const META_ALIASES = '_pr_aliases';
/** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */
/**
* HTTP client for PubChem REST API.
*
* @var PR_Core_Pubchem_Client
*/
private PR_Core_Pubchem_Client $pubchem;
/**
@@ -66,20 +76,27 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
return;
}
$counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ];
$counts = array(
'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']
) );
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']
)
);
}
/**
@@ -93,10 +110,10 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
$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
// 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';
@@ -125,26 +142,30 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
}
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 )
) );
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'] ?? [] );
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? array() );
$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
) );
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';
}
@@ -223,7 +244,7 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
return '[]';
}
$clean = [];
$clean = array();
foreach ( explode( ',', $value ) as $part ) {
$alias = sanitize_text_field( trim( $part ) );
if ( '' !== $alias ) {
@@ -231,7 +252,7 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
/**
@@ -243,14 +264,14 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
* @return string JSON array of sanitized strings.
*/
private function synonyms_to_json( array $synonyms ): string {
$clean = [];
$clean = array();
foreach ( $synonyms as $syn ) {
$alias = sanitize_text_field( (string) $syn );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
/**
@@ -259,12 +280,14 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
* @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 : [] );
$posts = get_posts(
array(
'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 : array() );
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Migration Runner.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Compares current schema version against target, runs pending migrations
*
* @package Peptide_Repo_Core
*/
/**
* Schema migration runner.
*
@@ -16,7 +28,11 @@ declare(strict_types=1);
*/
class PR_Core_Migration_Runner {
/** @var string Option key storing current schema version. */
/**
* Option key storing current schema version.
*
* @var string Option key storing current schema version.
*/
private const VERSION_OPTION = 'pr_core_schema_version';
/**
@@ -25,12 +41,12 @@ class PR_Core_Migration_Runner {
*
* @var string[]
*/
private const MIGRATIONS = [
private const MIGRATIONS = array(
'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',
];
);
/**
* Run all pending migrations up to PR_CORE_TARGET_SCHEMA_VERSION.
@@ -86,11 +102,11 @@ class PR_Core_Migration_Runner {
*
* PR_Core_Migration_0001_Dosing_Rows => migrations/class-pr-core-migration-0001-dosing-rows.php
*
* @param string $class Fully-qualified class name.
* @param string $class_name Fully-qualified class name.
* @return string Absolute file path.
*/
private function class_to_file( string $class ): string {
$suffix = substr( $class, 8 ); // Strip 'PR_Core_'.
private function class_to_file( string $class_name ): string {
$suffix = substr( $class_name, 8 ); // Strip 'PR_Core_'.
$filename = 'class-pr-core-' . str_replace( '_', '-', strtolower( $suffix ) ) . '.php';
return PR_CORE_PLUGIN_DIR . 'includes/migrations/' . $filename;

View File

@@ -1,6 +1,18 @@
<?php
/**
* Pubchem Client.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Fetches molecular formula, molecular weight, and synonyms from the
*
* @package Peptide_Repo_Core
*/
/**
* Lightweight PubChem REST client for the backfill migration.
*
@@ -20,16 +32,32 @@ declare(strict_types=1);
*/
class PR_Core_Pubchem_Client {
/** @var string PubChem REST base URL. */
/**
* PubChem REST base URL.
*
* @var string PubChem REST base URL.
*/
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
/** @var int HTTP timeout per request (seconds). */
/**
* HTTP timeout per request (seconds).
*
* @var int HTTP timeout per request (seconds).
*/
private const TIMEOUT = 8;
/** @var int Maximum retry attempts with exponential backoff. */
/**
* Maximum retry attempts with exponential backoff.
*
* @var int Maximum retry attempts with exponential backoff.
*/
private const MAX_RETRIES = 3;
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
/**
* Maximum synonyms to return (keeps post-meta size reasonable).
*
* @var int Maximum synonyms to return (keeps post-meta size reasonable).
*/
private const MAX_SYNONYMS = 10;
/**
@@ -109,11 +137,11 @@ class PR_Core_Pubchem_Client {
return null;
}
return [
return array(
'formula' => $formula,
'weight' => $weight,
'cid' => $cid,
];
);
}
/**
@@ -130,14 +158,14 @@ class PR_Core_Pubchem_Client {
$body = $this->get_with_retry( $url );
if ( null === $body ) {
return [];
return array();
}
$data = json_decode( $body, true );
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? [];
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? array();
if ( ! is_array( $synonyms ) ) {
return [];
return array();
}
return array_slice( $synonyms, 0, self::MAX_SYNONYMS );
@@ -176,10 +204,13 @@ class PR_Core_Pubchem_Client {
*/
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' ],
] );
$response = wp_remote_get(
$url,
array(
'timeout' => self::TIMEOUT,
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) ) {
return null;
@@ -194,13 +225,15 @@ class PR_Core_Pubchem_Client {
}
// WP-CLI / plain PHP fallback.
$context = stream_context_create( [
'http' => [
'timeout' => self::TIMEOUT,
'header' => "Accept: application/json\r\n",
'method' => 'GET',
],
] );
$context = stream_context_create(
array(
'http' => array(
'timeout' => self::TIMEOUT,
'header' => "Accept: application/json\r\n",
'method' => 'GET',
),
)
);
$body = @file_get_contents( $url, false, $context );
return ( false === $body ) ? null : $body;

View File

@@ -1,6 +1,18 @@
<?php
/**
* Candidate Queue Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD for AI-extracted dosing candidates awaiting human review
*
* @package Peptide_Repo_Core
*/
/**
* Repository for AI candidate queue (pr_ai_candidate_queue table).
*
@@ -28,7 +40,7 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -47,25 +59,25 @@ class PR_Core_Candidate_Queue_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_ai_candidate_queue';
$where = [ 'queue_status = %s' ];
$params = [ sanitize_text_field( $status ) ];
$where = array( 'queue_status = %s' );
$params = array( sanitize_text_field( $status ) );
if ( $peptide_id > 0 ) {
$where[] = 'peptide_id = %d';
$params[] = $peptide_id;
}
$params[] = $limit;
$params[] = $limit;
$where_sql = implode( ' AND ', $where );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY extraction_confidence DESC LIMIT %d",
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY extraction_confidence DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$params
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Candidate_DTO( $row ),
@@ -85,7 +97,7 @@ class PR_Core_Candidate_Queue_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_ai_candidate_queue';
$row = [
$row = array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null,
'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null,
@@ -106,7 +118,7 @@ class PR_Core_Candidate_Queue_Repository {
'extraction_confidence' => (float) ( $data['extraction_confidence'] ?? 0 ),
'queue_status' => 'pending',
'extracted_at' => current_time( 'mysql' ),
];
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert( $table, $row );
@@ -134,30 +146,32 @@ class PR_Core_Candidate_Queue_Repository {
// Copy candidate data into a dosing row.
$dosing_repo = new PR_Core_Dosing_Repository();
$dosing_id = $dosing_repo->insert( [
'peptide_id' => $candidate->peptide_id,
'dose_min' => $candidate->dose_min,
'dose_max' => $candidate->dose_max,
'dose_unit' => $candidate->dose_unit,
'route' => $candidate->route,
'frequency' => $candidate->frequency,
'duration_value' => $candidate->duration_value,
'duration_unit' => $candidate->duration_unit,
'population' => $candidate->population,
'indication' => $candidate->indication,
'evidence_strength' => $candidate->evidence_strength,
'study_title' => $candidate->study_title,
'study_year' => $candidate->study_year,
'citation_pubmed_id' => $candidate->citation_pubmed_id,
'citation_doi' => $candidate->citation_doi,
'citation_url' => $candidate->citation_url,
'notes' => $candidate->notes,
'source' => 'ai-candidate-approved',
'ai_candidate_id' => $candidate_id,
'added_by' => $reviewer_id,
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
] );
$dosing_id = $dosing_repo->insert(
array(
'peptide_id' => $candidate->peptide_id,
'dose_min' => $candidate->dose_min,
'dose_max' => $candidate->dose_max,
'dose_unit' => $candidate->dose_unit,
'route' => $candidate->route,
'frequency' => $candidate->frequency,
'duration_value' => $candidate->duration_value,
'duration_unit' => $candidate->duration_unit,
'population' => $candidate->population,
'indication' => $candidate->indication,
'evidence_strength' => $candidate->evidence_strength,
'study_title' => $candidate->study_title,
'study_year' => $candidate->study_year,
'citation_pubmed_id' => $candidate->citation_pubmed_id,
'citation_doi' => $candidate->citation_doi,
'citation_url' => $candidate->citation_url,
'notes' => $candidate->notes,
'source' => 'ai-candidate-approved',
'ai_candidate_id' => $candidate_id,
'added_by' => $reviewer_id,
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
)
);
if ( 0 === $dosing_id ) {
return 0;
@@ -204,7 +218,7 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE queue_status = %s", $status )
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE queue_status = %s", $status ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
@@ -224,15 +238,15 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->update(
$table,
[
array(
'queue_status' => sanitize_text_field( $status ),
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
'reviewer_notes' => sanitize_textarea_field( $notes ),
],
[ 'id' => $id ],
[ '%s', '%d', '%s', '%s' ],
[ '%d' ]
),
array( 'id' => $id ),
array( '%s', '%d', '%s', '%s' ),
array( '%d' )
);
return false !== $result;

View File

@@ -1,6 +1,18 @@
<?php
/**
* Dosing Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations for dosing data, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for dosing rows (pr_dosing_rows table).
*
@@ -25,7 +37,7 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -39,12 +51,12 @@ class PR_Core_Dosing_Repository {
* @param array<string, mixed> $filters Optional: route, population, evidence_strength.
* @return PR_Core_Dosing_Row_DTO[]
*/
public function find_by_peptide( int $peptide_id, array $filters = [] ): array {
public function find_by_peptide( int $peptide_id, array $filters = array() ): array {
global $wpdb;
$table = $wpdb->prefix . 'pr_dosing_rows';
$where = [ 'peptide_id = %d', 'superseded_by_id IS NULL' ];
$params = [ $peptide_id ];
$where = array( 'peptide_id = %d', 'superseded_by_id IS NULL' );
$params = array( $peptide_id );
if ( ! empty( $filters['route'] ) ) {
$where[] = 'route = %s';
@@ -65,12 +77,12 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC",
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- dynamic {$where_sql} is builder-constructed, not a placeholder
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$params
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Dosing_Row_DTO( $row ),
@@ -92,11 +104,15 @@ class PR_Core_Dosing_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_dosing_rows';
$row = $this->sanitize_row( $data );
$row = $this->sanitize_row( $data );
$row['added_at'] = current_time( 'mysql' );
$row['schema_version'] = 1;
/** @see PR_Core::register_public_filters() — Documented lifecycle hook. */
/**
* Property.
*
* @see PR_Core::register_public_filters() — Documented lifecycle hook.
*/
do_action( 'pr_core_before_dosing_row_publish', $row );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
@@ -137,10 +153,10 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
[ 'superseded_by_id' => $new_id ],
[ 'id' => $old_id ],
[ '%d' ],
[ '%d' ]
array( 'superseded_by_id' => $new_id ),
array( 'id' => $old_id ),
array( '%d' ),
array( '%d' )
);
return $new_id;
@@ -159,7 +175,7 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL",
"SELECT COUNT(*) FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id
)
);
@@ -172,7 +188,7 @@ class PR_Core_Dosing_Repository {
* @return array<string, mixed> Sanitized row.
*/
private function sanitize_row( array $data ): array {
return [
return array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null,
'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null,
@@ -195,6 +211,6 @@ class PR_Core_Dosing_Repository {
'added_by' => absint( $data['added_by'] ?? get_current_user_id() ),
'reviewed_by' => isset( $data['reviewed_by'] ) ? absint( $data['reviewed_by'] ) : null,
'reviewed_at' => $data['reviewed_at'] ?? null,
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Legal Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations for per-country legal status data, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for legal status cells (pr_legal_cells table).
*
@@ -28,7 +40,7 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -48,11 +60,11 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL ORDER BY country_code ASC",
"SELECT * FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL ORDER BY country_code ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ),
@@ -74,7 +86,7 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE peptide_id = %d AND country_code = %s AND superseded_by_id IS NULL",
"SELECT * FROM {$table} WHERE peptide_id = %d AND country_code = %s AND superseded_by_id IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id,
strtoupper( sanitize_text_field( $country_code ) )
),
@@ -97,11 +109,11 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE country_code = %s AND superseded_by_id IS NULL ORDER BY peptide_id ASC",
"SELECT * FROM {$table} WHERE country_code = %s AND superseded_by_id IS NULL ORDER BY peptide_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
strtoupper( sanitize_text_field( $country_code ) )
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ),
@@ -161,10 +173,10 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
[ 'superseded_by_id' => $new_id ],
[ 'id' => $old_id ],
[ '%d' ],
[ '%d' ]
array( 'superseded_by_id' => $new_id ),
array( 'id' => $old_id ),
array( '%d' ),
array( '%d' )
);
return $new_id;
@@ -184,7 +196,7 @@ class PR_Core_Legal_Repository {
$status = 'unclear';
}
return [
return array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'country_code' => strtoupper( sanitize_text_field( $data['country_code'] ?? '' ) ),
'status' => $status,
@@ -195,6 +207,6 @@ class PR_Core_Legal_Repository {
'last_verified_at' => sanitize_text_field( $data['last_verified_at'] ?? current_time( 'mysql' ) ),
'schema_version' => 1,
'reviewer_id' => absint( $data['reviewer_id'] ?? get_current_user_id() ),
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Peptide Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations over peptide posts, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for peptide CPT records.
*
@@ -36,12 +48,14 @@ class PR_Core_Peptide_Repository {
* @return PR_Core_Peptide_DTO|null
*/
public function find_by_slug( string $slug ): ?PR_Core_Peptide_DTO {
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => sanitize_title( $slug ),
'posts_per_page' => 1,
'post_status' => 'any',
] );
$posts = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => sanitize_title( $slug ),
'posts_per_page' => 1,
'post_status' => 'any',
)
);
return ! empty( $posts ) ? $this->post_to_dto( $posts[0] ) : null;
}
@@ -56,14 +70,16 @@ class PR_Core_Peptide_Repository {
public function search( string $query, int $limit = 20 ): array {
$query = sanitize_text_field( $query );
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
's' => $query,
'posts_per_page' => $limit,
'post_status' => 'publish',
] );
$posts = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
's' => $query,
'posts_per_page' => $limit,
'post_status' => 'publish',
)
);
return array_map( [ $this, 'post_to_dto' ], $posts );
return array_map( array( $this, 'post_to_dto' ), $posts );
}
/**
@@ -73,40 +89,44 @@ class PR_Core_Peptide_Repository {
* evidence_strength, per_page, page.
* @return PR_Core_Peptide_DTO[]
*/
public function find_all( array $filters = [] ): array {
$args = [
public function find_all( array $filters = array() ): array {
$args = array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'posts_per_page' => (int) ( $filters['per_page'] ?? 100 ),
'paged' => (int) ( $filters['page'] ?? 1 ),
'orderby' => 'title',
'order' => 'ASC',
];
);
$args['post_status'] = $filters['status'] ?? 'publish';
if ( ! empty( $filters['category'] ) ) {
$args['tax_query'] = [ [
'taxonomy' => PR_Core_Peptide_CPT::TAX_CATEGORY,
'field' => 'slug',
'terms' => sanitize_text_field( $filters['category'] ),
] ];
$args['tax_query'] = array(
array(
'taxonomy' => PR_Core_Peptide_CPT::TAX_CATEGORY,
'field' => 'slug',
'terms' => sanitize_text_field( $filters['category'] ),
),
);
}
// v0.2.0: `family` filter silently ignored — `pr_peptide_family` taxonomy
// was removed with the CPT consolidation. Callers passing `family` get
// all-category results rather than a fatal. Remove this key from the
// v0.2.0: `family` filter silently ignored — `pr_peptide_family` taxonomy.
// was removed with the CPT consolidation. Callers passing `family` get.
// all-category results rather than a fatal. Remove this key from the.
// REST schema in a future minor bump.
if ( ! empty( $filters['evidence_strength'] ) ) {
$args['meta_query'] = [ [
'key' => 'evidence_strength',
'value' => sanitize_text_field( $filters['evidence_strength'] ),
] ];
$args['meta_query'] = array(
array(
'key' => 'evidence_strength',
'value' => sanitize_text_field( $filters['evidence_strength'] ),
),
);
}
$posts = get_posts( $args );
return array_map( [ $this, 'post_to_dto' ], $posts );
return array_map( array( $this, 'post_to_dto' ), $posts );
}
/**
@@ -128,34 +148,36 @@ class PR_Core_Peptide_Repository {
*/
private function post_to_dto( \WP_Post $post ): PR_Core_Peptide_DTO {
$aliases_raw = get_post_meta( $post->ID, 'aliases', true );
$aliases = json_decode( $aliases_raw ?: '[]', true ) ?: [];
$aliases = json_decode( $aliases_raw ?: '[]', true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, [ 'fields' => 'names' ] );
// v0.2.0: `pr_peptide_family` taxonomy removed. DTO `families` field
// preserved as empty array to keep the REST response shape stable for
$categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, array( 'fields' => 'names' ) );
// v0.2.0: `pr_peptide_family` taxonomy removed. DTO `families` field.
// preserved as empty array to keep the REST response shape stable for.
// clients; will be dropped in a future minor bump with a release note.
$families = [];
$families = array();
return new PR_Core_Peptide_DTO( [
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'content' => $post->post_content,
'excerpt' => $post->post_excerpt,
'status' => $post->post_status,
'display_name' => get_post_meta( $post->ID, 'display_name', true ) ?: '',
'aliases' => $aliases,
'molecular_formula' => get_post_meta( $post->ID, 'molecular_formula', true ) ?: '',
'molecular_weight' => (float) get_post_meta( $post->ID, 'molecular_weight', true ),
'cas_number' => get_post_meta( $post->ID, 'cas_number', true ) ?: '',
'drugbank_id' => get_post_meta( $post->ID, 'drugbank_id', true ) ?: '',
'chembl_id' => get_post_meta( $post->ID, 'chembl_id', true ) ?: '',
'evidence_strength' => get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical',
'editorial_review_status' => get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft',
'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '',
'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ),
'categories' => is_array( $categories ) ? $categories : [],
'families' => $families,
] );
return new PR_Core_Peptide_DTO(
array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'content' => $post->post_content,
'excerpt' => $post->post_excerpt,
'status' => $post->post_status,
'display_name' => get_post_meta( $post->ID, 'display_name', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'aliases' => $aliases,
'molecular_formula' => get_post_meta( $post->ID, 'molecular_formula', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'molecular_weight' => (float) get_post_meta( $post->ID, 'molecular_weight', true ),
'cas_number' => get_post_meta( $post->ID, 'cas_number', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'drugbank_id' => get_post_meta( $post->ID, 'drugbank_id', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'chembl_id' => get_post_meta( $post->ID, 'chembl_id', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'evidence_strength' => get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'editorial_review_status' => get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ),
'categories' => is_array( $categories ) ? $categories : array(),
'families' => $families,
)
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Verification Scanner.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Runs periodic scans of all published peptides, computes verification status
*
* @package Peptide_Repo_Core
*/
/**
* Verification scanner for peptide monographs.
*
@@ -28,15 +40,15 @@ class PR_Core_Verification_Scanner {
* @return void
*/
public static function run_scan(): void {
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( [ 'status' => 'publish' ] );
$repo = new PR_Core_Peptide_Repository();
$peptides = $repo->find_all( array( 'status' => 'publish' ) );
$due_peptides = [];
$overdue_peptides = [];
$due_peptides = array();
$overdue_peptides = array();
foreach ( $peptides as $dto ) {
$last_verified = get_post_meta( $dto->id, '_pr_last_source_verified', true );
$velocity = get_post_meta( $dto->id, '_pr_verification_velocity', true ) ?: 'medium';
$velocity = get_post_meta( $dto->id, '_pr_verification_velocity', true ) ?: 'medium'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
// Empty verified date = treat as overdue.
if ( empty( $last_verified ) ) {
@@ -102,17 +114,17 @@ class PR_Core_Verification_Scanner {
* @return void
*/
private static function log_scan( int $total, int $due, int $overdue ): void {
$log = get_option( self::SCAN_LOG_OPTION_KEY, [] );
$log = get_option( self::SCAN_LOG_OPTION_KEY, array() );
if ( ! is_array( $log ) ) {
$log = [];
$log = array();
}
$log[] = [
$log[] = array(
'timestamp' => current_time( 'mysql' ),
'total' => $total,
'due' => $due,
'overdue' => $overdue,
];
);
// Keep only the last MAX_LOG_ENTRIES.
if ( count( $log ) > self::MAX_LOG_ENTRIES ) {
@@ -137,9 +149,12 @@ class PR_Core_Verification_Scanner {
// Parse comma-separated emails.
$emails = array_map( 'trim', explode( ',', $recipients ) );
$emails = array_filter( $emails, static function ( $email ) {
return is_email( $email );
} );
$emails = array_filter(
$emails,
static function ( $email ) {
return is_email( $email );
}
);
if ( empty( $emails ) ) {
return;
@@ -153,7 +168,7 @@ class PR_Core_Verification_Scanner {
$body = self::build_email_body( $due_peptides, $overdue_peptides );
wp_mail( $emails, $subject, $body, [ 'Content-Type: text/html; charset=UTF-8' ] );
wp_mail( $emails, $subject, $body, array( 'Content-Type: text/html; charset=UTF-8' ) );
}
/**
@@ -170,7 +185,7 @@ class PR_Core_Verification_Scanner {
$body .= '<h3>Overdue for review (' . count( $overdue_peptides ) . ')</h3><ul>';
foreach ( $overdue_peptides as $dto ) {
$edit_url = get_edit_post_link( $dto->id );
$body .= sprintf(
$body .= sprintf(
'<li><a href="%s">%s</a></li>',
esc_url( $edit_url ),
esc_html( $dto->title )
@@ -183,7 +198,7 @@ class PR_Core_Verification_Scanner {
$body .= '<h3>Due for review (' . count( $due_peptides ) . ')</h3><ul>';
foreach ( $due_peptides as $dto ) {
$edit_url = get_edit_post_link( $dto->id );
$body .= sprintf(
$body .= sprintf(
'<li><a href="%s">%s</a></li>',
esc_url( $edit_url ),
esc_html( $dto->title )

View File

@@ -1,6 +1,18 @@
<?php
/**
* Repo Daily Taxonomy.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Defines the `repo_daily_category` taxonomy attached to repo_daily CPT
*
* @package Peptide_Repo_Core
*/
/**
* Registers the repo_daily_category taxonomy for Repo Daily articles.
*
@@ -17,7 +29,11 @@ declare(strict_types=1);
*/
class PR_Core_Repo_Daily_Taxonomy {
/** @var string Taxonomy: repo_daily_category. */
/**
* Taxonomy: repo_daily_category.
*
* @var string Taxonomy: repo_daily_category.
*/
public const TAX_CATEGORY = 'repo_daily_category';
/**
@@ -26,7 +42,7 @@ class PR_Core_Repo_Daily_Taxonomy {
* @return void
*/
public function register_hooks(): void {
add_action( 'init', [ __CLASS__, 'register_taxonomy' ] );
add_action( 'init', array( __CLASS__, 'register_taxonomy' ) );
}
/**
@@ -41,19 +57,26 @@ class PR_Core_Repo_Daily_Taxonomy {
return;
}
register_taxonomy( self::TAX_CATEGORY, PR_Core_Repo_Daily_CPT::POST_TYPE, [
'labels' => [
'name' => __( 'Category', 'peptide-repo-core' ),
'singular_name' => __( 'Category', 'peptide-repo-core' ),
],
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => false,
'rewrite' => [ 'slug' => 'daily/category', 'with_front' => false ],
] );
register_taxonomy(
self::TAX_CATEGORY,
PR_Core_Repo_Daily_CPT::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Category', 'peptide-repo-core' ),
'singular_name' => __( 'Category', 'peptide-repo-core' ),
),
'public' => true,
'publicly_queryable' => true,
'show_in_rest' => true,
'show_ui' => true,
'show_admin_column' => true,
'hierarchical' => false,
'rewrite' => array(
'slug' => 'daily/category',
'with_front' => false,
),
)
);
}
/**
@@ -67,7 +90,7 @@ class PR_Core_Repo_Daily_Taxonomy {
* @return void
*/
public static function seed_terms(): void {
$terms = [ 'article', 'guide', 'comparison', 'news' ];
$terms = array( 'article', 'guide', 'comparison', 'news' );
foreach ( $terms as $term ) {
if ( ! term_exists( $term, self::TAX_CATEGORY ) ) {