feat: add browsable peptide directory with category filtering, REST API, and extended meta fields (v4.3.0)
Adds [peptide_directory] shortcode with card grid, category taxonomy (8 terms), search, pagination, detail modal, and /v1/compounds REST endpoint. Includes 9 new meta fields (half-life, stability, solubility, etc.), admin data tools for migration/re-enrichment, integration hooks for cross-plugin extensibility, dark mode support, and full uninstall cleanup. Refactors AI generator into PSA_OpenRouter + PSA_KB_Builder classes. Adds PHPUnit tests for PostType and Directory classes. Fixes QA findings: dead JS selector, renderError loading state, numeric meta validation, prepared SQL in uninstall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -49,6 +49,28 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
composer run lint
|
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 ──────────────────────────────────────────────
|
# ── JavaScript Lint ──────────────────────────────────────────────
|
||||||
lint-js:
|
lint-js:
|
||||||
name: JavaScript Linting
|
name: JavaScript Linting
|
||||||
|
|||||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -244,6 +244,26 @@ jobs:
|
|||||||
|
|
||||||
echo "✓ Deployment verification successful"
|
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
|
- name: Deploy summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,6 +10,11 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment & credentials
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.credentials
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|||||||
@@ -17,19 +17,25 @@ peptide-search-ai/
|
|||||||
│
|
│
|
||||||
├── includes/
|
├── includes/
|
||||||
│ ├── class-psa-config.php Configuration constants (rate limits, timeouts, tokens, TTL)
|
│ ├── class-psa-config.php Configuration constants (rate limits, timeouts, tokens, TTL)
|
||||||
│ ├── class-psa-error.php [Deprecated] Standardized error response structure
|
│ ├── class-psa-encryption.php AES-256-CBC encryption for API keys at rest
|
||||||
│ ├── class-psa-post-type.php Custom Post Type (CPT) registration; meta field definitions
|
│ ├── 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-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-pubchem.php PubChem PUG REST API integration for molecular enrichment
|
||||||
│ ├── class-psa-admin.php Settings page; API key, model, auto-publish configuration
|
│ ├── class-psa-admin.php Settings page, usage dashboard, migration tools, re-enrichment actions
|
||||||
│ └── class-psa-template.php Single peptide page rendering; quick-facts tables, badges
|
│ └── class-psa-template.php Single peptide page rendering; quick-facts, extended data, badges
|
||||||
│
|
│
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── js/
|
│ ├── 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/
|
│ └── 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/
|
└── .github/workflows/
|
||||||
├── ci.yml PHP lint, PHPCS, JS syntax checks on PR/push
|
├── ci.yml PHP lint, PHPCS, JS syntax checks on PR/push
|
||||||
@@ -94,6 +100,23 @@ graph LR
|
|||||||
style D fill:#ffcdd2
|
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<br/>(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
|
### Background Generation Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -140,10 +163,16 @@ graph TD
|
|||||||
- Max tokens: 4096
|
- Max tokens: 4096
|
||||||
- Called via WP-Cron (non-blocking)
|
- 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`
|
- Endpoint: `https://openrouter.ai/api/v1/chat/completions`
|
||||||
- Requires API key: database `psa_settings['api_key']` or constant `PSA_OPENROUTER_KEY`
|
- Requires API key: database `psa_settings['api_key']` or constant `PSA_OPENROUTER_KEY`
|
||||||
- Retry logic: 3 retries with exponential backoff (5s base delay)
|
- 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
|
**Configuration:** `Settings > Peptide Search AI` in WordPress admin
|
||||||
- API Key field (or via `PSA_OPENROUTER_KEY` in wp-config.php for security)
|
- 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_attempts` | Retry counter | Integer | System (temporary) |
|
||||||
| `psa_generation_error` | Error message (if generation failed) | Text | System (temporary) |
|
| `psa_generation_error` | Error message (if generation failed) | Text | System (temporary) |
|
||||||
| `psa_generation_completed` | Generation completion timestamp | Unix timestamp | System |
|
| `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)
|
### Options (Plugin Settings)
|
||||||
|
|
||||||
@@ -349,10 +403,15 @@ $psa_settings = array(
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
**CI Pipeline:** `.github/workflows/ci.yml`
|
**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
|
- WordPress Coding Standards (PHPCS) validation
|
||||||
- JavaScript syntax checks (Node.js)
|
- 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:**
|
**Manual Testing Checklist:**
|
||||||
- Search for existing peptide → results appear
|
- Search for existing peptide → results appear
|
||||||
- Search for new peptide (valid) → "pending" message → check back → content generated
|
- Search for new peptide (valid) → "pending" message → check back → content generated
|
||||||
|
|||||||
123
CHANGELOG.md
Executable file
123
CHANGELOG.md
Executable file
@@ -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).
|
||||||
@@ -312,11 +312,14 @@ psa_get_admin_user_id() /* Get first admin user ID */
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
PSA_Config /* Configuration constants */
|
PSA_Config /* Configuration constants */
|
||||||
PSA_Error /* Error handling [deprecated] */
|
|
||||||
PSA_Post_Type /* CPT registration & meta */
|
PSA_Post_Type /* CPT registration & meta */
|
||||||
PSA_Search /* Search, AJAX, REST API, shortcode */
|
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_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_Admin /* Settings pages & fields */
|
||||||
PSA_Template /* Single page rendering */
|
PSA_Template /* Single page rendering */
|
||||||
```
|
```
|
||||||
|
|||||||
621
assets/css/psa-directory.css
Executable file
621
assets/css/psa-directory.css
Executable file
@@ -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;
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
hideAll();
|
hideAll();
|
||||||
$error.html('<strong>Error:</strong> ' + esc(msg)).show();
|
$error.html('<strong>' + esc(psaAjax.i18n.error) + '</strong> ' + esc(msg)).show();
|
||||||
$error.attr('role', 'alert');
|
$error.attr('role', 'alert');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +58,12 @@
|
|||||||
|
|
||||||
function sourceBadge(source) {
|
function sourceBadge(source) {
|
||||||
var map = {
|
var map = {
|
||||||
'pubchem': { label: 'Verified', cls: 'psa-badge-verified' },
|
'pubchem': { label: psaAjax.i18n.verified, cls: 'psa-badge-verified' },
|
||||||
'manual': { label: 'Curated', cls: 'psa-badge-manual' }
|
'manual': { label: psaAjax.i18n.curated, cls: 'psa-badge-manual' }
|
||||||
};
|
};
|
||||||
var b = map[source];
|
var b = map[source];
|
||||||
if (!b) return '';
|
if (!b) return '';
|
||||||
return '<span class="psa-result-badge ' + b.cls + '">' + b.label + '</span>';
|
return '<span class="psa-result-badge ' + b.cls + '">' + esc(b.label) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,8 +72,7 @@
|
|||||||
function renderResults(data) {
|
function renderResults(data) {
|
||||||
hideAll();
|
hideAll();
|
||||||
|
|
||||||
var html = '<p class="psa-result-count">' + data.total + ' peptide' +
|
var html = '<p class="psa-result-count">' + data.total + ' ' + esc(psaAjax.i18n.peptidesFound) + '</p>';
|
||||||
(data.total !== 1 ? 's' : '') + ' found</p>';
|
|
||||||
|
|
||||||
data.results.forEach(function (item) {
|
data.results.forEach(function (item) {
|
||||||
html += '<a href="' + esc(item.url) + '" class="psa-result-item">';
|
html += '<a href="' + esc(item.url) + '" class="psa-result-item">';
|
||||||
@@ -100,7 +99,7 @@
|
|||||||
function showPending(name) {
|
function showPending(name) {
|
||||||
hideAll();
|
hideAll();
|
||||||
$pending.find('.psa-pending-inner p').html(
|
$pending.find('.psa-pending-inner p').html(
|
||||||
'<strong>' + esc(name) + '</strong> is currently being added to our database. Please check back again later.'
|
'<strong>' + esc(name) + '</strong> ' + esc(psaAjax.i18n.pendingMsg)
|
||||||
);
|
);
|
||||||
$pending.show();
|
$pending.show();
|
||||||
}
|
}
|
||||||
@@ -111,7 +110,7 @@
|
|||||||
function showInvalid(name, reason) {
|
function showInvalid(name, reason) {
|
||||||
hideAll();
|
hideAll();
|
||||||
$invalid.find('.psa-invalid-text').html(
|
$invalid.find('.psa-invalid-text').html(
|
||||||
'<strong>' + esc(name) + '</strong> does not appear to be a recognized peptide. Please check the spelling or try a different search term.'
|
'<strong>' + esc(name) + '</strong> ' + esc(psaAjax.i18n.invalidMsg)
|
||||||
);
|
);
|
||||||
$invalid.show();
|
$invalid.show();
|
||||||
}
|
}
|
||||||
@@ -144,7 +143,7 @@
|
|||||||
success: function (response) {
|
success: function (response) {
|
||||||
$results.attr('aria-busy', 'false');
|
$results.attr('aria-busy', 'false');
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
showError(response.data || 'Search failed.');
|
showError(response.data || psaAjax.i18n.searchFailed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var d = response.data;
|
var d = response.data;
|
||||||
@@ -159,24 +158,24 @@
|
|||||||
showInvalid(query, d.message);
|
showInvalid(query, d.message);
|
||||||
break;
|
break;
|
||||||
case 'rate_limited':
|
case 'rate_limited':
|
||||||
showError(d.message || 'Too many requests. Please try again later.');
|
showError(d.message || psaAjax.i18n.rateLimited);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
showError('Unexpected response.');
|
showError(psaAjax.i18n.unexpected);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function (jqXHR, textStatus, errorThrown) {
|
error: function (jqXHR, textStatus) {
|
||||||
$results.attr('aria-busy', 'false');
|
$results.attr('aria-busy', 'false');
|
||||||
var errorMsg = 'Could not connect to the server. Please try again.';
|
var errorMsg = psaAjax.i18n.networkError;
|
||||||
if (textStatus === 'timeout') {
|
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') {
|
} else if (textStatus === 'error') {
|
||||||
if (jqXHR.status === 0) {
|
if (jqXHR.status === 0) {
|
||||||
errorMsg = 'Network error. Please check your connection and try again.';
|
errorMsg = psaAjax.i18n.connectionError;
|
||||||
} else if (jqXHR.status === 500) {
|
} else if (jqXHR.status === 500) {
|
||||||
errorMsg = 'Server error. Please try again later.';
|
errorMsg = psaAjax.i18n.serverError;
|
||||||
} else if (jqXHR.status === 503) {
|
} else if (jqXHR.status === 503) {
|
||||||
errorMsg = 'Server is temporarily unavailable. Please try again later.';
|
errorMsg = psaAjax.i18n.unavailable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showError(errorMsg);
|
showError(errorMsg);
|
||||||
|
|||||||
501
assets/js/psa-directory.js
Executable file
501
assets/js/psa-directory.js
Executable file
@@ -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 =
|
||||||
|
'<div class="psa-dir-header">' +
|
||||||
|
'<h2 class="psa-dir-title">' + esc(i18n.title) + '</h2>' +
|
||||||
|
'<p class="psa-dir-subtitle">' + esc(i18n.subtitle) + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-search-wrap">' +
|
||||||
|
'<input type="text" class="psa-dir-search" placeholder="' + esc(i18n.searchPlaceholder) + '" aria-label="' + esc(i18n.searchPlaceholder) + '" />' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-tabs" role="tablist">' +
|
||||||
|
'<button class="psa-dir-tab psa-dir-tab--active" data-tab="single" role="tab" aria-selected="true">' + esc(i18n.singleReagents) + '</button>' +
|
||||||
|
'<button class="psa-dir-tab" data-tab="protocols" role="tab" aria-selected="false">' + esc(i18n.protocolModels) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-filters" role="group" aria-label="Category filter"></div>' +
|
||||||
|
'<div class="psa-dir-content">' +
|
||||||
|
'<div class="psa-dir-grid" role="list"></div>' +
|
||||||
|
'<div class="psa-dir-coming-soon" style="display:none;">' +
|
||||||
|
'<p>' + esc(i18n.comingSoon) + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-empty" style="display:none;">' +
|
||||||
|
'<p>' + esc(i18n.noResults) + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-loader" style="display:none;">' +
|
||||||
|
'<div class="psa-spinner"></div>' +
|
||||||
|
'<p>' + esc(i18n.loading) + '</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-load-more-wrap" style="display:none;">' +
|
||||||
|
'<button class="psa-dir-load-more">' + esc(i18n.loadMore) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="psa-dir-modal" role="dialog" aria-modal="true" aria-label="Peptide details" style="display:none;">' +
|
||||||
|
'<div class="psa-dir-modal__backdrop"></div>' +
|
||||||
|
'<div class="psa-dir-modal__content" tabindex="-1"></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
renderFilters();
|
||||||
|
bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Render — Filters
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderFilters() {
|
||||||
|
var wrap = root.querySelector('.psa-dir-filters');
|
||||||
|
var html = '<button class="psa-dir-filter psa-dir-filter--active" data-category="">' + esc(i18n.showAll) + '</button>';
|
||||||
|
config.categories.forEach(function (cat) {
|
||||||
|
html += '<button class="psa-dir-filter" data-category="' + esc(cat.slug) + '">' + esc(cat.name) + '</button>';
|
||||||
|
});
|
||||||
|
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 = '<span class="psa-dir-card__badge psa-dir-badge--' + esc(c.categories[0].slug) + '">' + esc(c.categories[0].name) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var metaHtml = '';
|
||||||
|
if (c.half_life) {
|
||||||
|
metaHtml += '<div class="psa-dir-card__meta-item"><span class="psa-dir-card__meta-label">' + esc(i18n.halfLife) + '</span><span class="psa-dir-card__meta-value">' + esc(c.half_life) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (c.stability) {
|
||||||
|
metaHtml += '<div class="psa-dir-card__meta-item"><span class="psa-dir-card__meta-label">' + esc(i18n.stability) + '</span><span class="psa-dir-card__meta-value">' + esc(c.stability) + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var extrasHtml = c.extras ? c.extras : '';
|
||||||
|
|
||||||
|
html += '<div class="psa-dir-card" role="listitem" data-id="' + c.id + '">' +
|
||||||
|
'<div class="psa-dir-card__header">' +
|
||||||
|
'<h3 class="psa-dir-card__title">' + esc(c.name) + '</h3>' +
|
||||||
|
catBadge +
|
||||||
|
'</div>' +
|
||||||
|
(metaHtml ? '<div class="psa-dir-card__meta">' + metaHtml + '</div>' : '') +
|
||||||
|
extrasHtml +
|
||||||
|
'<button class="psa-dir-card__btn" data-id="' + c.id + '">' + esc(i18n.viewDetails) + '</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = '<p class="psa-dir-error">Failed to load directory. Please try again.</p>';
|
||||||
|
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 += '<span class="psa-dir-card__badge psa-dir-badge--' + esc(cat.slug) + '">' + esc(cat.name) + '</span> ';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += '<div class="psa-dir-modal__data-item">' +
|
||||||
|
'<span class="psa-dir-modal__data-label">' + esc(f.label) + '</span>' +
|
||||||
|
'<span class="psa-dir-modal__data-value">' + esc(f.value) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var researchHtml = '';
|
||||||
|
if (compound.typical_dose_mcg || compound.cycle_parameters) {
|
||||||
|
researchHtml = '<div class="psa-dir-modal__section">' +
|
||||||
|
'<h4>' + esc(i18n.researchParams) + '</h4>';
|
||||||
|
if (compound.typical_dose_mcg) {
|
||||||
|
researchHtml += '<p><strong>' + esc(i18n.typicalDose) + ':</strong> ' + esc(compound.typical_dose_mcg) + '</p>';
|
||||||
|
}
|
||||||
|
if (compound.cycle_parameters) {
|
||||||
|
researchHtml += '<p><strong>' + esc(i18n.cycleParams) + ':</strong> ' + esc(compound.cycle_parameters) + '</p>';
|
||||||
|
}
|
||||||
|
researchHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var pubchemHtml = '';
|
||||||
|
if (compound.pubchem_cid) {
|
||||||
|
pubchemHtml = '<a class="psa-dir-modal__pubchem" href="https://pubchem.ncbi.nlm.nih.gov/compound/' + parseInt(compound.pubchem_cid, 10) + '" target="_blank" rel="noopener">' + esc(i18n.pubchemLink) + '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var sequenceHtml = '';
|
||||||
|
if (compound.sequence) {
|
||||||
|
sequenceHtml = '<div class="psa-dir-modal__section">' +
|
||||||
|
'<h4>' + esc(i18n.sequence) + '</h4>' +
|
||||||
|
'<div class="psa-dir-modal__sequence-wrap">' +
|
||||||
|
'<code class="psa-dir-modal__sequence">' + esc(compound.sequence) + '</code>' +
|
||||||
|
'<button class="psa-dir-modal__copy" data-seq="' + esc(compound.sequence) + '">' + esc(i18n.copySequence) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var mechanismHtml = '';
|
||||||
|
if (compound.mechanism) {
|
||||||
|
mechanismHtml = '<div class="psa-dir-modal__section">' +
|
||||||
|
'<h4>Mechanism of Action</h4>' +
|
||||||
|
'<p>' + esc(compound.mechanism).replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>' + // Safe: esc() HTML-encodes all user content before structural tags are inserted.
|
||||||
|
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML =
|
||||||
|
'<button class="psa-dir-modal__close" aria-label="' + esc(i18n.close) + '">×</button>' +
|
||||||
|
'<h2 class="psa-dir-modal__title">' + esc(compound.name) + '</h2>' +
|
||||||
|
'<div class="psa-dir-modal__badges">' + catBadges + '</div>' +
|
||||||
|
(compound.description ? '<p class="psa-dir-modal__desc">' + esc(compound.description) + '</p>' : '') +
|
||||||
|
sequenceHtml +
|
||||||
|
(dataGrid ? '<div class="psa-dir-modal__data-grid">' + dataGrid + '</div>' : '') +
|
||||||
|
researchHtml +
|
||||||
|
mechanismHtml +
|
||||||
|
pubchemHtml +
|
||||||
|
'<a class="psa-dir-modal__full-link" href="' + esc(compound.url) + '">' + esc(i18n.viewFullPage) + '</a>';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Admin settings page for Peptide Search AI.
|
* Admin settings page for Peptide Search AI.
|
||||||
* Configured for OpenRouter API access.
|
*
|
||||||
|
* What: Registers settings page, fields, sanitization, usage dashboard, migration tools.
|
||||||
|
* Who calls it: WordPress admin_menu and admin_init hooks via PSA_Admin::init().
|
||||||
|
* Dependencies: PSA_Encryption (encrypt API keys), PSA_Cost_Tracker (usage display), PSA_Config, PSA_Post_Type.
|
||||||
*
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-encryption.php
|
||||||
|
* @see includes/class-psa-cost-tracker.php
|
||||||
|
* @see includes/class-psa-post-type.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -12,15 +19,16 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
|
|
||||||
class PSA_Admin {
|
class PSA_Admin {
|
||||||
|
|
||||||
public static function init() {
|
public static function init(): void {
|
||||||
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
|
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
|
||||||
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
|
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
|
||||||
|
add_action( 'admin_init', array( __CLASS__, 'handle_admin_actions' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add settings page under Settings menu.
|
* Add settings page under Settings menu.
|
||||||
*/
|
*/
|
||||||
public static function add_menu_pages() {
|
public static function add_menu_pages(): void {
|
||||||
add_options_page(
|
add_options_page(
|
||||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||||
@@ -33,7 +41,7 @@ class PSA_Admin {
|
|||||||
/**
|
/**
|
||||||
* Register settings fields.
|
* Register settings fields.
|
||||||
*/
|
*/
|
||||||
public static function register_settings() {
|
public static function register_settings(): void {
|
||||||
register_setting(
|
register_setting(
|
||||||
'psa_settings_group',
|
'psa_settings_group',
|
||||||
'psa_settings',
|
'psa_settings',
|
||||||
@@ -43,62 +51,16 @@ class PSA_Admin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// OpenRouter Section.
|
// OpenRouter Section.
|
||||||
add_settings_section(
|
add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( __CLASS__, 'render_section_description' ), 'peptide-search-ai' );
|
||||||
'psa_ai_section',
|
add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( __CLASS__, 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' );
|
||||||
__( 'OpenRouter API Settings', 'peptide-search-ai' ),
|
add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
|
||||||
array( __CLASS__, 'render_section_description' ),
|
add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( __CLASS__, 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_section' );
|
||||||
'peptide-search-ai'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'api_key',
|
|
||||||
__( 'API Key', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_api_key_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_ai_section'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'ai_model',
|
|
||||||
__( 'Generation Model', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_model_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_ai_section'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'validation_model',
|
|
||||||
__( 'Validation Model', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_validation_model_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_ai_section'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Behavior Section.
|
// Behavior Section.
|
||||||
add_settings_section(
|
add_settings_section( 'psa_behavior_section', __( 'Behavior Settings', 'peptide-search-ai' ), null, 'peptide-search-ai' );
|
||||||
'psa_behavior_section',
|
add_settings_field( 'auto_publish', __( 'Auto-Publish', 'peptide-search-ai' ), array( __CLASS__, 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' );
|
||||||
__( 'Behavior Settings', 'peptide-search-ai' ),
|
add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( __CLASS__, 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' );
|
||||||
null,
|
add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( __CLASS__, 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' );
|
||||||
'peptide-search-ai'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'auto_publish',
|
|
||||||
__( 'Auto-Publish', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_publish_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_behavior_section'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'use_pubchem',
|
|
||||||
__( 'PubChem Enrichment', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_pubchem_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_behavior_section'
|
|
||||||
);
|
|
||||||
add_settings_field(
|
|
||||||
'monthly_budget',
|
|
||||||
__( 'Monthly API Budget (USD)', 'peptide-search-ai' ),
|
|
||||||
array( __CLASS__, 'render_budget_field' ),
|
|
||||||
'peptide-search-ai',
|
|
||||||
'psa_behavior_section'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,18 +69,15 @@ class PSA_Admin {
|
|||||||
* @param array $input Raw input from the form.
|
* @param array $input Raw input from the form.
|
||||||
* @return array Sanitized settings.
|
* @return array Sanitized settings.
|
||||||
*/
|
*/
|
||||||
public static function sanitize_settings( $input ) {
|
public static function sanitize_settings( array $input ): array {
|
||||||
// Handle API key: encrypt new values, preserve existing on empty/mask.
|
|
||||||
$api_key_input = $input['api_key'] ?? '';
|
$api_key_input = $input['api_key'] ?? '';
|
||||||
$api_key_value = '';
|
$api_key_value = '';
|
||||||
|
|
||||||
if ( ! empty( $api_key_input ) && '••••••••' !== $api_key_input ) {
|
if ( ! empty( $api_key_input ) && '••••••••' !== $api_key_input ) {
|
||||||
// New key provided: encrypt it.
|
|
||||||
$sanitized = sanitize_text_field( $api_key_input );
|
$sanitized = sanitize_text_field( $api_key_input );
|
||||||
$encrypted = PSA_Encryption::encrypt( $sanitized );
|
$encrypted = PSA_Encryption::encrypt( $sanitized );
|
||||||
$api_key_value = ( false !== $encrypted ) ? $encrypted : '';
|
$api_key_value = ( false !== $encrypted ) ? $encrypted : '';
|
||||||
} else {
|
} else {
|
||||||
// Empty or mask string: preserve existing value.
|
|
||||||
$existing = self::get_setting( 'api_key', '' );
|
$existing = self::get_setting( 'api_key', '' );
|
||||||
$api_key_value = $existing;
|
$api_key_value = $existing;
|
||||||
}
|
}
|
||||||
@@ -128,22 +87,215 @@ class PSA_Admin {
|
|||||||
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
|
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
|
||||||
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
|
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
|
||||||
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
|
'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',
|
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
|
||||||
'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
|
'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 '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '</p></div>';
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'reenrich' === $action && wp_verify_nonce( $nonce, 'psa_reenrich' ) ) {
|
||||||
|
$queued = self::queue_reenrichment();
|
||||||
|
add_action( 'admin_notices', function () use ( $queued ) {
|
||||||
|
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( sprintf(
|
||||||
|
/* translators: %d = number of peptides queued */
|
||||||
|
__( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ),
|
||||||
|
$queued
|
||||||
|
) ) . '</p></div>';
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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
|
// Field renderers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
public static function render_section_description() {
|
public static function render_section_description(): void {
|
||||||
?>
|
?>
|
||||||
<p>
|
<p>
|
||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
__( 'This plugin uses <a href="%s" target="_blank" rel="noopener">OpenRouter</a> 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 <a href="%s" target="_blank" rel="noopener">OpenRouter</a> to access AI models.', 'peptide-search-ai' ),
|
||||||
esc_url( 'https://openrouter.ai/' )
|
esc_url( 'https://openrouter.ai/' )
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
@@ -151,78 +303,67 @@ class PSA_Admin {
|
|||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_api_key_field() {
|
public static function render_api_key_field(): void {
|
||||||
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
|
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
|
||||||
?>
|
?>
|
||||||
<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span>
|
<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span>
|
||||||
<strong><?php esc_html_e( 'Configured via wp-config.php', 'peptide-search-ai' ); ?></strong>
|
<strong><?php esc_html_e( 'Configured via wp-config.php', 'peptide-search-ai' ); ?></strong>
|
||||||
<p class="description"><?php esc_html_e( 'The API key is securely set using the PSA_OPENROUTER_KEY constant in wp-config.php. This is the recommended approach.', 'peptide-search-ai' ); ?></p>
|
<p class="description"><?php esc_html_e( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ); ?></p>
|
||||||
<?php
|
<?php
|
||||||
} else {
|
} else {
|
||||||
$value = self::get_setting( 'api_key', '' );
|
$value = self::get_setting( 'api_key', '' );
|
||||||
// Show mask if key exists, empty if not.
|
|
||||||
$display_value = ! empty( $value ) ? '••••••••' : '';
|
$display_value = ! empty( $value ) ? '••••••••' : '';
|
||||||
?>
|
?>
|
||||||
<input type="password" name="psa_settings[api_key]" value="<?php echo esc_attr( $display_value ); ?>" class="regular-text" autocomplete="off" />
|
<input type="password" name="psa_settings[api_key]" value="<?php echo esc_attr( $display_value ); ?>" class="regular-text" autocomplete="off" />
|
||||||
<p class="description">
|
<p class="description">
|
||||||
<?php esc_html_e( 'Your OpenRouter API key. Get one at', 'peptide-search-ai' ); ?>
|
<?php esc_html_e( 'Your OpenRouter API key.', 'peptide-search-ai' ); ?>
|
||||||
<a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener">openrouter.ai/settings/keys</a>.<br />
|
<a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener"><?php esc_html_e( 'Get one here', 'peptide-search-ai' ); ?></a>.
|
||||||
<strong><?php esc_html_e( 'Recommended:', 'peptide-search-ai' ); ?></strong> <?php esc_html_e( 'For better security, add', 'peptide-search-ai' ); ?> <code>define( 'PSA_OPENROUTER_KEY', 'your-key-here' );</code> <?php esc_html_e( 'to your wp-config.php instead.', 'peptide-search-ai' ); ?>
|
|
||||||
</p>
|
</p>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_model_field() {
|
public static function render_model_field(): void {
|
||||||
$value = self::get_setting( 'ai_model', '' );
|
$value = self::get_setting( 'ai_model', '' );
|
||||||
?>
|
?>
|
||||||
<input type="text" name="psa_settings[ai_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
|
<input type="text" name="psa_settings[ai_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
|
||||||
<p class="description">
|
<p class="description"><?php esc_html_e( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ); ?></p>
|
||||||
<?php esc_html_e( 'Model for full content generation. Default:', 'peptide-search-ai' ); ?> <code>google/gemini-2.5-flash</code>.<br />
|
|
||||||
<?php esc_html_e( 'Browse models at', 'peptide-search-ai' ); ?> <a href="https://openrouter.ai/models" target="_blank" rel="noopener">openrouter.ai/models</a>.
|
|
||||||
<?php esc_html_e( 'Examples:', 'peptide-search-ai' ); ?> <code>meta-llama/llama-4-maverick</code>, <code>deepseek/deepseek-chat-v3-0324</code>, <code>qwen/qwen-2.5-72b-instruct</code>.
|
|
||||||
</p>
|
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_validation_model_field() {
|
public static function render_validation_model_field(): void {
|
||||||
$value = self::get_setting( 'validation_model', '' );
|
$value = self::get_setting( 'validation_model', '' );
|
||||||
?>
|
?>
|
||||||
<input type="text" name="psa_settings[validation_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default (fast/cheap model)', 'peptide-search-ai' ); ?>" />
|
<input type="text" name="psa_settings[validation_model]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" placeholder="<?php esc_attr_e( 'Leave blank for default', 'peptide-search-ai' ); ?>" />
|
||||||
<p class="description">
|
<p class="description"><?php esc_html_e( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ); ?></p>
|
||||||
<?php esc_html_e( 'Lighter model for peptide name validation. Default:', 'peptide-search-ai' ); ?> <code>google/gemini-2.0-flash-001</code>.<br />
|
|
||||||
<?php esc_html_e( 'Use a fast, cheap model here to keep validation quick. Examples:', 'peptide-search-ai' ); ?> <code>meta-llama/llama-4-scout</code>, <code>mistralai/mistral-small-3.1-24b-instruct</code>.
|
|
||||||
</p>
|
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_publish_field() {
|
public static function render_publish_field(): void {
|
||||||
$value = self::get_setting( 'auto_publish', 'draft' );
|
$value = self::get_setting( 'auto_publish', 'draft' );
|
||||||
?>
|
?>
|
||||||
<select name="psa_settings[auto_publish]">
|
<select name="psa_settings[auto_publish]">
|
||||||
<option value="draft" <?php selected( $value, 'draft' ); ?>><?php esc_html_e( 'Save as Draft (review before publishing)', 'peptide-search-ai' ); ?></option>
|
<option value="draft" <?php selected( $value, 'draft' ); ?>><?php esc_html_e( 'Save as Draft', 'peptide-search-ai' ); ?></option>
|
||||||
<option value="publish" <?php selected( $value, 'publish' ); ?>><?php esc_html_e( 'Publish Immediately', 'peptide-search-ai' ); ?></option>
|
<option value="publish" <?php selected( $value, 'publish' ); ?>><?php esc_html_e( 'Publish Immediately', 'peptide-search-ai' ); ?></option>
|
||||||
</select>
|
</select>
|
||||||
<p class="description"><?php esc_html_e( 'Whether AI-generated peptides are published immediately or saved as drafts for review.', 'peptide-search-ai' ); ?></p>
|
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_pubchem_field() {
|
public static function render_pubchem_field(): void {
|
||||||
$value = self::get_setting( 'use_pubchem', '1' );
|
$value = self::get_setting( 'use_pubchem', '1' );
|
||||||
?>
|
?>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="psa_settings[use_pubchem]" value="1" <?php checked( $value, '1' ); ?> />
|
<input type="checkbox" name="psa_settings[use_pubchem]" value="1" <?php checked( $value, '1' ); ?> />
|
||||||
<?php esc_html_e( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' ); ?>
|
<?php esc_html_e( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' ); ?>
|
||||||
</label>
|
</label>
|
||||||
<p class="description"><?php esc_html_e( 'When enabled, the plugin queries PubChem\'s free API to validate molecular weight, formula, and other properties.', 'peptide-search-ai' ); ?></p>
|
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function render_budget_field() {
|
public static function render_budget_field(): void {
|
||||||
$value = self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
|
$value = self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET );
|
||||||
?>
|
?>
|
||||||
<input type="number" name="psa_settings[monthly_budget]" value="<?php echo esc_attr( $value ); ?>" step="0.01" min="0" />
|
<input type="number" name="psa_settings[monthly_budget]" value="<?php echo esc_attr( $value ); ?>" step="0.01" min="0" />
|
||||||
<p class="description"><?php esc_html_e( 'Set to 0 for unlimited. Generation will stop when this budget is reached.', 'peptide-search-ai' ); ?></p>
|
<p class="description"><?php esc_html_e( 'Set to 0 for unlimited.', 'peptide-search-ai' ); ?></p>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +371,9 @@ class PSA_Admin {
|
|||||||
// Settings page
|
// Settings page
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
public static function render_settings_page() {
|
public static function render_settings_page(): void {
|
||||||
|
$migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' );
|
||||||
|
$reenrich_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=reenrich' ), 'psa_reenrich' );
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
|
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
|
||||||
@@ -248,24 +391,37 @@ class PSA_Admin {
|
|||||||
<?php self::render_usage_summary(); ?>
|
<?php self::render_usage_summary(); ?>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h2><?php esc_html_e( 'How It Works', 'peptide-search-ai' ); ?></h2>
|
<h2><?php esc_html_e( 'Data Tools', 'peptide-search-ai' ); ?></h2>
|
||||||
<ol style="max-width:600px; line-height:1.8;">
|
<table class="form-table">
|
||||||
<li><?php esc_html_e( 'Add the shortcode [peptide_search] to any page.', 'peptide-search-ai' ); ?></li>
|
<tr>
|
||||||
<li><?php esc_html_e( 'Visitors search for a peptide name.', 'peptide-search-ai' ); ?></li>
|
<th><?php esc_html_e( 'Assign Categories', 'peptide-search-ai' ); ?></th>
|
||||||
<li><?php esc_html_e( 'If found, results appear instantly.', 'peptide-search-ai' ); ?></li>
|
<td>
|
||||||
<li><?php esc_html_e( 'If not found, the system automatically validates the name, creates a placeholder, and generates content in the background.', 'peptide-search-ai' ); ?></li>
|
<a href="<?php echo esc_url( $migrate_url ); ?>" class="button">
|
||||||
<li><?php esc_html_e( 'The visitor sees a "being added" message and can check back later.', 'peptide-search-ai' ); ?></li>
|
<?php esc_html_e( 'Migrate Existing Peptides to Categories', 'peptide-search-ai' ); ?>
|
||||||
</ol>
|
</a>
|
||||||
|
<p class="description"><?php esc_html_e( 'Reads AI-generated category data from existing peptides and assigns taxonomy terms. Safe to run multiple times.', 'peptide-search-ai' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Re-enrich Data', 'peptide-search-ai' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<a href="<?php echo esc_url( $reenrich_url ); ?>" class="button" onclick="return confirm('<?php esc_attr_e( 'This will queue background AI regeneration for peptides missing extended data. It will consume API credits. Continue?', 'peptide-search-ai' ); ?>');">
|
||||||
|
<?php esc_html_e( 'Re-enrich Existing Peptides', 'peptide-search-ai' ); ?>
|
||||||
|
</a>
|
||||||
|
<p class="description"><?php esc_html_e( 'Queues background regeneration for published peptides missing half-life, stability, and other extended fields. Respects daily generation cap.', 'peptide-search-ai' ); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h2><?php esc_html_e( 'Shortcode', 'peptide-search-ai' ); ?></h2>
|
<h2><?php esc_html_e( 'Shortcodes', 'peptide-search-ai' ); ?></h2>
|
||||||
<p><code>[peptide_search]</code></p>
|
<p><code>[peptide_search]</code> — <?php esc_html_e( 'Search form with auto-generation', 'peptide-search-ai' ); ?></p>
|
||||||
<p><?php esc_html_e( 'Customize placeholder:', 'peptide-search-ai' ); ?> <code>[peptide_search placeholder="Search peptides..."]</code></p>
|
<p><code>[peptide_directory]</code> — <?php esc_html_e( 'Browsable directory grid with filters and detail modal', 'peptide-search-ai' ); ?></p>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h2><?php esc_html_e( 'REST API', 'peptide-search-ai' ); ?></h2>
|
<h2><?php esc_html_e( 'REST API', 'peptide-search-ai' ); ?></h2>
|
||||||
<p><code>GET /wp-json/peptides/v1/search?q=BPC-157</code></p>
|
<p><code>GET /wp-json/peptides/v1/search?q=BPC-157</code> — <?php esc_html_e( 'Search (published only)', 'peptide-search-ai' ); ?></p>
|
||||||
<p class="description"><?php esc_html_e( 'The REST endpoint returns published peptides only — it does not trigger auto-generation.', 'peptide-search-ai' ); ?></p>
|
<p><code>GET /wp-json/peptide-search-ai/v1/compounds?search=&category=&page=1</code> — <?php esc_html_e( 'Directory listing', 'peptide-search-ai' ); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
@@ -273,15 +429,15 @@ class PSA_Admin {
|
|||||||
/**
|
/**
|
||||||
* Render the API usage summary section.
|
* Render the API usage summary section.
|
||||||
*/
|
*/
|
||||||
private static function render_usage_summary() {
|
private static function render_usage_summary(): void {
|
||||||
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
|
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
|
||||||
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
|
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
|
||||||
$budget = floatval( self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
|
$budget = floatval( self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
|
||||||
$budget_remaining = ( 0 === $budget ) ? __( 'Unlimited', 'peptide-search-ai' ) : '$' . number_format( max( 0, $budget - $current_spend ), 2 );
|
$budget_remaining = ( 0 === $budget ) ? __( 'Unlimited', 'peptide-search-ai' ) : '$' . number_format( max( 0, $budget - $current_spend ), 2 );
|
||||||
$recent_logs = PSA_Cost_Tracker::get_recent_logs( 20 );
|
$recent_logs = PSA_Cost_Tracker::get_recent_logs( 20 );
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 4px; margin-bottom: 20px;">
|
<div style="background:#f5f5f5;padding:15px;border-radius:4px;margin-bottom:20px;">
|
||||||
<h3><?php esc_html_e( 'This Month', 'peptide-search-ai' ); ?></h3>
|
<h3><?php esc_html_e( 'This Month', 'peptide-search-ai' ); ?></h3>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?><br />
|
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?><br />
|
||||||
@@ -292,7 +448,7 @@ class PSA_Admin {
|
|||||||
|
|
||||||
<?php if ( ! empty( $recent_logs ) ) : ?>
|
<?php if ( ! empty( $recent_logs ) ) : ?>
|
||||||
<h3><?php esc_html_e( 'Recent API Calls (Last 20)', 'peptide-search-ai' ); ?></h3>
|
<h3><?php esc_html_e( 'Recent API Calls (Last 20)', 'peptide-search-ai' ); ?></h3>
|
||||||
<table class="widefat striped" style="margin-bottom: 20px;">
|
<table class="widefat striped" style="margin-bottom:20px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( 'Date', 'peptide-search-ai' ); ?></th>
|
<th><?php esc_html_e( 'Date', 'peptide-search-ai' ); ?></th>
|
||||||
@@ -313,7 +469,7 @@ class PSA_Admin {
|
|||||||
<td>$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></td>
|
<td>$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></td>
|
||||||
<td><?php echo esc_html( $log->request_type ); ?></td>
|
<td><?php echo esc_html( $log->request_type ); ?></td>
|
||||||
<td><?php echo esc_html( $log->peptide_name ); ?></td>
|
<td><?php echo esc_html( $log->peptide_name ); ?></td>
|
||||||
<td><?php echo ( 1 === (int) $log->success ) ? '✓' : '✗'; ?></td>
|
<td><?php echo ( 1 === (int) $log->success ) ? '✓' : '✗'; ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -329,7 +485,7 @@ class PSA_Admin {
|
|||||||
* @param mixed $default Default value.
|
* @param mixed $default Default value.
|
||||||
* @return mixed
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
private static function get_setting( $key, $default = '' ) {
|
private static function get_setting( string $key, $default = '' ) {
|
||||||
$options = get_option( 'psa_settings', array() );
|
$options = get_option( 'psa_settings', array() );
|
||||||
return $options[ $key ] ?? $default;
|
return $options[ $key ] ?? $default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Handles AI content generation for new peptide entries.
|
* Handles AI content generation for new peptide entries.
|
||||||
* Uses OpenRouter API to access a wide range of open-source and commercial models.
|
*
|
||||||
* Includes peptide name validation and background generation.
|
* What: Orchestrates the full AI pipeline — validation, generation, PubChem enrichment, KB article creation.
|
||||||
|
* Who calls it: PSA_Search (validation + generation scheduling), WP-Cron (background_generate).
|
||||||
|
* Dependencies: PSA_Config, PSA_Cost_Tracker, PSA_Encryption, PSA_PubChem, PSA_Post_Type, OpenRouter API.
|
||||||
*
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-search.php
|
||||||
|
* @see includes/class-psa-cost-tracker.php
|
||||||
|
* @see includes/class-psa-pubchem.php
|
||||||
|
* @see includes/class-psa-post-type.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -13,12 +20,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
|
|
||||||
class PSA_AI_Generator {
|
class PSA_AI_Generator {
|
||||||
|
|
||||||
/**
|
public static function init(): void {
|
||||||
* OpenRouter API base URL.
|
|
||||||
*/
|
|
||||||
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
|
|
||||||
public static function init() {
|
|
||||||
add_action( 'psa_generate_peptide_background', array( __CLASS__, 'background_generate' ), 10, 2 );
|
add_action( 'psa_generate_peptide_background', array( __CLASS__, 'background_generate' ), 10, 2 );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ class PSA_AI_Generator {
|
|||||||
* @param string $name The name to validate.
|
* @param string $name The name to validate.
|
||||||
* @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string }
|
* @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string }
|
||||||
*/
|
*/
|
||||||
public static function validate_peptide_name( $name ) {
|
public static function validate_peptide_name( string $name ) {
|
||||||
// Input character validation — block injection attempts before API call.
|
// Input character validation — block injection attempts before API call.
|
||||||
if ( ! self::validate_peptide_input( $name ) ) {
|
if ( ! self::validate_peptide_input( $name ) ) {
|
||||||
return array(
|
return array(
|
||||||
@@ -62,7 +64,7 @@ class PSA_AI_Generator {
|
|||||||
$prompt = self::build_validation_prompt( $name );
|
$prompt = self::build_validation_prompt( $name );
|
||||||
// Allow filtering of validation prompt before sending.
|
// Allow filtering of validation prompt before sending.
|
||||||
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
|
$prompt = apply_filters( 'psa_validation_prompt', $prompt, $name );
|
||||||
$response = self::call_openrouter(
|
$response = PSA_OpenRouter::send_request(
|
||||||
$api_key,
|
$api_key,
|
||||||
$model ? $model : 'google/gemini-2.0-flash-001',
|
$model ? $model : 'google/gemini-2.0-flash-001',
|
||||||
$prompt,
|
$prompt,
|
||||||
@@ -75,7 +77,7 @@ class PSA_AI_Generator {
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = self::parse_ai_response( $response );
|
$data = PSA_OpenRouter::parse_response( $response );
|
||||||
if ( is_wp_error( $data ) ) {
|
if ( is_wp_error( $data ) ) {
|
||||||
return array(
|
return array(
|
||||||
'is_valid' => false,
|
'is_valid' => false,
|
||||||
@@ -91,7 +93,7 @@ class PSA_AI_Generator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Cache for 24 hours (using PSA_Config for consistency).
|
// 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;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ class PSA_AI_Generator {
|
|||||||
* @param int $post_id The placeholder post ID.
|
* @param int $post_id The placeholder post ID.
|
||||||
* @param string $peptide_name The peptide to research.
|
* @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 );
|
$post = get_post( $post_id );
|
||||||
if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) {
|
if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) {
|
||||||
error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' );
|
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'] ) ) {
|
if ( ! empty( $options['use_pubchem'] ) ) {
|
||||||
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
|
$pubchem_result = PSA_PubChem::lookup( $peptide_name );
|
||||||
if ( is_wp_error( $pubchem_result ) ) {
|
if ( is_wp_error( $pubchem_result ) ) {
|
||||||
// Log PubChem API failures.
|
|
||||||
error_log( 'PSA: PubChem lookup failed for "' . $peptide_name . '": ' . $pubchem_result->get_error_message() );
|
error_log( 'PSA: PubChem lookup failed for "' . $peptide_name . '": ' . $pubchem_result->get_error_message() );
|
||||||
} elseif ( is_array( $pubchem_result ) ) {
|
} elseif ( is_array( $pubchem_result ) ) {
|
||||||
$pubchem_data = $pubchem_result;
|
$pubchem_data = $pubchem_result;
|
||||||
@@ -180,8 +181,11 @@ class PSA_AI_Generator {
|
|||||||
// Save all meta fields — prefer PubChem data for molecular properties.
|
// Save all meta fields — prefer PubChem data for molecular properties.
|
||||||
self::save_peptide_meta( $post_id, $result, $pubchem_data );
|
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.
|
// 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.
|
// Clean up generation tracking.
|
||||||
delete_post_meta( $post_id, 'psa_generation_started' );
|
delete_post_meta( $post_id, 'psa_generation_started' );
|
||||||
@@ -200,7 +204,7 @@ class PSA_AI_Generator {
|
|||||||
* @param string $peptide_name The peptide to research.
|
* @param string $peptide_name The peptide to research.
|
||||||
* @return array|WP_Error Parsed peptide data or error.
|
* @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();
|
$options = self::get_settings();
|
||||||
$api_key = $options['api_key'];
|
$api_key = $options['api_key'];
|
||||||
$model = $options['ai_model'];
|
$model = $options['ai_model'];
|
||||||
@@ -217,7 +221,7 @@ class PSA_AI_Generator {
|
|||||||
$prompt = self::build_generation_prompt( $peptide_name );
|
$prompt = self::build_generation_prompt( $peptide_name );
|
||||||
// Allow filtering of generation prompt before sending.
|
// Allow filtering of generation prompt before sending.
|
||||||
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
|
$prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name );
|
||||||
$response = self::call_openrouter(
|
$response = PSA_OpenRouter::send_request(
|
||||||
$api_key,
|
$api_key,
|
||||||
$model ? $model : 'google/gemini-2.5-flash',
|
$model ? $model : 'google/gemini-2.5-flash',
|
||||||
$prompt,
|
$prompt,
|
||||||
@@ -230,7 +234,7 @@ class PSA_AI_Generator {
|
|||||||
return $response;
|
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,
|
* 'total_estimated_cost_usd' => float,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public static function dry_run( $peptide_name ) {
|
public static function dry_run( string $peptide_name ): array {
|
||||||
$options = self::get_settings();
|
$options = self::get_settings();
|
||||||
$validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001';
|
$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';
|
$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 = self::build_validation_prompt( $peptide_name );
|
$validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 );
|
||||||
$validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 ); // rough estimate: 1 token ≈ 4 chars.
|
$validation_completion = PSA_Config::VALIDATION_MAX_TOKENS;
|
||||||
$validation_completion_tokens = PSA_Config::VALIDATION_MAX_TOKENS;
|
$validation_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $validation_prompt_tokens, $validation_completion );
|
||||||
$validation_cost = PSA_Cost_Tracker::estimate_cost(
|
|
||||||
$validation_model,
|
|
||||||
$validation_prompt_tokens,
|
|
||||||
$validation_completion_tokens
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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_prompt_tokens = (int) ceil( strlen( $generation_prompt ) / 4 );
|
||||||
$generation_completion_tokens = PSA_Config::GENERATION_MAX_TOKENS;
|
$generation_completion = PSA_Config::GENERATION_MAX_TOKENS;
|
||||||
$generation_cost = PSA_Cost_Tracker::estimate_cost(
|
$generation_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $generation_prompt_tokens, $generation_completion );
|
||||||
$generation_model,
|
|
||||||
$generation_prompt_tokens,
|
|
||||||
$generation_completion_tokens
|
|
||||||
);
|
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'validation' => array(
|
'validation' => array(
|
||||||
'tokens' => $validation_prompt_tokens + $validation_completion_tokens,
|
'tokens' => $validation_prompt_tokens + $validation_completion,
|
||||||
'estimated_cost_usd' => $validation_cost,
|
'estimated_cost_usd' => $validation_cost,
|
||||||
),
|
),
|
||||||
'generation' => array(
|
'generation' => array(
|
||||||
'tokens' => $generation_prompt_tokens + $generation_completion_tokens,
|
'tokens' => $generation_prompt_tokens + $generation_completion,
|
||||||
'estimated_cost_usd' => $generation_cost,
|
'estimated_cost_usd' => $generation_cost,
|
||||||
),
|
),
|
||||||
'total_estimated_cost_usd' => $validation_cost + $generation_cost,
|
'total_estimated_cost_usd' => $validation_cost + $generation_cost,
|
||||||
);
|
);
|
||||||
@@ -290,7 +284,7 @@ class PSA_AI_Generator {
|
|||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_settings() {
|
private static function get_settings(): array {
|
||||||
$defaults = array(
|
$defaults = array(
|
||||||
'api_key' => '',
|
'api_key' => '',
|
||||||
'ai_model' => '',
|
'ai_model' => '',
|
||||||
@@ -307,8 +301,6 @@ class PSA_AI_Generator {
|
|||||||
if ( false !== $decrypted ) {
|
if ( false !== $decrypted ) {
|
||||||
$settings['api_key'] = $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.
|
// Prefer wp-config.php constant over database value for security.
|
||||||
@@ -316,44 +308,68 @@ class PSA_AI_Generator {
|
|||||||
$settings['api_key'] = PSA_OPENROUTER_KEY;
|
$settings['api_key'] = PSA_OPENROUTER_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow filtering of settings before use.
|
|
||||||
$settings = apply_filters( 'psa_ai_settings', $settings );
|
$settings = apply_filters( 'psa_ai_settings', $settings );
|
||||||
|
|
||||||
return $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 int $post_id The post ID.
|
||||||
* @param array $ai_data AI-generated data.
|
* @param array $ai_data AI-generated data.
|
||||||
* @param array|null $pubchem_data PubChem data (or null).
|
* @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(
|
$meta = array(
|
||||||
'psa_sequence' => $ai_data['sequence'] ?? '',
|
'psa_sequence' => $ai_data['sequence'] ?? '',
|
||||||
'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
|
'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] )
|
||||||
? $pubchem_data['molecular_weight']
|
? $pubchem_data['molecular_weight']
|
||||||
: ( $ai_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']
|
? $pubchem_data['molecular_formula']
|
||||||
: ( $ai_data['molecular_formula'] ?? '' ),
|
: ( $ai_data['molecular_formula'] ?? '' ),
|
||||||
'psa_aliases' => $ai_data['aliases'] ?? '',
|
'psa_aliases' => $ai_data['aliases'] ?? '',
|
||||||
'psa_mechanism' => $ai_data['mechanism'] ?? '',
|
'psa_mechanism' => $ai_data['mechanism'] ?? '',
|
||||||
'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
|
'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '',
|
||||||
'psa_category_label' => $ai_data['category_label'] ?? '',
|
'psa_category_label' => $ai_data['category_label'] ?? '',
|
||||||
'psa_origin' => $ai_data['origin'] ?? '',
|
'psa_origin' => $ai_data['origin'] ?? '',
|
||||||
'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
|
'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ),
|
||||||
'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
|
'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ),
|
||||||
'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
|
'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ),
|
||||||
'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
|
'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '',
|
||||||
'psa_references' => $ai_data['references'] ?? '',
|
'psa_references' => $ai_data['references'] ?? '',
|
||||||
'psa_category' => $ai_data['category'] ?? '',
|
'psa_category' => $ai_data['category'] ?? '',
|
||||||
'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
|
'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated',
|
||||||
'psa_pubchem_cid' => $pubchem_data['cid'] ?? '',
|
'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 );
|
$meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data );
|
||||||
|
|
||||||
foreach ( $meta as $key => $value ) {
|
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.
|
* Validate peptide name input characters and block injection patterns.
|
||||||
*
|
*
|
||||||
* @param string $name The peptide name to validate.
|
* @param string $name The peptide name to validate.
|
||||||
* @return bool True if input looks legitimate.
|
* @return bool True if input looks legitimate.
|
||||||
*/
|
*/
|
||||||
private static function validate_peptide_input( $name ) {
|
private static function validate_peptide_input( string $name ): bool {
|
||||||
// Allow only characters that appear in legitimate peptide names.
|
|
||||||
if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) {
|
if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Block obvious prompt injection patterns.
|
|
||||||
$blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' );
|
$blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' );
|
||||||
$lower = strtolower( $name );
|
$lower = strtolower( $name );
|
||||||
foreach ( $blocked as $word ) {
|
foreach ( $blocked as $word ) {
|
||||||
@@ -386,14 +461,10 @@ class PSA_AI_Generator {
|
|||||||
/**
|
/**
|
||||||
* Build the lightweight validation prompt.
|
* 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.
|
* @param string $name The peptide name to validate.
|
||||||
* @return string Prompt text ready to send to OpenRouter API.
|
* @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 );
|
$json_name = wp_json_encode( $name );
|
||||||
return <<<PROMPT
|
return <<<PROMPT
|
||||||
You are a peptide and biochemistry expert. Determine if the peptide name given in the following JSON-encoded string is a real, recognized peptide, protein fragment, or peptide-based compound.
|
You are a peptide and biochemistry expert. Determine if the peptide name given in the following JSON-encoded string is a real, recognized peptide, protein fragment, or peptide-based compound.
|
||||||
@@ -417,19 +488,12 @@ PROMPT;
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the comprehensive generation prompt.
|
* Build the comprehensive generation prompt (v4.3.0: includes extended fields).
|
||||||
*
|
|
||||||
* Constructs a detailed prompt that instructs the AI to generate structured JSON output
|
|
||||||
* with all required fields for a complete peptide database entry (name, sequence, mechanism,
|
|
||||||
* research benefits, dosing, safety, legal status, references, etc).
|
|
||||||
*
|
|
||||||
* The prompt is designed to ensure the AI returns clean, factual information suitable for
|
|
||||||
* scientific audiences. Uses JSON encoding for the peptide name to properly escape special characters.
|
|
||||||
*
|
*
|
||||||
* @param string $peptide_name The peptide to research.
|
* @param string $peptide_name The peptide to research.
|
||||||
* @return string Prompt text ready to send to OpenRouter API.
|
* @return string Prompt text ready to send to OpenRouter API.
|
||||||
*/
|
*/
|
||||||
private static function build_generation_prompt( $peptide_name ) {
|
private static function build_generation_prompt( string $peptide_name ): string {
|
||||||
$json_name = wp_json_encode( $peptide_name );
|
$json_name = wp_json_encode( $peptide_name );
|
||||||
return <<<PROMPT
|
return <<<PROMPT
|
||||||
You are a scientific writer specializing in peptide research. Generate a comprehensive database entry for the peptide given in the following JSON-encoded string.
|
You are a scientific writer specializing in peptide research. Generate a comprehensive database entry for the peptide given in the following JSON-encoded string.
|
||||||
@@ -448,12 +512,20 @@ Return ONLY valid JSON (no markdown fences, no commentary) with exactly these fi
|
|||||||
"molecular_formula": "Chemical formula (e.g. C62H98N16O22)",
|
"molecular_formula": "Chemical formula (e.g. C62H98N16O22)",
|
||||||
"overview": "2-3 paragraphs: what this peptide is, its origin, discovery, and significance. Written for a scientifically literate audience.",
|
"overview": "2-3 paragraphs: what this peptide is, its origin, discovery, and significance. Written for a scientifically literate audience.",
|
||||||
"mechanism": "Detailed mechanism of action. Start with an intro paragraph, then describe each pathway as a labeled item like: Pathway name: Description. Separate items with double newlines.",
|
"mechanism": "Detailed mechanism of action. Start with an intro paragraph, then describe each pathway as a labeled item like: Pathway name: Description. Separate items with double newlines.",
|
||||||
"research_benefits": "Detailed research applications organized by therapeutic area. Use area names as subheadings followed by a paragraph, e.g.:\n\nMusculoskeletal Healing\nParagraph about musculoskeletal research...\n\nGastrointestinal Protection\nParagraph about GI research...\n\nInclude 3-5 areas depending on the peptide.",
|
"research_benefits": "Detailed research applications organized by therapeutic area. Use area names as subheadings followed by a paragraph.",
|
||||||
"administration_dosing": "Include a disclaimer that this is from research only. Then describe: typical research dose range, routes of administration, frequency, and duration from studies.",
|
"administration_dosing": "Include a disclaimer that this is from research only. Then describe: typical research dose range, routes of administration, frequency, and duration from studies.",
|
||||||
"safety_side_effects": "Known safety profile from animal studies. Include any anecdotally reported side effects. Note theoretical concerns. Mention that long-term human safety data is limited.",
|
"safety_side_effects": "Known safety profile from animal studies. Include any anecdotally reported side effects. Note theoretical concerns.",
|
||||||
"legal_regulatory": "Current regulatory and legal status. Mention FDA status, WADA status if applicable, classification as research chemical.",
|
"legal_regulatory": "Current regulatory and legal status. Mention FDA status, WADA status if applicable, classification as research chemical.",
|
||||||
"references": "5-8 real published scientific references with authors, title, journal, year. Use real studies only.",
|
"references": "5-8 real published scientific references with authors, title, journal, year. Use real studies only.",
|
||||||
"category": "Classify into exactly ONE: GH Secretagogues, Healing & Repair, Melanocortin Peptides, Metabolic & Anti-Aging, Nootropic & Neuroprotective"
|
"category": "Classify into exactly ONE of: Tissue Repair, Lipid Metabolism, Aging Research, Dermatological, Metabolic, Growth Hormone, Immunology, Endocrine",
|
||||||
|
"half_life": "Approximate half-life (e.g. '~4-6 hours', '15-20 minutes'). Write 'Unknown' if not established.",
|
||||||
|
"stability": "Stability information (e.g. '28 Days at 2-8°C after reconstitution'). Include lyophilized and reconstituted if available.",
|
||||||
|
"solubility": "Recommended reconstitution solvent (e.g. 'Bacteriostatic Water', 'Sterile Water')",
|
||||||
|
"vial_size_mg": "Most common research vial size in mg as a number (e.g. 5, 10, 2). Use 0 if not applicable.",
|
||||||
|
"storage_lyophilized": "Storage conditions for lyophilized/powder form (e.g. '-20°C, protect from light')",
|
||||||
|
"storage_reconstituted": "Storage conditions after reconstitution (e.g. '2-8°C, use within 28 days')",
|
||||||
|
"typical_dose_mcg": "Typical research dose range (e.g. '200-300 mcg', '100-200 mcg/kg'). Include units.",
|
||||||
|
"cycle_parameters": "Typical research cycle/protocol info (e.g. '4-6 weeks on, 2 weeks off', 'Daily subcutaneous injection')"
|
||||||
}
|
}
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
@@ -461,367 +533,8 @@ Important:
|
|||||||
- If information is uncertain or unknown, say so explicitly rather than fabricating data.
|
- If information is uncertain or unknown, say so explicitly rather than fabricating data.
|
||||||
- All content is for research and educational purposes.
|
- All content is for research and educational purposes.
|
||||||
- Write in clean paragraphs. Do not use markdown formatting symbols like # or ** or *.
|
- Write in clean paragraphs. Do not use markdown formatting symbols like # or ** or *.
|
||||||
- For research_benefits, use the therapeutic area name on its own line as a subheading, followed by a descriptive paragraph.
|
- For the "category" field, use EXACTLY one of the eight listed categories.
|
||||||
PROMPT;
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// OpenRouter API Call
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call OpenRouter API with automatic retry on rate limits.
|
|
||||||
*
|
|
||||||
* Implements exponential backoff retry strategy for rate limit (429) errors.
|
|
||||||
* On first rate limit: waits 5s before retry.
|
|
||||||
* On second rate limit: waits 10s before retry.
|
|
||||||
* On third rate limit: waits 20s before final attempt.
|
|
||||||
* Other errors are returned immediately without 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( $api_key, $model, $prompt, $max_tokens = PSA_Config::GENERATION_MAX_TOKENS, $request_type = 'generation', $peptide_name = '' ) {
|
|
||||||
$max_retries = PSA_Config::API_RETRY_MAX;
|
|
||||||
$base_delay = PSA_Config::API_RETRY_BASE_DELAY; // seconds
|
|
||||||
|
|
||||||
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
|
|
||||||
$result = self::call_openrouter_once( $api_key, $model, $prompt, $max_tokens, $request_type, $peptide_name );
|
|
||||||
|
|
||||||
// If not a rate limit error, return immediately.
|
|
||||||
if ( ! is_wp_error( $result ) || 'rate_limited' !== $result->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 <think>...</think> tags (some models like DeepSeek include reasoning).
|
|
||||||
$cleaned = preg_replace( '/<think>.*?<\/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 .= '<strong>Category:</strong> ' . esc_html( $cat_label );
|
|
||||||
}
|
|
||||||
if ( $origin ) {
|
|
||||||
$header .= ' | <strong>Origin:</strong> ' . esc_html( $origin );
|
|
||||||
}
|
|
||||||
if ( $sequence ) {
|
|
||||||
$header .= ' | <strong>Sequence:</strong> ' . esc_html( $sequence );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build full article content.
|
|
||||||
$c = $header ? '<p>' . $header . '</p>' . "\n\n" : '';
|
|
||||||
|
|
||||||
// Overview.
|
|
||||||
$c .= '<h2>Overview</h2>' . "\n";
|
|
||||||
$c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' );
|
|
||||||
|
|
||||||
// Mechanism of Action.
|
|
||||||
if ( ! empty( $ai_data['mechanism'] ) ) {
|
|
||||||
$c .= '<h2>Mechanism of Action</h2>' . "\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 .= '<h2>Research & Potential Benefits</h2>' . "\n";
|
|
||||||
$c .= '<p><em>The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.</em></p>' . "\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 .= '<h2>Administration & Dosing (Research Context)</h2>' . "\n";
|
|
||||||
$c .= '<p><strong>Important:</strong> 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.</p>' . "\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 .= '<h2>Safety & Side Effects</h2>' . "\n";
|
|
||||||
$c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legal & Regulatory Status.
|
|
||||||
if ( ! empty( $ai_data['legal_regulatory'] ) ) {
|
|
||||||
$c .= '<h2>Legal & Regulatory Status</h2>' . "\n";
|
|
||||||
$c .= self::paragraphs( $ai_data['legal_regulatory'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// References.
|
|
||||||
if ( ! empty( $ai_data['references'] ) ) {
|
|
||||||
$c .= '<h2>Notable Research References</h2>' . "\n";
|
|
||||||
$c .= self::paragraphs( $ai_data['references'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Medical Disclaimer.
|
|
||||||
$c .= "\n" . '<p><strong>Medical Disclaimer:</strong> 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.</p>';
|
|
||||||
|
|
||||||
$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 .= '<p>' . wp_kses_post( $p ) . '</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 .= '<h3>' . esc_html( $block ) . '</h3>' . "\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 .= '<h3>' . esc_html( $first_line ) . '</h3>' . "\n";
|
|
||||||
$out .= '<p>' . wp_kses_post( $rest ) . '</p>' . "\n";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$out .= '<p>' . wp_kses_post( $block ) . '</p>' . "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Configuration constants for Peptide Search AI.
|
* Configuration constants for Peptide Search AI.
|
||||||
* Centralizes all magic numbers and configuration values.
|
*
|
||||||
|
* What: Centralizes all magic numbers and configuration values used across the plugin.
|
||||||
|
* Who calls it: Every class in the plugin reads these constants for limits, timeouts, and defaults.
|
||||||
|
* Dependencies: None (pure constants class).
|
||||||
*
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Tracks API usage costs and enforces monthly budget limits.
|
* Tracks API usage costs and enforces monthly budget limits.
|
||||||
*
|
*
|
||||||
@@ -20,7 +21,7 @@ class PSA_Cost_Tracker {
|
|||||||
* Create the API logs table if it doesn't exist.
|
* Create the API logs table if it doesn't exist.
|
||||||
* Uses dbDelta for safe schema updates.
|
* Uses dbDelta for safe schema updates.
|
||||||
*/
|
*/
|
||||||
public static function create_table() {
|
public static function create_table(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$table_name = $wpdb->prefix . 'psa_api_logs';
|
$table_name = $wpdb->prefix . 'psa_api_logs';
|
||||||
@@ -68,7 +69,7 @@ class PSA_Cost_Tracker {
|
|||||||
* }
|
* }
|
||||||
* @return int|false Insert ID or false on error.
|
* @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;
|
global $wpdb;
|
||||||
|
|
||||||
$table_name = $wpdb->prefix . 'psa_api_logs';
|
$table_name = $wpdb->prefix . 'psa_api_logs';
|
||||||
@@ -126,7 +127,7 @@ class PSA_Cost_Tracker {
|
|||||||
* @param int|null $month Month (defaults to current month).
|
* @param int|null $month Month (defaults to current month).
|
||||||
* @return float Total USD spent in the 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;
|
global $wpdb;
|
||||||
|
|
||||||
if ( null === $year ) {
|
if ( null === $year ) {
|
||||||
@@ -165,7 +166,7 @@ class PSA_Cost_Tracker {
|
|||||||
* @param int|null $month Month (defaults to current month).
|
* @param int|null $month Month (defaults to current month).
|
||||||
* @return int Total tokens used in the 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;
|
global $wpdb;
|
||||||
|
|
||||||
if ( null === $year ) {
|
if ( null === $year ) {
|
||||||
@@ -203,7 +204,7 @@ class PSA_Cost_Tracker {
|
|||||||
*
|
*
|
||||||
* @return bool True if budget exceeded, false otherwise.
|
* @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() );
|
$settings = get_option( 'psa_settings', array() );
|
||||||
$budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
|
$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.
|
* @param int $limit Maximum number of logs to return.
|
||||||
* @return array Array of log objects.
|
* @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;
|
global $wpdb;
|
||||||
|
|
||||||
$table_name = $wpdb->prefix . 'psa_api_logs';
|
$table_name = $wpdb->prefix . 'psa_api_logs';
|
||||||
@@ -248,7 +249,7 @@ class PSA_Cost_Tracker {
|
|||||||
* @param int $completion_tokens Tokens in completion.
|
* @param int $completion_tokens Tokens in completion.
|
||||||
* @return float Estimated cost in USD.
|
* @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).
|
// Known model pricing per 1 million tokens (input, output).
|
||||||
$pricing = array(
|
$pricing = array(
|
||||||
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
|
'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.
|
* Drop the API logs table during uninstall.
|
||||||
*/
|
*/
|
||||||
public static function drop_table() {
|
public static function drop_table(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$table_name = $wpdb->prefix . 'psa_api_logs';
|
$table_name = $wpdb->prefix . 'psa_api_logs';
|
||||||
|
|||||||
317
includes/class-psa-directory.php
Executable file
317
includes/class-psa-directory.php
Executable file
@@ -0,0 +1,317 @@
|
|||||||
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
|
/**
|
||||||
|
* Browsable peptide directory — REST endpoint, shortcode, and data formatting.
|
||||||
|
*
|
||||||
|
* What: Provides the [peptide_directory] shortcode and GET /v1/compounds REST endpoint.
|
||||||
|
* Who calls it: WordPress shortcode parser, REST API router, main plugin file via PSA_Directory::init().
|
||||||
|
* Dependencies: PSA_Post_Type (meta fields, taxonomy), PSA_Config.
|
||||||
|
*
|
||||||
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-post-type.php — CPT + taxonomy registration, meta keys
|
||||||
|
* @see includes/class-psa-search.php — existing search shortcode (separate from directory)
|
||||||
|
* @see assets/js/psa-directory.js — frontend grid, filter, modal logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PSA_Directory {
|
||||||
|
|
||||||
|
/** Cards per page for directory pagination. */
|
||||||
|
const PER_PAGE_DEFAULT = 12;
|
||||||
|
const PER_PAGE_MAX = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register hooks.
|
||||||
|
*/
|
||||||
|
public static function init(): void {
|
||||||
|
add_shortcode( 'peptide_directory', array( __CLASS__, 'render_shortcode' ) );
|
||||||
|
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// REST API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the /v1/compounds endpoint for directory listing and cross-plugin data access.
|
||||||
|
*
|
||||||
|
* Public endpoint (no auth required) — serves the browsable directory.
|
||||||
|
* Rate limiting should be handled at the server/CDN level.
|
||||||
|
*/
|
||||||
|
public static function register_rest_routes(): void {
|
||||||
|
register_rest_route(
|
||||||
|
'peptide-search-ai/v1',
|
||||||
|
'/compounds',
|
||||||
|
array(
|
||||||
|
'methods' => '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();
|
||||||
|
?>
|
||||||
|
<div id="psa-directory-root" class="psa-dir-wrap" aria-label="<?php esc_attr_e( 'Peptide Directory', 'peptide-search-ai' ); ?>">
|
||||||
|
<noscript>
|
||||||
|
<p><?php esc_html_e( 'Please enable JavaScript to browse the peptide directory.', 'peptide-search-ai' ); ?></p>
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Handles encryption/decryption of sensitive data at rest.
|
* Handles encryption/decryption of sensitive data at rest.
|
||||||
*
|
*
|
||||||
@@ -25,7 +26,7 @@ class PSA_Encryption {
|
|||||||
* @param string $plaintext The plaintext to encrypt.
|
* @param string $plaintext The plaintext to encrypt.
|
||||||
* @return string|false Base64-encoded IV + ciphertext, or false on failure.
|
* @return string|false Base64-encoded IV + ciphertext, or false on failure.
|
||||||
*/
|
*/
|
||||||
public static function encrypt( $plaintext ) {
|
public static function encrypt( string $plaintext ) {
|
||||||
if ( empty( $plaintext ) ) {
|
if ( empty( $plaintext ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,7 @@ class PSA_Encryption {
|
|||||||
* @param string $ciphertext The base64-encoded IV + ciphertext.
|
* @param string $ciphertext The base64-encoded IV + ciphertext.
|
||||||
* @return string|false Decrypted plaintext, or false on failure.
|
* @return string|false Decrypted plaintext, or false on failure.
|
||||||
*/
|
*/
|
||||||
public static function decrypt( $ciphertext ) {
|
public static function decrypt( string $ciphertext ) {
|
||||||
if ( empty( $ciphertext ) ) {
|
if ( empty( $ciphertext ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ class PSA_Encryption {
|
|||||||
* @param string $value The value to check.
|
* @param string $value The value to check.
|
||||||
* @return bool True if the value appears to be encrypted.
|
* @return bool True if the value appears to be encrypted.
|
||||||
*/
|
*/
|
||||||
public static function is_encrypted( $value ) {
|
public static function is_encrypted( $value ): bool {
|
||||||
if ( empty( $value ) || ! is_string( $value ) ) {
|
if ( empty( $value ) || ! is_string( $value ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Standardized error handling for Peptide Search AI.
|
|
||||||
* Provides a unified interface for sending error responses with consistent structure.
|
|
||||||
*
|
|
||||||
* @package PeptideSearchAI
|
|
||||||
* @deprecated 4.1.0 Currently unused. Retained for potential future integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PSA_Error {
|
|
||||||
|
|
||||||
// Error type constants
|
|
||||||
const TYPE_VALIDATION = 'validation_error';
|
|
||||||
const TYPE_RATE_LIMIT = 'rate_limit_error';
|
|
||||||
const TYPE_SYSTEM = 'system_error';
|
|
||||||
const TYPE_NOT_FOUND = 'not_found';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a standardized error response.
|
|
||||||
*
|
|
||||||
* @param string $type The error type (one of the TYPE_* constants).
|
|
||||||
* @param string $message User-friendly error message.
|
|
||||||
* @param int $http_code HTTP response code (default 400).
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public static function send( $type, $message, $http_code = 400 ) {
|
|
||||||
wp_send_json_error(
|
|
||||||
array(
|
|
||||||
'error_type' => $type,
|
|
||||||
'message' => $message,
|
|
||||||
),
|
|
||||||
$http_code
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
247
includes/class-psa-kb-builder.php
Executable file
247
includes/class-psa-kb-builder.php
Executable file
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
|
/**
|
||||||
|
* Echo Knowledge Base article builder for generated peptide content.
|
||||||
|
*
|
||||||
|
* What: Creates structured KB articles from AI-generated peptide data with proper
|
||||||
|
* HTML formatting, category assignment, and cross-referencing to the source peptide post.
|
||||||
|
* Who calls it: PSA_AI_Generator::background_generate() after successful content generation.
|
||||||
|
* Dependencies: WordPress post API, Echo Knowledge Base CPT (epkb_post_type_1).
|
||||||
|
*
|
||||||
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-ai-generator.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PSA_KB_Builder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category slug mapping for Echo Knowledge Base taxonomy.
|
||||||
|
*/
|
||||||
|
const 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',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 .= '<strong>Category:</strong> ' . esc_html( $cat_label );
|
||||||
|
}
|
||||||
|
if ( $origin ) {
|
||||||
|
$header .= ' | <strong>Origin:</strong> ' . esc_html( $origin );
|
||||||
|
}
|
||||||
|
if ( $sequence ) {
|
||||||
|
$header .= ' | <strong>Sequence:</strong> ' . esc_html( $sequence );
|
||||||
|
}
|
||||||
|
|
||||||
|
$c = $header ? '<p>' . $header . '</p>' . "\n\n" : '';
|
||||||
|
|
||||||
|
// Overview.
|
||||||
|
$c .= '<h2>Overview</h2>' . "\n";
|
||||||
|
$c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' );
|
||||||
|
|
||||||
|
// Mechanism of Action.
|
||||||
|
if ( ! empty( $ai_data['mechanism'] ) ) {
|
||||||
|
$c .= '<h2>Mechanism of Action</h2>' . "\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 .= '<h2>Research & Potential Benefits</h2>' . "\n";
|
||||||
|
$c .= '<p><em>The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.</em></p>' . "\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 .= '<h2>Administration & Dosing (Research Context)</h2>' . "\n";
|
||||||
|
$c .= '<p><strong>Important:</strong> 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.</p>' . "\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 .= '<h2>Safety & Side Effects</h2>' . "\n";
|
||||||
|
$c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legal & Regulatory Status.
|
||||||
|
if ( ! empty( $ai_data['legal_regulatory'] ) ) {
|
||||||
|
$c .= '<h2>Legal & Regulatory Status</h2>' . "\n";
|
||||||
|
$c .= self::paragraphs( $ai_data['legal_regulatory'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// References.
|
||||||
|
if ( ! empty( $ai_data['references'] ) ) {
|
||||||
|
$c .= '<h2>Notable Research References</h2>' . "\n";
|
||||||
|
$c .= self::paragraphs( $ai_data['references'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medical Disclaimer.
|
||||||
|
$c .= "\n" . '<p><strong>Medical Disclaimer:</strong> 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.</p>';
|
||||||
|
|
||||||
|
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 <p> 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 .= '<p>' . wp_kses_post( $p ) . '</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 .= '<h3>' . esc_html( $block ) . '</h3>' . "\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 .= '<h3>' . esc_html( $first_line ) . '</h3>' . "\n";
|
||||||
|
$out .= '<p>' . wp_kses_post( $rest ) . '</p>' . "\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out .= '<p>' . wp_kses_post( $block ) . '</p>' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
197
includes/class-psa-openrouter.php
Executable file
197
includes/class-psa-openrouter.php
Executable file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
|
/**
|
||||||
|
* OpenRouter API communication layer.
|
||||||
|
*
|
||||||
|
* What: Handles HTTP requests to OpenRouter API with retry logic, rate limit handling,
|
||||||
|
* and response parsing. Logs all calls via PSA_Cost_Tracker.
|
||||||
|
* Who calls it: PSA_AI_Generator for validation and generation API calls.
|
||||||
|
* Dependencies: WordPress HTTP API (wp_remote_post), PSA_Config, PSA_Cost_Tracker.
|
||||||
|
*
|
||||||
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-ai-generator.php
|
||||||
|
* @see includes/class-psa-cost-tracker.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PSA_OpenRouter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter API base URL.
|
||||||
|
*/
|
||||||
|
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call OpenRouter API with automatic retry on rate limits.
|
||||||
|
*
|
||||||
|
* Implements exponential backoff retry strategy for rate limit (429) errors.
|
||||||
|
* On first rate limit: waits 5s before retry.
|
||||||
|
* On second rate limit: waits 10s before retry.
|
||||||
|
* On third rate limit: waits 20s before final attempt.
|
||||||
|
* Other errors are returned immediately without 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.
|
||||||
|
*/
|
||||||
|
public static function send_request( string $api_key, string $model, string $prompt, int $max_tokens = PSA_Config::GENERATION_MAX_TOKENS, string $request_type = 'generation', string $peptide_name = '' ) {
|
||||||
|
$max_retries = PSA_Config::API_RETRY_MAX;
|
||||||
|
$base_delay = PSA_Config::API_RETRY_BASE_DELAY; // seconds
|
||||||
|
|
||||||
|
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
|
||||||
|
$result = self::send_request_once( $api_key, $model, $prompt, $max_tokens, $request_type, $peptide_name );
|
||||||
|
|
||||||
|
// If not a rate limit error, return immediately.
|
||||||
|
if ( ! is_wp_error( $result ) || 'rate_limited' !== $result->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 <think>...</think> tags (some models like DeepSeek include reasoning).
|
||||||
|
$cleaned = preg_replace( '/<think>.*?<\/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Registers the Peptide custom post type and its meta fields.
|
* Registers the Peptide custom post type, taxonomy, and meta fields.
|
||||||
|
*
|
||||||
|
* What: Registers the 'peptide' CPT, 'peptide_category' taxonomy, meta boxes, and save handlers.
|
||||||
|
* Who calls it: Main plugin file via psa_init() and psa_admin_init() hooks.
|
||||||
|
* Dependencies: WordPress CPT API, taxonomy API, meta box API.
|
||||||
*
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-ai-generator.php — auto-assigns category term after generation
|
||||||
|
* @see includes/class-psa-directory.php — reads taxonomy for directory filtering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -11,33 +18,66 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
|
|
||||||
class PSA_Post_Type {
|
class PSA_Post_Type {
|
||||||
|
|
||||||
/** Meta field keys used throughout the plugin. */
|
/**
|
||||||
|
* Default peptide category terms pre-populated on activation.
|
||||||
|
* Key = slug, value = display name.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CATEGORIES = array(
|
||||||
|
'tissue-repair' => '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(
|
const META_FIELDS = array(
|
||||||
'psa_sequence' => 'Amino Acid Sequence',
|
'psa_sequence' => 'Amino Acid Sequence',
|
||||||
'psa_molecular_weight' => 'Molecular Weight',
|
'psa_molecular_weight' => 'Molecular Weight',
|
||||||
'psa_molecular_formula' => 'Molecular Formula',
|
'psa_molecular_formula' => 'Molecular Formula',
|
||||||
'psa_aliases' => 'Aliases / Synonyms',
|
'psa_aliases' => 'Aliases / Synonyms',
|
||||||
'psa_mechanism' => 'Mechanism of Action',
|
'psa_mechanism' => 'Mechanism of Action',
|
||||||
'psa_research_apps' => 'Research Applications',
|
'psa_research_apps' => 'Research Applications',
|
||||||
'psa_safety_profile' => 'Safety & Side Effects',
|
'psa_safety_profile' => 'Safety & Side Effects',
|
||||||
'psa_dosage_info' => 'Dosage Information',
|
'psa_dosage_info' => 'Dosage Information',
|
||||||
'psa_references' => 'References & Citations',
|
'psa_references' => 'References & Citations',
|
||||||
'psa_source' => 'Data Source',
|
'psa_source' => 'Data Source',
|
||||||
'psa_pubchem_cid' => 'PubChem CID',
|
'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( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
|
||||||
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
|
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.
|
* Register the peptide CPT.
|
||||||
*/
|
*/
|
||||||
public static function register_peptide_post_type() {
|
public static function register_peptide_post_type(): void {
|
||||||
if ( post_type_exists( 'peptide' ) ) {
|
if ( post_type_exists( 'peptide' ) ) {
|
||||||
return;
|
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.
|
* Add meta boxes to the peptide editor.
|
||||||
*/
|
*/
|
||||||
public static function add_meta_boxes() {
|
public static function add_meta_boxes(): void {
|
||||||
add_meta_box(
|
add_meta_box(
|
||||||
'psa_peptide_data',
|
'psa_peptide_data',
|
||||||
'Peptide Scientific Data',
|
'Peptide Scientific Data',
|
||||||
@@ -84,14 +171,23 @@ class PSA_Post_Type {
|
|||||||
'normal',
|
'normal',
|
||||||
'high'
|
'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.
|
* @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' );
|
wp_nonce_field( 'psa_save_meta', 'psa_meta_nonce' );
|
||||||
|
|
||||||
$textarea_fields = array(
|
$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 '<table class="form-table">';
|
||||||
|
foreach ( self::EXTENDED_META_FIELDS as $key => $label ) {
|
||||||
|
$value = get_post_meta( $post->ID, $key, true );
|
||||||
|
echo '<tr>';
|
||||||
|
echo '<th><label for="' . esc_attr( $key ) . '">' . esc_html( $label ) . '</label></th>';
|
||||||
|
echo '<td>';
|
||||||
|
|
||||||
|
if ( 'psa_vial_size_mg' === $key ) {
|
||||||
|
echo '<input type="number" step="0.01" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" /> mg';
|
||||||
|
} elseif ( 'psa_amino_acid_count' === $key ) {
|
||||||
|
echo '<input type="number" step="1" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="small-text" />';
|
||||||
|
} else {
|
||||||
|
echo '<input type="text" name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" value="' . esc_attr( $value ) . '" class="regular-text" />';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</td></tr>';
|
||||||
|
}
|
||||||
|
echo '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save meta field values (core + extended).
|
||||||
*
|
*
|
||||||
* @param int $post_id The post ID.
|
* @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
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||||
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
|
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
|
||||||
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
|
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
|
||||||
@@ -152,11 +275,69 @@ class PSA_Post_Type {
|
|||||||
return;
|
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 ] ) ) {
|
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 );
|
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<string> Meta keys.
|
||||||
|
*/
|
||||||
|
public static function get_all_meta_keys(): array {
|
||||||
|
return array_merge(
|
||||||
|
array_keys( self::META_FIELDS ),
|
||||||
|
array_keys( self::EXTENDED_META_FIELDS )
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* PubChem PUG REST API integration for molecular data enrichment.
|
* PubChem PUG REST API integration for molecular data enrichment.
|
||||||
*
|
*
|
||||||
|
* What: Fetches verified molecular data (formula, weight, SMILES, InChI) from PubChem.
|
||||||
|
* Who calls it: PSA_AI_Generator::background_generate() during peptide content generation.
|
||||||
|
* Dependencies: WordPress HTTP API (wp_remote_get), PSA_Config for cache TTL.
|
||||||
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-ai-generator.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -20,7 +26,7 @@ class PSA_PubChem {
|
|||||||
* @param string $name Peptide or compound name.
|
* @param string $name Peptide or compound name.
|
||||||
* @return array|WP_Error|null Array of properties, WP_Error on failure, or null if not found.
|
* @return array|WP_Error|null Array of properties, WP_Error on failure, or null if not found.
|
||||||
*/
|
*/
|
||||||
public static function lookup( $name ) {
|
public static function lookup( string $name ) {
|
||||||
$options = get_option( 'psa_settings', array() );
|
$options = get_option( 'psa_settings', array() );
|
||||||
$use_pubchem = $options['use_pubchem'] ?? '1';
|
$use_pubchem = $options['use_pubchem'] ?? '1';
|
||||||
|
|
||||||
@@ -79,7 +85,7 @@ class PSA_PubChem {
|
|||||||
* @param string $name Compound name.
|
* @param string $name Compound name.
|
||||||
* @return int|WP_Error|null CID, WP_Error on API failure, or null if not found.
|
* @return int|WP_Error|null CID, WP_Error on API failure, or null if not found.
|
||||||
*/
|
*/
|
||||||
private static function get_cid_by_name( $name ) {
|
private static function get_cid_by_name( string $name ) {
|
||||||
$url = self::API_BASE . '/compound/name/' . rawurlencode( $name ) . '/cids/JSON';
|
$url = self::API_BASE . '/compound/name/' . rawurlencode( $name ) . '/cids/JSON';
|
||||||
|
|
||||||
$response = wp_remote_get(
|
$response = wp_remote_get(
|
||||||
@@ -112,7 +118,7 @@ class PSA_PubChem {
|
|||||||
* @param int $cid PubChem Compound ID.
|
* @param int $cid PubChem Compound ID.
|
||||||
* @return array|WP_Error|null Properties array, WP_Error on failure, or null if not found.
|
* @return array|WP_Error|null Properties array, WP_Error on failure, or null if not found.
|
||||||
*/
|
*/
|
||||||
private static function get_properties( $cid ) {
|
private static function get_properties( int $cid ) {
|
||||||
$props = 'MolecularFormula,MolecularWeight,IUPACName,CanonicalSMILES,InChI';
|
$props = 'MolecularFormula,MolecularWeight,IUPACName,CanonicalSMILES,InChI';
|
||||||
$url = self::API_BASE . '/compound/cid/' . (int) $cid . '/property/' . $props . '/JSON';
|
$url = self::API_BASE . '/compound/cid/' . (int) $cid . '/property/' . $props . '/JSON';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
|
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
|
||||||
* When a peptide is not found, it automatically validates and queues generation.
|
*
|
||||||
|
* What: Core search logic — AJAX handler, REST endpoint, shortcode rendering, cache management.
|
||||||
|
* Who calls it: WordPress AJAX/REST hooks, shortcode parser, save_post_peptide action.
|
||||||
|
* Dependencies: PSA_Config, PSA_AI_Generator, WordPress AJAX/REST/shortcode APIs.
|
||||||
*
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-ai-generator.php
|
||||||
|
* @see includes/class-psa-config.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -19,7 +25,7 @@ class PSA_Search {
|
|||||||
*/
|
*/
|
||||||
private static $instance_count = 0;
|
private static $instance_count = 0;
|
||||||
|
|
||||||
public static function init() {
|
public static function init(): void {
|
||||||
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
|
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
|
||||||
add_action( 'wp_ajax_psa_search', array( __CLASS__, 'ajax_search' ) );
|
add_action( 'wp_ajax_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||||
add_action( 'wp_ajax_nopriv_psa_search', array( __CLASS__, 'ajax_search' ) );
|
add_action( 'wp_ajax_nopriv_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||||
@@ -38,7 +44,7 @@ class PSA_Search {
|
|||||||
*
|
*
|
||||||
* @return int The current generation counter (default 0).
|
* @return int The current generation counter (default 0).
|
||||||
*/
|
*/
|
||||||
private static function get_cache_generation() {
|
private static function get_cache_generation(): int {
|
||||||
$gen = (int) get_option( 'psa_search_cache_gen', 0 );
|
$gen = (int) get_option( 'psa_search_cache_gen', 0 );
|
||||||
return $gen;
|
return $gen;
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ class PSA_Search {
|
|||||||
*
|
*
|
||||||
* @param int $post_id The post ID being saved.
|
* @param int $post_id The post ID being saved.
|
||||||
*/
|
*/
|
||||||
public static function invalidate_search_cache( $post_id ) {
|
public static function invalidate_search_cache( int $post_id ): void {
|
||||||
$current_gen = self::get_cache_generation();
|
$current_gen = self::get_cache_generation();
|
||||||
update_option( 'psa_search_cache_gen', $current_gen + 1 );
|
update_option( 'psa_search_cache_gen', $current_gen + 1 );
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,7 @@ class PSA_Search {
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function flush_all_search_caches() {
|
public static function flush_all_search_caches(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
$wpdb->query(
|
$wpdb->query(
|
||||||
@@ -87,7 +93,7 @@ class PSA_Search {
|
|||||||
* Register REST API routes for external/headless access.
|
* Register REST API routes for external/headless access.
|
||||||
* Note: REST route only returns existing published results — it does NOT trigger generation.
|
* 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(
|
register_rest_route(
|
||||||
'peptides/v1',
|
'peptides/v1',
|
||||||
'/search',
|
'/search',
|
||||||
@@ -171,7 +177,7 @@ class PSA_Search {
|
|||||||
* @param array $atts Shortcode attributes.
|
* @param array $atts Shortcode attributes.
|
||||||
* @return string HTML output.
|
* @return string HTML output.
|
||||||
*/
|
*/
|
||||||
public static function render_search_form( $atts ) {
|
public static function render_search_form( $atts ): string {
|
||||||
self::$instance_count++;
|
self::$instance_count++;
|
||||||
$instance = self::$instance_count;
|
$instance = self::$instance_count;
|
||||||
|
|
||||||
@@ -224,7 +230,7 @@ class PSA_Search {
|
|||||||
*
|
*
|
||||||
* @return void Exits with wp_send_json_* responses.
|
* @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' );
|
check_ajax_referer( 'psa_search_nonce', 'nonce' );
|
||||||
|
|
||||||
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
|
$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.
|
* @param WP_Post $pending The pending post.
|
||||||
* @return void Exits with wp_send_json_success().
|
* @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 );
|
$started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true );
|
||||||
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
|
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
|
||||||
$timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT;
|
$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.
|
* @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();
|
$ip = psa_get_client_ip();
|
||||||
$rate_key = 'psa_rate_' . md5( $ip );
|
$rate_key = 'psa_rate_' . md5( $ip );
|
||||||
|
|
||||||
@@ -346,7 +352,7 @@ class PSA_Search {
|
|||||||
* @param string $query The search query (peptide name).
|
* @param string $query The search query (peptide name).
|
||||||
* @return void Exits with wp_send_json_* responses.
|
* @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.
|
// Global daily generation cap to prevent cost overruns.
|
||||||
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
|
$daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
|
||||||
$daily_count = (int) get_transient( $daily_key );
|
$daily_count = (int) get_transient( $daily_key );
|
||||||
@@ -424,7 +430,7 @@ class PSA_Search {
|
|||||||
* @param string $query Search term (peptide name or alias).
|
* @param string $query Search term (peptide name or alias).
|
||||||
* @return WP_Post|null Pending post object or null if not found.
|
* @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;
|
global $wpdb;
|
||||||
|
|
||||||
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
||||||
@@ -457,7 +463,7 @@ class PSA_Search {
|
|||||||
* @param string $peptide_name The canonical peptide name.
|
* @param string $peptide_name The canonical peptide name.
|
||||||
* @return int|WP_Error Post ID or error.
|
* @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(
|
$post_id = wp_insert_post(
|
||||||
array(
|
array(
|
||||||
'post_type' => 'peptide',
|
'post_type' => 'peptide',
|
||||||
@@ -485,7 +491,7 @@ class PSA_Search {
|
|||||||
* @param int $post_id The placeholder post ID.
|
* @param int $post_id The placeholder post ID.
|
||||||
* @param string $peptide_name The peptide to research.
|
* @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 );
|
$args = array( $post_id, $peptide_name );
|
||||||
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
|
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
|
||||||
wp_schedule_single_event( time(), '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.
|
* @param string $query Search term.
|
||||||
* @return array { results: array, total: int, query: string }
|
* @return array { results: array, total: int, query: string }
|
||||||
*/
|
*/
|
||||||
public static function search_peptides( $query ) {
|
public static function search_peptides( string $query ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
// Check transient cache first.
|
// Check transient cache first.
|
||||||
@@ -564,7 +570,7 @@ class PSA_Search {
|
|||||||
* @param int $post_id The post ID.
|
* @param int $post_id The post ID.
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function format_peptide( $post_id ) {
|
public static function format_peptide( int $post_id ): array {
|
||||||
$post = get_post( $post_id );
|
$post = get_post( $post_id );
|
||||||
if ( ! $post ) {
|
if ( ! $post ) {
|
||||||
return array();
|
return array();
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
/**
|
/**
|
||||||
* Custom template rendering for single peptide pages.
|
* Custom template rendering for single peptide pages.
|
||||||
*
|
*
|
||||||
|
* What: Appends structured peptide data (quick facts, extended data, sections, badges) below post content.
|
||||||
|
* Who calls it: WordPress 'the_content' filter on single peptide pages.
|
||||||
|
* Dependencies: WordPress post meta API, PSA_Post_Type meta field keys.
|
||||||
|
*
|
||||||
* @package PeptideSearchAI
|
* @package PeptideSearchAI
|
||||||
|
* @see includes/class-psa-post-type.php
|
||||||
|
* @see includes/class-psa-directory.php — detail modal uses the same data via REST
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
@@ -11,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
|
|
||||||
class PSA_Template {
|
class PSA_Template {
|
||||||
|
|
||||||
public static function init() {
|
public static function init(): void {
|
||||||
add_filter( 'the_content', array( __CLASS__, 'append_peptide_data' ) );
|
add_filter( 'the_content', array( __CLASS__, 'append_peptide_data' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +28,7 @@ class PSA_Template {
|
|||||||
* @param string $content The post content.
|
* @param string $content The post content.
|
||||||
* @return string Modified content.
|
* @return string Modified content.
|
||||||
*/
|
*/
|
||||||
public static function append_peptide_data( $content ) {
|
public static function append_peptide_data( string $content ): string {
|
||||||
if ( ! is_singular( 'peptide' ) || ! in_the_loop() || ! is_main_query() ) {
|
if ( ! is_singular( 'peptide' ) || ! in_the_loop() || ! is_main_query() ) {
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
@@ -30,17 +37,14 @@ class PSA_Template {
|
|||||||
$source = get_post_meta( $post_id, 'psa_source', true );
|
$source = get_post_meta( $post_id, 'psa_source', true );
|
||||||
$pubchem_cid = get_post_meta( $post_id, 'psa_pubchem_cid', true );
|
$pubchem_cid = get_post_meta( $post_id, 'psa_pubchem_cid', true );
|
||||||
|
|
||||||
// Build template data for filtering.
|
|
||||||
$data = array(
|
$data = array(
|
||||||
'post_id' => $post_id,
|
'post_id' => $post_id,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'pubchem_cid' => $pubchem_cid,
|
'pubchem_cid' => $pubchem_cid,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Allow filtering of template data before rendering.
|
|
||||||
$data = apply_filters( 'psa_template_data', $data, $post_id );
|
$data = apply_filters( 'psa_template_data', $data, $post_id );
|
||||||
|
|
||||||
// Restore values from possibly filtered data.
|
|
||||||
$post_id = $data['post_id'] ?? $post_id;
|
$post_id = $data['post_id'] ?? $post_id;
|
||||||
$source = $data['source'] ?? $source;
|
$source = $data['source'] ?? $source;
|
||||||
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
|
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
|
||||||
@@ -48,38 +52,117 @@ class PSA_Template {
|
|||||||
$html = '<div class="psa-peptide-data">';
|
$html = '<div class="psa-peptide-data">';
|
||||||
|
|
||||||
// Quick facts box.
|
// 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 .= '</div>';
|
||||||
|
|
||||||
|
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 );
|
$weight = get_post_meta( $post_id, 'psa_molecular_weight', true );
|
||||||
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
|
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
|
||||||
$seq = get_post_meta( $post_id, 'psa_sequence', true );
|
$seq = get_post_meta( $post_id, 'psa_sequence', true );
|
||||||
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
|
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
|
||||||
|
|
||||||
if ( $weight || $formula || $seq ) {
|
if ( ! $weight && ! $formula && ! $seq ) {
|
||||||
$html .= '<div class="psa-quick-facts">';
|
return '';
|
||||||
$html .= '<h3>Quick Facts</h3>';
|
|
||||||
$html .= '<table class="psa-facts-table">';
|
|
||||||
|
|
||||||
if ( $aliases ) {
|
|
||||||
$html .= '<tr><th>Also Known As</th><td>' . esc_html( $aliases ) . '</td></tr>';
|
|
||||||
}
|
|
||||||
if ( $seq ) {
|
|
||||||
$html .= '<tr><th>Sequence</th><td><code class="psa-sequence">' . esc_html( $seq ) . '</code></td></tr>';
|
|
||||||
}
|
|
||||||
if ( $formula ) {
|
|
||||||
$html .= '<tr><th>Molecular Formula</th><td>' . esc_html( $formula ) . '</td></tr>';
|
|
||||||
}
|
|
||||||
if ( $weight ) {
|
|
||||||
$html .= '<tr><th>Molecular Weight</th><td>' . esc_html( $weight ) . '</td></tr>';
|
|
||||||
}
|
|
||||||
if ( $pubchem_cid ) {
|
|
||||||
$cid_int = intval( $pubchem_cid );
|
|
||||||
$html .= '<tr><th>PubChem CID</th><td><a href="https://pubchem.ncbi.nlm.nih.gov/compound/' . $cid_int . '" target="_blank" rel="noopener">' . $cid_int . '</a></td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '</table>';
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detailed sections.
|
$html = '<div class="psa-quick-facts">';
|
||||||
|
$html .= '<h3>Quick Facts</h3>';
|
||||||
|
$html .= '<table class="psa-facts-table">';
|
||||||
|
|
||||||
|
if ( $aliases ) {
|
||||||
|
$html .= '<tr><th>Also Known As</th><td>' . esc_html( $aliases ) . '</td></tr>';
|
||||||
|
}
|
||||||
|
if ( $seq ) {
|
||||||
|
$html .= '<tr><th>Sequence</th><td><code class="psa-sequence">' . esc_html( $seq ) . '</code></td></tr>';
|
||||||
|
}
|
||||||
|
if ( $formula ) {
|
||||||
|
$html .= '<tr><th>Molecular Formula</th><td>' . esc_html( $formula ) . '</td></tr>';
|
||||||
|
}
|
||||||
|
if ( $weight ) {
|
||||||
|
$html .= '<tr><th>Molecular Weight</th><td>' . esc_html( $weight ) . '</td></tr>';
|
||||||
|
}
|
||||||
|
if ( $pubchem_cid ) {
|
||||||
|
$cid_int = intval( $pubchem_cid );
|
||||||
|
$html .= '<tr><th>PubChem CID</th><td><a href="https://pubchem.ncbi.nlm.nih.gov/compound/' . $cid_int . '" target="_blank" rel="noopener">' . $cid_int . '</a></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</table></div>';
|
||||||
|
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 .= '<tr><th>' . esc_html( $label ) . '</th><td>' . esc_html( $display ) . '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $rows ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="psa-quick-facts">' .
|
||||||
|
'<h3>Research Parameters</h3>' .
|
||||||
|
'<table class="psa-facts-table">' . $rows . '</table>' .
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
$detail_fields = array(
|
||||||
'psa_mechanism' => 'Mechanism of Action',
|
'psa_mechanism' => 'Mechanism of Action',
|
||||||
'psa_research_apps' => 'Research Applications',
|
'psa_research_apps' => 'Research Applications',
|
||||||
@@ -88,6 +171,7 @@ class PSA_Template {
|
|||||||
'psa_references' => 'References',
|
'psa_references' => 'References',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
foreach ( $detail_fields as $key => $label ) {
|
foreach ( $detail_fields as $key => $label ) {
|
||||||
$value = get_post_meta( $post_id, $key, true );
|
$value = get_post_meta( $post_id, $key, true );
|
||||||
if ( ! empty( $value ) ) {
|
if ( ! empty( $value ) ) {
|
||||||
@@ -98,34 +182,43 @@ class PSA_Template {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source badge.
|
return $html;
|
||||||
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'];
|
|
||||||
|
|
||||||
$html .= '<div class="psa-source-badge ' . esc_attr( $badge['class'] ) . '">';
|
/**
|
||||||
$html .= esc_html( $badge['label'] );
|
* Render the source badge.
|
||||||
if ( 'ai-generated' === $source ) {
|
*
|
||||||
$html .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.';
|
* @param string $source The data source value.
|
||||||
}
|
* @return string HTML (empty if source is empty or 'pending').
|
||||||
$html .= '</div>';
|
*/
|
||||||
|
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 = '<div class="psa-source-badge ' . esc_attr( $badge['class'] ) . '">';
|
||||||
|
$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 .= '</div>';
|
$html .= '</div>';
|
||||||
|
|
||||||
return $content . $html;
|
return $html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: Peptide Search AI
|
* Plugin Name: Peptide Search AI
|
||||||
* Plugin URI: https://example.com/peptide-search-ai
|
* Plugin URI: https://example.com/peptide-search-ai
|
||||||
* Description: Searchable peptide database with AI-powered auto-population.
|
* Description: Searchable peptide database with AI-powered auto-population and browsable directory.
|
||||||
* Version: 4.1.0
|
* Version: 4.3.0
|
||||||
* Author: Terence
|
* Author: Terence
|
||||||
* License: GPL v2 or later
|
* License: GPL v2 or later
|
||||||
* Text Domain: peptide-search-ai
|
* Text Domain: peptide-search-ai
|
||||||
@@ -15,28 +15,32 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'PSA_VERSION', '4.1.0' );
|
define( 'PSA_VERSION', '4.3.0' );
|
||||||
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||||
define( 'PSA_PLUGIN_FILE', __FILE__ );
|
define( 'PSA_PLUGIN_FILE', __FILE__ );
|
||||||
|
|
||||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-config.php';
|
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-encryption.php';
|
||||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.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-search.php';
|
||||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.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-ai-generator.php';
|
||||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-pubchem.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-admin.php';
|
||||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-template.php';
|
require_once PSA_PLUGIN_DIR . 'includes/class-psa-template.php';
|
||||||
|
require_once PSA_PLUGIN_DIR . 'includes/class-psa-directory.php';
|
||||||
|
|
||||||
function psa_init() {
|
function psa_init() {
|
||||||
PSA_Post_Type::register_peptide_post_type();
|
PSA_Post_Type::register_peptide_post_type();
|
||||||
|
PSA_Post_Type::register_taxonomy();
|
||||||
PSA_Search::init();
|
PSA_Search::init();
|
||||||
PSA_AI_Generator::init();
|
PSA_AI_Generator::init();
|
||||||
PSA_Admin::init();
|
PSA_Admin::init();
|
||||||
PSA_Template::init();
|
PSA_Template::init();
|
||||||
|
PSA_Directory::init();
|
||||||
}
|
}
|
||||||
add_action( 'init', 'psa_init' );
|
add_action( 'init', 'psa_init' );
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ function psa_is_kb_page() {
|
|||||||
* - KB page (page ID 73)
|
* - KB page (page ID 73)
|
||||||
* - Any page/post containing the [peptide_search] shortcode
|
* - Any page/post containing the [peptide_search] shortcode
|
||||||
* - Single peptide pages
|
* - Single peptide pages
|
||||||
|
* - Any page/post containing the [peptide_directory] shortcode
|
||||||
*/
|
*/
|
||||||
function psa_maybe_enqueue_assets() {
|
function psa_maybe_enqueue_assets() {
|
||||||
// Homepage: always enqueue (hero section + search overlay use the shortcode).
|
// Homepage: always enqueue (hero section + search overlay use the shortcode).
|
||||||
@@ -80,16 +85,26 @@ function psa_maybe_enqueue_assets() {
|
|||||||
return;
|
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;
|
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' );
|
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() {
|
function psa_enqueue_frontend_assets() {
|
||||||
wp_enqueue_style(
|
wp_enqueue_style(
|
||||||
@@ -113,10 +128,58 @@ function psa_enqueue_frontend_assets() {
|
|||||||
array(
|
array(
|
||||||
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||||
'nonce' => wp_create_nonce( 'psa_search_nonce' ),
|
'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.
|
* 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() {
|
function psa_activate() {
|
||||||
PSA_Post_Type::register_peptide_post_type();
|
PSA_Post_Type::register_peptide_post_type();
|
||||||
|
PSA_Post_Type::register_taxonomy();
|
||||||
|
PSA_Post_Type::populate_default_categories();
|
||||||
PSA_Cost_Tracker::create_table();
|
PSA_Cost_Tracker::create_table();
|
||||||
flush_rewrite_rules();
|
flush_rewrite_rules();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Define WP_Error class if it doesn't exist.
|
||||||
if ( ! class_exists( 'WP_Error' ) ) {
|
if ( ! class_exists( 'WP_Error' ) ) {
|
||||||
class WP_Error {
|
class WP_Error {
|
||||||
@@ -111,4 +126,7 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
|||||||
|
|
||||||
// Require the class files under test.
|
// Require the class files under test.
|
||||||
require_once ABSPATH . 'includes/class-psa-config.php';
|
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';
|
require_once ABSPATH . 'includes/class-psa-ai-generator.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"}';
|
$response_text = '{"is_valid": true, "canonical_name": "BPC-157", "reason": "A known peptide"}';
|
||||||
|
|
||||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
|
||||||
$method->setAccessible( true );
|
|
||||||
|
|
||||||
$result = $method->invoke( null, $response_text );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
$this->assertIsArray( $result );
|
||||||
$this->assertArrayHasKey( 'is_valid', $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 = <<<JSON
|
$response_text = <<<JSON
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"is_valid": true, "canonical_name": "Semaglutide", "reason": "GLP-1 agonist"}
|
{"is_valid": true, "canonical_name": "Semaglutide", "reason": "GLP-1 agonist"}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
JSON;
|
JSON;
|
||||||
|
|
||||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
|
||||||
$method->setAccessible( true );
|
|
||||||
|
|
||||||
$result = $method->invoke( null, $response_text );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
$this->assertIsArray( $result );
|
||||||
$this->assertTrue( $result['is_valid'] );
|
$this->assertTrue( $result['is_valid'] );
|
||||||
@@ -136,9 +129,9 @@ JSON;
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test parse_ai_response with <think> tags (DeepSeek model).
|
* Test parse_response with <think> tags (DeepSeek model).
|
||||||
*/
|
*/
|
||||||
public function test_parse_ai_response_with_think_tags() {
|
public function test_parse_response_with_think_tags() {
|
||||||
$response_text = <<<JSON
|
$response_text = <<<JSON
|
||||||
<think>
|
<think>
|
||||||
This is a known peptide from various sources.
|
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"}
|
{"is_valid": true, "canonical_name": "LL-37", "reason": "Antimicrobial peptide"}
|
||||||
JSON;
|
JSON;
|
||||||
|
|
||||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
|
||||||
$method->setAccessible( true );
|
|
||||||
|
|
||||||
$result = $method->invoke( null, $response_text );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
$this->assertIsArray( $result );
|
||||||
$this->assertTrue( $result['is_valid'] );
|
$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}';
|
$response_text = '{not valid json}';
|
||||||
|
|
||||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
|
||||||
$method->setAccessible( true );
|
|
||||||
|
|
||||||
$result = $method->invoke( null, $response_text );
|
|
||||||
|
|
||||||
$this->assertTrue( is_wp_error( $result ) );
|
$this->assertTrue( is_wp_error( $result ) );
|
||||||
$this->assertEquals( 'parse_error', $result->get_error_code() );
|
$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 = '';
|
$response_text = '';
|
||||||
|
|
||||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
|
||||||
$method->setAccessible( true );
|
|
||||||
|
|
||||||
$result = $method->invoke( null, $response_text );
|
|
||||||
|
|
||||||
$this->assertTrue( is_wp_error( $result ) );
|
$this->assertTrue( is_wp_error( $result ) );
|
||||||
$this->assertEquals( 'parse_error', $result->get_error_code() );
|
$this->assertEquals( 'parse_error', $result->get_error_code() );
|
||||||
|
|||||||
136
tests/unit/DirectoryTest.php
Executable file
136
tests/unit/DirectoryTest.php
Executable file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PSA_Directory class.
|
||||||
|
*
|
||||||
|
* Covers: constants, format_compound() output structure.
|
||||||
|
*
|
||||||
|
* @package PeptideSearchAI
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare( strict_types=1 );
|
||||||
|
|
||||||
|
// Stub WordPress functions needed by PSA_Directory.
|
||||||
|
if ( ! function_exists( 'add_shortcode' ) ) {
|
||||||
|
function add_shortcode() {}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'add_action' ) ) {
|
||||||
|
function add_action() {}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'register_rest_route' ) ) {
|
||||||
|
function register_rest_route() {}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'get_permalink' ) ) {
|
||||||
|
function get_permalink( $id ) {
|
||||||
|
return 'https://example.com/peptides/test-peptide/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'wp_get_object_terms' ) ) {
|
||||||
|
function wp_get_object_terms( $id, $taxonomy, $args = array() ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'get_post_meta' ) ) {
|
||||||
|
function get_post_meta( $post_id, $key = '', $single = false ) {
|
||||||
|
$meta = array(
|
||||||
|
'psa_half_life' => '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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
144
tests/unit/PostTypeTest.php
Executable file
144
tests/unit/PostTypeTest.php
Executable file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PSA_Post_Type class.
|
||||||
|
*
|
||||||
|
* Covers: taxonomy constants, default categories list, meta field constants,
|
||||||
|
* extended meta fields, and get_all_meta_keys() helper.
|
||||||
|
*
|
||||||
|
* @package PeptideSearchAI
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare( strict_types=1 );
|
||||||
|
|
||||||
|
// Stub WordPress functions needed by PSA_Post_Type but not by the test bootstrap.
|
||||||
|
if ( ! function_exists( 'post_type_exists' ) ) {
|
||||||
|
function post_type_exists( $post_type ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'register_post_type' ) ) {
|
||||||
|
function register_post_type( $post_type, $args = array() ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'taxonomy_exists' ) ) {
|
||||||
|
function taxonomy_exists( $taxonomy ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'register_taxonomy' ) ) {
|
||||||
|
function register_taxonomy( $taxonomy, $object_type, $args = array() ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( '__' ) ) {
|
||||||
|
function __( $text, $domain = 'default' ) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'add_action' ) ) {
|
||||||
|
function add_action() {}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'add_filter' ) ) {
|
||||||
|
function add_filter() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname( __DIR__ ) . '/bootstrap.php';
|
||||||
|
require_once ABSPATH . 'includes/class-psa-post-type.php';
|
||||||
|
|
||||||
|
class PostTypeTest extends \PHPUnit\Framework\TestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify default categories constant contains exactly 8 entries.
|
||||||
|
*/
|
||||||
|
public function test_default_categories_count(): void {
|
||||||
|
$this->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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,18 +25,35 @@ if ( ! empty( $peptide_ids ) ) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete plugin options.
|
// 2. Delete peptide_category taxonomy terms and relationships.
|
||||||
delete_option( 'psa_settings' );
|
$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(
|
$wpdb->query(
|
||||||
"DELETE FROM {$wpdb->options}
|
$wpdb->prepare(
|
||||||
WHERE option_name LIKE '_transient_psa_%'
|
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
|
||||||
OR option_name LIKE '_transient_timeout_psa_%'"
|
$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
|
$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();
|
flush_rewrite_rules();
|
||||||
|
|||||||
Reference in New Issue
Block a user