diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100755
index 0000000..c5edfc3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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"
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100755
index 0000000..848805d
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,254 @@
+name: Deploy to WordPress
+
+on:
+ push:
+ branches: [ main ]
+
+permissions:
+ contents: write
+
+# Cancel previous deploys if a new push arrives while one is running.
+concurrency:
+ group: deploy-production
+ cancel-in-progress: true
+
+jobs:
+ # -- Run tests first --
+ test:
+ name: Pre-deploy tests
+ uses: ./.github/workflows/ci.yml
+
+ # -- Validate deployment readiness --
+ validate:
+ name: Validate deployment
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Verify required files exist
+ run: |
+ echo "Checking for required plugin files..."
+ required_files=(
+ "peptide-search-ai.php"
+ "includes/"
+ "assets/"
+ "readme.txt"
+ )
+
+ for file in "${required_files[@]}"; do
+ if [ ! -e "$file" ]; then
+ echo "❌ Missing required file: $file"
+ exit 1
+ fi
+ echo "✓ Found: $file"
+ done
+
+ - name: Verify PSA_VERSION constant
+ run: |
+ echo "Checking PSA_VERSION constant..."
+ if grep -q "define( 'PSA_VERSION'" peptide-search-ai.php; then
+ version=$(grep "define( 'PSA_VERSION'" peptide-search-ai.php | sed "s/.*'\([^']*\)'.*/\1/")
+ echo "✓ PSA_VERSION found: $version"
+ else
+ echo "❌ PSA_VERSION constant not found in peptide-search-ai.php"
+ exit 1
+ fi
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+
+ - name: Validate PHP syntax on all files
+ run: |
+ echo "Validating PHP syntax..."
+ find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -print0 | \
+ xargs -0 -n1 php -l > /dev/null && echo "✓ All PHP files are valid"
+
+ - name: Check for sensitive files
+ run: |
+ echo "Scanning for sensitive files..."
+ found_sensitive=0
+
+ sensitive_patterns=(
+ "\.env"
+ "\.env\."
+ "credentials"
+ "\.key$"
+ "\.pem$"
+ "private_key"
+ "\.sql$"
+ )
+
+ for pattern in "${sensitive_patterns[@]}"; do
+ if find . -name "$pattern" -not -path './vendor/*' -not -path './.git/*' 2>/dev/null | grep -q .; then
+ echo "❌ Found sensitive file matching pattern: $pattern"
+ find . -name "$pattern" -not -path './vendor/*' -not -path './.git/*' 2>/dev/null
+ found_sensitive=1
+ fi
+ done
+
+ if [ $found_sensitive -eq 1 ]; then
+ exit 1
+ fi
+ echo "✓ No sensitive files detected"
+
+ # -- Push clean plugin files to the deploy branch --
+ sync-deploy-branch:
+ name: Sync deploy branch
+ runs-on: ubuntu-latest
+ needs: [test, validate]
+ environment: production
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Sync deploy branch
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ # Fetch the remote deploy branch (may not exist on first run).
+ git fetch origin deploy:deploy 2>/dev/null || true
+
+ # If deploy branch exists, check it out; otherwise create it.
+ if git rev-parse --verify deploy >/dev/null 2>&1; then
+ git checkout deploy
+ else
+ git checkout --orphan deploy
+ git rm -rf --cached . > /dev/null 2>&1 || true
+ fi
+
+ # Remove all tracked files so we start clean each time.
+ git rm -rf . > /dev/null 2>&1 || true
+
+ # Checkout only the plugin runtime files from main.
+ git checkout main -- \
+ assets/ \
+ includes/ \
+ peptide-search-ai.php \
+ readme.txt
+
+ # Commit only if there are actual changes.
+ git add -A
+ if git diff-index --quiet HEAD -- 2>/dev/null; then
+ echo "No changes to deploy."
+ else
+ git commit -m "Deploy ${GITHUB_SHA::7} from main"
+ fi
+
+ # Normal push (fast-forward).
+ git push origin deploy
+
+ # -- Deploy to Hostinger via SSH --
+ deploy:
+ name: Deploy to Hostinger
+ runs-on: ubuntu-latest
+ needs: sync-deploy-branch
+ environment: production
+ steps:
+ - name: Pull deploy branch on Hostinger
+ uses: appleboy/ssh-action@v1
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ port: ${{ secrets.SSH_PORT }}
+ script_stop: true
+ envs: DEPLOY_KEY
+ script: |
+ # Ensure GitHub host key is trusted (one-time setup).
+ mkdir -p ~/.ssh
+ ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
+
+ # Install deploy key so Hostinger can pull from the private repo.
+ if [ ! -f ~/.ssh/deploy_key ]; then
+ echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ fi
+
+ # Configure SSH to use the deploy key for github.com.
+ if ! grep -q "github.com" ~/.ssh/config 2>/dev/null; then
+ cat >> ~/.ssh/config <<'SSHEOF'
+ Host github.com
+ IdentityFile ~/.ssh/deploy_key
+ StrictHostKeyChecking no
+ SSHEOF
+ chmod 600 ~/.ssh/config
+ fi
+
+ PLUGIN_DIR="$HOME/domains/peptiderepo.com/public_html/wp-content/plugins/peptide-search-ai"
+ REPO_URL="git@github.com:peptiderepo/peptide-search-ai.git"
+
+ if [ -d "$PLUGIN_DIR/.git" ]; then
+ # Existing clone -- just pull latest.
+ cd "$PLUGIN_DIR"
+ git fetch origin deploy
+ git reset --hard origin/deploy
+ else
+ # First deploy -- clone the deploy branch.
+ rm -rf "$PLUGIN_DIR"
+ git clone -b deploy "$REPO_URL" "$PLUGIN_DIR"
+ fi
+
+ echo "Deployed to $PLUGIN_DIR"
+ ls -la "$PLUGIN_DIR"
+ env:
+ DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
+
+ - name: Verify deployment on server
+ uses: appleboy/ssh-action@v1
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ port: ${{ secrets.SSH_PORT }}
+ script_stop: true
+ script: |
+ PLUGIN_DIR="$HOME/domains/peptiderepo.com/public_html/wp-content/plugins/peptide-search-ai"
+
+ echo "Verifying deployment..."
+ if [ ! -d "$PLUGIN_DIR" ]; then
+ echo "❌ Plugin directory not found at $PLUGIN_DIR"
+ exit 1
+ fi
+
+ # Check for required files
+ required_files=(
+ "peptide-search-ai.php"
+ "includes"
+ "assets"
+ "readme.txt"
+ )
+
+ for file in "${required_files[@]}"; do
+ if [ ! -e "$PLUGIN_DIR/$file" ]; then
+ echo "❌ Missing file: $file"
+ exit 1
+ fi
+ echo "✓ Found: $file"
+ done
+
+ # Extract and log deployed version
+ if [ -f "$PLUGIN_DIR/peptide-search-ai.php" ]; then
+ version=$(grep "Version:" "$PLUGIN_DIR/peptide-search-ai.php" | head -1 | sed 's/.*Version: *//; s/ *.*//')
+ echo "✓ Deployed version: $version"
+ else
+ echo "❌ Plugin file not found"
+ exit 1
+ fi
+
+ echo "✓ Deployment verification successful"
+
+ - name: Deploy summary
+ run: |
+ echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
+ echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
+ echo "- **Method:** SSH direct deploy (git pull on Hostinger)" >> $GITHUB_STEP_SUMMARY
+ echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY
diff --git a/.gitignore b/.gitignore
new file mode 100755
index 0000000..6429e3d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Dependencies
+vendor/
+node_modules/
+
+# IDE / OS
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+Thumbs.db
+
+# Build artifacts
+*.zip
+*.tar.gz
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
new file mode 100755
index 0000000..53b4be7
--- /dev/null
+++ b/.phpcs.xml.dist
@@ -0,0 +1,28 @@
+
+
+ PHP CodeSniffer configuration for Peptide Search AI WordPress plugin
+
+
+
+
+
+
+
+
+ peptide-search-ai.php
+ includes/
+
+
+ /vendor/*
+ /node_modules/*
+ /.git/*
+ /.github/*
+
+
+
+
+
+
+
+
+
diff --git a/assets/css/peptide-search.css b/assets/css/peptide-search.css
new file mode 100755
index 0000000..e271197
--- /dev/null
+++ b/assets/css/peptide-search.css
@@ -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;
+ }
+}
diff --git a/assets/js/peptide-search.js b/assets/js/peptide-search.js
new file mode 100755
index 0000000..b9c48a9
--- /dev/null
+++ b/assets/js/peptide-search.js
@@ -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('Error: ' + 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 '' + b.label + ' ';
+ }
+
+ /**
+ * Render search results.
+ */
+ function renderResults(data) {
+ hideAll();
+
+ var html = '
' + data.total + ' peptide' +
+ (data.total !== 1 ? 's' : '') + ' found
';
+
+ data.results.forEach(function (item) {
+ html += '';
+ html += '' + esc(item.title);
+ if (item.source) html += sourceBadge(item.source);
+ html += ' ';
+
+ var meta = [];
+ if (item.molecular_weight) meta.push('MW: ' + esc(item.molecular_weight) + ' ');
+ if (item.molecular_formula) meta.push('Formula: ' + esc(item.molecular_formula) + ' ');
+ if (item.sequence && item.sequence.length <= 40) meta.push('Seq: ' + esc(item.sequence) + ' ');
+ if (meta.length) html += '' + meta.join('') + '
';
+
+ if (item.excerpt) html += '' + esc(item.excerpt) + '
';
+ html += ' ';
+ });
+
+ $results.html(html).show();
+ }
+
+ /**
+ * Show the pending/in-progress message.
+ */
+ function showPending(name) {
+ hideAll();
+ $pending.find('.psa-pending-inner p').html(
+ '' + esc(name) + ' is currently being added to our database. Please check back again later.'
+ );
+ $pending.show();
+ }
+
+ /**
+ * Show the invalid peptide message.
+ */
+ function showInvalid(name, reason) {
+ hideAll();
+ $invalid.find('.psa-invalid-text').html(
+ '' + esc(name) + ' does not appear to be a recognized peptide. Please check the spelling or try a different search term.'
+ );
+ $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 (jqXHR.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();
+ doSearch($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);
diff --git a/includes/class-psa-admin.php b/includes/class-psa-admin.php
index bded3db..521f565 100755
--- a/includes/class-psa-admin.php
+++ b/includes/class-psa-admin.php
@@ -92,6 +92,13 @@ class PSA_Admin {
'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'
+ );
}
/**
@@ -123,6 +130,7 @@ class PSA_Admin {
'auto_publish' => sanitize_text_field( $input['auto_publish'] ?? 'draft' ),
// Checkbox: if key is absent in POST, it was unchecked → '0'.
'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0',
+ 'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ),
);
}
@@ -210,6 +218,14 @@ class PSA_Admin {
+
+
+
+
+
+
+
@@ -250,6 +270,58 @@ class PSA_Admin {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ created_at ); ?>
+ model ); ?>
+ total_tokens ) ); ?>
+ $estimated_cost_usd, 4 ) ); ?>
+ request_type ); ?>
+ peptide_name ); ?>
+ success ) ? '✓' : '✗'; ?>
+
+
+
+
+
+ { tokens: int, estimated_cost_usd: float },
+ * 'generation' => { tokens: int, estimated_cost_usd: float },
+ * 'total_estimated_cost_usd' => float,
+ * }
+ */
+ public static function dry_run( $peptide_name ) {
+ $options = self::get_settings();
+ $validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001';
+ $generation_model = $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash';
+
+ // Estimate validation: prompt tokens from validation prompt + completion tokens.
+ $validation_prompt = self::build_validation_prompt( $peptide_name );
+ $validation_prompt_tokens = (int) ceil( strlen( $validation_prompt ) / 4 ); // rough estimate: 1 token ≈ 4 chars.
+ $validation_completion_tokens = PSA_Config::VALIDATION_MAX_TOKENS;
+ $validation_cost = PSA_Cost_Tracker::estimate_cost(
+ $validation_model,
+ $validation_prompt_tokens,
+ $validation_completion_tokens
+ );
+
+ // Estimate generation: prompt tokens from generation prompt + completion tokens.
+ $generation_prompt = self::build_generation_prompt( $peptide_name );
+ $generation_prompt_tokens = (int) ceil( strlen( $generation_prompt ) / 4 );
+ $generation_completion_tokens = PSA_Config::GENERATION_MAX_TOKENS;
+ $generation_cost = PSA_Cost_Tracker::estimate_cost(
+ $generation_model,
+ $generation_prompt_tokens,
+ $generation_completion_tokens
+ );
+
+ return array(
+ 'validation' => array(
+ 'tokens' => $validation_prompt_tokens + $validation_completion_tokens,
+ 'estimated_cost_usd' => $validation_cost,
+ ),
+ 'generation' => array(
+ 'tokens' => $generation_prompt_tokens + $generation_completion_tokens,
+ 'estimated_cost_usd' => $generation_cost,
+ ),
+ 'total_estimated_cost_usd' => $validation_cost + $generation_cost,
+ );
+ }
+
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -416,18 +478,20 @@ PROMPT;
* 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 $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 ) {
+ 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 );
+ $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() ) {
@@ -454,13 +518,15 @@ PROMPT;
/**
* 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 $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 ) {
+ 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' );
@@ -525,6 +591,22 @@ PROMPT;
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'];
}
diff --git a/includes/class-psa-config.php b/includes/class-psa-config.php
new file mode 100755
index 0000000..e5095e5
--- /dev/null
+++ b/includes/class-psa-config.php
@@ -0,0 +1,44 @@
+prefix . 'psa_api_logs';
+ $charset_collate = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE $table_name (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
+ model VARCHAR(100) NOT NULL,
+ prompt_tokens INT NOT NULL DEFAULT 0,
+ completion_tokens INT NOT NULL DEFAULT 0,
+ total_tokens INT NOT NULL DEFAULT 0,
+ estimated_cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
+ request_type VARCHAR(20) NOT NULL,
+ peptide_name VARCHAR(200) DEFAULT '',
+ success TINYINT(1) NOT NULL DEFAULT 1,
+ INDEX idx_created_at (created_at),
+ INDEX idx_request_type (request_type)
+ ) $charset_collate;";
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+
+ // Check for errors during table creation.
+ if ( ! empty( $wpdb->last_error ) ) {
+ error_log( 'PSA: Table creation error: ' . $wpdb->last_error );
+ }
+ }
+
+ /**
+ * Log an API call with token counts and cost.
+ *
+ * @param array $data {
+ * @type string $provider API provider (default: 'openrouter')
+ * @type string $model Model identifier
+ * @type int $prompt_tokens Tokens in prompt
+ * @type int $completion_tokens Tokens in completion
+ * @type int $total_tokens Total tokens (or 0 to auto-sum)
+ * @type string $request_type 'validation' or 'generation'
+ * @type string $peptide_name Name of peptide being processed
+ * @type bool $success Whether API call succeeded
+ * @type float $estimated_cost_usd Cost (or 0 to auto-calculate)
+ * }
+ * @return int|false Insert ID or false on error.
+ */
+ public static function log_api_call( $data ) {
+ global $wpdb;
+
+ $table_name = $wpdb->prefix . 'psa_api_logs';
+
+ // Sanitize and prepare data.
+ $provider = $data['provider'] ?? 'openrouter';
+ $model = $data['model'] ?? '';
+ $prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 );
+ $completion_tokens = (int) ( $data['completion_tokens'] ?? 0 );
+ $total_tokens = (int) ( $data['total_tokens'] ?? 0 );
+ $request_type = $data['request_type'] ?? 'generation';
+ $peptide_name = $data['peptide_name'] ?? '';
+ $success = ! empty( $data['success'] ) ? 1 : 0;
+ $estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 );
+
+ // Auto-sum tokens if not provided.
+ if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) {
+ $total_tokens = $prompt_tokens + $completion_tokens;
+ }
+
+ // Auto-calculate cost if not provided.
+ if ( 0 === $estimated_cost && $prompt_tokens > 0 ) {
+ $estimated_cost = self::estimate_cost( $model, $prompt_tokens, $completion_tokens );
+ }
+
+ $insert_data = array(
+ 'provider' => sanitize_text_field( $provider ),
+ 'model' => sanitize_text_field( $model ),
+ 'prompt_tokens' => $prompt_tokens,
+ 'completion_tokens' => $completion_tokens,
+ 'total_tokens' => $total_tokens,
+ 'estimated_cost_usd' => $estimated_cost,
+ 'request_type' => sanitize_text_field( $request_type ),
+ 'peptide_name' => sanitize_text_field( $peptide_name ),
+ 'success' => $success,
+ );
+
+ $insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d' );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $result = $wpdb->insert( $table_name, $insert_data, $insert_format );
+
+ if ( false === $result ) {
+ error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error );
+ return false;
+ }
+
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Get total spend for a given month.
+ *
+ * @param int|null $year Year (defaults to current year).
+ * @param int|null $month Month (defaults to current month).
+ * @return float Total USD spent in the month.
+ */
+ public static function get_monthly_spend( $year = null, $month = null ) {
+ global $wpdb;
+
+ if ( null === $year ) {
+ $year = (int) gmdate( 'Y' );
+ }
+ if ( null === $month ) {
+ $month = (int) gmdate( 'm' );
+ }
+
+ $table_name = $wpdb->prefix . 'psa_api_logs';
+
+ // Ensure valid month.
+ $month = max( 1, min( 12, (int) $month ) );
+
+ // Build date range for the month.
+ $start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
+ $end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) );
+ $end_date = $end_date . ' 23:59:59';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $result = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM $table_name WHERE created_at >= %s AND created_at <= %s",
+ $start_date,
+ $end_date
+ )
+ );
+
+ return floatval( $result );
+ }
+
+ /**
+ * Get total token usage for a given month.
+ *
+ * @param int|null $year Year (defaults to current year).
+ * @param int|null $month Month (defaults to current month).
+ * @return int Total tokens used in the month.
+ */
+ public static function get_monthly_tokens( $year = null, $month = null ) {
+ global $wpdb;
+
+ if ( null === $year ) {
+ $year = (int) gmdate( 'Y' );
+ }
+ if ( null === $month ) {
+ $month = (int) gmdate( 'm' );
+ }
+
+ $table_name = $wpdb->prefix . 'psa_api_logs';
+
+ // Ensure valid month.
+ $month = max( 1, min( 12, (int) $month ) );
+
+ // Build date range for the month.
+ $start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
+ $end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) );
+ $end_date = $end_date . ' 23:59:59';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $result = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COALESCE(SUM(total_tokens), 0) FROM $table_name WHERE created_at >= %s AND created_at <= %s",
+ $start_date,
+ $end_date
+ )
+ );
+
+ return (int) $result;
+ }
+
+ /**
+ * Check if current month's budget has been exceeded.
+ * Budget of 0 means unlimited.
+ *
+ * @return bool True if budget exceeded, false otherwise.
+ */
+ public static function is_budget_exceeded() {
+ $settings = get_option( 'psa_settings', array() );
+ $budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET );
+
+ // Budget of 0 = unlimited.
+ if ( 0 === $budget ) {
+ return false;
+ }
+
+ $current_spend = self::get_monthly_spend();
+ return $current_spend >= $budget;
+ }
+
+ /**
+ * Get the most recent API call logs.
+ *
+ * @param int $limit Maximum number of logs to return.
+ * @return array Array of log objects.
+ */
+ public static function get_recent_logs( $limit = 20 ) {
+ global $wpdb;
+
+ $table_name = $wpdb->prefix . 'psa_api_logs';
+ $limit = max( 1, (int) $limit );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $results = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM $table_name ORDER BY created_at DESC LIMIT %d",
+ $limit
+ )
+ );
+
+ return is_array( $results ) ? $results : array();
+ }
+
+ /**
+ * Estimate cost for an API call based on model and token counts.
+ * Uses known pricing, defaults to conservative estimate for unknown models.
+ *
+ * @param string $model Model identifier.
+ * @param int $prompt_tokens Tokens in prompt.
+ * @param int $completion_tokens Tokens in completion.
+ * @return float Estimated cost in USD.
+ */
+ public static function estimate_cost( $model, $prompt_tokens, $completion_tokens ) {
+ // Known model pricing per 1 million tokens (input, output).
+ $pricing = array(
+ 'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
+ 'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
+ );
+
+ $model = sanitize_text_field( $model );
+
+ if ( isset( $pricing[ $model ] ) ) {
+ list( $input_price, $output_price ) = $pricing[ $model ];
+ } else {
+ // Conservative default for unknown models.
+ $input_price = 1.0;
+ $output_price = 2.0;
+ }
+
+ // Convert per 1M to per token.
+ $input_cost = ( $input_price / 1000000 ) * $prompt_tokens;
+ $output_cost = ( $output_price / 1000000 ) * $completion_tokens;
+
+ return (float) ( $input_cost + $output_cost );
+ }
+
+ /**
+ * Drop the API logs table during uninstall.
+ */
+ public static function drop_table() {
+ global $wpdb;
+
+ $table_name = $wpdb->prefix . 'psa_api_logs';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->query( "DROP TABLE IF EXISTS $table_name" );
+ }
+}
diff --git a/includes/class-psa-error.php b/includes/class-psa-error.php
new file mode 100755
index 0000000..ea95600
--- /dev/null
+++ b/includes/class-psa-error.php
@@ -0,0 +1,39 @@
+ $type,
+ 'message' => $message,
+ ),
+ $http_code
+ );
+ }
+}
diff --git a/includes/class-psa-post-type.php b/includes/class-psa-post-type.php
new file mode 100755
index 0000000..79043dd
--- /dev/null
+++ b/includes/class-psa-post-type.php
@@ -0,0 +1,162 @@
+ '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 '';
+ }
+
+ /**
+ * 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 );
+ }
+ }
+ }
+}
diff --git a/includes/class-psa-pubchem.php b/includes/class-psa-pubchem.php
new file mode 100755
index 0000000..cfe20da
--- /dev/null
+++ b/includes/class-psa-pubchem.php
@@ -0,0 +1,152 @@
+ 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'] ?? '',
+ );
+ }
+}
diff --git a/includes/class-psa-search.php b/includes/class-psa-search.php
new file mode 100755
index 0000000..61630fe
--- /dev/null
+++ b/includes/class-psa-search.php
@@ -0,0 +1,584 @@
+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).
+ *
+ * Uses dual-layer rate limiting (transient + object cache) for resilience.
+ * If Redis/Memcached evicts the transient, we fall back to the object cache.
+ *
+ * Limitation: IP spoofing or cache server restart could bypass the limit.
+ * For high-traffic deployments, consider a custom DB table with persistent
+ * rate limit tracking (e.g., wp_psa_rate_limits with indexed IP + timestamp).
+ *
+ * @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.
+ // Uses dual-layer caching (transient + object cache) for resilience.
+ $ip = psa_get_client_ip();
+ $rate_key = 'psa_rest_rate_' . md5( $ip );
+
+ // Check transient first.
+ $count_transient = (int) get_transient( $rate_key );
+
+ // Check object cache as secondary source for resilience.
+ $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
+
+ // Use the highest count from either source.
+ $count = max( $count_transient, $count_cache );
+
+ if ( $count >= PSA_Config::REST_RATE_LIMIT ) {
+ return new WP_REST_Response(
+ array( 'error' => 'Rate limit exceeded. Please try again later.' ),
+ 429
+ );
+ }
+
+ // Update both transient and object cache for resilience.
+ set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS );
+ wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', 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();
+ ?>
+
+
+
+
+
+
+
Searching our database...
+
+
+
+
+
⏳
+
This peptide is currently being added to our database. Please check back again later.
+
+
+
+
+
+ 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.',
+ )
+ );
+ return;
+ }
+
+ // 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.
+ *
+ * Uses dual-layer caching (transient + object cache) for resilience.
+ * If Redis/Memcached evicts the transient, we fall back to the object cache.
+ * If either source reports the limit exceeded, the request is rate-limited.
+ *
+ * Limitation: IP spoofing or cache server restart could bypass the limit.
+ * For high-traffic deployments, consider a custom DB table with persistent
+ * rate limit tracking (e.g., wp_psa_rate_limits with indexed IP + timestamp).
+ *
+ * @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 );
+
+ // Check transient first.
+ $count_transient = (int) get_transient( $rate_key );
+
+ // Check object cache as secondary source for resilience.
+ $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' );
+
+ // Use the highest count from either source.
+ $count = max( $count_transient, $count_cache );
+
+ 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 ) {
+ // Global daily generation cap to prevent cost overruns.
+ $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
+ $daily_count = (int) get_transient( $daily_key );
+ if ( $daily_count >= PSA_Config::DAILY_GENERATION_CAP ) {
+ wp_send_json_success(
+ array(
+ 'status' => 'rate_limited',
+ 'message' => 'Our system has reached its daily limit for adding new peptides. Please try again tomorrow.',
+ )
+ );
+ return;
+ }
+
+ // 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.
+ // Update both transient and object cache for resilience.
+ $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 );
+ wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', 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.' );
+ }
+
+ // Increment global daily generation counter.
+ $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' );
+ $daily_count = (int) get_transient( $daily_key );
+ set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS );
+
+ // 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 (non-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.
+ *
+ * Cache key includes the generation counter, so invalidate_search_cache()
+ * can purge all caches by incrementing the counter (O(1) vs. expensive DELETE).
+ *
+ * @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.
+ // Include generation counter in cache key for fast invalidation.
+ $cache_key = 'psa_search_' . self::get_cache_generation() . '_' . 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 ),
+ );
+ }
+}
diff --git a/includes/class-psa-template.php b/includes/class-psa-template.php
new file mode 100755
index 0000000..5cc1506
--- /dev/null
+++ b/includes/class-psa-template.php
@@ -0,0 +1,131 @@
+ $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 = '';
+
+ // 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 .= '
';
+ $html .= '
Quick Facts ';
+ $html .= '
';
+
+ if ( $aliases ) {
+ $html .= 'Also Known As ' . esc_html( $aliases ) . ' ';
+ }
+ if ( $seq ) {
+ $html .= 'Sequence ' . esc_html( $seq ) . ' ';
+ }
+ if ( $formula ) {
+ $html .= 'Molecular Formula ' . esc_html( $formula ) . ' ';
+ }
+ if ( $weight ) {
+ $html .= 'Molecular Weight ' . esc_html( $weight ) . ' ';
+ }
+ if ( $pubchem_cid ) {
+ $cid_int = intval( $pubchem_cid );
+ $html .= 'PubChem CID ' . $cid_int . ' ';
+ }
+
+ $html .= '
';
+ $html .= '
';
+ }
+
+ // 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 .= '
';
+ $html .= '
' . esc_html( $label ) . ' ';
+ $html .= '
' . wp_kses_post( wpautop( $value ) ) . '
';
+ $html .= '
';
+ }
+ }
+
+ // 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 .= '
';
+ $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 .= '
';
+ }
+
+ $html .= '
';
+
+ return $content . $html;
+ }
+}
diff --git a/peptide-search-ai.php b/peptide-search-ai.php
index b049769..530d4f9 100755
--- a/peptide-search-ai.php
+++ b/peptide-search-ai.php
@@ -25,6 +25,7 @@ require_once PSA_PLUGIN_DIR . 'includes/class-psa-error.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-encryption.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-post-type.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-search.php';
+require_once PSA_PLUGIN_DIR . 'includes/class-psa-cost-tracker.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-ai-generator.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-pubchem.php';
require_once PSA_PLUGIN_DIR . 'includes/class-psa-admin.php';
@@ -160,6 +161,7 @@ add_action( 'wp_footer', 'psa_replace_kb_search' );
function psa_activate() {
PSA_Post_Type::register_peptide_post_type();
+ PSA_Cost_Tracker::create_table();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'psa_activate' );
diff --git a/readme.txt b/readme.txt
new file mode 100755
index 0000000..565847d
--- /dev/null
+++ b/readme.txt
@@ -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.
+
diff --git a/uninstall.php b/uninstall.php
new file mode 100755
index 0000000..877989b
--- /dev/null
+++ b/uninstall.php
@@ -0,0 +1,42 @@
+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. Drop API logs table.
+$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}psa_api_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+
+// 5. Flush rewrite rules to clean up the peptide post type permalinks.
+flush_rewrite_rules();