diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c5edfc3..d206df4 100755
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,6 +49,28 @@ jobs:
run: |
composer run lint
+ # ── PHPUnit Tests ────────────────────────────────────────────────
+ test-phpunit:
+ name: PHPUnit Tests (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: ['7.4', '8.1', '8.3']
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: composer
+
+ - name: Install Composer dependencies
+ run: composer install --no-interaction --prefer-dist --no-progress
+
+ - name: Run PHPUnit tests
+ run: composer run test
+
# ── JavaScript Lint ──────────────────────────────────────────────
lint-js:
name: JavaScript Linting
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 848805d..c2e4c1e 100755
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -244,6 +244,26 @@ jobs:
echo "✓ Deployment verification successful"
+ - name: HTTP health check
+ run: |
+ echo "Verifying site is accessible after deploy..."
+ HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://peptiderepo.com/")
+ if [ "$HTTP_STATUS" -eq 200 ]; then
+ echo "✓ Site returned HTTP $HTTP_STATUS"
+ else
+ echo "⚠ Site returned HTTP $HTTP_STATUS (expected 200)"
+ echo "The deploy completed but the site may need attention."
+ exit 1
+ fi
+
+ # Verify REST API endpoint is responding.
+ REST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "https://peptiderepo.com/wp-json/peptides/v1/search?q=BPC-157")
+ if [ "$REST_STATUS" -eq 200 ]; then
+ echo "✓ REST API returned HTTP $REST_STATUS"
+ else
+ echo "⚠ REST API returned HTTP $REST_STATUS (expected 200)"
+ fi
+
- name: Deploy summary
run: |
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
diff --git a/.gitignore b/.gitignore
index 6429e3d..e9e1ebc 100755
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,11 @@ node_modules/
.DS_Store
Thumbs.db
+# Environment & credentials
+.env
+.env.local
+.env.credentials
+
# Build artifacts
*.zip
*.tar.gz
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b6c9e1c..07ada92 100755
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -17,19 +17,25 @@ peptide-search-ai/
│
├── includes/
│ ├── class-psa-config.php Configuration constants (rate limits, timeouts, tokens, TTL)
-│ ├── class-psa-error.php [Deprecated] Standardized error response structure
-│ ├── class-psa-post-type.php Custom Post Type (CPT) registration; meta field definitions
+│ ├── class-psa-encryption.php AES-256-CBC encryption for API keys at rest
+│ ├── class-psa-post-type.php CPT registration, peptide_category taxonomy, meta field definitions
│ ├── class-psa-search.php AJAX search, REST API route, shortcode rendering, cache mgmt
-│ ├── class-psa-ai-generator.php AI calls (validation + generation); OpenRouter integration
+│ ├── class-psa-directory.php Browsable directory: [peptide_directory] shortcode + /v1/compounds REST endpoint
+│ ├── class-psa-ai-generator.php AI pipeline orchestrator (validation, generation, category assignment, meta save)
+│ ├── class-psa-openrouter.php OpenRouter API HTTP layer (retry, rate limit, response parsing)
+│ ├── class-psa-kb-builder.php Echo Knowledge Base article builder from AI data
+│ ├── class-psa-cost-tracker.php API usage logging, monthly budget enforcement, cost estimation
│ ├── class-psa-pubchem.php PubChem PUG REST API integration for molecular enrichment
-│ ├── class-psa-admin.php Settings page; API key, model, auto-publish configuration
-│ └── class-psa-template.php Single peptide page rendering; quick-facts tables, badges
+│ ├── class-psa-admin.php Settings page, usage dashboard, migration tools, re-enrichment actions
+│ └── class-psa-template.php Single peptide page rendering; quick-facts, extended data, badges
│
├── assets/
│ ├── js/
-│ │ └── peptide-search.js Frontend search, AJAX handler, result rendering (jQuery)
+│ │ ├── peptide-search.js Frontend search, AJAX handler, result rendering (jQuery)
+│ │ └── psa-directory.js Directory grid, category filters, detail modal (vanilla JS)
│ └── css/
-│ └── peptide-search.css Scoped styles for search UI, results, pending states, badges
+│ ├── peptide-search.css Scoped styles for search UI, results, pending states, badges
+│ └── psa-directory.css Directory card grid, filter pills, modal styles, dark mode support
│
└── .github/workflows/
├── ci.yml PHP lint, PHPCS, JS syntax checks on PR/push
@@ -94,6 +100,23 @@ graph LR
style D fill:#ffcdd2
```
+### Directory REST API Flow (v4.3.0)
+
+```mermaid
+graph LR
+ A["GET /wp-json/peptide-search-ai/v1/compounds"] --> B["Query peptide CPT"]
+ B -->|"?category=slug"| C["tax_query on peptide_category"]
+ B -->|"?search=term"| D["WP_Query title search"]
+ C --> E["Paginated JSON response (compounds, total, total_pages)"]
+ D --> E
+ B --> E
+ E -->|"?fields=basic"| F["Lightweight: id, name, category, vial_size"]
+ E -->|"?fields=full"| G["Full: all meta fields + extras"]
+
+ style A fill:#e3f2fd
+ style E fill:#c8e6c9
+```
+
### Background Generation Flow
```mermaid
@@ -140,10 +163,16 @@ graph TD
- Max tokens: 4096
- Called via WP-Cron (non-blocking)
-- **API Call:** `includes/class-psa-ai-generator.php` → `PSA_AI_Generator::call_openrouter()` (private static method)
+- **API Call:** `includes/class-psa-openrouter.php` → `PSA_OpenRouter::send_request()`
- Endpoint: `https://openrouter.ai/api/v1/chat/completions`
- Requires API key: database `psa_settings['api_key']` or constant `PSA_OPENROUTER_KEY`
- Retry logic: 3 retries with exponential backoff (5s base delay)
+ - Response parsing via `PSA_OpenRouter::parse_response()` (strips think tags, code fences)
+
+- **KB Article Creation:** `includes/class-psa-kb-builder.php` → `PSA_KB_Builder::create_article()`
+ - Creates Echo Knowledge Base articles from AI-generated data
+ - Builds structured HTML with h2/h3 headings, category assignment
+ - Cross-references peptide post ↔ KB article via post meta
**Configuration:** `Settings > Peptide Search AI` in WordPress admin
- API Key field (or via `PSA_OPENROUTER_KEY` in wp-config.php for security)
@@ -264,6 +293,31 @@ All stored on peptide posts as post meta:
| `psa_generation_attempts` | Retry counter | Integer | System (temporary) |
| `psa_generation_error` | Error message (if generation failed) | Text | System (temporary) |
| `psa_generation_completed` | Generation completion timestamp | Unix timestamp | System |
+| `psa_half_life` | Half-life display (e.g., "~4-6 hours") | Text | AI (v4.3.0) |
+| `psa_stability` | Stability info (e.g., "28 Days at 2°C") | Text | AI (v4.3.0) |
+| `psa_solubility` | Reconstitution solvent | Text | AI (v4.3.0) |
+| `psa_vial_size_mg` | Common vial size in mg | Float | AI (v4.3.0) |
+| `psa_storage_lyophilized` | Storage conditions (powder) | Text | AI (v4.3.0) |
+| `psa_storage_reconstituted` | Storage conditions (solution) | Text | AI (v4.3.0) |
+| `psa_typical_dose_mcg` | Typical research dose range | Text | AI (v4.3.0) |
+| `psa_cycle_parameters` | Cycle/protocol phase info | Text | AI (v4.3.0) |
+| `psa_amino_acid_count` | Number of amino acids | Integer | Derived from sequence (v4.3.0) |
+
+### Taxonomy: peptide_category (v4.3.0)
+
+Hierarchical taxonomy registered on the `peptide` CPT. Pre-populated terms:
+Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine.
+
+Auto-assigned during AI generation via `PSA_AI_Generator::assign_category_term()`.
+Migration for existing peptides via admin "Migrate Existing Peptides to Categories" button.
+
+### Integration Hooks (v4.3.0)
+
+| Hook | Type | Description |
+|------|------|-------------|
+| `psa_after_peptide_detail` | Action | Fired when a single peptide is rendered (single page or modal). Receives `$post_id`. |
+| `psa_directory_card_extras` | Filter | Allows adding HTML to directory cards (e.g., observation count badge). |
+| `psa_rest_compound_data` | Filter | Allows enriching REST compound data before response. Receives `$data`, `$post_id`. |
### Options (Plugin Settings)
@@ -349,10 +403,15 @@ $psa_settings = array(
## Testing
**CI Pipeline:** `.github/workflows/ci.yml`
-- PHP 7.4, 8.1, 8.3 syntax checks
+- PHPUnit tests across PHP 7.4, 8.1, 8.3
+- PHP syntax checks across PHP 7.4, 8.1, 8.3
- WordPress Coding Standards (PHPCS) validation
- JavaScript syntax checks (Node.js)
+**Post-Deploy:** `.github/workflows/deploy.yml`
+- HTTP health check verifies site returns 200
+- REST API smoke test verifies `/wp-json/peptides/v1/search?q=BPC-157` responds
+
**Manual Testing Checklist:**
- Search for existing peptide → results appear
- Search for new peptide (valid) → "pending" message → check back → content generated
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100755
index 0000000..0f2366e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,123 @@
+# Changelog
+
+All notable changes to the Peptide Search AI plugin will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [4.3.0] - Unreleased
+
+### Added
+- **Browsable Peptide Directory**: `[peptide_directory]` shortcode renders a card-based grid with category filtering, search, pagination, and detail modal.
+- **Category Taxonomy**: `peptide_category` taxonomy on the peptide CPT with 8 pre-populated terms (Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine).
+- **9 Extended Meta Fields**: half-life, stability, solubility, vial size, storage (lyophilized/reconstituted), typical dose, cycle parameters, amino acid count.
+- **REST API Endpoint**: `GET /wp-json/peptide-search-ai/v1/compounds` with search, category, pagination, and basic/full field sets for cross-plugin data access.
+- **Detail Modal**: Accessible overlay triggered from directory cards showing full compound data, copyable sequence, PubChem link, and "View Full Page" link.
+- **Integration Hooks**: `psa_after_peptide_detail` action, `psa_directory_card_extras` and `psa_rest_compound_data` filters for Peptide Community plugin.
+- **Category Auto-Assignment**: AI generator now assigns peptide_category taxonomy terms during content generation.
+- **Admin Data Tools**: "Migrate Existing Peptides to Categories" button and "Re-enrich Existing Peptides" button with background job queuing.
+- **Dark Mode Support**: Directory styles work with `data-theme="dark"` via CSS custom properties.
+- **Category Badge Colors**: Exported as CSS custom properties (`--psa-cat-*`) for reuse by other plugins.
+- New file: `includes/class-psa-directory.php` — directory REST endpoint and shortcode.
+- New file: `assets/js/psa-directory.js` — directory grid, filters, modal (vanilla JS).
+- New file: `assets/css/psa-directory.css` — directory styles with responsive breakpoints.
+- PHPUnit tests for PostType and Directory classes.
+
+### Changed
+- Updated AI generation prompt to request extended fields (half-life, stability, storage, etc.).
+- `class-psa-post-type.php` now includes taxonomy registration and EXTENDED_META_FIELDS constant.
+- `class-psa-template.php` refactored into smaller private methods; shows extended research data on single peptide pages.
+- `class-psa-admin.php` includes Data Tools section with migration and re-enrichment actions.
+- `peptide-search-ai.php` registers new directory class, taxonomy, and conditional asset loading.
+- Version bump to 4.3.0.
+
+## [4.2.0] - Unreleased
+
+### Added
+- PHPUnit test execution in CI pipeline across PHP 7.4, 8.1, 8.3.
+- HTTP health check and REST API smoke test after deployment.
+- `declare(strict_types=1)` and typed parameters/return types on all class files.
+- Consistent 3-question preamble docblocks on all classes (what / who calls / dependencies).
+- `@see` cross-references at top of related class files.
+- New `PSA_OpenRouter` class — extracted API communication layer from `PSA_AI_Generator`.
+- New `PSA_KB_Builder` class — extracted KB article builder from `PSA_AI_Generator`.
+- Internationalization (i18n) for all frontend JS strings via `wp_localize_script`.
+- This CHANGELOG file.
+
+### Changed
+- `PSA_AI_Generator` now delegates to `PSA_OpenRouter` and `PSA_KB_Builder` (reduced from 827 to ~470 lines).
+- Updated test suite to call `PSA_OpenRouter::parse_response()` instead of removed private method.
+- Updated `ARCHITECTURE.md` and `CONVENTIONS.md` to reflect new class structure.
+- Bumped minimum PHP recommendation to 7.4+ with strict type enforcement.
+
+### Removed
+- Deprecated `PSA_Error` class and `class-psa-error.php` (unused, no callers).
+
+## [4.1.0] - 2026-04-10
+
+### Added
+- AES-256-CBC encryption for API keys stored in the database (`PSA_Encryption` class).
+- Cost/token tracking with `wp_psa_api_logs` custom table (`PSA_Cost_Tracker` class).
+- Monthly budget system with hard-stop enforcement.
+- Dry-run mode for cost estimation before API calls.
+- Admin UI for API usage summary and recent call logs.
+- Monthly budget configuration field in settings.
+
+### Fixed
+- PHPCS `PreparedSQL` errors in cost tracker queries.
+
+## [4.0.0] - 2026-04-10
+
+### Security
+- **SEC-001 CRITICAL:** Removed spoofable `X-Forwarded-For` / `X-Real-IP` from IP detection; now trusts only `CF-Connecting-IP` + `REMOTE_ADDR`.
+- **SEC-004 HIGH:** Added peptide name input validation with character whitelist and prompt injection blocking.
+- **SEC-006 HIGH:** Added missing `return` after `wp_send_json_success` in pending retry max-retries block.
+- **SEC-007 MEDIUM:** KB articles now respect the `auto_publish` setting instead of always publishing.
+- **SEC-009 MEDIUM:** Replaced hardcoded page ID 73 with configurable `kb_page_id` setting.
+- **SEC-010 MEDIUM:** Fixed cache invalidation transient name pattern (added underscore prefix).
+- **SEC-011 MEDIUM:** Skipped `sleep()` during WP-Cron to avoid blocking PHP workers on rate limit retry.
+- **SEC-012 MEDIUM:** Added global daily generation cap (50/day) to prevent cost overruns.
+- **SEC-013 LOW:** Added `@deprecated` notice to unused `PSA_Error` class.
+
+### Added
+- PHPUnit test infrastructure with `bootstrap.php` and 20 unit tests across 2 test files.
+- `ARCHITECTURE.md` and `CONVENTIONS.md` documentation.
+
+### Fixed
+- JavaScript typos breaking form submit and network error handling.
+- CSS missing semicolons.
+- PHPCS compliance: short ternaries, array formatting, Yoda conditions, tab indentation.
+
+## [3.0.0] - 2026-04-09
+
+### Added
+- Dual-layer rate limiting (transient + object cache) for AJAX and REST endpoints.
+- Cache generation counter for O(1) cache invalidation instead of expensive DELETE queries.
+- PubChem data enrichment with molecular weight, formula, SMILES, and InChI.
+- Background generation via WP-Cron (non-blocking).
+- Retry logic with configurable max attempts for stale pending peptides.
+- REST API endpoint (`/wp-json/peptides/v1/search`) for headless access.
+- Multiple shortcode instances on the same page via `data-psa-instance` attribute.
+
+### Changed
+- Search results cached with generation counter for fast invalidation.
+- Batch post/meta priming to avoid N+1 queries in search results.
+
+## [2.0.0] - 2026-04-08
+
+### Added
+- AI-powered peptide name validation (lightweight model before full generation).
+- OpenRouter API integration for multi-model support.
+- Custom Post Type (`peptide`) with 15+ meta fields.
+- Admin settings page for API key, model selection, and behavior configuration.
+- Single peptide page template with quick-facts table and source badges.
+- Echo Knowledge Base article auto-creation from generated content.
+- CSS design system with `.psa-*` namespace and BEM-inspired structure.
+
+## [1.0.0] - 2026-04-07
+
+### Added
+- Initial release.
+- Basic peptide search shortcode.
+- AJAX-powered search with debounced input.
+- Responsive mobile layout (600px breakpoint).
diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 9cd1f14..35db3ad 100755
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -312,11 +312,14 @@ psa_get_admin_user_id() /* Get first admin user ID */
```php
PSA_Config /* Configuration constants */
-PSA_Error /* Error handling [deprecated] */
PSA_Post_Type /* CPT registration & meta */
PSA_Search /* Search, AJAX, REST API, shortcode */
-PSA_AI_Generator /* OpenRouter integration */
+PSA_AI_Generator /* AI pipeline orchestrator */
+PSA_OpenRouter /* OpenRouter API communication layer */
+PSA_KB_Builder /* Echo Knowledge Base article builder */
PSA_PubChem /* PubChem API integration */
+PSA_Cost_Tracker /* API usage tracking & budget enforcement */
+PSA_Encryption /* AES-256-CBC API key encryption */
PSA_Admin /* Settings pages & fields */
PSA_Template /* Single page rendering */
```
diff --git a/assets/css/psa-directory.css b/assets/css/psa-directory.css
new file mode 100755
index 0000000..70e36c4
--- /dev/null
+++ b/assets/css/psa-directory.css
@@ -0,0 +1,621 @@
+/* ==========================================================================
+ Peptide Directory — Card Grid, Filters, and Detail Modal
+ Namespace: .psa-dir-* (BEM)
+ ========================================================================== */
+
+/* --- CSS Custom Properties for category badge colors (reusable by other plugins) --- */
+:root {
+ --psa-cat-tissue-repair-bg: #d4edda;
+ --psa-cat-tissue-repair-fg: #155724;
+ --psa-cat-lipid-metabolism-bg: #d1ecf1;
+ --psa-cat-lipid-metabolism-fg: #0c5460;
+ --psa-cat-aging-research-bg: #e2d5f1;
+ --psa-cat-aging-research-fg: #432874;
+ --psa-cat-dermatological-bg: #fce4d6;
+ --psa-cat-dermatological-fg: #7c4a1a;
+ --psa-cat-metabolic-bg: #fff3cd;
+ --psa-cat-metabolic-fg: #856404;
+ --psa-cat-growth-hormone-bg: #cce5ff;
+ --psa-cat-growth-hormone-fg: #004085;
+ --psa-cat-immunology-bg: #f5c6cb;
+ --psa-cat-immunology-fg: #721c24;
+ --psa-cat-endocrine-bg: #c3e6cb;
+ --psa-cat-endocrine-fg: #1b5e20;
+}
+
+/* --- Wrapper --- */
+.psa-dir-wrap {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
+}
+
+/* --- Header --- */
+.psa-dir-header {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.psa-dir-title {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+}
+
+.psa-dir-subtitle {
+ font-size: 1.05rem;
+ color: #64748b;
+ margin: 0;
+}
+
+/* --- Search --- */
+.psa-dir-search-wrap {
+ max-width: 480px;
+ margin: 0 auto 1.5rem;
+}
+
+.psa-dir-search {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ outline: none;
+ transition: border-color 0.2s;
+ box-sizing: border-box;
+}
+
+.psa-dir-search:focus {
+ border-color: #2563eb;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
+}
+
+/* --- Tabs --- */
+.psa-dir-tabs {
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ margin-bottom: 1.5rem;
+}
+
+.psa-dir-tab {
+ padding: 8px 20px;
+ font-size: 14px;
+ font-weight: 600;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #fff;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.psa-dir-tab:hover {
+ background: #f8fafc;
+}
+
+.psa-dir-tab--active {
+ background: #2563eb;
+ color: #fff;
+ border-color: #2563eb;
+}
+
+/* --- Category Filters --- */
+.psa-dir-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+ margin-bottom: 2rem;
+}
+
+.psa-dir-filter {
+ padding: 6px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ background: #fff;
+ color: #475569;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.psa-dir-filter:hover {
+ border-color: #2563eb;
+ color: #2563eb;
+}
+
+.psa-dir-filter--active {
+ background: #2563eb;
+ color: #fff;
+ border-color: #2563eb;
+}
+
+/* --- Grid --- */
+.psa-dir-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1.25rem;
+}
+
+/* --- Card --- */
+.psa-dir-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.psa-dir-card:hover {
+ border-color: #2563eb;
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08);
+}
+
+.psa-dir-card__header {
+ margin-bottom: 0.75rem;
+}
+
+.psa-dir-card__title {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+}
+
+.psa-dir-card__badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 2px 10px;
+ border-radius: 12px;
+}
+
+.psa-dir-card__meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 1rem;
+ flex-grow: 1;
+}
+
+.psa-dir-card__meta-item {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+}
+
+.psa-dir-card__meta-label {
+ color: #64748b;
+ font-weight: 500;
+}
+
+.psa-dir-card__meta-value {
+ color: #1e293b;
+ font-weight: 600;
+}
+
+.psa-dir-card__btn {
+ width: 100%;
+ padding: 10px;
+ font-size: 14px;
+ font-weight: 600;
+ background: #2563eb;
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.2s;
+ margin-top: auto;
+}
+
+.psa-dir-card__btn:hover {
+ background: #1d4ed8;
+}
+
+/* --- Category Badge Colors --- */
+.psa-dir-badge--tissue-repair { background: var(--psa-cat-tissue-repair-bg); color: var(--psa-cat-tissue-repair-fg); }
+.psa-dir-badge--lipid-metabolism { background: var(--psa-cat-lipid-metabolism-bg); color: var(--psa-cat-lipid-metabolism-fg); }
+.psa-dir-badge--aging-research { background: var(--psa-cat-aging-research-bg); color: var(--psa-cat-aging-research-fg); }
+.psa-dir-badge--dermatological { background: var(--psa-cat-dermatological-bg); color: var(--psa-cat-dermatological-fg); }
+.psa-dir-badge--metabolic { background: var(--psa-cat-metabolic-bg); color: var(--psa-cat-metabolic-fg); }
+.psa-dir-badge--growth-hormone { background: var(--psa-cat-growth-hormone-bg); color: var(--psa-cat-growth-hormone-fg); }
+.psa-dir-badge--immunology { background: var(--psa-cat-immunology-bg); color: var(--psa-cat-immunology-fg); }
+.psa-dir-badge--endocrine { background: var(--psa-cat-endocrine-bg); color: var(--psa-cat-endocrine-fg); }
+
+/* --- Empty State --- */
+.psa-dir-empty,
+.psa-dir-coming-soon {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+ font-size: 1rem;
+}
+
+/* --- Loader --- */
+.psa-dir-loader {
+ text-align: center;
+ padding: 3rem 1rem;
+}
+
+.psa-dir-loader p {
+ color: #64748b;
+ margin-top: 1rem;
+}
+
+/* --- Load More --- */
+.psa-dir-load-more-wrap {
+ text-align: center;
+ margin-top: 2rem;
+}
+
+.psa-dir-load-more {
+ padding: 12px 32px;
+ font-size: 15px;
+ font-weight: 600;
+ background: #fff;
+ color: #2563eb;
+ border: 2px solid #2563eb;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.psa-dir-load-more:hover {
+ background: #2563eb;
+ color: #fff;
+}
+
+.psa-dir-load-more:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* --- Error --- */
+.psa-dir-error {
+ text-align: center;
+ color: #991b1b;
+ padding: 2rem;
+}
+
+/* ==========================================================================
+ Detail Modal
+ ========================================================================== */
+
+.psa-dir-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.psa-dir-modal__backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.psa-dir-modal__content {
+ position: relative;
+ background: #fff;
+ border-radius: 16px;
+ padding: 2rem;
+ max-width: 700px;
+ width: 90%;
+ max-height: 85vh;
+ overflow-y: auto;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+}
+
+.psa-dir-modal__close {
+ position: absolute;
+ top: 12px;
+ right: 16px;
+ font-size: 28px;
+ background: none;
+ border: none;
+ color: #94a3b8;
+ cursor: pointer;
+ line-height: 1;
+ padding: 4px;
+}
+
+.psa-dir-modal__close:hover {
+ color: #1e293b;
+}
+
+.psa-dir-modal__title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+ padding-right: 2rem;
+}
+
+.psa-dir-modal__badges {
+ margin-bottom: 1rem;
+}
+
+.psa-dir-modal__desc {
+ font-size: 15px;
+ line-height: 1.65;
+ color: #475569;
+ margin-bottom: 1.5rem;
+}
+
+/* --- Sequence --- */
+.psa-dir-modal__sequence-wrap {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ background: #f1f5f9;
+ padding: 12px;
+ border-radius: 8px;
+}
+
+.psa-dir-modal__sequence {
+ font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace;
+ font-size: 13px;
+ word-break: break-all;
+ flex: 1;
+ color: #334155;
+}
+
+.psa-dir-modal__copy {
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ background: #2563eb;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.psa-dir-modal__copy:hover {
+ background: #1d4ed8;
+}
+
+/* --- Data Grid --- */
+.psa-dir-modal__data-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+}
+
+.psa-dir-modal__data-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.psa-dir-modal__data-label {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: #64748b;
+}
+
+.psa-dir-modal__data-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+/* --- Modal Sections --- */
+.psa-dir-modal__section {
+ margin-bottom: 1.5rem;
+}
+
+.psa-dir-modal__section h4 {
+ font-size: 15px;
+ font-weight: 700;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+ padding-bottom: 6px;
+ border-bottom: 2px solid #2563eb;
+}
+
+.psa-dir-modal__section p {
+ font-size: 14px;
+ line-height: 1.6;
+ color: #475569;
+ margin: 0 0 0.5rem;
+}
+
+/* --- PubChem Link --- */
+.psa-dir-modal__pubchem {
+ display: inline-block;
+ font-size: 13px;
+ color: #2563eb;
+ text-decoration: none;
+ margin-bottom: 1rem;
+}
+
+.psa-dir-modal__pubchem:hover {
+ text-decoration: underline;
+}
+
+/* --- Full Page Link --- */
+.psa-dir-modal__full-link {
+ display: inline-block;
+ padding: 10px 24px;
+ font-size: 14px;
+ font-weight: 600;
+ background: #f1f5f9;
+ color: #2563eb;
+ text-decoration: none;
+ border-radius: 8px;
+ transition: background 0.2s;
+}
+
+.psa-dir-modal__full-link:hover {
+ background: #e2e8f0;
+}
+
+/* ==========================================================================
+ Responsive
+ ========================================================================== */
+
+/* Tablet: 2 columns */
+@media (max-width: 900px) {
+ .psa-dir-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Mobile: 1 column */
+@media (max-width: 600px) {
+ .psa-dir-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .psa-dir-title {
+ font-size: 1.5rem;
+ }
+
+ .psa-dir-modal__content {
+ width: 95%;
+ padding: 1.25rem;
+ max-height: 90vh;
+ }
+
+ .psa-dir-modal__data-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .psa-dir-filters {
+ gap: 6px;
+ }
+
+ .psa-dir-filter {
+ font-size: 12px;
+ padding: 5px 12px;
+ }
+}
+
+/* ==========================================================================
+ Dark Mode Support
+ Uses the theme's data-theme="dark" attribute and CSS custom properties.
+ ========================================================================== */
+
+[data-theme="dark"] .psa-dir-wrap {
+ color: #e2e8f0;
+}
+
+[data-theme="dark"] .psa-dir-title {
+ color: #f1f5f9;
+}
+
+[data-theme="dark"] .psa-dir-subtitle {
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-search {
+ background: #1e293b;
+ border-color: #334155;
+ color: #f1f5f9;
+}
+
+[data-theme="dark"] .psa-dir-tab {
+ background: #1e293b;
+ border-color: #334155;
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-tab--active {
+ background: #2563eb;
+ color: #fff;
+}
+
+[data-theme="dark"] .psa-dir-filter {
+ background: #1e293b;
+ border-color: #334155;
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-filter--active {
+ background: #2563eb;
+ color: #fff;
+}
+
+[data-theme="dark"] .psa-dir-card {
+ background: #1e293b;
+ border-color: #334155;
+}
+
+[data-theme="dark"] .psa-dir-card__title {
+ color: #f1f5f9;
+}
+
+[data-theme="dark"] .psa-dir-card__meta-label {
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-card__meta-value {
+ color: #e2e8f0;
+}
+
+[data-theme="dark"] .psa-dir-modal__content {
+ background: #0f172a;
+ color: #e2e8f0;
+}
+
+[data-theme="dark"] .psa-dir-modal__title {
+ color: #f1f5f9;
+}
+
+[data-theme="dark"] .psa-dir-modal__desc {
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-modal__data-grid {
+ background: #1e293b;
+ border-color: #334155;
+}
+
+[data-theme="dark"] .psa-dir-modal__data-label {
+ color: #94a3b8;
+}
+
+[data-theme="dark"] .psa-dir-modal__data-value {
+ color: #e2e8f0;
+}
+
+[data-theme="dark"] .psa-dir-modal__sequence-wrap {
+ background: #1e293b;
+}
+
+[data-theme="dark"] .psa-dir-modal__sequence {
+ color: #e2e8f0;
+}
+
+[data-theme="dark"] .psa-dir-modal__close {
+ color: #64748b;
+}
+
+[data-theme="dark"] .psa-dir-modal__close:hover {
+ color: #f1f5f9;
+}
diff --git a/assets/js/peptide-search.js b/assets/js/peptide-search.js
index b9c48a9..c19c50d 100755
--- a/assets/js/peptide-search.js
+++ b/assets/js/peptide-search.js
@@ -46,7 +46,7 @@
function showError(msg) {
hideAll();
- $error.html('Error: ' + esc(msg)).show();
+ $error.html('' + esc(psaAjax.i18n.error) + ' ' + esc(msg)).show();
$error.attr('role', 'alert');
}
@@ -58,12 +58,12 @@
function sourceBadge(source) {
var map = {
- 'pubchem': { label: 'Verified', cls: 'psa-badge-verified' },
- 'manual': { label: 'Curated', cls: 'psa-badge-manual' }
+ 'pubchem': { label: psaAjax.i18n.verified, cls: 'psa-badge-verified' },
+ 'manual': { label: psaAjax.i18n.curated, cls: 'psa-badge-manual' }
};
var b = map[source];
if (!b) return '';
- return '' + b.label + ' ';
+ return '' + esc(b.label) + ' ';
}
/**
@@ -72,8 +72,7 @@
function renderResults(data) {
hideAll();
- var html = '
' + data.total + ' peptide' +
- (data.total !== 1 ? 's' : '') + ' found
';
+ var html = '' + data.total + ' ' + esc(psaAjax.i18n.peptidesFound) + '
';
data.results.forEach(function (item) {
html += '';
@@ -100,7 +99,7 @@
function showPending(name) {
hideAll();
$pending.find('.psa-pending-inner p').html(
- '' + esc(name) + ' is currently being added to our database. Please check back again later.'
+ '' + esc(name) + ' ' + esc(psaAjax.i18n.pendingMsg)
);
$pending.show();
}
@@ -111,7 +110,7 @@
function showInvalid(name, reason) {
hideAll();
$invalid.find('.psa-invalid-text').html(
- '' + esc(name) + ' does not appear to be a recognized peptide. Please check the spelling or try a different search term.'
+ '' + esc(name) + ' ' + esc(psaAjax.i18n.invalidMsg)
);
$invalid.show();
}
@@ -144,7 +143,7 @@
success: function (response) {
$results.attr('aria-busy', 'false');
if (!response.success) {
- showError(response.data || 'Search failed.');
+ showError(response.data || psaAjax.i18n.searchFailed);
return;
}
var d = response.data;
@@ -159,24 +158,24 @@
showInvalid(query, d.message);
break;
case 'rate_limited':
- showError(d.message || 'Too many requests. Please try again later.');
+ showError(d.message || psaAjax.i18n.rateLimited);
break;
default:
- showError('Unexpected response.');
+ showError(psaAjax.i18n.unexpected);
}
},
- error: function (jqXHR, textStatus, errorThrown) {
+ error: function (jqXHR, textStatus) {
$results.attr('aria-busy', 'false');
- var errorMsg = 'Could not connect to the server. Please try again.';
+ var errorMsg = psaAjax.i18n.networkError;
if (textStatus === 'timeout') {
- errorMsg = 'Request timed out. The server took too long to respond. Please try again.';
+ errorMsg = psaAjax.i18n.timeout;
} else if (textStatus === 'error') {
if (jqXHR.status === 0) {
- errorMsg = 'Network error. Please check your connection and try again.';
+ errorMsg = psaAjax.i18n.connectionError;
} else if (jqXHR.status === 500) {
- errorMsg = 'Server error. Please try again later.';
+ errorMsg = psaAjax.i18n.serverError;
} else if (jqXHR.status === 503) {
- errorMsg = 'Server is temporarily unavailable. Please try again later.';
+ errorMsg = psaAjax.i18n.unavailable;
}
}
showError(errorMsg);
diff --git a/assets/js/psa-directory.js b/assets/js/psa-directory.js
new file mode 100755
index 0000000..e8ca9ac
--- /dev/null
+++ b/assets/js/psa-directory.js
@@ -0,0 +1,501 @@
+/**
+ * Peptide Directory — Frontend JavaScript
+ *
+ * Renders the browsable directory grid with category filters, search,
+ * pagination, and detail modal. Uses vanilla JS (no build step required).
+ *
+ * Data source: REST API at /wp-json/peptide-search-ai/v1/compounds
+ * Config: psaDirectory global (localized from class-psa-directory.php)
+ *
+ * @see includes/class-psa-directory.php — shortcode + REST endpoint
+ * @see assets/css/psa-directory.css — all .psa-dir-* styles
+ */
+(function () {
+ 'use strict';
+
+ var config = window.psaDirectory;
+ if (!config) return;
+
+ var root = document.getElementById('psa-directory-root');
+ if (!root) return;
+
+ var state = {
+ compounds: [],
+ page: 1,
+ totalPages: 0,
+ total: 0,
+ category: '',
+ search: '',
+ loading: false,
+ activeTab: 'single',
+ modalData: null
+ };
+
+ var i18n = config.i18n;
+
+ // -------------------------------------------------------------------------
+ // Utility
+ // -------------------------------------------------------------------------
+
+ function esc(str) {
+ if (!str) return '';
+ var d = document.createElement('div');
+ d.appendChild(document.createTextNode(str));
+ return d.innerHTML;
+ }
+
+ function debounce(fn, delay) {
+ var timer;
+ return function () {
+ var args = arguments, ctx = this;
+ clearTimeout(timer);
+ timer = setTimeout(function () { fn.apply(ctx, args); }, delay);
+ };
+ }
+
+ // -------------------------------------------------------------------------
+ // API
+ // -------------------------------------------------------------------------
+
+ function fetchCompounds(append) {
+ if (state.loading) return;
+ state.loading = true;
+ updateLoadMoreBtn();
+
+ var url = config.restUrl +
+ '?page=' + state.page +
+ '&per_page=' + config.perPage;
+
+ if (state.search) url += '&search=' + encodeURIComponent(state.search);
+ if (state.category) url += '&category=' + encodeURIComponent(state.category);
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('GET', url, true);
+ xhr.setRequestHeader('X-WP-Nonce', config.nonce);
+ xhr.onload = function () {
+ state.loading = false;
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ if (append) {
+ state.compounds = state.compounds.concat(data.compounds);
+ } else {
+ state.compounds = data.compounds;
+ }
+ state.total = data.total;
+ state.totalPages = data.total_pages;
+ renderGrid();
+ } else {
+ renderError();
+ }
+ };
+ xhr.onerror = function () {
+ state.loading = false;
+ renderError();
+ };
+ xhr.send();
+ }
+
+ function fetchCompoundDetail(id, callback) {
+ var url = config.restUrl + '?search=&per_page=1&page=1&fields=full';
+ /* Find by iterating existing data first to avoid extra API call. */
+ var existing = null;
+ for (var i = 0; i < state.compounds.length; i++) {
+ if (state.compounds[i].id === id) {
+ existing = state.compounds[i];
+ break;
+ }
+ }
+ if (existing && existing.sequence !== undefined) {
+ callback(existing);
+ return;
+ }
+ /* Fallback: fetch from API (shouldn't normally be needed for full fields). */
+ callback(existing);
+ }
+
+ // -------------------------------------------------------------------------
+ // Render — Shell
+ // -------------------------------------------------------------------------
+
+ function renderShell() {
+ root.innerHTML =
+ '' +
+ '' +
+ ' ' +
+ '
' +
+ '' +
+ '' + esc(i18n.singleReagents) + ' ' +
+ '' + esc(i18n.protocolModels) + ' ' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' + esc(i18n.comingSoon) + '
' +
+ '
' +
+ '
' +
+ '
' + esc(i18n.noResults) + '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' + esc(i18n.loading) + '
' +
+ '
' +
+ '
' +
+ '' +
+ '' + esc(i18n.loadMore) + ' ' +
+ '
' +
+ '';
+
+ renderFilters();
+ bindEvents();
+ }
+
+ // -------------------------------------------------------------------------
+ // Render — Filters
+ // -------------------------------------------------------------------------
+
+ function renderFilters() {
+ var wrap = root.querySelector('.psa-dir-filters');
+ var html = '' + esc(i18n.showAll) + ' ';
+ config.categories.forEach(function (cat) {
+ html += '' + esc(cat.name) + ' ';
+ });
+ wrap.innerHTML = html;
+ }
+
+ // -------------------------------------------------------------------------
+ // Render — Grid
+ // -------------------------------------------------------------------------
+
+ function renderGrid() {
+ var grid = root.querySelector('.psa-dir-grid');
+ var empty = root.querySelector('.psa-dir-empty');
+ var loader = root.querySelector('.psa-dir-loader');
+ var coming = root.querySelector('.psa-dir-coming-soon');
+
+ loader.style.display = 'none';
+ coming.style.display = 'none';
+
+ if (state.activeTab === 'protocols') {
+ grid.style.display = 'none';
+ empty.style.display = 'none';
+ coming.style.display = 'block';
+ updateLoadMoreBtn();
+ return;
+ }
+
+ grid.style.display = '';
+
+ if (state.compounds.length === 0 && !state.loading) {
+ empty.style.display = 'block';
+ grid.innerHTML = '';
+ updateLoadMoreBtn();
+ return;
+ }
+
+ empty.style.display = 'none';
+
+ var html = '';
+ state.compounds.forEach(function (c) {
+ var catBadge = '';
+ if (c.categories && c.categories.length > 0) {
+ catBadge = '' + esc(c.categories[0].name) + ' ';
+ }
+
+ var metaHtml = '';
+ if (c.half_life) {
+ metaHtml += '' + esc(i18n.halfLife) + ' ' + esc(c.half_life) + '
';
+ }
+ if (c.stability) {
+ metaHtml += '' + esc(i18n.stability) + ' ' + esc(c.stability) + '
';
+ }
+
+ var extrasHtml = c.extras ? c.extras : '';
+
+ html += '' +
+ '' +
+ (metaHtml ? '
' + metaHtml + '
' : '') +
+ extrasHtml +
+ '
' + esc(i18n.viewDetails) + ' ' +
+ '
';
+ });
+
+ grid.innerHTML = html;
+ updateLoadMoreBtn();
+ }
+
+ function updateLoadMoreBtn() {
+ var wrap = root.querySelector('.psa-dir-load-more-wrap');
+ var btn = root.querySelector('.psa-dir-load-more');
+
+ if (state.activeTab === 'protocols' || state.page >= state.totalPages || state.compounds.length === 0) {
+ wrap.style.display = 'none';
+ } else {
+ wrap.style.display = 'block';
+ btn.textContent = state.loading ? i18n.loading : i18n.loadMore;
+ btn.disabled = state.loading;
+ }
+ }
+
+ function renderError() {
+ state.loading = false;
+ var grid = root.querySelector('.psa-dir-grid');
+ grid.innerHTML = 'Failed to load directory. Please try again.
';
+ root.querySelector('.psa-dir-loader').style.display = 'none';
+ updateLoadMoreBtn();
+ }
+
+ // -------------------------------------------------------------------------
+ // Render — Modal
+ // -------------------------------------------------------------------------
+
+ function openModal(compound) {
+ state.modalData = compound;
+ var modal = root.querySelector('.psa-dir-modal');
+ var content = root.querySelector('.psa-dir-modal__content');
+
+ var catBadges = '';
+ if (compound.categories) {
+ compound.categories.forEach(function (cat) {
+ catBadges += '' + esc(cat.name) + ' ';
+ });
+ }
+
+ var dataGrid = '';
+ var fields = [
+ { label: i18n.solubility, value: compound.solubility },
+ { label: i18n.vialSize, value: compound.vial_size_mg ? compound.vial_size_mg + ' mg' : '' },
+ { label: i18n.storageLyo, value: compound.storage_lyophilized },
+ { label: i18n.storageRecon, value: compound.storage_reconstituted },
+ { label: i18n.halfLife, value: compound.half_life },
+ { label: i18n.stability, value: compound.stability },
+ { label: i18n.molecularWeight, value: compound.molecular_weight },
+ { label: i18n.formula, value: compound.molecular_formula }
+ ];
+ fields.forEach(function (f) {
+ if (f.value) {
+ dataGrid += '' +
+ '' + esc(f.label) + ' ' +
+ '' + esc(f.value) + ' ' +
+ '
';
+ }
+ });
+
+ var researchHtml = '';
+ if (compound.typical_dose_mcg || compound.cycle_parameters) {
+ researchHtml = '' +
+ '
' + esc(i18n.researchParams) + ' ';
+ if (compound.typical_dose_mcg) {
+ researchHtml += '
' + esc(i18n.typicalDose) + ': ' + esc(compound.typical_dose_mcg) + '
';
+ }
+ if (compound.cycle_parameters) {
+ researchHtml += '
' + esc(i18n.cycleParams) + ': ' + esc(compound.cycle_parameters) + '
';
+ }
+ researchHtml += '
';
+ }
+
+ var pubchemHtml = '';
+ if (compound.pubchem_cid) {
+ pubchemHtml = ' ' + esc(i18n.pubchemLink) + ' ';
+ }
+
+ var sequenceHtml = '';
+ if (compound.sequence) {
+ sequenceHtml = '' +
+ '
' + esc(i18n.sequence) + ' ' +
+ '
' +
+ '' + esc(compound.sequence) + '' +
+ '' + esc(i18n.copySequence) + ' ' +
+ '
' +
+ '
';
+ }
+
+ var mechanismHtml = '';
+ if (compound.mechanism) {
+ mechanismHtml = '' +
+ '
Mechanism of Action ' +
+ '
' + esc(compound.mechanism).replace(/\n\n/g, '
').replace(/\n/g, ' ') + '
' + // Safe: esc() HTML-encodes all user content before structural tags are inserted.
+
+ '
';
+ }
+
+ content.innerHTML =
+ '× ' +
+ '' + esc(compound.name) + ' ' +
+ '' + catBadges + '
' +
+ (compound.description ? '' + esc(compound.description) + '
' : '') +
+ sequenceHtml +
+ (dataGrid ? '' + dataGrid + '
' : '') +
+ researchHtml +
+ mechanismHtml +
+ pubchemHtml +
+ '' + esc(i18n.viewFullPage) + ' ';
+
+ modal.style.display = 'flex';
+ content.focus();
+
+ // Update URL hash for direct linking.
+ if (compound.slug) {
+ history.replaceState(null, '', '#' + compound.slug);
+ }
+
+ // Fire integration hook for Peptide Community.
+ document.dispatchEvent(new CustomEvent('psa_after_peptide_detail', {
+ detail: { postId: compound.id }
+ }));
+ }
+
+ function closeModal() {
+ var modal = root.querySelector('.psa-dir-modal');
+ modal.style.display = 'none';
+ state.modalData = null;
+ history.replaceState(null, '', window.location.pathname + window.location.search);
+ }
+
+ // -------------------------------------------------------------------------
+ // Events
+ // -------------------------------------------------------------------------
+
+ function bindEvents() {
+ // Search input.
+ var searchInput = root.querySelector('.psa-dir-search');
+ var debouncedSearch = debounce(function () {
+ state.search = searchInput.value.trim();
+ state.page = 1;
+ showLoader();
+ fetchCompounds(false);
+ }, 400);
+ searchInput.addEventListener('input', debouncedSearch);
+
+ // Category filter buttons.
+ root.querySelector('.psa-dir-filters').addEventListener('click', function (e) {
+ var btn = e.target.closest('.psa-dir-filter');
+ if (!btn) return;
+
+ root.querySelectorAll('.psa-dir-filter').forEach(function (b) {
+ b.classList.remove('psa-dir-filter--active');
+ });
+ btn.classList.add('psa-dir-filter--active');
+
+ state.category = btn.getAttribute('data-category');
+ state.page = 1;
+ showLoader();
+ fetchCompounds(false);
+ });
+
+ // Tab buttons.
+ root.querySelector('.psa-dir-tabs').addEventListener('click', function (e) {
+ var btn = e.target.closest('.psa-dir-tab');
+ if (!btn) return;
+
+ root.querySelectorAll('.psa-dir-tab').forEach(function (b) {
+ b.classList.remove('psa-dir-tab--active');
+ b.setAttribute('aria-selected', 'false');
+ });
+ btn.classList.add('psa-dir-tab--active');
+ btn.setAttribute('aria-selected', 'true');
+
+ state.activeTab = btn.getAttribute('data-tab');
+ renderGrid();
+ });
+
+ // Load More button.
+ root.querySelector('.psa-dir-load-more').addEventListener('click', function () {
+ state.page++;
+ fetchCompounds(true);
+ });
+
+ // Card "View Research Details" button — delegate on grid.
+ root.querySelector('.psa-dir-grid').addEventListener('click', function (e) {
+ var btn = e.target.closest('.psa-dir-card__btn');
+ if (!btn) return;
+ var id = parseInt(btn.getAttribute('data-id'), 10);
+ fetchCompoundDetail(id, function (compound) {
+ if (compound) openModal(compound);
+ });
+ });
+
+ // Modal close: X button, backdrop click, Escape key.
+ root.querySelector('.psa-dir-modal').addEventListener('click', function (e) {
+ if (e.target.classList.contains('psa-dir-modal__backdrop') || e.target.classList.contains('psa-dir-modal__close')) {
+ closeModal();
+ }
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && state.modalData) {
+ closeModal();
+ }
+ });
+
+ // Copy sequence button.
+ root.addEventListener('click', function (e) {
+ var copyBtn = e.target.closest('.psa-dir-modal__copy');
+ if (!copyBtn) return;
+ var seq = copyBtn.getAttribute('data-seq');
+ navigator.clipboard.writeText(seq).then(function () {
+ copyBtn.textContent = i18n.copied;
+ setTimeout(function () { copyBtn.textContent = i18n.copySequence; }, 2000);
+ });
+ });
+
+ // Check URL hash on load for direct modal linking.
+ checkUrlHash();
+ }
+
+ function showLoader() {
+ var grid = root.querySelector('.psa-dir-grid');
+ var loader = root.querySelector('.psa-dir-loader');
+ var empty = root.querySelector('.psa-dir-empty');
+ grid.innerHTML = '';
+ empty.style.display = 'none';
+ loader.style.display = 'block';
+ }
+
+ /**
+ * If URL has a hash like #bpc-157, find the matching compound and open modal.
+ */
+ function checkUrlHash() {
+ var hash = window.location.hash.replace('#', '');
+ if (!hash) return;
+ /* Wait for initial load, then look for matching slug. */
+ var checkInterval = setInterval(function () {
+ if (state.loading) return;
+ clearInterval(checkInterval);
+ for (var i = 0; i < state.compounds.length; i++) {
+ if (state.compounds[i].slug === hash) {
+ openModal(state.compounds[i]);
+ return;
+ }
+ }
+ }, 200);
+ /* Safety: stop checking after 5 seconds. */
+ setTimeout(function () { clearInterval(checkInterval); }, 5000);
+ }
+
+ // -------------------------------------------------------------------------
+ // Init
+ // -------------------------------------------------------------------------
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ function init() {
+ renderShell();
+ showLoader();
+ fetchCompounds(false);
+ }
+
+})();
diff --git a/includes/class-psa-admin.php b/includes/class-psa-admin.php
index 521f565..a1ff9ea 100755
--- a/includes/class-psa-admin.php
+++ b/includes/class-psa-admin.php
@@ -1,9 +1,16 @@
sanitize_text_field( $input['ai_model'] ?? '' ),
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
- // Checkbox: if key is absent in POST, it was unchecked → '0'.
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
+ /**
+ * Handle admin actions (migration, re-enrich) via GET params with nonce verification.
+ */
+ public static function handle_admin_actions(): void {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! isset( $_GET['psa_action'] ) || ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $action = sanitize_text_field( wp_unslash( $_GET['psa_action'] ) );
+ $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+
+ if ( 'migrate_categories' === $action && wp_verify_nonce( $nonce, 'psa_migrate_categories' ) ) {
+ self::run_category_migration();
+ add_action( 'admin_notices', function () {
+ echo '' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '
';
+ } );
+ }
+
+ if ( 'reenrich' === $action && wp_verify_nonce( $nonce, 'psa_reenrich' ) ) {
+ $queued = self::queue_reenrichment();
+ add_action( 'admin_notices', function () use ( $queued ) {
+ echo '' . esc_html( sprintf(
+ /* translators: %d = number of peptides queued */
+ __( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ),
+ $queued
+ ) ) . '
';
+ } );
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Migration: assign categories to existing peptides
+ // -------------------------------------------------------------------------
+
+ /**
+ * One-time migration: assign peptide_category terms to existing peptide posts.
+ *
+ * Reads the free-text psa_category meta from each published peptide and maps
+ * it to a taxonomy term. Safe to run multiple times (idempotent).
+ */
+ private static function run_category_migration(): void {
+ $posts = get_posts(
+ array(
+ 'post_type' => 'peptide',
+ 'post_status' => array( 'publish', 'draft' ),
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ )
+ );
+
+ foreach ( $posts as $post_id ) {
+ // Skip posts that already have a category term assigned.
+ $terms = wp_get_object_terms( $post_id, 'peptide_category' );
+ if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
+ continue;
+ }
+
+ $category = get_post_meta( $post_id, 'psa_category', true );
+ $label = get_post_meta( $post_id, 'psa_category_label', true );
+ $text = ! empty( $category ) ? $category : $label;
+
+ if ( empty( $text ) ) {
+ continue;
+ }
+
+ // Use the same mapping logic as the AI generator.
+ $slug = self::map_category_to_slug( $text );
+ if ( ! empty( $slug ) ) {
+ wp_set_object_terms( $post_id, $slug, 'peptide_category' );
+ }
+ }
+ }
+
+ /**
+ * Map free-text category label to taxonomy slug.
+ *
+ * @param string $text Category label from AI or meta.
+ * @return string Taxonomy term slug, or empty if no match.
+ */
+ private static function map_category_to_slug( string $text ): string {
+ $map = array(
+ 'gh secretagogues' => 'growth-hormone',
+ 'growth hormone' => 'growth-hormone',
+ 'healing & repair' => 'tissue-repair',
+ 'healing and repair' => 'tissue-repair',
+ 'tissue repair' => 'tissue-repair',
+ 'tissue healing' => 'tissue-repair',
+ 'cytoprotective' => 'tissue-repair',
+ 'melanocortin peptides' => 'dermatological',
+ 'melanocortin' => 'dermatological',
+ 'dermatological' => 'dermatological',
+ 'metabolic & anti-aging' => 'metabolic',
+ 'metabolic' => 'metabolic',
+ 'anti-aging' => 'aging-research',
+ 'aging research' => 'aging-research',
+ 'immunology' => 'immunology',
+ 'immune' => 'immunology',
+ 'nootropic' => 'immunology',
+ 'neuroprotective' => 'immunology',
+ 'lipid metabolism' => 'lipid-metabolism',
+ 'endocrine' => 'endocrine',
+ );
+
+ $lower = strtolower( trim( $text ) );
+ if ( isset( $map[ $lower ] ) ) {
+ return $map[ $lower ];
+ }
+
+ // Fuzzy match.
+ foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $slug => $name ) {
+ if ( stripos( $text, $name ) !== false || stripos( $text, str_replace( '-', ' ', $slug ) ) !== false ) {
+ return $slug;
+ }
+ }
+
+ return '';
+ }
+
+ // -------------------------------------------------------------------------
+ // Re-enrichment: queue background regeneration for posts missing new fields
+ // -------------------------------------------------------------------------
+
+ /**
+ * Queue re-enrichment for published peptides missing the new extended fields.
+ *
+ * Schedules WP-Cron events for each peptide that needs regeneration.
+ * Respects the daily generation cap to avoid cost overruns.
+ *
+ * @return int Number of peptides queued.
+ */
+ private static function queue_reenrichment(): int {
+ $posts = get_posts(
+ array(
+ 'post_type' => 'peptide',
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ // Only select posts missing the half_life field (proxy for "needs extended data").
+ 'meta_query' => array(
+ 'relation' => 'OR',
+ array(
+ 'key' => 'psa_half_life',
+ 'compare' => 'NOT EXISTS',
+ ),
+ array(
+ 'key' => 'psa_half_life',
+ 'value' => '',
+ ),
+ ),
+ )
+ );
+
+ $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
+ $daily_count = (int) get_transient( $daily_key );
+ $queued = 0;
+
+ foreach ( $posts as $post_id ) {
+ // Respect daily cap.
+ if ( ( $daily_count + $queued ) >= PSA_Config::DAILY_GENERATION_CAP ) {
+ break;
+ }
+
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ continue;
+ }
+
+ // Mark as pending so the generator picks it up, then schedule.
+ update_post_meta( $post_id, 'psa_source', 'pending' );
+ wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );
+ update_post_meta( $post_id, 'psa_generation_started', time() );
+
+ $args = array( $post_id, $post->post_title );
+ if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
+ // Stagger jobs 30 seconds apart to avoid rate limit hammering.
+ wp_schedule_single_event( time() + ( $queued * 30 ), 'psa_generate_peptide_background', $args );
+ }
+
+ $queued++;
+ }
+
+ // Bump daily counter.
+ set_transient( $daily_key, $daily_count + $queued, DAY_IN_SECONDS );
+
+ // Kick off the cron runner.
+ if ( $queued > 0 ) {
+ spawn_cron();
+ }
+
+ return $queued;
+ }
+
// -------------------------------------------------------------------------
// Field renderers
// -------------------------------------------------------------------------
- public static function render_section_description() {
+ public static function render_section_description(): void {
?>
OpenRouter to access AI models. OpenRouter provides a unified API for hundreds of models including open-source options like Gemini Flash, Llama, Mistral, Qwen, and DeepSeek, as well as commercial models.', 'peptide-search-ai' ),
+ /* translators: %s = OpenRouter URL */
+ __( 'This plugin uses OpenRouter to access AI models.', 'peptide-search-ai' ),
esc_url( 'https://openrouter.ai/' )
);
?>
@@ -151,78 +303,67 @@ class PSA_Admin {
-
+
-
- openrouter.ai/settings/keys .
- define( 'PSA_OPENROUTER_KEY', 'your-key-here' );
+
+ .
-
- google/gemini-2.5-flash.
- openrouter.ai/models .
- meta-llama/llama-4-maverick, deepseek/deepseek-chat-v3-0324, qwen/qwen-2.5-72b-instruct.
-
+
-
-
- google/gemini-2.0-flash-001.
- meta-llama/llama-4-scout, mistralai/mistral-small-3.1-24b-instruct.
-
+
+
- >
+ >
>
-
/>
-
-
+
@@ -248,24 +391,37 @@ class PSA_Admin {
-
-
-
-
-
-
-
-
+
+
-
-
[peptide_search]
-
[peptide_search placeholder="Search peptides..."]
+
+
[peptide_search] —
+
[peptide_directory] —
-
GET /wp-json/peptides/v1/search?q=BPC-157
-
+
GET /wp-json/peptides/v1/search?q=BPC-157 —
+
GET /wp-json/peptide-search-ai/v1/compounds?search=&category=&page=1 —
-
+
$
@@ -292,7 +448,7 @@ class PSA_Admin {
-
+
@@ -313,7 +469,7 @@ class PSA_Admin {
$estimated_cost_usd, 4 ) ); ?>
request_type ); ?>
peptide_name ); ?>
- success ) ? '✓' : '✗'; ?>
+ success ) ? '✓' : '✗'; ?>
@@ -329,7 +485,7 @@ class PSA_Admin {
* @param mixed $default Default value.
* @return mixed
*/
- private static function get_setting( $key, $default = '' ) {
+ private static function get_setting( string $key, $default = '' ) {
$options = get_option( 'psa_settings', array() );
return $options[ $key ] ?? $default;
}
diff --git a/includes/class-psa-ai-generator.php b/includes/class-psa-ai-generator.php
index 0de4eb1..7051500 100755
--- a/includes/class-psa-ai-generator.php
+++ b/includes/class-psa-ai-generator.php
@@ -1,10 +1,17 @@
false,
@@ -91,7 +93,7 @@ class PSA_AI_Generator {
);
// Cache for 24 hours (using PSA_Config for consistency).
- set_transient( $cache_key, $result, DAY_IN_SECONDS ); // Validation cache TTL
+ set_transient( $cache_key, $result, DAY_IN_SECONDS );
return $result;
}
@@ -102,7 +104,7 @@ class PSA_AI_Generator {
* @param int $post_id The placeholder post ID.
* @param string $peptide_name The peptide to research.
*/
- public static function background_generate( $post_id, $peptide_name ) {
+ public static function background_generate( int $post_id, string $peptide_name ): void {
$post = get_post( $post_id );
if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) {
error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' );
@@ -150,7 +152,6 @@ class PSA_AI_Generator {
if ( ! empty( $options['use_pubchem'] ) ) {
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
if ( is_wp_error( $pubchem_result ) ) {
- // Log PubChem API failures.
error_log( 'PSA: PubChem lookup failed for "' . $peptide_name . '": ' . $pubchem_result->get_error_message() );
} elseif ( is_array( $pubchem_result ) ) {
$pubchem_data = $pubchem_result;
@@ -180,8 +181,11 @@ class PSA_AI_Generator {
// Save all meta fields — prefer PubChem data for molecular properties.
self::save_peptide_meta( $post_id, $result, $pubchem_data );
+ // Auto-assign peptide_category taxonomy term based on AI category.
+ self::assign_category_term( $post_id, $result );
+
// Create a matching Knowledge Base article.
- self::create_kb_article( $post_id, $result, $peptide_name, $post_status );
+ PSA_KB_Builder::create_article( $post_id, $result, $peptide_name, $post_status );
// Clean up generation tracking.
delete_post_meta( $post_id, 'psa_generation_started' );
@@ -200,7 +204,7 @@ class PSA_AI_Generator {
* @param string $peptide_name The peptide to research.
* @return array|WP_Error Parsed peptide data or error.
*/
- public static function generate_peptide_content( $peptide_name ) {
+ public static function generate_peptide_content( string $peptide_name ) {
$options = self::get_settings();
$api_key = $options['api_key'];
$model = $options['ai_model'];
@@ -217,7 +221,7 @@ class PSA_AI_Generator {
$prompt = self::build_generation_prompt( $peptide_name );
// Allow filtering of generation prompt before sending.
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
- $response = self::call_openrouter(
+ $response = PSA_OpenRouter::send_request(
$api_key,
$model ? $model : 'google/gemini-2.5-flash',
$prompt,
@@ -230,7 +234,7 @@ class PSA_AI_Generator {
return $response;
}
- return self::parse_ai_response( $response );
+ return PSA_OpenRouter::parse_response( $response );
}
/**
@@ -243,39 +247,29 @@ class PSA_AI_Generator {
* 'total_estimated_cost_usd' => float,
* }
*/
- public static function dry_run( $peptide_name ) {
- $options = self::get_settings();
+ public static function dry_run( string $peptide_name ): array {
+ $options = self::get_settings();
$validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001';
$generation_model = $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash';
- // Estimate validation: prompt tokens from validation prompt + completion tokens.
- $validation_prompt = self::build_validation_prompt( $peptide_name );
- $validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 ); // rough estimate: 1 token ≈ 4 chars.
- $validation_completion_tokens = PSA_Config::VALIDATION_MAX_TOKENS;
- $validation_cost = PSA_Cost_Tracker::estimate_cost(
- $validation_model,
- $validation_prompt_tokens,
- $validation_completion_tokens
- );
+ $validation_prompt = self::build_validation_prompt( $peptide_name );
+ $validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 );
+ $validation_completion = PSA_Config::VALIDATION_MAX_TOKENS;
+ $validation_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $validation_prompt_tokens, $validation_completion );
- // Estimate generation: prompt tokens from generation prompt + completion tokens.
- $generation_prompt = self::build_generation_prompt( $peptide_name );
+ $generation_prompt = self::build_generation_prompt( $peptide_name );
$generation_prompt_tokens = (int) ceil( strlen( $generation_prompt ) / 4 );
- $generation_completion_tokens = PSA_Config::GENERATION_MAX_TOKENS;
- $generation_cost = PSA_Cost_Tracker::estimate_cost(
- $generation_model,
- $generation_prompt_tokens,
- $generation_completion_tokens
- );
+ $generation_completion = PSA_Config::GENERATION_MAX_TOKENS;
+ $generation_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $generation_prompt_tokens, $generation_completion );
return array(
- 'validation' => array(
- 'tokens' => $validation_prompt_tokens + $validation_completion_tokens,
- 'estimated_cost_usd' => $validation_cost,
+ 'validation' => array(
+ 'tokens' => $validation_prompt_tokens + $validation_completion,
+ 'estimated_cost_usd' => $validation_cost,
),
- 'generation' => array(
- 'tokens' => $generation_prompt_tokens + $generation_completion_tokens,
- 'estimated_cost_usd' => $generation_cost,
+ 'generation' => array(
+ 'tokens' => $generation_prompt_tokens + $generation_completion,
+ 'estimated_cost_usd' => $generation_cost,
),
'total_estimated_cost_usd' => $validation_cost + $generation_cost,
);
@@ -290,7 +284,7 @@ class PSA_AI_Generator {
*
* @return array
*/
- private static function get_settings() {
+ private static function get_settings(): array {
$defaults = array(
'api_key' => '',
'ai_model' => '',
@@ -307,8 +301,6 @@ class PSA_AI_Generator {
if ( false !== $decrypted ) {
$settings['api_key'] = $decrypted;
}
- // If decrypt fails, the key might be stored in plaintext (pre-encryption migration).
- // Leave it as-is for backward compatibility — it will be encrypted on next save.
}
// Prefer wp-config.php constant over database value for security.
@@ -316,44 +308,68 @@ class PSA_AI_Generator {
$settings['api_key'] = PSA_OPENROUTER_KEY;
}
- // Allow filtering of settings before use.
$settings = apply_filters( 'psa_ai_settings', $settings );
return $settings;
}
/**
- * Save meta fields for a peptide post.
+ * Save meta fields for a peptide post (core + extended).
*
* @param int $post_id The post ID.
* @param array $ai_data AI-generated data.
* @param array|null $pubchem_data PubChem data (or null).
*/
- private static function save_peptide_meta( $post_id, $ai_data, $pubchem_data ) {
+ private static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void {
+ // Derive amino acid count from sequence if available.
+ $amino_acid_count = '';
+ $sequence = $ai_data['sequence'] ?? '';
+ if ( ! empty( $sequence ) ) {
+ // Count residues: split on dashes for three-letter codes, or count chars for single-letter.
+ if ( strpos( $sequence, '-' ) !== false ) {
+ $amino_acid_count = (string) count( explode( '-', $sequence ) );
+ } else {
+ $amino_acid_count = (string) strlen( preg_replace( '/[^A-Z]/', '', $sequence ) );
+ }
+ }
+ // AI may also return this directly.
+ if ( ! empty( $ai_data['amino_acid_count'] ) ) {
+ $amino_acid_count = (string) intval( $ai_data['amino_acid_count'] );
+ }
+
$meta = array(
- 'psa_sequence' => $ai_data['sequence'] ?? '',
- 'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
+ 'psa_sequence' => $ai_data['sequence'] ?? '',
+ 'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
? $pubchem_data['molecular_weight']
: ( $ai_data['molecular_weight'] ?? '' ),
- 'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] )
+ 'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] )
? $pubchem_data['molecular_formula']
: ( $ai_data['molecular_formula'] ?? '' ),
- 'psa_aliases' => $ai_data['aliases'] ?? '',
- 'psa_mechanism' => $ai_data['mechanism'] ?? '',
- 'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
- 'psa_category_label' => $ai_data['category_label'] ?? '',
- 'psa_origin' => $ai_data['origin'] ?? '',
- 'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
- 'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
- 'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
- 'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
- 'psa_references' => $ai_data['references'] ?? '',
- 'psa_category' => $ai_data['category'] ?? '',
- 'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
- 'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
+ 'psa_aliases' => $ai_data['aliases'] ?? '',
+ 'psa_mechanism' => $ai_data['mechanism'] ?? '',
+ 'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
+ 'psa_category_label' => $ai_data['category_label'] ?? '',
+ 'psa_origin' => $ai_data['origin'] ?? '',
+ 'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
+ 'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
+ 'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
+ 'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
+ 'psa_references' => $ai_data['references'] ?? '',
+ 'psa_category' => $ai_data['category'] ?? '',
+ 'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
+ 'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
+ // Extended fields (v4.3.0).
+ 'psa_half_life' => $ai_data['half_life'] ?? '',
+ 'psa_stability' => $ai_data['stability'] ?? '',
+ 'psa_solubility' => $ai_data['solubility'] ?? '',
+ 'psa_vial_size_mg' => $ai_data['vial_size_mg'] ?? '',
+ 'psa_storage_lyophilized' => $ai_data['storage_lyophilized'] ?? '',
+ 'psa_storage_reconstituted' => $ai_data['storage_reconstituted'] ?? '',
+ 'psa_typical_dose_mcg' => $ai_data['typical_dose_mcg'] ?? '',
+ 'psa_cycle_parameters' => $ai_data['cycle_parameters'] ?? '',
+ 'psa_amino_acid_count' => $amino_acid_count,
);
- // Allow filtering of meta before save.
$meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data );
foreach ( $meta as $key => $value ) {
@@ -361,18 +377,77 @@ class PSA_AI_Generator {
}
}
+ /**
+ * Auto-assign a peptide_category taxonomy term based on AI-returned category.
+ *
+ * Maps the AI's free-text category to the registered taxonomy terms.
+ * Falls back to fuzzy matching if no exact match found.
+ *
+ * @param int $post_id The peptide post ID.
+ * @param array $ai_data AI-generated data containing 'category' or 'category_label'.
+ */
+ private static function assign_category_term( int $post_id, array $ai_data ): void {
+ $ai_category = $ai_data['category'] ?? $ai_data['category_label'] ?? '';
+ if ( empty( $ai_category ) ) {
+ return;
+ }
+
+ // Mapping from AI free-text categories to taxonomy slugs.
+ // Why: AI returns varied labels ("GH Secretagogues", "Healing & Repair") that need
+ // to map to our controlled vocabulary of taxonomy terms.
+ $category_map = array(
+ 'gh secretagogues' => 'growth-hormone',
+ 'growth hormone' => 'growth-hormone',
+ 'healing & repair' => 'tissue-repair',
+ 'healing and repair' => 'tissue-repair',
+ 'tissue repair' => 'tissue-repair',
+ 'tissue healing' => 'tissue-repair',
+ 'cytoprotective' => 'tissue-repair',
+ 'melanocortin peptides' => 'dermatological',
+ 'melanocortin' => 'dermatological',
+ 'dermatological' => 'dermatological',
+ 'metabolic & anti-aging' => 'metabolic',
+ 'metabolic and anti-aging' => 'metabolic',
+ 'metabolic' => 'metabolic',
+ 'anti-aging' => 'aging-research',
+ 'aging research' => 'aging-research',
+ 'nootropic & neuroprotective' => 'immunology',
+ 'nootropic' => 'immunology',
+ 'neuroprotective' => 'immunology',
+ 'immunology' => 'immunology',
+ 'immune' => 'immunology',
+ 'lipid metabolism' => 'lipid-metabolism',
+ 'endocrine' => 'endocrine',
+ );
+
+ $lower = strtolower( trim( $ai_category ) );
+ $slug = $category_map[ $lower ] ?? '';
+
+ // Fuzzy fallback: check if AI category contains any known slug keyword.
+ if ( empty( $slug ) ) {
+ foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $cat_slug => $cat_name ) {
+ if ( stripos( $ai_category, $cat_name ) !== false || stripos( $ai_category, str_replace( '-', ' ', $cat_slug ) ) !== false ) {
+ $slug = $cat_slug;
+ break;
+ }
+ }
+ }
+
+ if ( ! empty( $slug ) ) {
+ wp_set_object_terms( $post_id, $slug, 'peptide_category' );
+ }
+ }
+
/**
* Validate peptide name input characters and block injection patterns.
*
* @param string $name The peptide name to validate.
* @return bool True if input looks legitimate.
*/
- private static function validate_peptide_input( $name ) {
- // Allow only characters that appear in legitimate peptide names.
+ private static function validate_peptide_input( string $name ): bool {
if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) {
return false;
}
- // Block obvious prompt injection patterns.
$blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' );
$lower = strtolower( $name );
foreach ( $blocked as $word ) {
@@ -386,14 +461,10 @@ class PSA_AI_Generator {
/**
* Build the lightweight validation prompt.
*
- * Uses wp_json_encode() to safely encode the peptide name within the prompt.
- * This ensures special characters and quotes are properly escaped before sending to the AI.
- * The JSON-encoded format also helps the AI parser recognize the input clearly.
- *
* @param string $name The peptide name to validate.
* @return string Prompt text ready to send to OpenRouter API.
*/
- private static function build_validation_prompt( $name ) {
+ private static function build_validation_prompt( string $name ): string {
$json_name = wp_json_encode( $name );
return <<get_error_code() ) {
- return $result;
- }
-
- // Rate limited — retry with exponential backoff.
- if ( $attempt < $max_retries ) {
- $delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s
- // Avoid blocking PHP workers during WP-Cron execution.
- if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
- error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' );
- return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' );
- }
- error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' );
- sleep( $delay );
- }
- }
-
- error_log( 'PSA: All ' . $max_retries . ' attempts failed due to rate limiting.' );
- return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached after ' . $max_retries . ' retries. Consider adding credits at openrouter.ai or switching to a non-free model.' );
- }
-
- /**
- * Single OpenRouter API call (no retry).
- *
- * @param string $api_key The OpenRouter API key.
- * @param string $model The model identifier.
- * @param string $prompt The prompt text.
- * @param int $max_tokens Maximum tokens to generate.
- * @param string $request_type 'validation' or 'generation' (default).
- * @param string $peptide_name Peptide name for logging.
- * @return string|WP_Error Response text or error.
- */
- private static function call_openrouter_once( $api_key, $model, $prompt, $max_tokens = 4096, $request_type = 'generation', $peptide_name = '' ) {
- $site_url = get_bloginfo( 'url' );
- $site_name = get_bloginfo( 'name' );
-
- $response = wp_remote_post(
- self::API_URL,
- array(
- 'timeout' => 120,
- 'headers' => array(
- 'Content-Type' => 'application/json',
- 'Authorization' => 'Bearer ' . $api_key,
- 'HTTP-Referer' => $site_url,
- 'X-Title' => $site_name,
- ),
- 'body' => wp_json_encode(
- array(
- 'model' => $model,
- 'messages' => array(
- array(
- 'role' => 'system',
- 'content' => 'You are a scientific database assistant. Return only valid JSON.',
- ),
- array(
- 'role' => 'user',
- 'content' => $prompt,
- ),
- ),
- 'max_tokens' => $max_tokens,
- 'temperature' => 0.3,
- )
- ),
- )
- );
-
- if ( is_wp_error( $response ) ) {
- error_log( 'PSA: OpenRouter connection error: ' . $response->get_error_message() );
- return new WP_Error( 'api_error', 'Failed to connect to OpenRouter API: ' . $response->get_error_message() );
- }
-
- $code = wp_remote_retrieve_response_code( $response );
- $raw_body = wp_remote_retrieve_body( $response );
- $body = json_decode( $raw_body, true );
-
- error_log( 'PSA: OpenRouter response code: ' . $code . ' for model: ' . $model );
-
- if ( 200 !== $code ) {
- if ( 429 === $code ) {
- error_log( 'PSA: OpenRouter rate limited (429)' );
- return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached. Please try again in a few moments.' );
- }
- if ( 402 === $code ) {
- error_log( 'PSA: OpenRouter insufficient credits (402)' );
- return new WP_Error( 'insufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai.' );
- }
- $msg = $body['error']['message'] ?? ( 'Unknown error (HTTP ' . $code . ')' );
- error_log( 'PSA: OpenRouter API error (' . $code . '): ' . $msg );
- error_log( 'PSA: OpenRouter raw response: ' . substr( $raw_body, 0, 500 ) );
- return new WP_Error( 'api_error', 'OpenRouter API error: ' . $msg );
- }
-
- if ( empty( $body['choices'][0]['message']['content'] ) ) {
- error_log( 'PSA: OpenRouter returned empty content. Raw: ' . substr( $raw_body, 0, 500 ) );
- return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' );
- }
-
- // Log API call with token usage.
- $prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
- $completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
-
- PSA_Cost_Tracker::log_api_call(
- array(
- 'provider' => 'openrouter',
- 'model' => $model,
- 'prompt_tokens' => $prompt_tokens,
- 'completion_tokens' => $completion_tokens,
- 'request_type' => $request_type,
- 'peptide_name' => $peptide_name,
- 'success' => true,
- )
- );
-
- return $body['choices'][0]['message']['content'];
- }
-
- /**
- * Parse AI response text into structured data.
- *
- * @param string $response_text Raw response text.
- * @return array|WP_Error Parsed data or error.
- */
- private static function parse_ai_response( $response_text ) {
- // Strip any ... tags (some models like DeepSeek include reasoning).
- $cleaned = preg_replace( '/.*?<\/think>/s', '', $response_text );
-
- // Strip markdown code fences if present.
- $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $cleaned );
- $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );
- $cleaned = trim( $cleaned );
-
- $data = json_decode( $cleaned, true );
- if ( JSON_ERROR_NONE !== json_last_error() ) {
- error_log( 'PSA: JSON parse error: ' . json_last_error_msg() . '. Raw (first 500 chars): ' . substr( $response_text, 0, 500 ) );
- return new WP_Error(
- 'parse_error',
- 'Failed to parse AI response as JSON: ' . json_last_error_msg()
- );
- }
-
- return $data;
- }
-
- /**
- * Create a matching Echo Knowledge Base article with full structured content.
- *
- * @param int $peptide_post_id The peptide post ID.
- * @param array $ai_data The parsed AI data.
- * @param string $peptide_name The peptide name.
- * @param string $post_status The post status (default: 'draft').
- */
- private static function create_kb_article( $peptide_post_id, $ai_data, $peptide_name, $post_status = 'draft' ) {
- if ( ! post_type_exists( 'epkb_post_type_1' ) ) {
- error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' );
- return;
- }
-
- $title = sanitize_text_field( $ai_data['name'] ?? $peptide_name );
- $existing = get_posts(
- array(
- 'post_type' => 'epkb_post_type_1',
- 'title' => $title,
- 'post_status' => 'any',
- 'numberposts' => 1,
- )
- );
-
- if ( ! empty( $existing ) ) {
- error_log( 'PSA: KB article already exists for "' . $peptide_name . '".' );
- return;
- }
-
- // Build header meta line.
- $cat_label = ! empty( $ai_data['category_label'] ) ? $ai_data['category_label'] : '';
- $origin = ! empty( $ai_data['origin'] ) ? $ai_data['origin'] : '';
- $sequence = ! empty( $ai_data['sequence'] ) ? $ai_data['sequence'] : '';
-
- $header = '';
- if ( $cat_label ) {
- $header .= 'Category: ' . esc_html( $cat_label );
- }
- if ( $origin ) {
- $header .= ' | Origin: ' . esc_html( $origin );
- }
- if ( $sequence ) {
- $header .= ' | Sequence: ' . esc_html( $sequence );
- }
-
- // Build full article content.
- $c = $header ? '' . $header . '
' . "\n\n" : '';
-
- // Overview.
- $c .= 'Overview ' . "\n";
- $c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' );
-
- // Mechanism of Action.
- if ( ! empty( $ai_data['mechanism'] ) ) {
- $c .= 'Mechanism of Action ' . "\n";
- $c .= self::paragraphs( $ai_data['mechanism'] );
- }
-
- // Research & Potential Benefits (with h3 subheadings).
- if ( ! empty( $ai_data['research_benefits'] ) || ! empty( $ai_data['research_applications'] ) ) {
- $c .= 'Research & Potential Benefits ' . "\n";
- $c .= 'The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.
' . "\n";
- $research = $ai_data['research_benefits'] ?? $ai_data['research_applications'] ?? '';
- $c .= self::research_with_subheadings( $research );
- }
-
- // Administration & Dosing.
- if ( ! empty( $ai_data['administration_dosing'] ) || ! empty( $ai_data['dosage_info'] ) ) {
- $c .= 'Administration & Dosing (Research Context) ' . "\n";
- $c .= 'Important: The following dosing information is derived from animal studies and extrapolated research. This peptide is not approved for human use. This information is provided for educational purposes only.
' . "\n";
- $c .= self::paragraphs( $ai_data['administration_dosing'] ?? $ai_data['dosage_info'] ?? '' );
- }
-
- // Safety & Side Effects.
- if ( ! empty( $ai_data['safety_side_effects'] ) || ! empty( $ai_data['safety_profile'] ) ) {
- $c .= 'Safety & Side Effects ' . "\n";
- $c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' );
- }
-
- // Legal & Regulatory Status.
- if ( ! empty( $ai_data['legal_regulatory'] ) ) {
- $c .= 'Legal & Regulatory Status ' . "\n";
- $c .= self::paragraphs( $ai_data['legal_regulatory'] );
- }
-
- // References.
- if ( ! empty( $ai_data['references'] ) ) {
- $c .= 'Notable Research References ' . "\n";
- $c .= self::paragraphs( $ai_data['references'] );
- }
-
- // Medical Disclaimer.
- $c .= "\n" . 'Medical Disclaimer: The information on this page is provided for educational and research purposes only. This peptide is not approved for human therapeutic use. Nothing on this page constitutes medical advice. Always consult a licensed healthcare professional before using any peptide, supplement, or research chemical.
';
-
- $kb_post_id = wp_insert_post(
- array(
- 'post_title' => $title,
- 'post_content' => wp_kses_post( $c ),
- 'post_type' => 'epkb_post_type_1',
- 'post_status' => $post_status,
- ),
- true
- );
-
- if ( is_wp_error( $kb_post_id ) ) {
- error_log( 'PSA: Failed to create KB article: ' . $kb_post_id->get_error_message() );
- return;
- }
-
- // Assign KB category.
- $category_map = array(
- 'GH Secretagogues' => 'gh-secretagogues',
- 'Healing & Repair' => 'healing-repair',
- 'Melanocortin Peptides' => 'melanocortin-peptides',
- 'Metabolic & Anti-Aging' => 'metabolic-anti-aging',
- 'Nootropic & Neuroprotective' => 'nootropic-neuroprotective',
- );
-
- $cat_name = trim( $ai_data['category'] ?? '' );
- if ( isset( $category_map[ $cat_name ] ) ) {
- $term = get_term_by( 'slug', $category_map[ $cat_name ], 'epkb_post_type_1_category' );
- if ( $term ) {
- wp_set_object_terms( $kb_post_id, array( (int) $term->term_id ), 'epkb_post_type_1_category' );
- }
- }
-
- update_post_meta( $peptide_post_id, 'psa_kb_article_id', $kb_post_id );
- update_post_meta( $kb_post_id, 'psa_peptide_post_id', $peptide_post_id );
-
- error_log( 'PSA: Created KB article #' . $kb_post_id . ' for "' . $peptide_name . '"' );
- }
-
- /**
- * Convert text to HTML paragraphs.
- */
- private static function paragraphs( $text ) {
- if ( empty( $text ) ) {
- return '';
- }
- $text = str_replace( "\r\n", "\n", $text );
- $parts = preg_split( '/\n{2,}/', trim( $text ) );
- $out = '';
- foreach ( $parts as $p ) {
- $p = trim( $p );
- if ( '' === $p ) {
- continue;
- }
- $out .= '' . wp_kses_post( $p ) . '
' . "\n";
- }
- return $out;
- }
-
- /**
- * Parse research text into h3 subheadings + paragraphs.
- */
- private static function research_with_subheadings( $text ) {
- if ( empty( $text ) ) {
- return '';
- }
- $text = str_replace( "\r\n", "\n", trim( $text ) );
- $blocks = preg_split( '/\n{2,}/', $text );
- $out = '';
- foreach ( $blocks as $block ) {
- $block = trim( $block );
- if ( '' === $block ) {
- continue;
- }
- // If block is short (under 80 chars) and has no period, treat as a subheading.
- if ( strlen( $block ) < 80 && strpos( $block, '.' ) === false ) {
- $out .= '' . esc_html( $block ) . ' ' . "\n";
- } else {
- // Check if first line is a subheading followed by content.
- $first_nl = strpos( $block, "\n" );
- if ( false !== $first_nl ) {
- $first_line = trim( substr( $block, 0, $first_nl ) );
- $rest = trim( substr( $block, $first_nl + 1 ) );
- if ( strlen( $first_line ) < 80 && strpos( $first_line, '.' ) === false ) {
- $out .= '' . esc_html( $first_line ) . ' ' . "\n";
- $out .= '' . wp_kses_post( $rest ) . '
' . "\n";
- continue;
- }
- }
- $out .= '' . wp_kses_post( $block ) . '
' . "\n";
- }
- }
- return $out;
- }
}
diff --git a/includes/class-psa-config.php b/includes/class-psa-config.php
index e5095e5..070972e 100755
--- a/includes/class-psa-config.php
+++ b/includes/class-psa-config.php
@@ -1,7 +1,11 @@
prefix . 'psa_api_logs';
@@ -68,7 +69,7 @@ class PSA_Cost_Tracker {
* }
* @return int|false Insert ID or false on error.
*/
- public static function log_api_call( $data ) {
+ public static function log_api_call( array $data ) {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
@@ -126,7 +127,7 @@ class PSA_Cost_Tracker {
* @param int|null $month Month (defaults to current month).
* @return float Total USD spent in the month.
*/
- public static function get_monthly_spend( $year = null, $month = null ) {
+ public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float {
global $wpdb;
if ( null === $year ) {
@@ -165,7 +166,7 @@ class PSA_Cost_Tracker {
* @param int|null $month Month (defaults to current month).
* @return int Total tokens used in the month.
*/
- public static function get_monthly_tokens( $year = null, $month = null ) {
+ public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int {
global $wpdb;
if ( null === $year ) {
@@ -203,7 +204,7 @@ class PSA_Cost_Tracker {
*
* @return bool True if budget exceeded, false otherwise.
*/
- public static function is_budget_exceeded() {
+ public static function is_budget_exceeded(): bool {
$settings = get_option( 'psa_settings', array() );
$budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
@@ -222,7 +223,7 @@ class PSA_Cost_Tracker {
* @param int $limit Maximum number of logs to return.
* @return array Array of log objects.
*/
- public static function get_recent_logs( $limit = 20 ) {
+ public static function get_recent_logs( int $limit = 20 ): array {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
@@ -248,7 +249,7 @@ class PSA_Cost_Tracker {
* @param int $completion_tokens Tokens in completion.
* @return float Estimated cost in USD.
*/
- public static function estimate_cost( $model, $prompt_tokens, $completion_tokens ) {
+ public static function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
// Known model pricing per 1 million tokens (input, output).
$pricing = array(
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
@@ -275,7 +276,7 @@ class PSA_Cost_Tracker {
/**
* Drop the API logs table during uninstall.
*/
- public static function drop_table() {
+ public static function drop_table(): void {
global $wpdb;
$table_name = $wpdb->prefix . 'psa_api_logs';
diff --git a/includes/class-psa-directory.php b/includes/class-psa-directory.php
new file mode 100755
index 0000000..4f5570b
--- /dev/null
+++ b/includes/class-psa-directory.php
@@ -0,0 +1,317 @@
+ 'GET',
+ 'callback' => array( __CLASS__, 'rest_compounds' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'search' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'default' => '',
+ ),
+ 'category' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'default' => '',
+ ),
+ 'fields' => array(
+ 'type' => 'string',
+ 'enum' => array( 'basic', 'full' ),
+ 'default' => 'full',
+ ),
+ 'page' => array(
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ 'default' => self::PER_PAGE_DEFAULT,
+ 'minimum' => 1,
+ 'maximum' => self::PER_PAGE_MAX,
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * REST callback for GET /v1/compounds.
+ *
+ * Supports search, category filtering, pagination, and basic/full field sets.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return WP_REST_Response
+ */
+ public static function rest_compounds( $request ) {
+ $search = $request->get_param( 'search' );
+ $category = $request->get_param( 'category' );
+ $fields = $request->get_param( 'fields' );
+ $page = max( 1, (int) $request->get_param( 'page' ) );
+ $per_page = min( self::PER_PAGE_MAX, max( 1, (int) $request->get_param( 'per_page' ) ) );
+
+ $query_args = array(
+ 'post_type' => 'peptide',
+ 'post_status' => 'publish',
+ 'posts_per_page' => $per_page,
+ 'paged' => $page,
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+ );
+
+ // Search filter: title and alias matching.
+ if ( ! empty( $search ) ) {
+ $query_args['s'] = $search;
+ }
+
+ // Category filter: taxonomy query.
+ if ( ! empty( $category ) ) {
+ $query_args['tax_query'] = array(
+ array(
+ 'taxonomy' => 'peptide_category',
+ 'field' => 'slug',
+ 'terms' => $category,
+ ),
+ );
+ }
+
+ $query = new WP_Query( $query_args );
+
+ // Prime caches for performance.
+ $post_ids = wp_list_pluck( $query->posts, 'ID' );
+ if ( ! empty( $post_ids ) ) {
+ update_meta_cache( 'post', $post_ids );
+ }
+
+ $compounds = array();
+ foreach ( $query->posts as $post ) {
+ $compounds[] = self::format_compound( $post, $fields );
+ }
+
+ $response_data = array(
+ 'compounds' => $compounds,
+ 'total' => (int) $query->found_posts,
+ 'total_pages' => (int) $query->max_num_pages,
+ 'page' => $page,
+ 'per_page' => $per_page,
+ );
+
+ $response = new WP_REST_Response( $response_data, 200 );
+
+ // Pagination headers for client convenience.
+ $response->header( 'X-WP-Total', (string) $query->found_posts );
+ $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
+
+ return $response;
+ }
+
+ /**
+ * Format a single compound for REST response.
+ *
+ * @param WP_Post $post The peptide post.
+ * @param string $fields 'basic' for lightweight data, 'full' for everything.
+ * @return array Formatted compound data.
+ */
+ public static function format_compound( $post, string $fields = 'full' ): array {
+ $post_id = $post->ID;
+
+ // Get category terms.
+ $terms = wp_get_object_terms( $post_id, 'peptide_category', array( 'fields' => 'all' ) );
+ $categories = array();
+ if ( ! is_wp_error( $terms ) ) {
+ foreach ( $terms as $term ) {
+ $categories[] = array(
+ 'slug' => $term->slug,
+ 'name' => $term->name,
+ );
+ }
+ }
+
+ $basic = array(
+ 'id' => $post_id,
+ 'name' => $post->post_title,
+ 'slug' => $post->post_name,
+ 'url' => get_permalink( $post_id ),
+ 'categories' => $categories,
+ 'half_life' => get_post_meta( $post_id, 'psa_half_life', true ),
+ 'stability' => get_post_meta( $post_id, 'psa_stability', true ),
+ 'source' => get_post_meta( $post_id, 'psa_source', true ),
+ );
+
+ // Allow other plugins to add data to directory cards (e.g., observation count).
+ $basic['extras'] = apply_filters( 'psa_directory_card_extras', '', $post_id );
+
+ if ( 'basic' === $fields ) {
+ // Lightweight response for cross-plugin use.
+ $basic['vial_size_mg'] = get_post_meta( $post_id, 'psa_vial_size_mg', true );
+ return $basic;
+ }
+
+ // Full response: merge in all meta for detail modal.
+ $full = array_merge(
+ $basic,
+ array(
+ 'excerpt' => wp_trim_words( $post->post_content, 30 ),
+ 'description' => wp_trim_words( $post->post_content, 80 ),
+ 'sequence' => get_post_meta( $post_id, 'psa_sequence', true ),
+ 'molecular_weight' => get_post_meta( $post_id, 'psa_molecular_weight', true ),
+ 'molecular_formula' => get_post_meta( $post_id, 'psa_molecular_formula', true ),
+ 'aliases' => get_post_meta( $post_id, 'psa_aliases', true ),
+ 'mechanism' => get_post_meta( $post_id, 'psa_mechanism', true ),
+ 'research_apps' => get_post_meta( $post_id, 'psa_research_apps', true ),
+ 'safety_profile' => get_post_meta( $post_id, 'psa_safety_profile', true ),
+ 'dosage_info' => get_post_meta( $post_id, 'psa_dosage_info', true ),
+ 'references' => get_post_meta( $post_id, 'psa_references', true ),
+ 'pubchem_cid' => get_post_meta( $post_id, 'psa_pubchem_cid', true ),
+ 'solubility' => get_post_meta( $post_id, 'psa_solubility', true ),
+ 'vial_size_mg' => get_post_meta( $post_id, 'psa_vial_size_mg', true ),
+ 'storage_lyophilized' => get_post_meta( $post_id, 'psa_storage_lyophilized', true ),
+ 'storage_reconstituted' => get_post_meta( $post_id, 'psa_storage_reconstituted', true ),
+ 'typical_dose_mcg' => get_post_meta( $post_id, 'psa_typical_dose_mcg', true ),
+ 'cycle_parameters' => get_post_meta( $post_id, 'psa_cycle_parameters', true ),
+ 'amino_acid_count' => get_post_meta( $post_id, 'psa_amino_acid_count', true ),
+ )
+ );
+
+ // Allow other plugins (e.g., Peptide Community) to enrich the compound data.
+ $full = apply_filters( 'psa_rest_compound_data', $full, $post_id );
+
+ return $full;
+ }
+
+ // -------------------------------------------------------------------------
+ // Shortcode
+ // -------------------------------------------------------------------------
+
+ /**
+ * Render the [peptide_directory] shortcode.
+ *
+ * Outputs a container div that the frontend JS populates with the card grid.
+ * Passes REST endpoint URL and category data via wp_localize_script.
+ *
+ * @param array $atts Shortcode attributes (unused for now, reserved for future).
+ * @return string HTML output.
+ */
+ public static function render_shortcode( $atts ): string {
+ // Get categories for filter buttons.
+ $terms = get_terms(
+ array(
+ 'taxonomy' => 'peptide_category',
+ 'hide_empty' => false,
+ 'orderby' => 'name',
+ 'order' => 'ASC',
+ )
+ );
+
+ $categories = array();
+ if ( ! is_wp_error( $terms ) ) {
+ foreach ( $terms as $term ) {
+ $categories[] = array(
+ 'slug' => $term->slug,
+ 'name' => $term->name,
+ );
+ }
+ }
+
+ // Localize script data for the directory JS.
+ wp_localize_script(
+ 'psa-directory',
+ 'psaDirectory',
+ array(
+ 'restUrl' => esc_url_raw( rest_url( 'peptide-search-ai/v1/compounds' ) ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ 'categories' => $categories,
+ 'perPage' => self::PER_PAGE_DEFAULT,
+ 'i18n' => array(
+ 'title' => __( 'Peptide Directory', 'peptide-search-ai' ),
+ 'subtitle' => __( 'Browse reagents with detailed research parameters.', 'peptide-search-ai' ),
+ 'searchPlaceholder' => __( 'Search peptides...', 'peptide-search-ai' ),
+ 'showAll' => __( 'Show All', 'peptide-search-ai' ),
+ 'loadMore' => __( 'Load More', 'peptide-search-ai' ),
+ 'loading' => __( 'Loading...', 'peptide-search-ai' ),
+ 'noResults' => __( 'No peptides found matching your criteria.', 'peptide-search-ai' ),
+ 'viewDetails' => __( 'View Research Details', 'peptide-search-ai' ),
+ 'viewFullPage' => __( 'View Full Page', 'peptide-search-ai' ),
+ 'close' => __( 'Close', 'peptide-search-ai' ),
+ 'halfLife' => __( 'Half-Life', 'peptide-search-ai' ),
+ 'stability' => __( 'Stability', 'peptide-search-ai' ),
+ 'solubility' => __( 'Solubility', 'peptide-search-ai' ),
+ 'vialSize' => __( 'Vial Size', 'peptide-search-ai' ),
+ 'storageLyo' => __( 'Storage (Lyophilized)', 'peptide-search-ai' ),
+ 'storageRecon' => __( 'Storage (Reconstituted)', 'peptide-search-ai' ),
+ 'molecularWeight' => __( 'Molecular Weight', 'peptide-search-ai' ),
+ 'formula' => __( 'Formula', 'peptide-search-ai' ),
+ 'typicalDose' => __( 'Typical Dose', 'peptide-search-ai' ),
+ 'cycleParams' => __( 'Cycle Parameters', 'peptide-search-ai' ),
+ 'sequence' => __( 'Sequence', 'peptide-search-ai' ),
+ 'singleReagents' => __( 'Single Reagents', 'peptide-search-ai' ),
+ 'protocolModels' => __( 'Protocol Models', 'peptide-search-ai' ),
+ 'comingSoon' => __( 'Coming Soon', 'peptide-search-ai' ),
+ 'researchParams' => __( 'Research Parameters', 'peptide-search-ai' ),
+ 'communityObs' => __( 'Community Observations', 'peptide-search-ai' ),
+ 'pubchemLink' => __( 'View on PubChem', 'peptide-search-ai' ),
+ 'copySequence' => __( 'Copy', 'peptide-search-ai' ),
+ 'copied' => __( 'Copied!', 'peptide-search-ai' ),
+ ),
+ )
+ );
+
+ ob_start();
+ ?>
+
+ $type,
- 'message' => $message,
- ),
- $http_code
- );
- }
-}
diff --git a/includes/class-psa-kb-builder.php b/includes/class-psa-kb-builder.php
new file mode 100755
index 0000000..db2417d
--- /dev/null
+++ b/includes/class-psa-kb-builder.php
@@ -0,0 +1,247 @@
+ 'gh-secretagogues',
+ 'Healing & Repair' => 'healing-repair',
+ 'Melanocortin Peptides' => 'melanocortin-peptides',
+ 'Metabolic & Anti-Aging' => 'metabolic-anti-aging',
+ 'Nootropic & Neuroprotective' => 'nootropic-neuroprotective',
+ );
+
+ /**
+ * Create a matching Echo Knowledge Base article with full structured content.
+ *
+ * Checks for Echo KB plugin, avoids duplicates, builds structured HTML with
+ * h2/h3 headings, assigns KB category, and cross-references the peptide post.
+ *
+ * @param int $peptide_post_id The peptide post ID to cross-reference.
+ * @param array $ai_data The parsed AI data with all content fields.
+ * @param string $peptide_name The peptide name for logging and fallback title.
+ * @param string $post_status The post status ('draft' or 'publish').
+ * @return void
+ */
+ public static function create_article( int $peptide_post_id, array $ai_data, string $peptide_name, string $post_status = 'draft' ): void {
+ if ( ! post_type_exists( 'epkb_post_type_1' ) ) {
+ error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' );
+ return;
+ }
+
+ $title = sanitize_text_field( $ai_data['name'] ?? $peptide_name );
+ $existing = get_posts(
+ array(
+ 'post_type' => 'epkb_post_type_1',
+ 'title' => $title,
+ 'post_status' => 'any',
+ 'numberposts' => 1,
+ )
+ );
+
+ if ( ! empty( $existing ) ) {
+ error_log( 'PSA: KB article already exists for "' . $peptide_name . '".' );
+ return;
+ }
+
+ $content = self::build_article_content( $ai_data );
+ $kb_post_id = wp_insert_post(
+ array(
+ 'post_title' => $title,
+ 'post_content' => wp_kses_post( $content ),
+ 'post_type' => 'epkb_post_type_1',
+ 'post_status' => $post_status,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $kb_post_id ) ) {
+ error_log( 'PSA: Failed to create KB article: ' . $kb_post_id->get_error_message() );
+ return;
+ }
+
+ self::assign_category( $kb_post_id, $ai_data );
+
+ // Cross-reference both posts.
+ update_post_meta( $peptide_post_id, 'psa_kb_article_id', $kb_post_id );
+ update_post_meta( $kb_post_id, 'psa_peptide_post_id', $peptide_post_id );
+
+ error_log( 'PSA: Created KB article #' . $kb_post_id . ' for "' . $peptide_name . '"' );
+ }
+
+ /**
+ * Build the full article HTML content from AI data.
+ *
+ * @param array $ai_data The parsed AI data.
+ * @return string HTML content for the KB article.
+ */
+ private static function build_article_content( array $ai_data ): string {
+ // Build header meta line.
+ $cat_label = ! empty( $ai_data['category_label'] ) ? $ai_data['category_label'] : '';
+ $origin = ! empty( $ai_data['origin'] ) ? $ai_data['origin'] : '';
+ $sequence = ! empty( $ai_data['sequence'] ) ? $ai_data['sequence'] : '';
+
+ $header = '';
+ if ( $cat_label ) {
+ $header .= 'Category: ' . esc_html( $cat_label );
+ }
+ if ( $origin ) {
+ $header .= ' | Origin: ' . esc_html( $origin );
+ }
+ if ( $sequence ) {
+ $header .= ' | Sequence: ' . esc_html( $sequence );
+ }
+
+ $c = $header ? '' . $header . '
' . "\n\n" : '';
+
+ // Overview.
+ $c .= 'Overview ' . "\n";
+ $c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' );
+
+ // Mechanism of Action.
+ if ( ! empty( $ai_data['mechanism'] ) ) {
+ $c .= 'Mechanism of Action ' . "\n";
+ $c .= self::paragraphs( $ai_data['mechanism'] );
+ }
+
+ // Research & Potential Benefits (with h3 subheadings).
+ if ( ! empty( $ai_data['research_benefits'] ) || ! empty( $ai_data['research_applications'] ) ) {
+ $c .= 'Research & Potential Benefits ' . "\n";
+ $c .= 'The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.
' . "\n";
+ $research = $ai_data['research_benefits'] ?? $ai_data['research_applications'] ?? '';
+ $c .= self::research_with_subheadings( $research );
+ }
+
+ // Administration & Dosing.
+ if ( ! empty( $ai_data['administration_dosing'] ) || ! empty( $ai_data['dosage_info'] ) ) {
+ $c .= 'Administration & Dosing (Research Context) ' . "\n";
+ $c .= 'Important: The following dosing information is derived from animal studies and extrapolated research. This peptide is not approved for human use. This information is provided for educational purposes only.
' . "\n";
+ $c .= self::paragraphs( $ai_data['administration_dosing'] ?? $ai_data['dosage_info'] ?? '' );
+ }
+
+ // Safety & Side Effects.
+ if ( ! empty( $ai_data['safety_side_effects'] ) || ! empty( $ai_data['safety_profile'] ) ) {
+ $c .= 'Safety & Side Effects ' . "\n";
+ $c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' );
+ }
+
+ // Legal & Regulatory Status.
+ if ( ! empty( $ai_data['legal_regulatory'] ) ) {
+ $c .= 'Legal & Regulatory Status ' . "\n";
+ $c .= self::paragraphs( $ai_data['legal_regulatory'] );
+ }
+
+ // References.
+ if ( ! empty( $ai_data['references'] ) ) {
+ $c .= 'Notable Research References ' . "\n";
+ $c .= self::paragraphs( $ai_data['references'] );
+ }
+
+ // Medical Disclaimer.
+ $c .= "\n" . 'Medical Disclaimer: The information on this page is provided for educational and research purposes only. This peptide is not approved for human therapeutic use. Nothing on this page constitutes medical advice. Always consult a licensed healthcare professional before using any peptide, supplement, or research chemical.
';
+
+ return $c;
+ }
+
+ /**
+ * Assign Echo KB category to a KB article based on AI-classified category.
+ *
+ * @param int $kb_post_id The KB article post ID.
+ * @param array $ai_data The parsed AI data containing 'category' field.
+ * @return void
+ */
+ private static function assign_category( int $kb_post_id, array $ai_data ): void {
+ $cat_name = trim( $ai_data['category'] ?? '' );
+ if ( isset( self::CATEGORY_MAP[ $cat_name ] ) ) {
+ $term = get_term_by( 'slug', self::CATEGORY_MAP[ $cat_name ], 'epkb_post_type_1_category' );
+ if ( $term ) {
+ wp_set_object_terms( $kb_post_id, array( (int) $term->term_id ), 'epkb_post_type_1_category' );
+ }
+ }
+ }
+
+ /**
+ * Convert text to HTML paragraphs.
+ *
+ * Splits on double newlines and wraps each chunk in tags.
+ *
+ * @param string $text Raw text content.
+ * @return string HTML paragraphs.
+ */
+ public static function paragraphs( string $text ): string {
+ if ( empty( $text ) ) {
+ return '';
+ }
+ $text = str_replace( "\r\n", "\n", $text );
+ $parts = preg_split( '/\n{2,}/', trim( $text ) );
+ $out = '';
+ foreach ( $parts as $p ) {
+ $p = trim( $p );
+ if ( '' === $p ) {
+ continue;
+ }
+ $out .= '
' . wp_kses_post( $p ) . '
' . "\n";
+ }
+ return $out;
+ }
+
+ /**
+ * Parse research text into h3 subheadings + paragraphs.
+ *
+ * Short lines (<80 chars, no period) become h3 subheadings.
+ * Longer blocks become regular paragraphs.
+ *
+ * @param string $text Research benefits text with embedded subheadings.
+ * @return string HTML with h3 subheadings and paragraphs.
+ */
+ public static function research_with_subheadings( string $text ): string {
+ if ( empty( $text ) ) {
+ return '';
+ }
+ $text = str_replace( "\r\n", "\n", trim( $text ) );
+ $blocks = preg_split( '/\n{2,}/', $text );
+ $out = '';
+ foreach ( $blocks as $block ) {
+ $block = trim( $block );
+ if ( '' === $block ) {
+ continue;
+ }
+ // If block is short (under 80 chars) and has no period, treat as a subheading.
+ if ( strlen( $block ) < 80 && strpos( $block, '.' ) === false ) {
+ $out .= '' . esc_html( $block ) . ' ' . "\n";
+ } else {
+ // Check if first line is a subheading followed by content.
+ $first_nl = strpos( $block, "\n" );
+ if ( false !== $first_nl ) {
+ $first_line = trim( substr( $block, 0, $first_nl ) );
+ $rest = trim( substr( $block, $first_nl + 1 ) );
+ if ( strlen( $first_line ) < 80 && strpos( $first_line, '.' ) === false ) {
+ $out .= '' . esc_html( $first_line ) . ' ' . "\n";
+ $out .= '' . wp_kses_post( $rest ) . '
' . "\n";
+ continue;
+ }
+ }
+ $out .= '' . wp_kses_post( $block ) . '
' . "\n";
+ }
+ }
+ return $out;
+ }
+}
diff --git a/includes/class-psa-openrouter.php b/includes/class-psa-openrouter.php
new file mode 100755
index 0000000..78336b9
--- /dev/null
+++ b/includes/class-psa-openrouter.php
@@ -0,0 +1,197 @@
+get_error_code() ) {
+ return $result;
+ }
+
+ // Rate limited — retry with exponential backoff.
+ if ( $attempt < $max_retries ) {
+ $delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s
+ // Avoid blocking PHP workers during WP-Cron execution.
+ if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
+ error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' );
+ return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' );
+ }
+ error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' );
+ sleep( $delay );
+ }
+ }
+
+ error_log( 'PSA: All ' . $max_retries . ' attempts failed due to rate limiting.' );
+ return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached after ' . $max_retries . ' retries. Consider adding credits at openrouter.ai or switching to a non-free model.' );
+ }
+
+ /**
+ * Single OpenRouter API call (no retry).
+ *
+ * @param string $api_key The OpenRouter API key.
+ * @param string $model The model identifier.
+ * @param string $prompt The prompt text.
+ * @param int $max_tokens Maximum tokens to generate.
+ * @param string $request_type 'validation' or 'generation' (default).
+ * @param string $peptide_name Peptide name for logging.
+ * @return string|WP_Error Response text or error.
+ */
+ private static function send_request_once( string $api_key, string $model, string $prompt, int $max_tokens = 4096, string $request_type = 'generation', string $peptide_name = '' ) {
+ $site_url = get_bloginfo( 'url' );
+ $site_name = get_bloginfo( 'name' );
+
+ $response = wp_remote_post(
+ self::API_URL,
+ array(
+ 'timeout' => 120,
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ 'Authorization' => 'Bearer ' . $api_key,
+ 'HTTP-Referer' => $site_url,
+ 'X-Title' => $site_name,
+ ),
+ 'body' => wp_json_encode(
+ array(
+ 'model' => $model,
+ 'messages' => array(
+ array(
+ 'role' => 'system',
+ 'content' => 'You are a scientific database assistant. Return only valid JSON.',
+ ),
+ array(
+ 'role' => 'user',
+ 'content' => $prompt,
+ ),
+ ),
+ 'max_tokens' => $max_tokens,
+ 'temperature' => 0.3,
+ )
+ ),
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ error_log( 'PSA: OpenRouter connection error: ' . $response->get_error_message() );
+ return new WP_Error( 'api_error', 'Failed to connect to OpenRouter API: ' . $response->get_error_message() );
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ $raw_body = wp_remote_retrieve_body( $response );
+ $body = json_decode( $raw_body, true );
+
+ error_log( 'PSA: OpenRouter response code: ' . $code . ' for model: ' . $model );
+
+ if ( 200 !== $code ) {
+ if ( 429 === $code ) {
+ error_log( 'PSA: OpenRouter rate limited (429)' );
+ return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached. Please try again in a few moments.' );
+ }
+ if ( 402 === $code ) {
+ error_log( 'PSA: OpenRouter insufficient credits (402)' );
+ return new WP_Error( 'insufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai.' );
+ }
+ $msg = $body['error']['message'] ?? ( 'Unknown error (HTTP ' . $code . ')' );
+ error_log( 'PSA: OpenRouter API error (' . $code . '): ' . $msg );
+ error_log( 'PSA: OpenRouter raw response: ' . substr( $raw_body, 0, 500 ) );
+ return new WP_Error( 'api_error', 'OpenRouter API error: ' . $msg );
+ }
+
+ if ( empty( $body['choices'][0]['message']['content'] ) ) {
+ error_log( 'PSA: OpenRouter returned empty content. Raw: ' . substr( $raw_body, 0, 500 ) );
+ return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' );
+ }
+
+ // Log API call with token usage.
+ $prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
+ $completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
+
+ PSA_Cost_Tracker::log_api_call(
+ array(
+ 'provider' => 'openrouter',
+ 'model' => $model,
+ 'prompt_tokens' => $prompt_tokens,
+ 'completion_tokens' => $completion_tokens,
+ 'request_type' => $request_type,
+ 'peptide_name' => $peptide_name,
+ 'success' => true,
+ )
+ );
+
+ return $body['choices'][0]['message']['content'];
+ }
+
+ /**
+ * Parse AI response text into structured data.
+ *
+ * Strips thinking tags (e.g., DeepSeek) and markdown code fences,
+ * then decodes the JSON response.
+ *
+ * @param string $response_text Raw response text from OpenRouter.
+ * @return array|WP_Error Parsed data or error.
+ */
+ public static function parse_response( string $response_text ) {
+ // Strip any ... tags (some models like DeepSeek include reasoning).
+ $cleaned = preg_replace( '/.*?<\/think>/s', '', $response_text );
+
+ // Strip markdown code fences if present.
+ $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $cleaned );
+ $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );
+ $cleaned = trim( $cleaned );
+
+ $data = json_decode( $cleaned, true );
+ if ( JSON_ERROR_NONE !== json_last_error() ) {
+ error_log( 'PSA: JSON parse error: ' . json_last_error_msg() . '. Raw (first 500 chars): ' . substr( $response_text, 0, 500 ) );
+ return new WP_Error(
+ 'parse_error',
+ 'Failed to parse AI response as JSON: ' . json_last_error_msg()
+ );
+ }
+
+ return $data;
+ }
+}
diff --git a/includes/class-psa-post-type.php b/includes/class-psa-post-type.php
index 79043dd..f9e3387 100755
--- a/includes/class-psa-post-type.php
+++ b/includes/class-psa-post-type.php
@@ -1,8 +1,15 @@
'Tissue Repair',
+ 'lipid-metabolism' => 'Lipid Metabolism',
+ 'aging-research' => 'Aging Research',
+ 'dermatological' => 'Dermatological',
+ 'metabolic' => 'Metabolic',
+ 'growth-hormone' => 'Growth Hormone',
+ 'immunology' => 'Immunology',
+ 'endocrine' => 'Endocrine',
+ );
+
+ /** Core meta field keys used throughout the plugin. */
const META_FIELDS = array(
- 'psa_sequence' => 'Amino Acid Sequence',
- 'psa_molecular_weight' => 'Molecular Weight',
- 'psa_molecular_formula' => 'Molecular Formula',
- 'psa_aliases' => 'Aliases / Synonyms',
- 'psa_mechanism' => 'Mechanism of Action',
- 'psa_research_apps' => 'Research Applications',
- 'psa_safety_profile' => 'Safety & Side Effects',
- 'psa_dosage_info' => 'Dosage Information',
- 'psa_references' => 'References & Citations',
- 'psa_source' => 'Data Source',
- 'psa_pubchem_cid' => 'PubChem CID',
+ 'psa_sequence' => 'Amino Acid Sequence',
+ 'psa_molecular_weight' => 'Molecular Weight',
+ 'psa_molecular_formula' => 'Molecular Formula',
+ 'psa_aliases' => 'Aliases / Synonyms',
+ 'psa_mechanism' => 'Mechanism of Action',
+ 'psa_research_apps' => 'Research Applications',
+ 'psa_safety_profile' => 'Safety & Side Effects',
+ 'psa_dosage_info' => 'Dosage Information',
+ 'psa_references' => 'References & Citations',
+ 'psa_source' => 'Data Source',
+ 'psa_pubchem_cid' => 'PubChem CID',
);
/**
- * Register admin-only hooks (meta boxes, save).
+ * Extended meta fields added in v4.3.0 for the directory feature.
+ * Separated from META_FIELDS so existing code continues to work unchanged.
*/
- public static function init_admin() {
+ const EXTENDED_META_FIELDS = array(
+ 'psa_half_life' => 'Half-Life',
+ 'psa_stability' => 'Stability',
+ 'psa_solubility' => 'Solubility / Reconstitution',
+ 'psa_vial_size_mg' => 'Common Vial Size (mg)',
+ 'psa_storage_lyophilized' => 'Storage (Lyophilized)',
+ 'psa_storage_reconstituted' => 'Storage (Reconstituted)',
+ 'psa_typical_dose_mcg' => 'Typical Research Dose',
+ 'psa_cycle_parameters' => 'Cycle / Protocol Parameters',
+ 'psa_amino_acid_count' => 'Amino Acid Count',
+ );
+
+ /**
+ * Register admin-only hooks (meta boxes, save, admin columns).
+ */
+ public static function init_admin(): void {
add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
+ add_filter( 'manage_peptide_posts_columns', array( __CLASS__, 'add_admin_columns' ) );
+ add_action( 'manage_peptide_posts_custom_column', array( __CLASS__, 'render_admin_column' ), 10, 2 );
}
/**
* Register the peptide CPT.
*/
- public static function register_peptide_post_type() {
+ public static function register_peptide_post_type(): void {
if ( post_type_exists( 'peptide' ) ) {
return;
}
@@ -72,10 +112,57 @@ class PSA_Post_Type {
);
}
+ /**
+ * Register the peptide_category taxonomy on the peptide CPT.
+ * Called from psa_init() after CPT registration.
+ */
+ public static function register_taxonomy(): void {
+ if ( taxonomy_exists( 'peptide_category' ) ) {
+ return;
+ }
+
+ $labels = array(
+ 'name' => __( 'Peptide Categories', 'peptide-search-ai' ),
+ 'singular_name' => __( 'Peptide Category', 'peptide-search-ai' ),
+ 'search_items' => __( 'Search Categories', 'peptide-search-ai' ),
+ 'all_items' => __( 'All Categories', 'peptide-search-ai' ),
+ 'edit_item' => __( 'Edit Category', 'peptide-search-ai' ),
+ 'update_item' => __( 'Update Category', 'peptide-search-ai' ),
+ 'add_new_item' => __( 'Add New Category', 'peptide-search-ai' ),
+ 'new_item_name' => __( 'New Category Name', 'peptide-search-ai' ),
+ 'menu_name' => __( 'Categories', 'peptide-search-ai' ),
+ );
+
+ register_taxonomy(
+ 'peptide_category',
+ 'peptide',
+ array(
+ 'labels' => $labels,
+ 'hierarchical' => true,
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'show_admin_column' => true,
+ 'rewrite' => array( 'slug' => 'peptide-category' ),
+ )
+ );
+ }
+
+ /**
+ * Pre-populate default category terms on activation.
+ * Safe to call multiple times — skips existing terms.
+ */
+ public static function populate_default_categories(): void {
+ foreach ( self::DEFAULT_CATEGORIES as $slug => $name ) {
+ if ( ! term_exists( $slug, 'peptide_category' ) ) {
+ wp_insert_term( $name, 'peptide_category', array( 'slug' => $slug ) );
+ }
+ }
+ }
+
/**
* Add meta boxes to the peptide editor.
*/
- public static function add_meta_boxes() {
+ public static function add_meta_boxes(): void {
add_meta_box(
'psa_peptide_data',
'Peptide Scientific Data',
@@ -84,14 +171,23 @@ class PSA_Post_Type {
'normal',
'high'
);
+ add_meta_box(
+ 'psa_extended_data',
+ 'Extended Research Data',
+ array( __CLASS__, 'render_extended_meta_box' ),
+ 'peptide',
+ 'normal',
+ 'default'
+ );
}
/**
- * Render the meta box fields.
+ * Render the core meta box fields.
*
* @param WP_Post $post The current post object.
+ * @return void
*/
- public static function render_meta_box( $post ) {
+ public static function render_meta_box( $post ): void {
wp_nonce_field( 'psa_save_meta', 'psa_meta_nonce' );
$textarea_fields = array(
@@ -135,11 +231,38 @@ class PSA_Post_Type {
}
/**
- * Save meta field values.
+ * Render the extended meta box fields (half-life, stability, storage, etc.).
+ *
+ * @param WP_Post $post The current post object.
+ * @return void
+ */
+ public static function render_extended_meta_box( $post ): void {
+ echo '';
+ }
+
+ /**
+ * Save meta field values (core + extended).
*
* @param int $post_id The post ID.
*/
- public static function save_meta( $post_id ) {
+ public static function save_meta( int $post_id ): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
@@ -152,11 +275,69 @@ class PSA_Post_Type {
return;
}
- foreach ( self::META_FIELDS as $key => $label ) {
+ $numeric_fields = array( 'psa_vial_size_mg', 'psa_amino_acid_count' );
+ $all_fields = array_merge( self::META_FIELDS, self::EXTENDED_META_FIELDS );
+ foreach ( $all_fields as $key => $label ) {
if ( isset( $_POST[ $key ] ) ) {
- $value = sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) );
+ $raw = wp_unslash( $_POST[ $key ] );
+ if ( in_array( $key, $numeric_fields, true ) ) {
+ // Numeric fields: store as non-negative number or empty string.
+ $value = ( '' === trim( $raw ) ) ? '' : (string) max( 0, floatval( $raw ) );
+ } else {
+ $value = sanitize_textarea_field( $raw );
+ }
update_post_meta( $post_id, $key, $value );
}
}
}
+
+ /**
+ * Add custom columns to the peptide admin list.
+ *
+ * @param array $columns Existing columns.
+ * @return array Modified columns.
+ */
+ public static function add_admin_columns( array $columns ): array {
+ $new = array();
+ foreach ( $columns as $key => $label ) {
+ $new[ $key ] = $label;
+ // Insert source column after title.
+ if ( 'title' === $key ) {
+ $new['psa_source'] = __( 'Source', 'peptide-search-ai' );
+ }
+ }
+ return $new;
+ }
+
+ /**
+ * Render custom column content in the admin post list.
+ *
+ * @param string $column Column key.
+ * @param int $post_id Post ID.
+ */
+ public static function render_admin_column( string $column, int $post_id ): void {
+ if ( 'psa_source' === $column ) {
+ $source = get_post_meta( $post_id, 'psa_source', true );
+ $labels = array(
+ 'ai-generated' => 'AI',
+ 'pubchem' => 'PubChem',
+ 'manual' => 'Manual',
+ 'pending' => 'Pending',
+ 'failed' => 'Failed',
+ );
+ echo esc_html( $labels[ $source ] ?? '—' );
+ }
+ }
+
+ /**
+ * Get all meta field keys (core + extended) as a flat array.
+ *
+ * @return array Meta keys.
+ */
+ public static function get_all_meta_keys(): array {
+ return array_merge(
+ array_keys( self::META_FIELDS ),
+ array_keys( self::EXTENDED_META_FIELDS )
+ );
+ }
}
diff --git a/includes/class-psa-pubchem.php b/includes/class-psa-pubchem.php
index cfe20da..747f45c 100755
--- a/includes/class-psa-pubchem.php
+++ b/includes/class-psa-pubchem.php
@@ -1,8 +1,14 @@
query(
@@ -87,7 +93,7 @@ class PSA_Search {
* Register REST API routes for external/headless access.
* Note: REST route only returns existing published results — it does NOT trigger generation.
*/
- public static function register_rest_routes() {
+ public static function register_rest_routes(): void {
register_rest_route(
'peptides/v1',
'/search',
@@ -171,7 +177,7 @@ class PSA_Search {
* @param array $atts Shortcode attributes.
* @return string HTML output.
*/
- public static function render_search_form( $atts ) {
+ public static function render_search_form( $atts ): string {
self::$instance_count++;
$instance = self::$instance_count;
@@ -224,7 +230,7 @@ class PSA_Search {
*
* @return void Exits with wp_send_json_* responses.
*/
- public static function ajax_search() {
+ public static function ajax_search(): void {
check_ajax_referer( 'psa_search_nonce', 'nonce' );
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
@@ -276,7 +282,7 @@ class PSA_Search {
* @param WP_Post $pending The pending post.
* @return void Exits with wp_send_json_success().
*/
- private static function handle_pending_retry( $pending ) {
+ private static function handle_pending_retry( $pending ): void {
$started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true );
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
$timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT;
@@ -324,7 +330,7 @@ class PSA_Search {
*
* @return bool True if under limit, false if limit exceeded.
*/
- private static function check_rate_limit() {
+ private static function check_rate_limit(): bool {
$ip = psa_get_client_ip();
$rate_key = 'psa_rate_' . md5( $ip );
@@ -346,7 +352,7 @@ class PSA_Search {
* @param string $query The search query (peptide name).
* @return void Exits with wp_send_json_* responses.
*/
- private static function handle_new_generation( $query ) {
+ private static function handle_new_generation( string $query ): void {
// Global daily generation cap to prevent cost overruns.
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
$daily_count = (int) get_transient( $daily_key );
@@ -424,7 +430,7 @@ class PSA_Search {
* @param string $query Search term (peptide name or alias).
* @return WP_Post|null Pending post object or null if not found.
*/
- public static function find_pending_peptide( $query ) {
+ public static function find_pending_peptide( string $query ) {
global $wpdb;
$like = '%' . $wpdb->esc_like( $query ) . '%';
@@ -457,7 +463,7 @@ class PSA_Search {
* @param string $peptide_name The canonical peptide name.
* @return int|WP_Error Post ID or error.
*/
- public static function create_pending_placeholder( $peptide_name ) {
+ public static function create_pending_placeholder( string $peptide_name ) {
$post_id = wp_insert_post(
array(
'post_type' => 'peptide',
@@ -485,7 +491,7 @@ class PSA_Search {
* @param int $post_id The placeholder post ID.
* @param string $peptide_name The peptide to research.
*/
- public static function schedule_background_generation( $post_id, $peptide_name ) {
+ public static function schedule_background_generation( int $post_id, string $peptide_name ): void {
$args = array( $post_id, $peptide_name );
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args );
@@ -503,7 +509,7 @@ class PSA_Search {
* @param string $query Search term.
* @return array { results: array, total: int, query: string }
*/
- public static function search_peptides( $query ) {
+ public static function search_peptides( string $query ): array {
global $wpdb;
// Check transient cache first.
@@ -564,7 +570,7 @@ class PSA_Search {
* @param int $post_id The post ID.
* @return array
*/
- public static function format_peptide( $post_id ) {
+ public static function format_peptide( int $post_id ): array {
$post = get_post( $post_id );
if ( ! $post ) {
return array();
diff --git a/includes/class-psa-template.php b/includes/class-psa-template.php
index 5cc1506..38020c8 100755
--- a/includes/class-psa-template.php
+++ b/includes/class-psa-template.php
@@ -1,8 +1,15 @@
$post_id,
'source' => $source,
'pubchem_cid' => $pubchem_cid,
);
- // Allow filtering of template data before rendering.
$data = apply_filters( 'psa_template_data', $data, $post_id );
- // Restore values from possibly filtered data.
$post_id = $data['post_id'] ?? $post_id;
$source = $data['source'] ?? $source;
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
@@ -48,38 +52,117 @@ class PSA_Template {
$html = '';
// Quick facts box.
+ $html .= self::render_quick_facts( $post_id, $pubchem_cid );
+
+ // Extended research data box (half-life, stability, storage, etc.).
+ $html .= self::render_extended_data( $post_id );
+
+ // Detailed sections.
+ $html .= self::render_detail_sections( $post_id );
+
+ // Source badge.
+ $html .= self::render_source_badge( $source );
+
+ // Integration hook: allow Peptide Community and other plugins to append content.
+ ob_start();
+ do_action( 'psa_after_peptide_detail', $post_id );
+ $html .= ob_get_clean();
+
+ $html .= '
';
+
+ return $content . $html;
+ }
+
+ /**
+ * Render the quick facts table.
+ *
+ * @param int $post_id Post ID.
+ * @param string $pubchem_cid PubChem CID (may be empty).
+ * @return string HTML.
+ */
+ private static function render_quick_facts( int $post_id, string $pubchem_cid ): string {
$weight = get_post_meta( $post_id, 'psa_molecular_weight', true );
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
$seq = get_post_meta( $post_id, 'psa_sequence', true );
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
- if ( $weight || $formula || $seq ) {
- $html .= '';
- $html .= '
Quick Facts ';
- $html .= '
';
-
- if ( $aliases ) {
- $html .= 'Also Known As ' . esc_html( $aliases ) . ' ';
- }
- if ( $seq ) {
- $html .= 'Sequence ' . esc_html( $seq ) . ' ';
- }
- if ( $formula ) {
- $html .= 'Molecular Formula ' . esc_html( $formula ) . ' ';
- }
- if ( $weight ) {
- $html .= 'Molecular Weight ' . esc_html( $weight ) . ' ';
- }
- if ( $pubchem_cid ) {
- $cid_int = intval( $pubchem_cid );
- $html .= 'PubChem CID ' . $cid_int . ' ';
- }
-
- $html .= '
';
- $html .= '
';
+ if ( ! $weight && ! $formula && ! $seq ) {
+ return '';
}
- // Detailed sections.
+ $html = '';
+ $html .= '
Quick Facts ';
+ $html .= '
';
+
+ if ( $aliases ) {
+ $html .= 'Also Known As ' . esc_html( $aliases ) . ' ';
+ }
+ if ( $seq ) {
+ $html .= 'Sequence ' . esc_html( $seq ) . ' ';
+ }
+ if ( $formula ) {
+ $html .= 'Molecular Formula ' . esc_html( $formula ) . ' ';
+ }
+ if ( $weight ) {
+ $html .= 'Molecular Weight ' . esc_html( $weight ) . ' ';
+ }
+ if ( $pubchem_cid ) {
+ $cid_int = intval( $pubchem_cid );
+ $html .= 'PubChem CID ' . $cid_int . ' ';
+ }
+
+ $html .= '
';
+ return $html;
+ }
+
+ /**
+ * Render the extended research data table (v4.3.0 fields).
+ *
+ * @param int $post_id Post ID.
+ * @return string HTML (empty string if no extended data).
+ */
+ private static function render_extended_data( int $post_id ): string {
+ $fields = array(
+ 'psa_half_life' => 'Half-Life',
+ 'psa_stability' => 'Stability',
+ 'psa_solubility' => 'Solubility',
+ 'psa_vial_size_mg' => 'Vial Size',
+ 'psa_storage_lyophilized' => 'Storage (Lyophilized)',
+ 'psa_storage_reconstituted' => 'Storage (Reconstituted)',
+ 'psa_typical_dose_mcg' => 'Typical Research Dose',
+ 'psa_cycle_parameters' => 'Cycle Parameters',
+ 'psa_amino_acid_count' => 'Amino Acid Count',
+ );
+
+ $rows = '';
+ foreach ( $fields as $key => $label ) {
+ $value = get_post_meta( $post_id, $key, true );
+ if ( ! empty( $value ) ) {
+ $display = $value;
+ if ( 'psa_vial_size_mg' === $key ) {
+ $display = floatval( $value ) . ' mg';
+ }
+ $rows .= '' . esc_html( $label ) . ' ' . esc_html( $display ) . ' ';
+ }
+ }
+
+ if ( empty( $rows ) ) {
+ return '';
+ }
+
+ return '' .
+ '
Research Parameters ' .
+ '
' .
+ '
';
+ }
+
+ /**
+ * Render detailed text sections (mechanism, research apps, safety, etc.).
+ *
+ * @param int $post_id Post ID.
+ * @return string HTML.
+ */
+ private static function render_detail_sections( int $post_id ): string {
$detail_fields = array(
'psa_mechanism' => 'Mechanism of Action',
'psa_research_apps' => 'Research Applications',
@@ -88,6 +171,7 @@ class PSA_Template {
'psa_references' => 'References',
);
+ $html = '';
foreach ( $detail_fields as $key => $label ) {
$value = get_post_meta( $post_id, $key, true );
if ( ! empty( $value ) ) {
@@ -98,34 +182,43 @@ class PSA_Template {
}
}
- // Source badge.
- if ( $source && 'pending' !== $source ) {
- $badges = array(
- 'ai-generated' => array(
- 'label' => 'AI-Generated Content',
- 'class' => 'psa-badge-ai',
- ),
- 'pubchem' => array(
- 'label' => 'PubChem Verified',
- 'class' => 'psa-badge-verified',
- ),
- 'manual' => array(
- 'label' => 'Manually Curated',
- 'class' => 'psa-badge-manual',
- ),
- );
- $badge = $badges[ $source ] ?? $badges['ai-generated'];
+ return $html;
+ }
- $html .= '';
- $html .= esc_html( $badge['label'] );
- if ( 'ai-generated' === $source ) {
- $html .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
- }
- $html .= '
';
+ /**
+ * Render the source badge.
+ *
+ * @param string $source The data source value.
+ * @return string HTML (empty if source is empty or 'pending').
+ */
+ private static function render_source_badge( string $source ): string {
+ if ( ! $source || 'pending' === $source ) {
+ return '';
}
+ $badges = array(
+ 'ai-generated' => array(
+ 'label' => 'AI-Generated Content',
+ 'class' => 'psa-badge-ai',
+ ),
+ 'pubchem' => array(
+ 'label' => 'PubChem Verified',
+ 'class' => 'psa-badge-verified',
+ ),
+ 'manual' => array(
+ 'label' => 'Manually Curated',
+ 'class' => 'psa-badge-manual',
+ ),
+ );
+ $badge = $badges[ $source ] ?? $badges['ai-generated'];
+
+ $html = '';
+ $html .= esc_html( $badge['label'] );
+ if ( 'ai-generated' === $source ) {
+ $html .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
+ }
$html .= '
';
- return $content . $html;
+ return $html;
}
}
diff --git a/peptide-search-ai.php b/peptide-search-ai.php
index e82c266..b9a0ead 100755
--- a/peptide-search-ai.php
+++ b/peptide-search-ai.php
@@ -2,8 +2,8 @@
/**
* Plugin Name: Peptide Search AI
* Plugin URI: https://example.com/peptide-search-ai
- * Description: Searchable peptide database with AI-powered auto-population.
- * Version: 4.1.0
+ * Description: Searchable peptide database with AI-powered auto-population and browsable directory.
+ * Version: 4.3.0
* Author: Terence
* License: GPL v2 or later
* Text Domain: peptide-search-ai
@@ -15,28 +15,32 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-define( 'PSA_VERSION', '4.1.0' );
+define( 'PSA_VERSION', '4.3.0' );
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PSA_PLUGIN_FILE', __FILE__ );
require_once PSA_PLUGIN_DIR . 'includes/class-psa-config.php';
-require_once PSA_PLUGIN_DIR . 'includes/class-psa-error.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-encryption.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php';
+require_once PSA_PLUGIN_DIR . 'includes/class-psa-openrouter.php';
+require_once PSA_PLUGIN_DIR . 'includes/class-psa-kb-builder.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-ai-generator.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-pubchem.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-admin.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-template.php';
+require_once PSA_PLUGIN_DIR . 'includes/class-psa-directory.php';
function psa_init() {
PSA_Post_Type::register_peptide_post_type();
+ PSA_Post_Type::register_taxonomy();
PSA_Search::init();
PSA_AI_Generator::init();
PSA_Admin::init();
PSA_Template::init();
+ PSA_Directory::init();
}
add_action( 'init', 'psa_init' );
@@ -61,6 +65,7 @@ function psa_is_kb_page() {
* - KB page (page ID 73)
* - Any page/post containing the [peptide_search] shortcode
* - Single peptide pages
+ * - Any page/post containing the [peptide_directory] shortcode
*/
function psa_maybe_enqueue_assets() {
// Homepage: always enqueue (hero section + search overlay use the shortcode).
@@ -80,16 +85,26 @@ function psa_maybe_enqueue_assets() {
return;
}
- if ( ! has_shortcode( $post->post_content, 'peptide_search' ) && ! is_singular( 'peptide' ) ) {
+ $has_search = has_shortcode( $post->post_content, 'peptide_search' );
+ $has_directory = has_shortcode( $post->post_content, 'peptide_directory' );
+ $is_peptide = is_singular( 'peptide' );
+
+ if ( ! $has_search && ! $has_directory && ! $is_peptide ) {
return;
}
- psa_enqueue_frontend_assets();
+ if ( $has_search || $is_peptide ) {
+ psa_enqueue_frontend_assets();
+ }
+
+ if ( $has_directory ) {
+ psa_enqueue_directory_assets();
+ }
}
add_action( 'wp_enqueue_scripts', 'psa_maybe_enqueue_assets' );
/**
- * Shared helper to enqueue CSS, JS, and localize script.
+ * Shared helper to enqueue search CSS, JS, and localize script.
*/
function psa_enqueue_frontend_assets() {
wp_enqueue_style(
@@ -113,10 +128,58 @@ function psa_enqueue_frontend_assets() {
array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'psa_search_nonce' ),
+ 'i18n' => array(
+ 'error' => __( 'Error:', 'peptide-search-ai' ),
+ 'searchFailed' => __( 'Search failed.', 'peptide-search-ai' ),
+ 'unexpected' => __( 'Unexpected response.', 'peptide-search-ai' ),
+ 'rateLimited' => __( 'Too many requests. Please try again later.', 'peptide-search-ai' ),
+ 'networkError' => __( 'Could not connect to the server. Please try again.', 'peptide-search-ai' ),
+ 'timeout' => __( 'Request timed out. The server took too long to respond. Please try again.', 'peptide-search-ai' ),
+ 'connectionError' => __( 'Network error. Please check your connection and try again.', 'peptide-search-ai' ),
+ 'serverError' => __( 'Server error. Please try again later.', 'peptide-search-ai' ),
+ 'unavailable' => __( 'Server is temporarily unavailable. Please try again later.', 'peptide-search-ai' ),
+ 'peptidesFound' => __( 'peptide(s) found', 'peptide-search-ai' ),
+ 'pendingMsg' => __( 'is currently being added to our database. Please check back again later.', 'peptide-search-ai' ),
+ 'invalidMsg' => __( 'does not appear to be a recognized peptide. Please check the spelling or try a different search term.', 'peptide-search-ai' ),
+ 'verified' => __( 'Verified', 'peptide-search-ai' ),
+ 'curated' => __( 'Curated', 'peptide-search-ai' ),
+ ),
)
);
}
+/**
+ * Enqueue directory-specific CSS and JS for the [peptide_directory] shortcode.
+ * Includes the search CSS as a base dependency (spinner styles, badge styles).
+ */
+function psa_enqueue_directory_assets() {
+ // Directory depends on search CSS for shared styles (spinner, badges).
+ wp_enqueue_style(
+ 'psa-styles',
+ PSA_PLUGIN_URL . 'assets/css/peptide-search.css',
+ array(),
+ PSA_VERSION
+ );
+
+ wp_enqueue_style(
+ 'psa-directory-styles',
+ PSA_PLUGIN_URL . 'assets/css/psa-directory.css',
+ array( 'psa-styles' ),
+ PSA_VERSION
+ );
+
+ wp_enqueue_script(
+ 'psa-directory',
+ PSA_PLUGIN_URL . 'assets/js/psa-directory.js',
+ array(),
+ PSA_VERSION,
+ true
+ );
+
+ // psaDirectory localization is handled in PSA_Directory::render_shortcode()
+ // because it needs to run after get_terms() to populate category data.
+}
+
/**
* On the KB page, hide Echo KB search and inject ours via wp_footer.
*/
@@ -161,6 +224,8 @@ add_action( 'wp_footer', 'psa_replace_kb_search' );
function psa_activate() {
PSA_Post_Type::register_peptide_post_type();
+ PSA_Post_Type::register_taxonomy();
+ PSA_Post_Type::populate_default_categories();
PSA_Cost_Tracker::create_table();
flush_rewrite_rules();
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index f6aae07..34f8ad5 100755
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -70,6 +70,21 @@ if ( ! function_exists( 'wp_kses_post' ) ) {
}
}
+if ( ! function_exists( 'wp_parse_args' ) ) {
+ function wp_parse_args( $args, $defaults = array() ) {
+ if ( is_array( $args ) ) {
+ return array_merge( $defaults, $args );
+ }
+ return $defaults;
+ }
+}
+
+if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( $text ) {
+ return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+}
+
// Define WP_Error class if it doesn't exist.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
@@ -111,4 +126,7 @@ if ( ! class_exists( 'WP_Error' ) ) {
// Require the class files under test.
require_once ABSPATH . 'includes/class-psa-config.php';
+require_once ABSPATH . 'includes/class-psa-cost-tracker.php';
+require_once ABSPATH . 'includes/class-psa-openrouter.php';
+require_once ABSPATH . 'includes/class-psa-kb-builder.php';
require_once ABSPATH . 'includes/class-psa-ai-generator.php';
diff --git a/tests/unit/AIGeneratorTest.php b/tests/unit/AIGeneratorTest.php
index 0c9a6a2..207b73b 100755
--- a/tests/unit/AIGeneratorTest.php
+++ b/tests/unit/AIGeneratorTest.php
@@ -97,16 +97,13 @@ class AIGeneratorTest extends TestCase {
}
/**
- * Test parse_ai_response with valid JSON string.
+ * Test parse_response with valid JSON string.
+ * Note: parse_ai_response was extracted to PSA_OpenRouter::parse_response() in v4.2.0.
*/
- public function test_parse_ai_response_valid_json() {
+ public function test_parse_response_valid_json() {
$response_text = '{"is_valid": true, "canonical_name": "BPC-157", "reason": "A known peptide"}';
- $reflection = new ReflectionClass( 'PSA_AI_Generator' );
- $method = $reflection->getMethod( 'parse_ai_response' );
- $method->setAccessible( true );
-
- $result = $method->invoke( null, $response_text );
+ $result = PSA_OpenRouter::parse_response( $response_text );
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'is_valid', $result );
@@ -115,20 +112,16 @@ class AIGeneratorTest extends TestCase {
}
/**
- * Test parse_ai_response with JSON wrapped in markdown fences.
+ * Test parse_response with JSON wrapped in markdown fences.
*/
- public function test_parse_ai_response_with_markdown_fences() {
+ public function test_parse_response_with_markdown_fences() {
$response_text = <<getMethod( 'parse_ai_response' );
- $method->setAccessible( true );
-
- $result = $method->invoke( null, $response_text );
+ $result = PSA_OpenRouter::parse_response( $response_text );
$this->assertIsArray( $result );
$this->assertTrue( $result['is_valid'] );
@@ -136,9 +129,9 @@ JSON;
}
/**
- * Test parse_ai_response with tags (DeepSeek model).
+ * Test parse_response with tags (DeepSeek model).
*/
- public function test_parse_ai_response_with_think_tags() {
+ public function test_parse_response_with_think_tags() {
$response_text = <<
This is a known peptide from various sources.
@@ -146,11 +139,7 @@ This is a known peptide from various sources.
{"is_valid": true, "canonical_name": "LL-37", "reason": "Antimicrobial peptide"}
JSON;
- $reflection = new ReflectionClass( 'PSA_AI_Generator' );
- $method = $reflection->getMethod( 'parse_ai_response' );
- $method->setAccessible( true );
-
- $result = $method->invoke( null, $response_text );
+ $result = PSA_OpenRouter::parse_response( $response_text );
$this->assertIsArray( $result );
$this->assertTrue( $result['is_valid'] );
@@ -158,32 +147,24 @@ JSON;
}
/**
- * Test parse_ai_response with invalid JSON returns WP_Error.
+ * Test parse_response with invalid JSON returns WP_Error.
*/
- public function test_parse_ai_response_invalid_json() {
+ public function test_parse_response_invalid_json() {
$response_text = '{not valid json}';
- $reflection = new ReflectionClass( 'PSA_AI_Generator' );
- $method = $reflection->getMethod( 'parse_ai_response' );
- $method->setAccessible( true );
-
- $result = $method->invoke( null, $response_text );
+ $result = PSA_OpenRouter::parse_response( $response_text );
$this->assertTrue( is_wp_error( $result ) );
$this->assertEquals( 'parse_error', $result->get_error_code() );
}
/**
- * Test parse_ai_response with empty string returns WP_Error.
+ * Test parse_response with empty string returns WP_Error.
*/
- public function test_parse_ai_response_empty_string() {
+ public function test_parse_response_empty_string() {
$response_text = '';
- $reflection = new ReflectionClass( 'PSA_AI_Generator' );
- $method = $reflection->getMethod( 'parse_ai_response' );
- $method->setAccessible( true );
-
- $result = $method->invoke( null, $response_text );
+ $result = PSA_OpenRouter::parse_response( $response_text );
$this->assertTrue( is_wp_error( $result ) );
$this->assertEquals( 'parse_error', $result->get_error_code() );
diff --git a/tests/unit/DirectoryTest.php b/tests/unit/DirectoryTest.php
new file mode 100755
index 0000000..26b155d
--- /dev/null
+++ b/tests/unit/DirectoryTest.php
@@ -0,0 +1,136 @@
+ '4-6 hours',
+ 'psa_stability' => '28 days at 2-8°C',
+ 'psa_source' => 'ai-generated',
+ 'psa_sequence' => 'Gly-Glu-Pro',
+ 'psa_molecular_weight' => '1419.53 Da',
+ 'psa_molecular_formula' => 'C62H98N16O22',
+ 'psa_aliases' => 'BPC-157, Pentadecapeptide',
+ 'psa_mechanism' => 'Test mechanism',
+ 'psa_research_apps' => 'Test research',
+ 'psa_safety_profile' => 'Test safety',
+ 'psa_dosage_info' => 'Test dosage',
+ 'psa_references' => 'Test refs',
+ 'psa_pubchem_cid' => '12345',
+ 'psa_solubility' => 'Bacteriostatic Water',
+ 'psa_vial_size_mg' => '5',
+ 'psa_storage_lyophilized' => '-20°C',
+ 'psa_storage_reconstituted' => '2-8°C',
+ 'psa_typical_dose_mcg' => '200-300 mcg',
+ 'psa_cycle_parameters' => '4-6 weeks',
+ 'psa_amino_acid_count' => '15',
+ );
+ return $meta[ $key ] ?? '';
+ }
+}
+if ( ! function_exists( 'wp_trim_words' ) ) {
+ function wp_trim_words( $text, $num_words = 55 ) {
+ $words = explode( ' ', $text );
+ return implode( ' ', array_slice( $words, 0, $num_words ) );
+ }
+}
+if ( ! function_exists( '__' ) ) {
+ function __( $text, $domain = 'default' ) {
+ return $text;
+ }
+}
+
+require_once dirname( __DIR__ ) . '/bootstrap.php';
+require_once ABSPATH . 'includes/class-psa-post-type.php';
+require_once ABSPATH . 'includes/class-psa-directory.php';
+
+class DirectoryTest extends \PHPUnit\Framework\TestCase {
+
+ /**
+ * Verify directory pagination constants.
+ */
+ public function test_per_page_constants(): void {
+ $this->assertSame( 12, PSA_Directory::PER_PAGE_DEFAULT );
+ $this->assertSame( 100, PSA_Directory::PER_PAGE_MAX );
+ }
+
+ /**
+ * Verify format_compound() returns expected keys for full field set.
+ */
+ public function test_format_compound_full_fields(): void {
+ $mock_post = new \stdClass();
+ $mock_post->ID = 42;
+ $mock_post->post_title = 'BPC-157';
+ $mock_post->post_name = 'bpc-157';
+ $mock_post->post_content = 'A synthetic peptide for research purposes.';
+
+ $result = PSA_Directory::format_compound( $mock_post, 'full' );
+
+ // Check required top-level keys.
+ $required_keys = array(
+ 'id', 'name', 'slug', 'url', 'categories', 'half_life', 'stability',
+ 'source', 'extras', 'excerpt', 'description', 'sequence',
+ 'molecular_weight', 'molecular_formula', 'aliases', 'mechanism',
+ 'pubchem_cid', 'solubility', 'vial_size_mg', 'storage_lyophilized',
+ 'storage_reconstituted', 'typical_dose_mcg', 'cycle_parameters',
+ 'amino_acid_count',
+ );
+
+ foreach ( $required_keys as $key ) {
+ $this->assertArrayHasKey( $key, $result, "Missing key in full compound: {$key}" );
+ }
+
+ $this->assertSame( 42, $result['id'] );
+ $this->assertSame( 'BPC-157', $result['name'] );
+ $this->assertSame( 'bpc-157', $result['slug'] );
+ }
+
+ /**
+ * Verify format_compound() with 'basic' fields returns lightweight data.
+ */
+ public function test_format_compound_basic_fields(): void {
+ $mock_post = new \stdClass();
+ $mock_post->ID = 42;
+ $mock_post->post_title = 'BPC-157';
+ $mock_post->post_name = 'bpc-157';
+ $mock_post->post_content = 'A synthetic peptide.';
+
+ $result = PSA_Directory::format_compound( $mock_post, 'basic' );
+
+ // Basic should NOT include detail fields.
+ $this->assertArrayHasKey( 'id', $result );
+ $this->assertArrayHasKey( 'name', $result );
+ $this->assertArrayHasKey( 'vial_size_mg', $result );
+ $this->assertArrayNotHasKey( 'sequence', $result );
+ $this->assertArrayNotHasKey( 'mechanism', $result );
+ $this->assertArrayNotHasKey( 'description', $result );
+ }
+}
diff --git a/tests/unit/PostTypeTest.php b/tests/unit/PostTypeTest.php
new file mode 100755
index 0000000..52c8ddb
--- /dev/null
+++ b/tests/unit/PostTypeTest.php
@@ -0,0 +1,144 @@
+assertCount( 8, PSA_Post_Type::DEFAULT_CATEGORIES );
+ }
+
+ /**
+ * Verify required category slugs exist in the constant.
+ */
+ public function test_default_categories_slugs(): void {
+ $required = array(
+ 'tissue-repair',
+ 'lipid-metabolism',
+ 'aging-research',
+ 'dermatological',
+ 'metabolic',
+ 'growth-hormone',
+ 'immunology',
+ 'endocrine',
+ );
+
+ foreach ( $required as $slug ) {
+ $this->assertArrayHasKey( $slug, PSA_Post_Type::DEFAULT_CATEGORIES, "Missing category slug: {$slug}" );
+ }
+ }
+
+ /**
+ * Verify core META_FIELDS constant has expected keys.
+ */
+ public function test_core_meta_fields_keys(): void {
+ $expected = array(
+ 'psa_sequence',
+ 'psa_molecular_weight',
+ 'psa_molecular_formula',
+ 'psa_aliases',
+ 'psa_mechanism',
+ 'psa_research_apps',
+ 'psa_safety_profile',
+ 'psa_dosage_info',
+ 'psa_references',
+ 'psa_source',
+ 'psa_pubchem_cid',
+ );
+
+ foreach ( $expected as $key ) {
+ $this->assertArrayHasKey( $key, PSA_Post_Type::META_FIELDS, "Missing core meta field: {$key}" );
+ }
+ }
+
+ /**
+ * Verify extended meta fields contain the 9 new v4.3.0 fields.
+ */
+ public function test_extended_meta_fields_count(): void {
+ $this->assertCount( 9, PSA_Post_Type::EXTENDED_META_FIELDS );
+ }
+
+ /**
+ * Verify specific extended field keys.
+ */
+ public function test_extended_meta_field_keys(): void {
+ $expected = array(
+ 'psa_half_life',
+ 'psa_stability',
+ 'psa_solubility',
+ 'psa_vial_size_mg',
+ 'psa_storage_lyophilized',
+ 'psa_storage_reconstituted',
+ 'psa_typical_dose_mcg',
+ 'psa_cycle_parameters',
+ 'psa_amino_acid_count',
+ );
+
+ foreach ( $expected as $key ) {
+ $this->assertArrayHasKey( $key, PSA_Post_Type::EXTENDED_META_FIELDS, "Missing extended meta field: {$key}" );
+ }
+ }
+
+ /**
+ * Verify get_all_meta_keys() returns combined core + extended keys.
+ */
+ public function test_get_all_meta_keys_merges_both(): void {
+ $all_keys = PSA_Post_Type::get_all_meta_keys();
+
+ // Should include both core and extended.
+ $this->assertContains( 'psa_sequence', $all_keys );
+ $this->assertContains( 'psa_half_life', $all_keys );
+ $this->assertContains( 'psa_amino_acid_count', $all_keys );
+
+ // Total should be core + extended.
+ $expected_count = count( PSA_Post_Type::META_FIELDS ) + count( PSA_Post_Type::EXTENDED_META_FIELDS );
+ $this->assertCount( $expected_count, $all_keys );
+ }
+}
diff --git a/uninstall.php b/uninstall.php
index 877989b..6381564 100755
--- a/uninstall.php
+++ b/uninstall.php
@@ -25,18 +25,35 @@ if ( ! empty( $peptide_ids ) ) {
}
}
-// 2. Delete plugin options.
-delete_option( 'psa_settings' );
+// 2. Delete peptide_category taxonomy terms and relationships.
+$terms = get_terms(
+ array(
+ 'taxonomy' => 'peptide_category',
+ 'hide_empty' => false,
+ 'fields' => 'ids',
+ )
+);
+if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
+ foreach ( $terms as $term_id ) {
+ wp_delete_term( (int) $term_id, 'peptide_category' );
+ }
+}
-// 3. Delete all plugin transients (validation cache, rate limits).
+// 3. Delete plugin options.
+delete_option( 'psa_settings' );
+delete_option( 'psa_search_cache_gen' );
+
+// 4. Delete all plugin transients (validation cache, rate limits).
$wpdb->query(
- "DELETE FROM {$wpdb->options}
- WHERE option_name LIKE '_transient_psa_%'
- OR option_name LIKE '_transient_timeout_psa_%'"
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $wpdb->esc_like( '_transient_psa_' ) . '%',
+ $wpdb->esc_like( '_transient_timeout_psa_' ) . '%'
+ )
);
-// 4. Drop API logs table.
+// 5. Drop API logs table.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}psa_api_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
-// 5. Flush rewrite rules to clean up the peptide post type permalinks.
+// 6. Flush rewrite rules to clean up the peptide post type permalinks.
flush_rewrite_rules();