Validated citations. */ public function get_citations( int $post_id ): array { $decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS ); if ( empty( $decoded ) ) { return array(); } $valid = array(); foreach ( $decoded as $item ) { if ( ! is_array( $item ) ) { continue; } $url = $this->sanitize_url( (string) ( $item['url'] ?? '' ) ); $title = sanitize_text_field( (string) ( $item['title'] ?? '' ) ); if ( '' === $url || '' === $title ) { continue; } $valid[] = array( 'url' => $url, 'title' => $title, 'doi' => isset( $item['doi'] ) ? $this->sanitize_doi( (string) $item['doi'] ) : null, ); } return $valid; } /** * Read and validate _prab_about_peptides meta. * * Returns Drug stub arrays for schema `about`. Each peptide post ID must * resolve to a published `peptide` post; unresolvable IDs are skipped. * * @param int $post_id WordPress post ID. * @return array> Drug stubs. */ public function get_about_peptides( int $post_id ): array { $decoded = $this->decode_json_meta( $post_id, self::META_ABOUT_PEPTIDES ); if ( empty( $decoded ) ) { return array(); } $stubs = array(); foreach ( $decoded as $raw_id ) { if ( ! is_numeric( $raw_id ) ) { continue; } $pid = absint( $raw_id ); $post = $pid > 0 ? get_post( $pid ) : null; if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) { continue; } $link = (string) get_permalink( $pid ); $stubs[] = array( '@type' => 'Drug', '@id' => $link . '#drug', 'name' => sanitize_text_field( $post->post_title ), 'url' => $link, ); } return $stubs; } /** * Read and validate _prab_review_mode meta. * * Unknown values are treated as 'editorial-system' (safe floor, contract v1). * * @param int $post_id WordPress post ID. * @return string 'human' | 'editorial-system'. */ public function get_review_mode( int $post_id ): string { $raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEW_MODE, true ) ); if ( 'human' === $raw ) { return 'human'; } if ( '' !== $raw && 'editorial-system' !== $raw ) { error_log( sprintf( '[PR Core] Unknown _prab_review_mode="%s" on post %d. Treating as editorial-system.', $raw, $post_id ) ); } return 'editorial-system'; } /** * Read and validate _prab_reviewed_at meta. * * Returns null when absent, empty, or unparseable as a date. * * @param int $post_id WordPress post ID. * @return string|null Sanitised datetime string, or null. */ public function get_reviewed_at( int $post_id ): ?string { $raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEWED_AT, true ) ); if ( '' === $raw || false === strtotime( $raw ) ) { return null; } return $raw; } /** * Resolve the honest reviewedBy value for a PRAB article. * * Returns a Person node only when mode='human' AND _prab_reviewed_by resolves * to an existing WP user. All other cases return the Organization node. * Never emits a fabricated Person — that is the core contract guarantee. * * Person @id: {home_url}/#/schema/person/{user_id} (Yoast convention). * Org @id: {home_url}/#organization (references Yoast's existing publisher node). * * @param int $post_id WordPress post ID. * @param string $review_mode Result of get_review_mode() for this post. * @return array Schema Person or Organization node. */ public function get_reviewed_by( int $post_id, string $review_mode ): array { if ( 'human' === $review_mode ) { $uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) ); $user = $uid > 0 ? get_userdata( $uid ) : false; if ( $user instanceof WP_User ) { return array( '@type' => 'Person', '@id' => home_url( '/#/schema/person/' . $uid ), 'name' => sanitize_text_field( $user->display_name ), ); } // Log missing/unresolvable user and fall through to Org. error_log( sprintf( '[PR Core] _prab_review_mode=human but reviewed_by user (ID=%d) unresolvable on post %d. Emitting Organization.', $uid, $post_id ) ); } return array( '@type' => 'Organization', '@id' => home_url( '/#organization' ), 'name' => 'Peptide Repo', 'url' => home_url(), ); } // ── Private helpers ────────────────────────────────────────────────── /** * Decode a JSON array from post meta, returning empty array on any error. * * @param int $post_id WordPress post ID. * @param string $meta_key Meta key to read. * @return array Decoded array, or empty array on failure. */ private function decode_json_meta( int $post_id, string $meta_key ): array { $raw = get_post_meta( $post_id, $meta_key, true ); if ( ! is_string( $raw ) || '' === $raw ) { return array(); } $decoded = json_decode( $raw, true ); return is_array( $decoded ) ? $decoded : array(); } /** * Sanitize and validate a URL, requiring http or https scheme. * * @param string $raw Raw URL string. * @return string Sanitised URL, or '' when invalid. */ private function sanitize_url( string $raw ): string { $url = esc_url_raw( trim( $raw ) ); if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) { return ''; } $scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME ); return in_array( $scheme, array( 'http', 'https' ), true ) ? $url : ''; } /** * Sanitize a DOI, returning a https://doi.org/ URI or null. * * Accepts bare DOI ("10.1234/abc"), doi:-prefixed, or full URI forms. * * @param string $raw Raw DOI string. * @return string|null DOI URI, or null if pattern does not match. */ private function sanitize_doi( string $raw ): ?string { $doi = trim( $raw ); foreach ( array( 'https://doi.org/', 'http://doi.org/', 'doi:' ) as $pfx ) { if ( str_starts_with( strtolower( $doi ), strtolower( $pfx ) ) ) { $doi = substr( $doi, strlen( $pfx ) ); break; } } return preg_match( '/^10\.\d{4,}\/\S+$/', $doi ) ? 'https://doi.org/' . $doi : null; } }