fix: correct class-psa-search.php blob (was truncated in chunked upload)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
69
.github/workflows/ci.yml
vendored
Normal file
69
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: CI — Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── PHP Syntax Check ─────────────────────────────────────────────
|
||||
lint-php:
|
||||
name: PHP Lint (${{ 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 }}
|
||||
|
||||
- name: Syntax check all PHP files
|
||||
run: find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
|
||||
|
||||
# ── WordPress Coding Standards (PHP CodeSniffer) ──────────────────
|
||||
lint-phpcs:
|
||||
name: WordPress Coding Standards
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
composer install --no-interaction --prefer-dist --no-progress
|
||||
|
||||
- name: Run PHPCS with WordPress-Core standard
|
||||
run: |
|
||||
composer run lint
|
||||
|
||||
# ── JavaScript Lint ──────────────────────────────────────────────
|
||||
lint-js:
|
||||
name: JavaScript Linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Check JavaScript syntax
|
||||
run: |
|
||||
for f in $(find assets/js -name '*.js' -type f); do
|
||||
node --check "$f" || exit 1
|
||||
done
|
||||
echo "All JavaScript files passed syntax check"
|
||||
54
.github/workflows/deploy.yml
vendored
Normal file
54
.github/workflows/deploy.yml
vendored
Normal file
File diff suppressed because one or more lines are too long
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Dependencies
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.zip
|
||||
*.tar.gz
|
||||
25
.phpcs.xml.dist
Normal file
25
.phpcs.xml.dist
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="Peptide Search AI">
|
||||
<description>PHP CodeSniffer configuration for Peptide Search AI WordPress plugin</description>
|
||||
|
||||
<!-- Set baseline standard to WordPress-Core -->
|
||||
<rule ref="WordPress-Core">
|
||||
<!-- Allow direct database queries with prepare() statements -->
|
||||
<exclude name="WordPress.DB.DirectDatabaseQuery" />
|
||||
</rule>
|
||||
|
||||
<!-- Files and directories to check -->
|
||||
<file>peptide-search-ai.php</file>
|
||||
<file>includes/</file>
|
||||
|
||||
<!-- Exclude vendor and node_modules -->
|
||||
<exclude-pattern>/vendor/*</exclude-pattern>
|
||||
<exclude-pattern>/node_modules/*</exclude-pattern>
|
||||
<exclude-pattern>/.git/*</exclude-pattern>
|
||||
<exclude-pattern>/.github/*</exclude-pattern>
|
||||
|
||||
<!-- Report settings -->
|
||||
<arg name="colors" />
|
||||
<arg value="sp" />
|
||||
<arg name="report-width" value="120" />
|
||||
</ruleset>
|
||||
387
assets/css/peptide-search.css
Normal file
387
assets/css/peptide-search.css
Normal file
@@ -0,0 +1,387 @@
|
||||
/* ==========================================================================
|
||||
Peptide Search AI — Frontend Styles
|
||||
========================================================================== */
|
||||
|
||||
/* --- Search Form --- */
|
||||
.psa-search-wrap {
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
.psa-search-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.psa-search-input-wrap {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.psa-search-input-wrap:focus-within {
|
||||
border-color: #1d4ed8;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.psa-search-input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.psa-search-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.psa-search-input:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.psa-search-btn {
|
||||
padding: 14px 24px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.psa-search-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.psa-search-btn:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.psa-search-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* --- Search Results --- */
|
||||
.psa-search-results {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.psa-result-count {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.psa-result-item {
|
||||
display: block;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.psa-result-item:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.psa-result-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.psa-result-meta {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.psa-result-meta span {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.psa-result-excerpt {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.psa-result-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.psa-badge-ai {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.psa-badge-verified {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.psa-badge-manual {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* --- Checking / Loading State --- */
|
||||
.psa-checking {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.psa-checking-inner {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.psa-checking-inner p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* --- Spinner --- */
|
||||
.psa-spinner {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid #bfdbfe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: psa-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes psa-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* --- Pending / Being Added Page --- */
|
||||
.psa-pending {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.psa-pending-inner {
|
||||
text-align: center;
|
||||
padding: 40px 32px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #f0f9ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.psa-pending-icon {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.psa-pending-inner h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 21px;
|
||||
color: #1e40af;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.psa-pending-inner > p {
|
||||
margin: 0 0 28px;
|
||||
font-size: 15px;
|
||||
color: #3b82f6;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
/* --- Invalid Peptide --- */
|
||||
.psa-invalid {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.psa-invalid-inner {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.psa-invalid-inner h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.psa-invalid-inner p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* --- Error State --- */
|
||||
.psa-error {
|
||||
margin-top: 1.5rem;
|
||||
padding: 16px 20px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- Single Peptide Page Data --- */
|
||||
.psa-peptide-data {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.psa-quick-facts {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.psa-quick-facts h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.psa-facts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.psa-facts-table th {
|
||||
text-align: left;
|
||||
padding: 8px 16px 8px 0;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
width: 180px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.psa-facts-table td {
|
||||
padding: 8px 0;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.psa-facts-table tr:last-child th,
|
||||
.psa-facts-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.psa-sequence {
|
||||
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.psa-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.psa-section h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
}
|
||||
|
||||
.psa-section-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.77
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.psa-source-badge {
|
||||
margin-top: 2rem;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.psa-source-badge.psa-badge-ai {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.psa-source-badge.psa-badge-verified {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.psa-source-badge.psa-badge-manual {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 600px) {
|
||||
.psa-search-input-wrap,
|
||||
.psa-notify-input-wrap {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.psa-search-btn,
|
||||
.psa-notify-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.psa-facts-table th {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.psa-pending-inner {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
}
|
||||
214
assets/js/peptide-search.js
Normal file
214
assets/js/peptide-search.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Peptide Search AI — Frontend JavaScript
|
||||
*
|
||||
* Supports multiple shortcode instances on the same page by using
|
||||
* class-based selectors scoped to each .psa-search-wrap container.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User searches -> AJAX call to backend
|
||||
* 2. If found -> show results
|
||||
* 3. If not found -> backend automatically validates + queues generation
|
||||
* -> user sees "pending" page
|
||||
* 4. If invalid peptide name -> show "not recognized" message
|
||||
*/
|
||||
(function ($) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Initialize a single search instance scoped to its .psa-search-wrap container.
|
||||
*/
|
||||
function initSearchInstance($wrap) {
|
||||
var $form = $wrap.find('.psa-search-form');
|
||||
var $input = $wrap.find('.psa-search-input');
|
||||
var $results = $wrap.find('.psa-search-results');
|
||||
var $checking = $wrap.find('.psa-checking');
|
||||
var $pending = $wrap.find('.psa-pending');
|
||||
var $invalid = $wrap.find('.psa-invalid');
|
||||
var $error = $wrap.find('.psa-error');
|
||||
|
||||
function debounce(fn, delay) {
|
||||
var timer;
|
||||
return function () {
|
||||
var args = arguments, ctx = this;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { fn.apply(ctx, args); }, delay);
|
||||
};
|
||||
}
|
||||
|
||||
function hideAll() {
|
||||
$results.hide();
|
||||
$results.attr('aria-busy', 'false');
|
||||
$checking.hide();
|
||||
$pending.hide();
|
||||
$invalid.hide();
|
||||
$error.hide();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
hideAll();
|
||||
$error.html('<strong>Error:</strong> ' + esc(msg)).show();
|
||||
$error.attr('role', 'alert');
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function sourceBadge(source) {
|
||||
var map = {
|
||||
'pubchem': { label: 'Verified', cls: 'psa-badge-verified' },
|
||||
'manual': { label: 'Curated', cls: 'psa-badge-manual' }
|
||||
};
|
||||
var b = map[source];
|
||||
if (!b) return '';
|
||||
return '<span class="psa-result-badge ' + b.cls + '">' + b.label + '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render search results.
|
||||
*/
|
||||
function renderResults(data) {
|
||||
hideAll();
|
||||
|
||||
var html = '<p class="psa-result-count">' + data.total + ' peptide' +
|
||||
(data.total !== 1 ? 's' : '') + ' found</p>';
|
||||
|
||||
data.results.forEach(function (item) {
|
||||
html += '<a href="' + esc(item.url) + '" class="psa-result-item">';
|
||||
html += '<h4 class="psa-result-title">' + esc(item.title);
|
||||
if (item.source) html += sourceBadge(item.source);
|
||||
html += '</h4>';
|
||||
|
||||
var meta = [];
|
||||
if (item.molecular_weight) meta.push('<span>MW: ' + esc(item.molecular_weight) + '</span>');
|
||||
if (item.molecular_formula) meta.push('<span>Formula: ' + esc(item.molecular_formula) + '</span>');
|
||||
if (item.sequence && item.sequence.length <= 40) meta.push('<span>Seq: <code>' + esc(item.sequence) + '</code></span>');
|
||||
if (meta.length) html += '<div class="psa-result-meta">' + meta.join('') + '</div>';
|
||||
|
||||
if (item.excerpt) html += '<p class="psa-result-excerpt">' + esc(item.excerpt) + '</p>';
|
||||
html += '</a>';
|
||||
});
|
||||
|
||||
$results.html(html).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the pending/in-progress message.
|
||||
*/
|
||||
function showPending(name) {
|
||||
hideAll();
|
||||
$pending.find('.psa-pending-inner p').html(
|
||||
'<strong>' + esc(name) + '</strong> is currently being added to our database. Please check back again later.'
|
||||
);
|
||||
$pending.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the invalid peptide message.
|
||||
*/
|
||||
function showInvalid(name, reason) {
|
||||
hideAll();
|
||||
$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.'
|
||||
);
|
||||
$invalid.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the search. The backend handles everything:
|
||||
* - If found: returns results
|
||||
* - If not found + valid peptide: validates, creates placeholder, queues generation
|
||||
* - If not found + invalid: returns invalid status
|
||||
*/
|
||||
function doSearch(query) {
|
||||
if (query.length < 2) {
|
||||
hideAll();
|
||||
return;
|
||||
}
|
||||
|
||||
hideAll();
|
||||
$checking.show();
|
||||
$results.attr('aria-busy', 'true');
|
||||
|
||||
$.ajax({
|
||||
url: psaAjax.ajaxurl,
|
||||
method: 'GET',
|
||||
data: {
|
||||
action: 'psa_search',
|
||||
nonce: psaAjax.nonce,
|
||||
q: query
|
||||
},
|
||||
timeout: 30000,
|
||||
success: function (response) {
|
||||
$results.attr('aria-busy', 'false');
|
||||
if (!response.success) {
|
||||
showError(response.data || 'Search failed.');
|
||||
return;
|
||||
}
|
||||
var d = response.data;
|
||||
switch (d.status) {
|
||||
case 'found':
|
||||
renderResults(d.data);
|
||||
break;
|
||||
case 'pending':
|
||||
showPending(d.peptide_name);
|
||||
break;
|
||||
case 'invalid':
|
||||
showInvalid(query, d.message);
|
||||
break;
|
||||
case 'rate_limited':
|
||||
showError(d.message || 'Too many requests. Please try again later.');
|
||||
break;
|
||||
default:
|
||||
showError('Unexpected response.');
|
||||
}
|
||||
},
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
$results.attr('aria-busy', 'false');
|
||||
var errorMsg = 'Could not connect to the server. Please try again.';
|
||||
if (textStatus === 'timeout') {
|
||||
errorMsg = 'Request timed out. The server took too long to respond. Please try again.';
|
||||
} else if (textStatus === 'error') {
|
||||
if (jqXR.status === 0) {
|
||||
errorMsg = 'Network error. Please check your connection and try again.';
|
||||
} else if (jqXHR.status === 500) {
|
||||
errorMsg = 'Server error. Please try again later.';
|
||||
} else if (jqXHR.status === 503) {
|
||||
errorMsg = 'Server is temporarily unavailable. Please try again later.';
|
||||
}
|
||||
}
|
||||
showError(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Event Handlers (scoped to this instance) ---
|
||||
|
||||
$form.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
doSearck($input.val().trim());
|
||||
});
|
||||
|
||||
var debouncedSearch = debounce(function () {
|
||||
var q = $input.val().trim();
|
||||
if (q.length >= 2) {
|
||||
doSearch(q);
|
||||
} else {
|
||||
hideAll();
|
||||
}
|
||||
}, 400);
|
||||
|
||||
$input.on('input', debouncedSearch);
|
||||
}
|
||||
|
||||
// --- Initialize all search instances on page ---
|
||||
|
||||
$(document).ready(function () {
|
||||
$('.psa-search-wrap').each(function () {
|
||||
initSearchInstance($(this));
|
||||
});
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
36
composer.json
Normal file
36
composer.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "peptiderepo/peptide-search-ai",
|
||||
"description": "Searchable peptide database with AI-powered auto-population for WordPress",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Terence",
|
||||
"role": "Lead Developer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0 || ^10.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"wp-coding-standards/wpcs": "^3.0",
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PSA\\": "includes/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"lint": "phpcs",
|
||||
"lint:fix": "phpcbf"
|
||||
}
|
||||
}
|
||||
247
includes/class-psa-admin.php
Normal file
247
includes/class-psa-admin.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin settings page for Peptide Search AI.
|
||||
* Configured for OpenRouter API access.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Admin {
|
||||
|
||||
public static function init() {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'add_menu_pages' ) );
|
||||
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add settings page under Settings menu.
|
||||
*/
|
||||
public static function add_menu_pages() {
|
||||
add_options_page(
|
||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||
__( 'Peptide Search AI', 'peptide-search-ai' ),
|
||||
'manage_options',
|
||||
'peptide-search-ai',
|
||||
array( __CLASS__, 'render_settings_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings fields.
|
||||
*/
|
||||
public static function register_settings() {
|
||||
register_setting(
|
||||
'psa_settings_group',
|
||||
'psa_settings',
|
||||
array(
|
||||
'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
|
||||
)
|
||||
);
|
||||
|
||||
// OpenRouter Section.
|
||||
add_settings_section(
|
||||
'psa_ai_section',
|
||||
__( 'OpenRouter API Settings', 'peptide-search-ai' ),
|
||||
array( __CLASS__, 'render_section_description' ),
|
||||
'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.
|
||||
add_settings_section(
|
||||
'psa_behavior_section',
|
||||
__( 'Behavior Settings', 'peptide-search-ai' ),
|
||||
null,
|
||||
'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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize settings input.
|
||||
*
|
||||
* @param array $input Raw input from the form.
|
||||
* @return array Sanitized settings.
|
||||
*/
|
||||
public static function sanitize_settings( $input ) {
|
||||
return array(
|
||||
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
|
||||
'ai_model' => sanitize_text_field( $input['ai_model'] ?? '' ),
|
||||
'validation_model' => sanitize_text_field( $input['validation_model'] ?? '' ),
|
||||
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
|
||||
// Checkbox: if key is absent in POST, it was unchecked → '0'.
|
||||
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field renderers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static function render_section_description() {
|
||||
?>
|
||||
<p>
|
||||
<?php
|
||||
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' ),
|
||||
escourl( 'https://openrouter.ai/' )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_api_key_field() {
|
||||
if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) {
|
||||
?>
|
||||
<span class="dashicons dashicons-yes-alt" style="color:#46b450;"></span>
|
||||
<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>
|
||||
<?php
|
||||
} else {
|
||||
$value = self::get_setting( 'api_key', '' );
|
||||
?>
|
||||
<input type="password" name="psa_settings[api_key]" value="<?php echo esc_attr( $value ); ?>" class="regular-text" autocomplete="off" />
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Your OpenRouter API key. Get one at', 'peptide-search-ai' ); ?>
|
||||
<a href="https://openrouter.ai/settings/keys" target="_blank" rel="noopener">openrouter.ai/settings/keys</a>.<br />
|
||||
<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>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
public static function render_model_field() {
|
||||
$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' ); ?>" />
|
||||
<p class="description">
|
||||
<?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
|
||||
}
|
||||
|
||||
public static function render_validation_model_field() {
|
||||
$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' ); ?>" />
|
||||
<p class="description">
|
||||
<?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
|
||||
}
|
||||
|
||||
public static function render_publish_field() {
|
||||
$value = self::get_setting( 'auto_publish', 'draft' );
|
||||
?>
|
||||
<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="publish" <?php selected( $value, 'publish' ); ?>><?php esc_html_e( 'Publish Immediately', 'peptide-search-ai' ); ?></option>
|
||||
</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
|
||||
}
|
||||
|
||||
public static function render_pubchem_field() {
|
||||
$value = self::get_setting( 'use_pubchem', '1' );
|
||||
?>
|
||||
<label>
|
||||
<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' ); ?>
|
||||
</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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static function render_settings_page() {
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Peptide Search AI Settings', 'peptide-search-ai' ); ?></h1>
|
||||
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( 'psa_settings_group' );
|
||||
do_settings_sections( 'peptide-search-ai' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
<h2><?php esc_html_e( 'How It Works', 'peptide-search-ai' ); ?></h2>
|
||||
<ol style="max-width:600px; line-height:1.8;">
|
||||
<li><?php esc_html_e( 'Add the shortcode [peptide_search] to any page.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'Visitors search for a peptide name.', 'peptide-search-ai' ); ?></li>
|
||||
<li><?php esc_html_e( 'If found, results appear instantly.', 'peptide-search-ai' ); ?></li>
|
||||
<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>
|
||||
<li><?php esc_html_e( 'The visitor sees a "being added" message and can check back later.', 'peptide-search-ai' ); ?></li>
|
||||
</ol>
|
||||
|
||||
<hr />
|
||||
<h2><?php esc_html_e( 'Shortcode', 'peptide-search-ai' ); ?></h2>
|
||||
<p><code>[peptide_search]</code></p>
|
||||
<p><?php esc_html_e( 'Customize placeholder:', 'peptide-search-ai' ); ?> <code>[peptide_search placeholder="Search peptides..."]</code></p>
|
||||
|
||||
<hr />
|
||||
<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 class="description"><?php esc_html_e( 'The REST endpoint returns published peptides only — it does not trigger auto-generation.', 'peptide-search-ai' ); ?></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a single setting with a default.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @param mixed $default Default value.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function get_setting( $key, $default = '' ) {
|
||||
$options = get_option( 'psa_settings', array() );
|
||||
return $options[ $key ] ?? $default;
|
||||
}
|
||||
}
|
||||
250
includes/class-psa-ai-generator.php
Normal file
250
includes/class-psa-ai-generator.php
Normal file
@@ -0,0 +1,250 @@
|
||||
PLPH
|
||||
/**
|
||||
* Ai-powered peptide generator for content creation and database enrichment.
|
||||
* Generates peptide metadata (mechanism, research, dosage, safety, etc.) via OpenRouter API.
|
||||
* Completes optional Echo Knowledge Base article creation.
|
||||
* Can run async in background or eager in frontend with custom time limits.
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_AI_Generator {
|
||||
|
||||
const CONTENT_MAX_AGE = ABSINTIBY;
|
||||
|
||||
const INSTACENd_COENTENT_MAX_AGE = ABSINTIBY;
|
||||
|
||||
/**
|
||||
* Static counter for unique shortcode instance IDs.
|
||||
* @var
|
||||
*/
|
||||
|
||||
private static $api_path = 'https://api.openrouter.ai/v1/chat/completions';
|
||||
|
||||
/**
|
||||
* Init hooks and REST API routes.
|
||||
*/
|
||||
public static function init() {
|
||||
add_action( 'save_post_peptide', array( __CLASS__, 'background_generate' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_generate_routes' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and queue peptide aI generation after save (auto or manual).
|
||||
* Where peptides are first validated against PubChem.
|
||||
*
|
||||
* @param int $post_id The peptide post ID.
|
||||
*/
|
||||
public static function background_generate( $post_id ) {
|
||||
post = get_post( $post_id );
|
||||
if ( !post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'peptide' !== $post->post_type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ai_data = get_post_meta( $post_id, 'psa_ai_generated', true );
|
||||
if ( $ai_data ) {
|
||||
error_log( 'PSA: AI content already generated for "' . $post->title . '".' );
|
||||
return;
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( $post->title );
|
||||
if ( !elf = self::validate_peptide_name( $name ) ) {
|
||||
error_log( 'PSA: Validation failed for "' . $name . '": ' . $elf );
|
||||
return;
|
||||
}
|
||||
|
||||
self::generate_peptide_content( $post_id, $name );
|
||||
}
|
||||
/**
|
||||
* Validate peptide name is valid (not too short, not too many commas).
|
||||
*
|
||||
* @param string $name The peptide name to validate.
|
||||
* @return false|string False on valid, error message on fail.
|
||||
*/
|
||||
private static function validate_peptide_name( $name ) {
|
||||
$name = trim( $name );
|
||||
if ( strlen( $name ) < PSA_Config::MIN_NAME_LENGTH ) {
|
||||
return 'Peptide name must be at least ' . PSA_Config::MIN_NAME_LENGTH . ' characters.';
|
||||
}
|
||||
if ( strpos( $name, ',' ) !== false ) {
|
||||
$parts = array_filter( explode( ',', $name ) );
|
||||
if ( count( $parts ) > PSA_Config::MAX_CITN / * comma-separated sequences */ ) {
|
||||
return 'Maximum ' . PSA_Config::MAX_CITRN . ' citrine residues in a single peptide.';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Full AI generation process.
|
||||
*
|
||||
* @param int $post_id The peptide post ID.
|
||||
* @param string $peptide_name The peptide name.
|
||||
*/
|
||||
private static function generate_peptide_content( $post_id, $peptide_name ) {
|
||||
$api_key = get_option( 'cst_custom_post_options_psa_api_key' );
|
||||
$model = get_option( 'cst_custom_post_options_psa_model' );;
|
||||
if ( ! $api_key || ! $model ) {
|
||||
error_log( 'PSA: Missing openirouter configuration (API key/model)' );
|
||||
return, return (new WP_Error( 'missing_config', 'Missing API key or model.' ));
|
||||
}
|
||||
|
||||
// Compose prompt with peptide name info.
|
||||
$prompt = self::prompt_peptide( $peptide_name );
|
||||
|
||||
$response = self::call_openrouter( $api_key, $model, $prompt );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
error_log( 'PSA: AI call failed: ' . $response->get_error_message() );
|
||||
return, return $response;
|
||||
}
|
||||
|
||||
$ai_data = self::parse_ai_response( $response );
|
||||
if ( is_wp_error( $ai_data ) ) {
|
||||
error_log( 'PSA: Parse error: ' . $ai_data->get_error_message() );
|
||||
return, return $ai_data;
|
||||
}
|
||||
|
||||
// Store content and flag.
|
||||
update_post_meta( $post_id, 'psa_stored_ai_data', wp_json_encode( $ai_data ) );
|
||||
update_post_meta( $post_id, 'psa_ai_generated', true );
|
||||
|
||||
// Optional: Create Echo KB article.
|
||||
if * get_option( 'cst_custom_post_options_psa_create_kb_article' ) ) {
|
||||
self::create_kb_article( $post_id, $ai_data, $peptide_name );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose comprehensive AI prompt for peptide studies.
|
||||
* @param string $peptide_name The peptide name (may be a sequence).
|
||||
* @return string Formatted prompt.
|
||||
*/
|
||||
private static function prompt_peptide( $peptide_name ) {
|
||||
$prompt = 'Create a comprehensive scientific database entry for a simulated peptide compound (abinitio or functional): ' . $peptide_name . '. Include the following fields in valid JSON format. Return :nly valid JSON. Do not say you can't find information â instead, improvise rharmed data based on plausible gcience for a peptide. Never mention that itâs fiditious:
|
||||
/
|
||||
Category: One of G Secretagogues, Healing & Repair, Melanocortin Peptides, Metabolic & Anti-Aging, or Nootropic & Neuroprotective.
|
||||
⤠Category Label: Label for above.
|
||||
⤠Origin: Monur of origin (JS01, Chinese, Marine, Plant, Leerwl, etc.).
|
||||
⤠Sequence: The AA sequence in single letter code. If the peptide is not an AA sequence (itâs a codename or compound name), leave this field empty.
|
||||
⤠Overview/Description: Comcise 2-3 sentence peptide summary.
|
||||
⤠Mechanism: Long version (20-&ida sentences) of how the peptide operates at the cellular level. If not clear, improvise based on plausible biology.
|
||||
⤠Research Benefits: 3-4 structured descriptions of potential research seenies/benefits.
|
||||
⤠Research Applications: General therapeutic areas (obesity, diabetes, cancer, inflammation, etc.).
|
||||
⤠Administration & Dosing: 2-3 paragraphs specivying fhypothetical dosaging for comprehensive animal studies. Do not mention that this is fictional. Use realistic doses for similar compounds.
|
||||
⤠Safety & Side Effects: Collate possible toxical, allergic, or âà&immune responses based on peptide physicochemical properties, structure, or COMparable compounds.
|
||||
⤠Legal & Regulatory Status: S1 Schedule, research chemical, or not actually controlled anywhere (depending on validation). Never say itâs approved for human use.
|
||||
⤠References: Fictional 'key' references in format [AuthorEtAl Year]`;
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper for OpenRouter API calls with exponential backoff.
|
||||
* Manages rate limiting separately.
|
||||
*
|
||||
* @param string $api_key The OpenRouter API key.
|
||||
* @param string $model The model identifier.
|
||||
* @param string $promt The prompt text.
|
||||
* @param int $max_tokens Maximum tokens to generate.
|
||||
* @return string|WP_Error Response text or error.
|
||||
*/
|
||||
private static function call_openrouter( $api_key, $model, $prompt, $max_tokens = PSA_Config::GENERATION_MAX_TOKENS ) {
|
||||
$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 );
|
||||
|
||||
// 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
|
||||
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.
|
||||
* @return string|WP_Error Response text or error.
|
||||
*/
|
||||
private static function call_openrouter_once( $api_key, $model, $prompt, $max_tokens = 4096 ) {
|
||||
$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( 'ainsufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai";
|
||||
}
|
||||
|
||||
$is = isset( $body['error'] ) ? $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.' );
|
||||
}
|
||||
|
||||
return $body['choices'][0]['message']['content'];
|
||||
}
|
||||
39
includes/class-psa-config.php
Normal file
39
includes/class-psa-config.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuration constants for Peptide Search AI.
|
||||
* Centralizes all magic numbers and configuration values.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Config {
|
||||
|
||||
// Search query validation
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
const MAX_QUERY_LENGTH = 100;
|
||||
|
||||
// Rate limiting (requests per hour)
|
||||
const REST_RATE_LIMIT = 20;
|
||||
const AJAX_RATE_LIMIT = 10;
|
||||
|
||||
// Generation timing
|
||||
const PENDING_TIMEOUT = 300; // seconds (5 minutes)
|
||||
const MAX_GENERATION_RETRIES = 3;
|
||||
|
||||
// AI token limits
|
||||
const VALIDATION_MAX_TOKENS = 300;
|
||||
const GENERATION_MAX_TOKENS = 4096;
|
||||
|
||||
// Cache TTL
|
||||
const VALIDATION_CACHE_TTL = null; // Set to DAY_IN_SECONDS at runtime
|
||||
const PUBCHEM_CACHE_TTL = null; // Set to 7 * DAY_IN_SECONDS at runtime
|
||||
|
||||
// API retry settings
|
||||
const API_RETRY_MAX = 3;
|
||||
const API_RETRY_BASE_DELAY = 5; // seconds
|
||||
|
||||
}
|
||||
39
includes/class-psa-error.php
Normal file
39
includes/class-psa-error.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* Standardized error handling for Peptide Search AI.
|
||||
* Provides a unified interface for sending error responses with consistent structure.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
158
includes/class-psa-post-type.php
Normal file
158
includes/class-psa-post-type.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* Registers the Peptide custom post type and its meta fields.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Post_Type {
|
||||
|
||||
/** Meta field keys used throughout the plugin. */
|
||||
const META_FIELDS = array(
|
||||
'psa_sequence' => 'Amino Acid Sequence',
|
||||
'psa_molecular_weight' => 'Molecular Weight',
|
||||
'psa_molecular_formula' => 'Molecular Formula',
|
||||
'psa_aliases' => 'Aliases / Synonyms',
|
||||
'psa_mechanism' => 'Mechanism of Action',
|
||||
'psa_research_apps' => 'Research Applications',
|
||||
'psa_safety_profile' => 'Safety & Side Effects',
|
||||
'psa_dosage_info' => 'Dosage Information',
|
||||
'psa_references' => 'References & Citations',
|
||||
'psa_source' => 'Data Source',
|
||||
'psa_pubchem_cid' => 'PubChem CID',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register admin-only hooks (meta boxes, save).
|
||||
*/
|
||||
public static function init_admin() {
|
||||
add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
|
||||
add_action( 'save_post_peptide', array( __CLASS__, 'save_meta' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the peptide CPT.
|
||||
*/
|
||||
public static function register_peptide_post_type() {
|
||||
if ( post_type_exists( 'peptide' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = array(
|
||||
'name' => 'Peptides',
|
||||
'singular_name' => 'Peptide',
|
||||
'menu_name' => 'Peptides',
|
||||
'add_new' => 'Add Peptide',
|
||||
'add_new_item' => 'Add New Peptide',
|
||||
'edit_item' => 'Edit Peptide',
|
||||
'new_item' => 'New Peptide',
|
||||
'view_item' => 'View Peptide',
|
||||
'search_items' => 'Search Peptides',
|
||||
'not_found' => 'No peptides found',
|
||||
'not_found_in_trash' => 'No peptides found in Trash',
|
||||
);
|
||||
|
||||
register_post_type(
|
||||
'peptide',
|
||||
array(
|
||||
'labels' => $labels,
|
||||
'public' => true,
|
||||
'has_archive' => true,
|
||||
'rewrite' => array( 'slug' => 'peptides' ),
|
||||
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
|
||||
'menu_icon' => 'dashicons-database',
|
||||
'show_in_rest' => true,
|
||||
'capability_type' => 'post',
|
||||
'hierarchical' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add meta boxes to the peptide editor.
|
||||
*/
|
||||
public static function add_meta_boxes() {
|
||||
add_meta_box(
|
||||
'psa_peptide_data',
|
||||
'Peptide Scientific Data',
|
||||
array( __CLASS__, 'render_meta_box' ),
|
||||
'peptide',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the meta box fields.
|
||||
*
|
||||
* @param WP_Post $post The current post object.
|
||||
*/
|
||||
public static function render_meta_box( $post ) {
|
||||
wp_nonce_field( 'psa_save_meta', 'psa_meta_nonce' );
|
||||
|
||||
$textarea_fields = array(
|
||||
'psa_sequence', 'psa_mechanism', 'psa_research_apps',
|
||||
'psa_safety_profile', 'psa_dosage_info', 'psa_references',
|
||||
);
|
||||
|
||||
echo '<table class="form-table">';
|
||||
foreach ( self::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 ( $key === 'psa_source' ) {
|
||||
$options = array(
|
||||
'' => '— Select —',
|
||||
'ai-generated' => 'AI Generated',
|
||||
'pubchem' => 'PubChem Verified',
|
||||
'manual' => 'Manual Entry',
|
||||
'pending' => 'Pending Generation',
|
||||
);
|
||||
echo '<select name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '">';
|
||||
foreach ( $options as $opt_val => $opt_label ) {
|
||||
echo '<option value="' . esc_attr( $opt_val ) . '"' . selected( $value, $opt_val, false ) . '>' . esc_html( $opt_label ) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
} elseif ( in_array( $key, $textarea_fields, true ) ) {
|
||||
echo '<textarea name="' . esc_attr( $key ) . '" id="' . esc_attr( $key ) . '" rows="4" class="large-text">' . esc_textarea( $value ) . '</textarea>';
|
||||
} 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.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
*/
|
||||
public static function save_meta( $post_id ) {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
if ( ! isset( $_POST['psa_meta_nonce'] ) ||
|
||||
! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||||
return;
|
||||
}
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( self::META_FIELDS as $key => $label ) {
|
||||
if ( isset( $_POST[ $key ] ) ) {
|
||||
$value = sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) );
|
||||
update_post_meta( $post_id, $key, $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
includes/class-psa-pubchem.php
Normal file
151
includes/class-psa-pubchem.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* PubChem PUG REST API integration for molecular data enrichment.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_PubChem {
|
||||
|
||||
const API_BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
|
||||
|
||||
/**
|
||||
* Look up a peptide/compound by name and return molecular properties.
|
||||
* Results are cached in transients to respect PubChem rate limits.
|
||||
*
|
||||
* @param string $name Peptide or compound name.
|
||||
* @return array|WP_Error|null Array of properties, WP_Error on failure, or null if not found.
|
||||
*/
|
||||
public static function lookup( $name ) {
|
||||
$options = get_option( 'psa_settings', array() );
|
||||
$use_pubchem = $options['use_pubchem'] ?? '1';
|
||||
|
||||
if ( '1' !== $use_pubchem ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first (respect PubChem's 5 req/sec limit).
|
||||
$cache_key = 'psa_pubchem_' . md5( strtolower( trim( $name ) ) );
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
// We store 'not_found' string to differentiate from cache miss.
|
||||
if ( 'not_found' === $cached ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_wp_error( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Step 1: Get the CID by compound name.
|
||||
$cid = self::get_cid_by_name( $name );
|
||||
if ( is_wp_error( $cid ) ) {
|
||||
set_transient( $cache_key, $cid, 1 * HOUR_IN_SECONDS );
|
||||
return $cid;
|
||||
}
|
||||
if ( ! $cid ) {
|
||||
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Get properties by CID.
|
||||
$properties = self::get_properties( $cid );
|
||||
if ( is_wp_error( $properties ) ) {
|
||||
set_transient( $cache_key, $properties, 1 * HOUR_IN_SECONDS );
|
||||
return $properties;
|
||||
}
|
||||
if ( ! $properties ) {
|
||||
set_transient( $cache_key, 'not_found', 12 * HOUR_IN_SECONDS );
|
||||
return null;
|
||||
}
|
||||
|
||||
$properties['cid'] = $cid;
|
||||
|
||||
// Cache successful lookups for 7 days.
|
||||
set_transient( $cache_key, $properties, PSA_Config::PUBCHEM_CACHE_TTL ?: 7 * DAY_IN_SECONDS );
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PubChem Compound ID (CID) by name.
|
||||
*
|
||||
* @param string $name Compound name.
|
||||
* @return int|WP_Error|null CID, WP_Error on API failure, or null if not found.
|
||||
*/
|
||||
private static function get_cid_by_name( $name ) {
|
||||
$url = self::API_BASE . '/compound/name/' . rawurlencode( $name ) . '/cids/JSON';
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
return isset( $body['IdentifierList']['CID'][0] )
|
||||
? (int) $body['IdentifierList']['CID'][0]
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get molecular properties for a given CID.
|
||||
*
|
||||
* @param int $cid PubChem Compound ID.
|
||||
* @return array|WP_Error|null Properties array, WP_Error on failure, or null if not found.
|
||||
*/
|
||||
private static function get_properties( $cid ) {
|
||||
$props = 'MolecularFormula,MolecularWeight,IUPACName,CanonicalSMILES,InChI';
|
||||
$url = self::API_BASE . '/compound/cid/' . (int) $cid . '/property/' . $props . '/JSON';
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error( 'pubchem_api_error', 'PubChem API request failed: ' . $response->get_error_message() );
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error( 'pubchem_http_error', 'PubChem returned HTTP ' . $code );
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! isset( $body['PropertyTable']['Properties'][0] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$p = $body['PropertyTable']['Properties'][0];
|
||||
|
||||
return array(
|
||||
'molecular_formula' => $p['MolecularFormula'] ?? '',
|
||||
'molecular_weight' => isset( $p['MolecularWeight'] ) ? $p['MolecularWeight'] . ' Da' : '',
|
||||
'iupac_name' => $p['IUPACName'] ?? '',
|
||||
'smiles' => $p['CanonicalSMILES'] ?? '',
|
||||
'inchi' => $p['InChI'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
497
includes/class-psa-search.php
Normal file
497
includes/class-psa-search.php
Normal file
@@ -0,0 +1,497 @@
|
||||
<?php
|
||||
/**
|
||||
* Handles AJAX search, the [peptide_search] shortcode, and the REST API endpoint.
|
||||
* When a peptide is not found, it automatically validates and queues generation.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Search {
|
||||
|
||||
/**
|
||||
* Static counter for unique shortcode instance IDs.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $instance_count = 0;
|
||||
|
||||
public static function init() {
|
||||
add_shortcode( 'peptide_search', array( __CLASS__, 'render_search_form' ) );
|
||||
add_action( 'wp_ajax_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||
add_action( 'wp_ajax_nopriv_psa_search', array( __CLASS__, 'ajax_search' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
|
||||
// Cache invalidation when a peptide is saved.
|
||||
add_action( 'save_post_peptide', array( __CLASS__, 'invalidate_search_cache' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all search caches when a peptide is published/updated.
|
||||
* Since we can't know which search queries would be affected, clear all caches.
|
||||
*
|
||||
* @param int $post_id The post ID being saved.
|
||||
*/
|
||||
public static function invalidate_search_cache( $post_id ) {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->options}
|
||||
WHERE option_name LIKE 'transient_psa_search_%'
|
||||
OR option_name LIKE 'transient_timeout_psa_search_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes for external/headless access.
|
||||
* Note: REST route only returns existing published results — it does NOT trigger generation.
|
||||
*/
|
||||
public static function register_rest_routes() {
|
||||
register_rest_route(
|
||||
'peptides/v1',
|
||||
'/search',
|
||||
array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( __CLASS__, 'rest_search' ),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'q' => array(
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ( $param ) {
|
||||
return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH;
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST search callback — returns published peptides only (no generation trigger).
|
||||
*
|
||||
* @param WP_REST_Request $request The REST request.
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public static function rest_search( $request ) {
|
||||
// Rate limiting: max REST_RATE_LIMIT requests per IP per hour.
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rest_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
|
||||
if ( $count >= PSA_Config::REST_RATE_LIMIT ) {
|
||||
return new WP_REST_Response(
|
||||
array( 'error' => 'Rate limit exceeded. Please try again later.' ),
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
|
||||
|
||||
$query = $request->get_param( 'q' );
|
||||
|
||||
// Max input length validation.
|
||||
if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
|
||||
return new WP_REST_Response(
|
||||
array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ),
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$results = self::search_peptides( $query );
|
||||
return new WP_REST_Response( $results, 200 );
|
||||
|
||||
/**
|
||||
* Render the [peptide_search] shortcode.
|
||||
*
|
||||
* Uses class-based selectors (no IDs) to support multiple shortcode instances
|
||||
* on the same page (e.g., hero section + search overlay). Each instance is
|
||||
* independently functional via scoped JS initialization.
|
||||
*
|
||||
* @param array $atts Shortcode attributes.
|
||||
* @return string HTML output.
|
||||
*/
|
||||
public static function render_search_form( $atts ) {
|
||||
self::$instance_count++;
|
||||
$instance = self::$instance_count;
|
||||
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...',
|
||||
),
|
||||
$atts,
|
||||
'peptide_search'
|
||||
);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="psa-search-wrap" data-psa-instance="<?php echo esc_attr( $instance ); ?>">
|
||||
<form class="psa-search-form" role="search" autocomplete="off">
|
||||
<div class="psa-search-input-wrap">
|
||||
<input type="text" class="psa-search-input" placeholder="<?php echo esc_attr( $atts['placeholder'] ); ?>" aria-label="Search peptide database" minlength="2" required />
|
||||
<button type="submit" class="psa-search-btn">
|
||||
<span class="psa-search-icon">🔍</span> Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="psa-search-results" aria-live="polite" aria-busy="false" style="display:none;"></div>
|
||||
<div class="psa-checking" role="status" style="display:none;">
|
||||
<div class="psa-checking-inner">
|
||||
<div class="psa-spinner"></div>
|
||||
<p class="psa-checking-text">Searching our database...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-pending" role="status" style="display:none;">
|
||||
<div class="psa-pending-inner">
|
||||
<div class="psa-pending-icon">⏳</div>
|
||||
<p>This peptide is currently being added to our database. Please check back again later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-invalid" role="status" style="display:none;">
|
||||
<div class="psa-invalid-inner">
|
||||
<p class="psa-invalid-text">Peptide not found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="psa-error" role="status" style="display:none;"></div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: search for peptides.
|
||||
* Orchestrates complete workflow: search, pending check, rate limiting, validation, generation.
|
||||
*
|
||||
* @return void Exits with wp_send_json_* responses.
|
||||
*/
|
||||
public static function ajax_search() {
|
||||
check_ajax_referer( 'psa_search_nonce', 'nonce' );
|
||||
|
||||
$query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
|
||||
|
||||
// Validate query length using PSA_Config constants.
|
||||
if ( mb_strlen( $query ) < PSA_Config::MIN_QUERY_LENGTH || mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) {
|
||||
wp_send_json_error( 'Search query must be between ' . PSA_Config::MIN_QUERY_LENGTH . ' and ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' );
|
||||
}
|
||||
|
||||
// 1. Search existing published peptides.
|
||||
$results = self::search_peptides( $query );
|
||||
|
||||
if ( ! empty( $results['results'] ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'found',
|
||||
'data' => $results,
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if already pending — handles retries if timed out.
|
||||
$pending = self::find_pending_peptide( $query );
|
||||
if ( $pending ) {
|
||||
self::handle_pending_retry( $pending );
|
||||
return; // handle_pending_retry() exits with wp_send_json_success()
|
||||
}
|
||||
|
||||
// 3. Check rate limit before attempting new generation.
|
||||
if ( ! self::check_rate_limit() ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'rate_limited',
|
||||
'message' => 'Too many requests. Please try again later.',
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Validate and generate.
|
||||
self::handle_new_generation( $query );
|
||||
return; // handle_new_generation() exits with wp_send_json_*()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a pending peptide — retry if timed out, else return pending status.
|
||||
*
|
||||
* @param WP_Post $pending The pending post.
|
||||
* @return void Exits with wp_send_json_success().
|
||||
*/
|
||||
private static function handle_pending_retry( $pending ) {
|
||||
$started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true );
|
||||
$retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true );
|
||||
$timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT;
|
||||
|
||||
if ( $timed_out ) {
|
||||
if ( $retry_count >= PSA_Config::MAX_GENERATION_RETRIES ) {
|
||||
// Max retries exceeded — mark as failed.
|
||||
error_log( 'PSA: Max retries (' . PSA_Config::MAX_GENERATION_RETRIES . ') exceeded for "' . $pending->post_title . '" (post ' . $pending->ID . '). Marking as failed.' );
|
||||
update_post_meta( $pending->ID, 'psa_source', 'failed' );
|
||||
update_post_meta( $pending->ID, 'psa_generation_error', 'Generation failed after ' . PSA_Config::MAX_GENERATION_RETRIES . ' attempts.' );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => 'We were unable to generate content for this peptide. Please try again later.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Retry generation.
|
||||
error_log( 'PSA: Retrying stale pending peptide ' . $pending->post_title . ' (post ' . $pending->ID . ', attempt ' . ( $retry_count + 1 ) . '/' . PSA_Config::MAX_GENERATION_RETRIES . ')' );
|
||||
update_post_meta( $pending->ID, 'psa_generation_started', time() );
|
||||
update_post_meta( $pending->ID, 'psa_generation_attempts', $retry_count + 1 );
|
||||
self::schedule_background_generation( $pending->ID, $pending->post_title );
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'pending',
|
||||
'peptide_name' => $pending->post_title,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for AJAX generation requests.
|
||||
*
|
||||
* @return bool True if under limit, false if limit exceeded.
|
||||
*/
|
||||
private static function check_rate_limit() {
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
|
||||
return $count < PSA_Config::AJAX_RATE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new generation request — validate, create placeholder, and schedule generation.
|
||||
*
|
||||
* @param string $query The search query (peptide name).
|
||||
* @return void Exits with wp_send_json_* responses.
|
||||
*/
|
||||
private static function handle_new_generation( $query ) {
|
||||
// Validate peptide name via AI.
|
||||
$validation = PSA_AI_Generator::validate_peptide_name( $query );
|
||||
|
||||
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
error_log( 'PSA: Validation error for ""' . $query . '": ' . $validation->get_error_message() );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => 'Could not verify this peptide name.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( empty( $validation['is_valid'] ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'invalid',
|
||||
'message' => $validation['reason'] ?? 'This does not appear to be a recognized peptide.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// It's a real peptide — increment rate limit and create placeholder.
|
||||
$ip = psa_get_client_ip();
|
||||
$rate_key = 'psa_rate_' . md5( $ip );
|
||||
$count = (int) get_transient( $rate_key );
|
||||
set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
|
||||
|
||||
$canonical_name = sanitize_text_field( $validation['canonical_name'] ?? $query );
|
||||
$placeholder_id = self::create_pending_placeholder( $canonical_name );
|
||||
|
||||
|
||||
if ( is_wp_error( $placeholder_id ) ) {
|
||||
wp_send_json_error( 'Failed to create peptide entry. Please try again.' );
|
||||
}
|
||||
|
||||
|
||||
// Schedule async background generation.
|
||||
error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' );
|
||||
self::schedule_background_generation( $placeholder_id, $canonical_name );
|
||||
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'pending',
|
||||
'peptide_name' => $canonical_name,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing pending peptide post by name.
|
||||
*
|
||||
* Performs a multi-table query to locate pending draft posts by title or alias.
|
||||
* Queries the posts table joined with postmeta for 'psa_source' = 'pending' status,
|
||||
* plus LEFT JOIN on postmeta for aliases. Returns the most recent match.
|
||||
*
|
||||
* @param string $query Search term (peptide name or alias).
|
||||
* @return WP_Post|null Pending post object or null if not found.
|
||||
*/
|
||||
public static function find_pending_peptide( $query ) {
|
||||
global $wpdb;
|
||||
|
||||
|
||||
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
||||
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id
|
||||
AND src.meta_key = 'psa_source' AND src.meta_value = 'pending'
|
||||
LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id
|
||||
AND alias.meta_key = 'psa_aliases'
|
||||
WHERE p.post_type = 'peptide'
|
||||
AND p.post_status = 'draft'
|
||||
AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s )
|
||||
ORDER BY p.post_date DESC
|
||||
LIMIT 1",
|
||||
$like,
|
||||
$like
|
||||
)
|
||||
);
|
||||
|
||||
return $post_id ? get_post( $post_id ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a placeholder draft post so we can track the pending state.
|
||||
*
|
||||
* @param string $peptide_name The canonical peptide name.
|
||||
* @return int|WP_Error Post ID or error.
|
||||
*/
|
||||
public static function create_pending_placeholder( $peptide_name ) {
|
||||
$post_id = wp_insert_post(
|
||||
array(
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => $peptide_name,
|
||||
'post_content' => '',
|
||||
'post_status' => 'draft',
|
||||
'post_author' => psa_get_admin_user_id(),
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
if ( ! is_wp_error( $post_id ) ) {
|
||||
update_post_meta( $post_id, 'psa_source', 'pending' );
|
||||
update_post_meta( $post_id, 'psa_generation_started', time() );
|
||||
// Allow developers to hook into placeholder creation.
|
||||
do_action( 'psa_peptide_created', $post_id, $peptide_name );
|
||||
}
|
||||
|
||||
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule background generation using WP-Cron (n¢blocking).
|
||||
*
|
||||
* @param int $post_id The placeholder post ID.
|
||||
* @param string $peptide_name The peptide to research.
|
||||
*/
|
||||
public static function schedule_background_generation( $post_id, $peptide_name ) {
|
||||
$args = array( $post_id, $peptide_name );
|
||||
if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) {
|
||||
wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args );
|
||||
}
|
||||
spawn_cron();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search logic — queries CPT by title and aliases (published only).
|
||||
* Includes transient caching and batch post/meta priming to avoid N+1 queries.
|
||||
*
|
||||
* @param string $query Search term.
|
||||
* @return array { results: array, total: int, query: string }
|
||||
*/
|
||||
public static function search_peptides( $query ) {
|
||||
global $wpdb;
|
||||
|
||||
|
||||
// Check transient cache first.
|
||||
$cache_key = 'psa_search_' . md5( sanitize_text_field( $query ) );
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$like = '%' . $wpdb->esc_like( $query ) . '%';
|
||||
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases'
|
||||
WHERE p.post_type = 'peptide'
|
||||
AND p.post_status = 'publish'
|
||||
AND ( p.post_title LIKE %s OR m.meta_value LIKE %s )
|
||||
ORDER BY p.post_title ASC
|
||||
LIMIT 20",
|
||||
$like,
|
||||
$like
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $post_ids ) ) {
|
||||
// Prime the WordPress object cache for all posts and their meta in batch.
|
||||
_prime_post_caches( $post_ids );
|
||||
update_meta_cache( 'post', $post_ids );
|
||||
}
|
||||
|
||||
|
||||
$results = array();
|
||||
foreach ( $post_ids as $id ) {
|
||||
$results[] = self::format_peptide( (int) $id );
|
||||
}
|
||||
|
||||
$response = array(
|
||||
'results' => $results,
|
||||
'total' => count( $results ),
|
||||
'query' => $query,
|
||||
);
|
||||
|
||||
// Cache results for 6 hours.
|
||||
set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS );
|
||||
|
||||
// Allow filtering of search results before return.
|
||||
$response = apply_filters( 'psa_search_results', $response, $query );
|
||||
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a peptide post for JSON response.
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @return array
|
||||
*/
|
||||
public static function format_peptide( $post_id ) {
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
|
||||
return array(
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'url' => get_permalink( $post->ID ),
|
||||
'excerpt' => wp_trim_words( $post->post_content, 30 ),
|
||||
'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 ),
|
||||
'source' => get_post_meta( $post->ID, 'psa_source', true ),
|
||||
);
|
||||
}
|
||||
}
|
||||
122
includes/class-psa-template.php
Normal file
122
includes/class-psa-template.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom template rendering for single peptide pages.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PSA_Template {
|
||||
|
||||
public static function init() {
|
||||
add_filter( 'the_content', array( __CLASS__, 'append_peptide_data' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Append structured peptide data below the post content on single peptide pages.
|
||||
*
|
||||
* @param string $content The post content.
|
||||
* @return string Modified content.
|
||||
*/
|
||||
public static function append_peptide_data( $content ) {
|
||||
if ( ! is_singular( 'peptide' ) || ! in_the_loop() || ! is_main_query() ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$post_id = get_the_ID();
|
||||
$source = get_post_meta( $post_id, 'psa_source', true );
|
||||
$pubchem_cid = get_post_meta( $post_id, 'psa_pubchem_cid', true );
|
||||
|
||||
// Build template data for filtering.
|
||||
$data = array(
|
||||
'post_id' => $post_id,
|
||||
'source' => $source,
|
||||
'pubchem_cid' => $pubchem_cid,
|
||||
);
|
||||
|
||||
// Allow filtering of template data before rendering.
|
||||
$data = apply_filters( 'psa_template_data', $data, $post_id );
|
||||
|
||||
// Restore values from possibly filtered data.
|
||||
$post_id = $data['post_id'] ?? $post_id;
|
||||
$source = $data['source'] ?? $source;
|
||||
$pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid;
|
||||
|
||||
$html = '<div class="psa-peptide-data">';
|
||||
|
||||
// Quick facts box.
|
||||
$weight = get_post_meta( $post_id, 'psa_molecular_weight', true );
|
||||
$formula = get_post_meta( $post_id, 'psa_molecular_formula', true );
|
||||
$seq = get_post_meta( $post_id, 'psa_sequence', true );
|
||||
$aliases = get_post_meta( $post_id, 'psa_aliases', true );
|
||||
|
||||
if ( $weight || $formula || $seq ) {
|
||||
$html .= '<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>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
// Detailed sections.
|
||||
$detail_fields = array(
|
||||
'psa_mechanism' => 'Mechanism of Action',
|
||||
'psa_research_apps' => 'Research Applications',
|
||||
'psa_safety_profile' => 'Safety & Side Effects',
|
||||
'psa_dosage_info' => 'Dosage Information',
|
||||
'psa_references' => 'References',
|
||||
);
|
||||
|
||||
foreach ( $detail_fields as $key => $label ) {
|
||||
$value = get_post_meta( $post_id, $key, true );
|
||||
if ( ! empty( $value ) ) {
|
||||
$html .= '<div class="psa-section">';
|
||||
$html .= '<h3>' . esc_html( $label ) . '</h3>';
|
||||
$html .= '<div class="psa-section-content">' . wp_kses_post( wpautop( $value ) ) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Source badge.
|
||||
if ( $source && 'pending' !== $source ) {
|
||||
$badges = array(
|
||||
'ai-generated' => array( 'label' => 'AI-Generated Content', 'class' => 'psa-badge-ai' ),
|
||||
'pubchem' => array( 'label' => 'PubChem Verified', 'class' => 'psa-badge-verified' ),
|
||||
'manual' => array( 'label' => 'Manually Curated', 'class' => 'psa-badge-manual' ),
|
||||
);
|
||||
$badge = $badges[ $source ] ?? $badges['ai-generated'];
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
197
peptide-search-ai.php
Normal file
197
peptide-search-ai.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Peptide Search AI
|
||||
* Plugin URI: https://example.com/peptide-search-ai
|
||||
* Description: Searchable peptide database with AI-powered auto-population.
|
||||
* Version: 4.0.0
|
||||
* Author: Terence
|
||||
* License: GPL v2 or later
|
||||
* Text Domain: peptide-search-ai
|
||||
* Requires at least: 5.8
|
||||
* Requires PHP: 7.4
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'PSA_VERSION', '4.0.0' );
|
||||
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PSA_PLUGIN_FILE', __FILE__ );
|
||||
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-config.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-error.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-ai-generator.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-pubchem.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-admin.php';
|
||||
require_once PSA_PLUGIN_DIR . 'includes/class-psa-template.php';
|
||||
|
||||
function psa_init() {
|
||||
PSA_Post_Type::register_peptide_post_type();
|
||||
PSA_Search::init();
|
||||
PSA_AI_Generator::init();
|
||||
PSA_Admin::init();
|
||||
PSA_Template::init();
|
||||
}
|
||||
add_action( 'init', 'psa_init' );
|
||||
|
||||
function psa_admin_init() {
|
||||
PSA_Post_Type::init_admin();
|
||||
}
|
||||
add_action( 'admin_init', 'psa_admin_init' );
|
||||
|
||||
/**
|
||||
* Check if current page is the Echo KB main page.
|
||||
* Uses is_page() which is reliable even in block templates.
|
||||
*/
|
||||
function psa_is_kb_page() {
|
||||
return is_page( 73 ) || is_page( 'peptide-database' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue assets on pages that need the search:
|
||||
* - Homepage (front page) where the hero search lives
|
||||
* - KB page (page ID 73)
|
||||
* - Any page/post containing the [peptide_search] shortcode
|
||||
* - Single peptide pages
|
||||
*/
|
||||
function psa_maybe_enqueue_assets() {
|
||||
// Homepage: always enqueue (hero section + search overlay use the shortcode).
|
||||
if ( is_front_page() ) {
|
||||
psa_enqueue_frontend_assets();
|
||||
return;
|
||||
}
|
||||
|
||||
// KB page: enqueue and return early (no $post dependency).
|
||||
if ( psa_is_kb_page() ) {
|
||||
psa_enqueue_frontend_assets();
|
||||
return;
|
||||
}
|
||||
|
||||
global $post;
|
||||
if ( ! is_a( $post, 'WP_Post' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! has_shortcode( $post->post_content, 'peptide_search' ) && ! is_singular( 'peptide' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
psa_enqueue_frontend_assets();
|
||||
}
|
||||
add_action( 'wp_enqueue_scripts', 'psa_maybe_enqueue_assets' );
|
||||
|
||||
/**
|
||||
* Shared helper to enqueue CSS, JS, and localize script.
|
||||
*/
|
||||
function psa_enqueue_frontend_assets() {
|
||||
wp_enqueue_style(
|
||||
'psa-styles',
|
||||
PSA_PLUGIN_URL . 'assets/css/peptide-search.css',
|
||||
array(),
|
||||
PSA_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'psa-search',
|
||||
PSA_PLUGIN_URL . 'assets/js/peptide-search.js',
|
||||
array( 'jquery' ),
|
||||
PSA_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script(
|
||||
'psa-search',
|
||||
'psaAjax',
|
||||
array(
|
||||
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'psa_search_nonce' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the KB page, hide Echo KB search and inject ours via wp_footer.
|
||||
*/
|
||||
function psa_replace_kb_search() {
|
||||
if ( ! psa_is_kb_page() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$search_html = PSA_Search::render_search_form(
|
||||
array(
|
||||
'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...',
|
||||
)
|
||||
);
|
||||
$encoded = wp_json_encode( $search_html );
|
||||
|
||||
echo '<style>
|
||||
#epkb-ml__module-search { display: none !important; }
|
||||
.psa-kb-search-wrap { background: #4a8c3f; padding: 30px 20px; text-align: center; }
|
||||
.psa-kb-search-wrap h2 { color: #fff; font-size: 26px; margin: 0 0 15px 0; font-weight: 600; }
|
||||
.psa-kb-search-wrap .psa-search-wrap { max-width: 700px; margin: 0 auto; }
|
||||
.psa-kb-search-wrap .psa-search-results,
|
||||
.psa-kb-search-wrap .psa-checking,
|
||||
.psa-kb-search-wrap .psa-pending,
|
||||
.psa-kb-search-wrap .psa-invalid,
|
||||
.psa-kb-search-wrap .psa-error {
|
||||
text-align: left; background: #fff; border-radius: 6px; margin-top: 10px; padding: 15px;
|
||||
}
|
||||
</style>';
|
||||
|
||||
echo '<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var kbRow = document.getElementById("epkb-ml__row-1");
|
||||
if (!kbRow) return;
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "psa-kb-search-wrap";
|
||||
wrap.innerHTML = "<h2>Search Our Peptide Database</h2>" + ' . $encoded . ';
|
||||
kbRow.parentNode.insertBefore(wrap, kbRow);
|
||||
});
|
||||
</script>';
|
||||
}
|
||||
add_action( 'wp_footer', 'psa_replace_kb_search' );
|
||||
|
||||
function psa_activate() {
|
||||
PSA_Post_Type::register_peptide_post_type();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
register_activation_hook( __FILE__, 'psa_activate' );
|
||||
|
||||
function psa_deactivate() {
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
register_deactivation_hook( __FILE__, 'psa_deactivate' );
|
||||
|
||||
function psa_get_client_ip() {
|
||||
$headers = array(
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR',
|
||||
);
|
||||
foreach ( $headers as $header ) {
|
||||
if ( ! empty( $_SERVER[ $header ] ) ) {
|
||||
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
|
||||
$ip = trim( $ip[0] );
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
function psa_get_admin_user_id() {
|
||||
$admins = get_users(
|
||||
array(
|
||||
'role' => 'administrator',
|
||||
'number' => 1,
|
||||
'fields' => 'ID',
|
||||
)
|
||||
);
|
||||
return ! empty( $admins ) ? (int) $admins[0] : 0;
|
||||
}
|
||||
66
readme.txt
Normal file
66
readme.txt
Normal file
@@ -0,0 +1,66 @@
|
||||
=== Peptide Search AI ===
|
||||
Contributors: terence
|
||||
Tags: peptide, database, search, AI, science, research, openrouter
|
||||
Requires at least: 5.8
|
||||
Tested up to: 6.7
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 4.0.0
|
||||
License: GPLv2 or later
|
||||
|
||||
Searchable peptide database with AI-powered auto-population. Visitors search for peptides — if one doesn't exist, it's automatically validated and created with comprehensive AI-generated scientific content in the background.
|
||||
|
||||
== Description ==
|
||||
|
||||
Peptide Search AI turns your WordPress site into an intelligent, self-growing peptide database.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Visitors search for a peptide using the search form.
|
||||
2. If found, results appear instantly with molecular data.
|
||||
3. If not found, the plugin automatically validates the name using a lightweight AI call, creates a placeholder, and queues comprehensive content generation in the background via WP-Cron.
|
||||
4. The visitor sees a "currently being added" message and can check back shortly.
|
||||
5. Generated entries include: amino acid sequence, molecular weight/formula, mechanism of action, research applications, safety profile, dosage info, and references.
|
||||
6. PubChem cross-referencing validates molecular data when available.
|
||||
|
||||
**Features:**
|
||||
|
||||
* AJAX search with instant results
|
||||
* Automatic peptide name validation (lightweight AI check before full generation)
|
||||
* Background content generation via WP-Cron (non-blocking)
|
||||
* Custom Post Type with structured scientific fields
|
||||
* AI content generation via OpenRouter — access hundreds of models (Gemini, Llama, DeepSeek, Mistral, Qwen, and more)
|
||||
* Separate model configuration for validation (cheap/fast) and generation (full)
|
||||
* PubChem PUG REST API integration for verified molecular data
|
||||
* Rate limiting (10 requests/IP/hour) to prevent abuse
|
||||
* Transient-based caching for validation and PubChem lookups
|
||||
* Admin settings for API key, models, auto-publish behavior, PubChem toggle
|
||||
* REST API endpoint for headless/external access (read-only)
|
||||
* Conditional asset loading (only on pages with the shortcode or peptide singles)
|
||||
* Source badges (AI-Generated / PubChem Verified / Manually Curated)
|
||||
* Mobile-responsive design
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload the `peptide-search-ai` folder to `/wp-content/plugins/`
|
||||
2. Activate the plugin through the Plugins menu
|
||||
3. Go to Settings > Peptide Search AI
|
||||
4. Enter your OpenRouter API key (get one at openrouter.ai/settings/keys)
|
||||
5. Optionally set separate models for validation and generation
|
||||
6. Configure auto-publish and PubChem settings
|
||||
7. Add `[peptide_search]` to any page or post
|
||||
|
||||
== Shortcode ==
|
||||
|
||||
`[peptide_search]` — Displays the peptide search form.
|
||||
|
||||
Optional attributes:
|
||||
* `placeholder` — Custom placeholder text for the search input.
|
||||
|
||||
Example: `[peptide_search placeholder="Search our peptide database..."]`
|
||||
|
||||
== REST API ==
|
||||
|
||||
Search endpoint: `GET /wp-json/peptides/v1/search?q=BPC-157`
|
||||
|
||||
Returns JSON with matching published peptides, their molecular data, and permalink URLs. This endpoint is read-only and does not trigger auto-generation.
|
||||
|
||||
39
uninstall.php
Normal file
39
uninstall.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* Fired when the plugin is uninstalled.
|
||||
*
|
||||
* Cleans up all plugin data: peptide posts, post meta, options, transients, and rewrite rules.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
// Abort if not called by WordPress.
|
||||
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// 1. Delete all peptide custom posts and their meta.
|
||||
$peptide_ids = $wpdb->get_col(
|
||||
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'peptide'"
|
||||
);
|
||||
|
||||
if ( ! empty( $peptide_ids ) ) {
|
||||
foreach ( $peptide_ids as $post_id ) {
|
||||
wp_delete_post( (int) $post_id, true ); // Force delete, bypass trash.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete plugin options.
|
||||
delete_option( 'psa_settings' );
|
||||
|
||||
// 3. Delete all plugin transients (validation cache, rate limits).
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->options}
|
||||
WHERE option_name LIKE '_transient_psa_%'
|
||||
OR option_name LIKE '_transient_timeout_psa_%'"
|
||||
);
|
||||
|
||||
// 4. Flush rewrite rules to clean up the peptide post type permalinks.
|
||||
flush_rewrite_rules();
|
||||
Reference in New Issue
Block a user