From e62b51a7648df13cdb3c85bdf6b254787ca3f712 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Wed, 22 Apr 2026 10:14:27 +0000 Subject: [PATCH] Deploy 868d31c from main --- assets/css/peptide-search.css | 387 +++++++++++++++ assets/css/psa-directory.css | 621 ++++++++++++++++++++++++ assets/js/peptide-search.js | 213 ++++++++ assets/js/psa-batch-enrich.js | 172 +++++++ assets/js/psa-directory.js | 501 +++++++++++++++++++ includes/class-psa-admin-page.php | 221 +++++++++ includes/class-psa-admin.php | 290 +++++++++++ includes/class-psa-ai-content.php | 228 +++++++++ includes/class-psa-ai-generator.php | 282 +++++++++++ includes/class-psa-batch-enrichment.php | 182 +++++++ includes/class-psa-config.php | 48 ++ includes/class-psa-cost-reporter.php | 136 ++++++ includes/class-psa-cost-tracker.php | 171 +++++++ includes/class-psa-dependency-check.php | 81 ++++ includes/class-psa-directory-api.php | 159 ++++++ includes/class-psa-directory.php | 114 +++++ includes/class-psa-encryption.php | 130 +++++ includes/class-psa-kb-builder.php | 247 ++++++++++ includes/class-psa-openrouter.php | 218 +++++++++ includes/class-psa-post-type-meta.php | 140 ++++++ includes/class-psa-post-type.php | 153 ++++++ includes/class-psa-pubchem.php | 158 ++++++ includes/class-psa-search-handler.php | 211 ++++++++ includes/class-psa-search.php | 300 ++++++++++++ includes/class-psa-template.php | 224 +++++++++ includes/class-psa-upgrade.php | 83 ++++ peptide-search-ai.php | 296 +++++++++++ readme.txt | 66 +++ 28 files changed, 6032 insertions(+) create mode 100755 assets/css/peptide-search.css create mode 100755 assets/css/psa-directory.css create mode 100755 assets/js/peptide-search.js create mode 100755 assets/js/psa-batch-enrich.js create mode 100755 assets/js/psa-directory.js create mode 100644 includes/class-psa-admin-page.php create mode 100755 includes/class-psa-admin.php create mode 100644 includes/class-psa-ai-content.php create mode 100755 includes/class-psa-ai-generator.php create mode 100755 includes/class-psa-batch-enrichment.php create mode 100755 includes/class-psa-config.php create mode 100644 includes/class-psa-cost-reporter.php create mode 100755 includes/class-psa-cost-tracker.php create mode 100644 includes/class-psa-dependency-check.php create mode 100644 includes/class-psa-directory-api.php create mode 100755 includes/class-psa-directory.php create mode 100755 includes/class-psa-encryption.php create mode 100755 includes/class-psa-kb-builder.php create mode 100755 includes/class-psa-openrouter.php create mode 100644 includes/class-psa-post-type-meta.php create mode 100755 includes/class-psa-post-type.php create mode 100755 includes/class-psa-pubchem.php create mode 100644 includes/class-psa-search-handler.php create mode 100755 includes/class-psa-search.php create mode 100755 includes/class-psa-template.php create mode 100644 includes/class-psa-upgrade.php create mode 100755 peptide-search-ai.php create mode 100755 readme.txt 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/css/psa-directory.css b/assets/css/psa-directory.css new file mode 100755 index 0000000..70e36c4 --- /dev/null +++ b/assets/css/psa-directory.css @@ -0,0 +1,621 @@ +/* ========================================================================== + Peptide Directory — Card Grid, Filters, and Detail Modal + Namespace: .psa-dir-* (BEM) + ========================================================================== */ + +/* --- CSS Custom Properties for category badge colors (reusable by other plugins) --- */ +:root { + --psa-cat-tissue-repair-bg: #d4edda; + --psa-cat-tissue-repair-fg: #155724; + --psa-cat-lipid-metabolism-bg: #d1ecf1; + --psa-cat-lipid-metabolism-fg: #0c5460; + --psa-cat-aging-research-bg: #e2d5f1; + --psa-cat-aging-research-fg: #432874; + --psa-cat-dermatological-bg: #fce4d6; + --psa-cat-dermatological-fg: #7c4a1a; + --psa-cat-metabolic-bg: #fff3cd; + --psa-cat-metabolic-fg: #856404; + --psa-cat-growth-hormone-bg: #cce5ff; + --psa-cat-growth-hormone-fg: #004085; + --psa-cat-immunology-bg: #f5c6cb; + --psa-cat-immunology-fg: #721c24; + --psa-cat-endocrine-bg: #c3e6cb; + --psa-cat-endocrine-fg: #1b5e20; +} + +/* --- Wrapper --- */ +.psa-dir-wrap { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; +} + +/* --- Header --- */ +.psa-dir-header { + text-align: center; + margin-bottom: 2rem; +} + +.psa-dir-title { + font-size: 2rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 0.5rem; +} + +.psa-dir-subtitle { + font-size: 1.05rem; + color: #64748b; + margin: 0; +} + +/* --- Search --- */ +.psa-dir-search-wrap { + max-width: 480px; + margin: 0 auto 1.5rem; +} + +.psa-dir-search { + width: 100%; + padding: 12px 16px; + font-size: 15px; + border: 2px solid #e2e8f0; + border-radius: 8px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.psa-dir-search:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +/* --- Tabs --- */ +.psa-dir-tabs { + display: flex; + justify-content: center; + gap: 4px; + margin-bottom: 1.5rem; +} + +.psa-dir-tab { + padding: 8px 20px; + font-size: 14px; + font-weight: 600; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + color: #64748b; + cursor: pointer; + transition: all 0.2s; +} + +.psa-dir-tab:hover { + background: #f8fafc; +} + +.psa-dir-tab--active { + background: #2563eb; + color: #fff; + border-color: #2563eb; +} + +/* --- Category Filters --- */ +.psa-dir-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-bottom: 2rem; +} + +.psa-dir-filter { + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + border: 1px solid #e2e8f0; + border-radius: 20px; + background: #fff; + color: #475569; + cursor: pointer; + transition: all 0.2s; +} + +.psa-dir-filter:hover { + border-color: #2563eb; + color: #2563eb; +} + +.psa-dir-filter--active { + background: #2563eb; + color: #fff; + border-color: #2563eb; +} + +/* --- Grid --- */ +.psa-dir-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; +} + +/* --- Card --- */ +.psa-dir-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.psa-dir-card:hover { + border-color: #2563eb; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08); +} + +.psa-dir-card__header { + margin-bottom: 0.75rem; +} + +.psa-dir-card__title { + font-size: 1.1rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 0.5rem; +} + +.psa-dir-card__badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 10px; + border-radius: 12px; +} + +.psa-dir-card__meta { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 1rem; + flex-grow: 1; +} + +.psa-dir-card__meta-item { + display: flex; + justify-content: space-between; + font-size: 13px; +} + +.psa-dir-card__meta-label { + color: #64748b; + font-weight: 500; +} + +.psa-dir-card__meta-value { + color: #1e293b; + font-weight: 600; +} + +.psa-dir-card__btn { + width: 100%; + padding: 10px; + font-size: 14px; + font-weight: 600; + background: #2563eb; + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + margin-top: auto; +} + +.psa-dir-card__btn:hover { + background: #1d4ed8; +} + +/* --- Category Badge Colors --- */ +.psa-dir-badge--tissue-repair { background: var(--psa-cat-tissue-repair-bg); color: var(--psa-cat-tissue-repair-fg); } +.psa-dir-badge--lipid-metabolism { background: var(--psa-cat-lipid-metabolism-bg); color: var(--psa-cat-lipid-metabolism-fg); } +.psa-dir-badge--aging-research { background: var(--psa-cat-aging-research-bg); color: var(--psa-cat-aging-research-fg); } +.psa-dir-badge--dermatological { background: var(--psa-cat-dermatological-bg); color: var(--psa-cat-dermatological-fg); } +.psa-dir-badge--metabolic { background: var(--psa-cat-metabolic-bg); color: var(--psa-cat-metabolic-fg); } +.psa-dir-badge--growth-hormone { background: var(--psa-cat-growth-hormone-bg); color: var(--psa-cat-growth-hormone-fg); } +.psa-dir-badge--immunology { background: var(--psa-cat-immunology-bg); color: var(--psa-cat-immunology-fg); } +.psa-dir-badge--endocrine { background: var(--psa-cat-endocrine-bg); color: var(--psa-cat-endocrine-fg); } + +/* --- Empty State --- */ +.psa-dir-empty, +.psa-dir-coming-soon { + text-align: center; + padding: 3rem 1rem; + color: #64748b; + font-size: 1rem; +} + +/* --- Loader --- */ +.psa-dir-loader { + text-align: center; + padding: 3rem 1rem; +} + +.psa-dir-loader p { + color: #64748b; + margin-top: 1rem; +} + +/* --- Load More --- */ +.psa-dir-load-more-wrap { + text-align: center; + margin-top: 2rem; +} + +.psa-dir-load-more { + padding: 12px 32px; + font-size: 15px; + font-weight: 600; + background: #fff; + color: #2563eb; + border: 2px solid #2563eb; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.psa-dir-load-more:hover { + background: #2563eb; + color: #fff; +} + +.psa-dir-load-more:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* --- Error --- */ +.psa-dir-error { + text-align: center; + color: #991b1b; + padding: 2rem; +} + +/* ========================================================================== + Detail Modal + ========================================================================== */ + +.psa-dir-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; +} + +.psa-dir-modal__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); +} + +.psa-dir-modal__content { + position: relative; + background: #fff; + border-radius: 16px; + padding: 2rem; + max-width: 700px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); +} + +.psa-dir-modal__close { + position: absolute; + top: 12px; + right: 16px; + font-size: 28px; + background: none; + border: none; + color: #94a3b8; + cursor: pointer; + line-height: 1; + padding: 4px; +} + +.psa-dir-modal__close:hover { + color: #1e293b; +} + +.psa-dir-modal__title { + font-size: 1.5rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 0.5rem; + padding-right: 2rem; +} + +.psa-dir-modal__badges { + margin-bottom: 1rem; +} + +.psa-dir-modal__desc { + font-size: 15px; + line-height: 1.65; + color: #475569; + margin-bottom: 1.5rem; +} + +/* --- Sequence --- */ +.psa-dir-modal__sequence-wrap { + display: flex; + align-items: flex-start; + gap: 8px; + background: #f1f5f9; + padding: 12px; + border-radius: 8px; +} + +.psa-dir-modal__sequence { + font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 13px; + word-break: break-all; + flex: 1; + color: #334155; +} + +.psa-dir-modal__copy { + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + background: #2563eb; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.psa-dir-modal__copy:hover { + background: #1d4ed8; +} + +/* --- Data Grid --- */ +.psa-dir-modal__data-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f8fafc; + border-radius: 10px; + border: 1px solid #e2e8f0; +} + +.psa-dir-modal__data-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.psa-dir-modal__data-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: #64748b; +} + +.psa-dir-modal__data-value { + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +/* --- Modal Sections --- */ +.psa-dir-modal__section { + margin-bottom: 1.5rem; +} + +.psa-dir-modal__section h4 { + font-size: 15px; + font-weight: 700; + color: #1e293b; + margin: 0 0 0.5rem; + padding-bottom: 6px; + border-bottom: 2px solid #2563eb; +} + +.psa-dir-modal__section p { + font-size: 14px; + line-height: 1.6; + color: #475569; + margin: 0 0 0.5rem; +} + +/* --- PubChem Link --- */ +.psa-dir-modal__pubchem { + display: inline-block; + font-size: 13px; + color: #2563eb; + text-decoration: none; + margin-bottom: 1rem; +} + +.psa-dir-modal__pubchem:hover { + text-decoration: underline; +} + +/* --- Full Page Link --- */ +.psa-dir-modal__full-link { + display: inline-block; + padding: 10px 24px; + font-size: 14px; + font-weight: 600; + background: #f1f5f9; + color: #2563eb; + text-decoration: none; + border-radius: 8px; + transition: background 0.2s; +} + +.psa-dir-modal__full-link:hover { + background: #e2e8f0; +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +/* Tablet: 2 columns */ +@media (max-width: 900px) { + .psa-dir-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Mobile: 1 column */ +@media (max-width: 600px) { + .psa-dir-grid { + grid-template-columns: 1fr; + } + + .psa-dir-title { + font-size: 1.5rem; + } + + .psa-dir-modal__content { + width: 95%; + padding: 1.25rem; + max-height: 90vh; + } + + .psa-dir-modal__data-grid { + grid-template-columns: 1fr; + } + + .psa-dir-filters { + gap: 6px; + } + + .psa-dir-filter { + font-size: 12px; + padding: 5px 12px; + } +} + +/* ========================================================================== + Dark Mode Support + Uses the theme's data-theme="dark" attribute and CSS custom properties. + ========================================================================== */ + +[data-theme="dark"] .psa-dir-wrap { + color: #e2e8f0; +} + +[data-theme="dark"] .psa-dir-title { + color: #f1f5f9; +} + +[data-theme="dark"] .psa-dir-subtitle { + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-search { + background: #1e293b; + border-color: #334155; + color: #f1f5f9; +} + +[data-theme="dark"] .psa-dir-tab { + background: #1e293b; + border-color: #334155; + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-tab--active { + background: #2563eb; + color: #fff; +} + +[data-theme="dark"] .psa-dir-filter { + background: #1e293b; + border-color: #334155; + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-filter--active { + background: #2563eb; + color: #fff; +} + +[data-theme="dark"] .psa-dir-card { + background: #1e293b; + border-color: #334155; +} + +[data-theme="dark"] .psa-dir-card__title { + color: #f1f5f9; +} + +[data-theme="dark"] .psa-dir-card__meta-label { + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-card__meta-value { + color: #e2e8f0; +} + +[data-theme="dark"] .psa-dir-modal__content { + background: #0f172a; + color: #e2e8f0; +} + +[data-theme="dark"] .psa-dir-modal__title { + color: #f1f5f9; +} + +[data-theme="dark"] .psa-dir-modal__desc { + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-modal__data-grid { + background: #1e293b; + border-color: #334155; +} + +[data-theme="dark"] .psa-dir-modal__data-label { + color: #94a3b8; +} + +[data-theme="dark"] .psa-dir-modal__data-value { + color: #e2e8f0; +} + +[data-theme="dark"] .psa-dir-modal__sequence-wrap { + background: #1e293b; +} + +[data-theme="dark"] .psa-dir-modal__sequence { + color: #e2e8f0; +} + +[data-theme="dark"] .psa-dir-modal__close { + color: #64748b; +} + +[data-theme="dark"] .psa-dir-modal__close:hover { + color: #f1f5f9; +} diff --git a/assets/js/peptide-search.js b/assets/js/peptide-search.js new file mode 100755 index 0000000..c19c50d --- /dev/null +++ b/assets/js/peptide-search.js @@ -0,0 +1,213 @@ +/** + * 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('' + esc(psaAjax.i18n.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: psaAjax.i18n.verified, cls: 'psa-badge-verified' }, + 'manual': { label: psaAjax.i18n.curated, cls: 'psa-badge-manual' } + }; + var b = map[source]; + if (!b) return ''; + return '' + esc(b.label) + ''; + } + + /** + * Render search results. + */ + function renderResults(data) { + hideAll(); + + var html = '

' + data.total + ' ' + esc(psaAjax.i18n.peptidesFound) + '

'; + + 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) + ' ' + esc(psaAjax.i18n.pendingMsg) + ); + $pending.show(); + } + + /** + * Show the invalid peptide message. + */ + function showInvalid(name, reason) { + hideAll(); + $invalid.find('.psa-invalid-text').html( + '' + esc(name) + ' ' + esc(psaAjax.i18n.invalidMsg) + ); + $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 || psaAjax.i18n.searchFailed); + 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 || psaAjax.i18n.rateLimited); + break; + default: + showError(psaAjax.i18n.unexpected); + } + }, + error: function (jqXHR, textStatus) { + $results.attr('aria-busy', 'false'); + var errorMsg = psaAjax.i18n.networkError; + if (textStatus === 'timeout') { + errorMsg = psaAjax.i18n.timeout; + } else if (textStatus === 'error') { + if (jqXHR.status === 0) { + errorMsg = psaAjax.i18n.connectionError; + } else if (jqXHR.status === 500) { + errorMsg = psaAjax.i18n.serverError; + } else if (jqXHR.status === 503) { + errorMsg = psaAjax.i18n.unavailable; + } + } + 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/assets/js/psa-batch-enrich.js b/assets/js/psa-batch-enrich.js new file mode 100755 index 0000000..20ab5a0 --- /dev/null +++ b/assets/js/psa-batch-enrich.js @@ -0,0 +1,172 @@ +/** + * Batch enrichment UI controller for the Peptide Search AI admin page. + * + * What: Drives the AJAX polling loop that enriches one peptide at a time with a progress bar. + * Who loads it: PSA_Admin::enqueue_batch_scripts() on the PSA settings page. + * Dependencies: jQuery (WP core), wp.ajax (WP admin AJAX helper), psaBatchEnrich localized data. + * + * @see includes/class-psa-batch-enrichment.php + * @see includes/class-psa-admin.php + */ +/* global jQuery, psaBatchEnrich */ +(function ($) { + 'use strict'; + + var isRunning = false; + var processed = 0; + var errors = 0; + var total = 0; + var remaining = 0; + + /** + * Initialize: bind the start button and fetch initial status. + */ + function init() { + var $btn = $('#psa-batch-enrich-start'); + if (!$btn.length) { + return; + } + + $btn.on('click', function (e) { + e.preventDefault(); + if (isRunning) { + return; + } + startEnrichment(); + }); + + // Fetch initial count on page load. + fetchStatus(); + } + + /** + * Fetch the current enrichment status (how many need enrichment). + */ + function fetchStatus() { + $.post(psaBatchEnrich.ajaxUrl, { + action: 'psa_batch_enrich_status', + _nonce: psaBatchEnrich.nonce + }, function (response) { + if (response.success) { + remaining = response.data.remaining; + total = response.data.remaining; // Total to process this run. + updateStatusText(response.data.remaining + ' peptides need enrichment'); + + if (response.data.remaining === 0) { + $('#psa-batch-enrich-start').prop('disabled', true).text('All enriched'); + } + } + }); + } + + /** + * Start the enrichment loop. + */ + function startEnrichment() { + isRunning = true; + processed = 0; + errors = 0; + + $('#psa-batch-enrich-start').prop('disabled', true).text('Running...'); + $('#psa-batch-enrich-progress').show(); + $('#psa-batch-enrich-log').empty().show(); + + updateProgress(0, total); + processNext(); + } + + /** + * Process one peptide, then loop. + */ + function processNext() { + if (!isRunning) { + return; + } + + $.post(psaBatchEnrich.ajaxUrl, { + action: 'psa_batch_enrich_next', + _nonce: psaBatchEnrich.nonce + }, function (response) { + if (!response.success) { + appendLog('Server error: ' + (response.data || 'Unknown'), 'error'); + finish(); + return; + } + + var data = response.data; + + if (data.status === 'done') { + appendLog('All peptides enriched!', 'success'); + finish(); + return; + } + + if (data.status === 'cap_reached') { + appendLog(data.message, 'warning'); + finish(); + return; + } + + processed++; + remaining = data.remaining; + + if (data.status === 'ok') { + appendLog('Enriched: ' + data.name, 'success'); + } else if (data.status === 'error') { + errors++; + appendLog('Failed: ' + data.name + ' — ' + data.message, 'error'); + } + + updateProgress(processed, processed + remaining); + + // Brief delay to avoid hammering the server — 2 seconds between requests. + setTimeout(processNext, 2000); + + }).fail(function (xhr) { + appendLog('Request failed (HTTP ' + xhr.status + '). Retrying in 10s...', 'warning'); + setTimeout(processNext, 10000); + }); + } + + /** + * Update the progress bar. + */ + function updateProgress(done, total) { + var pct = total > 0 ? Math.round((done / total) * 100) : 0; + $('#psa-batch-enrich-bar').css('width', pct + '%'); + $('#psa-batch-enrich-pct').text(pct + '% (' + done + '/' + total + ')'); + } + + /** + * Update the status text below the button. + */ + function updateStatusText(text) { + $('#psa-batch-enrich-status').text(text); + } + + /** + * Append a line to the log. + */ + function appendLog(message, type) { + var color = type === 'error' ? '#dc3232' : type === 'warning' ? '#dba617' : '#46b450'; + var icon = type === 'error' ? '✗' : type === 'warning' ? '⚠' : '✓'; + $('#psa-batch-enrich-log').prepend( + '
' + + icon + ' ' + $('').text(message).html() + + '
' + ); + } + + /** + * End the run. + */ + function finish() { + isRunning = false; + $('#psa-batch-enrich-start').prop('disabled', false).text('Re-enrich Peptides'); + + var summary = 'Done. ' + processed + ' processed, ' + errors + ' errors, ' + remaining + ' remaining.'; + updateStatusText(summary); + } + + $(document).ready(init); +})(jQuery); diff --git a/assets/js/psa-directory.js b/assets/js/psa-directory.js new file mode 100755 index 0000000..e8ca9ac --- /dev/null +++ b/assets/js/psa-directory.js @@ -0,0 +1,501 @@ +/** + * Peptide Directory — Frontend JavaScript + * + * Renders the browsable directory grid with category filters, search, + * pagination, and detail modal. Uses vanilla JS (no build step required). + * + * Data source: REST API at /wp-json/peptide-search-ai/v1/compounds + * Config: psaDirectory global (localized from class-psa-directory.php) + * + * @see includes/class-psa-directory.php — shortcode + REST endpoint + * @see assets/css/psa-directory.css — all .psa-dir-* styles + */ +(function () { + 'use strict'; + + var config = window.psaDirectory; + if (!config) return; + + var root = document.getElementById('psa-directory-root'); + if (!root) return; + + var state = { + compounds: [], + page: 1, + totalPages: 0, + total: 0, + category: '', + search: '', + loading: false, + activeTab: 'single', + modalData: null + }; + + var i18n = config.i18n; + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + function esc(str) { + if (!str) return ''; + var d = document.createElement('div'); + d.appendChild(document.createTextNode(str)); + return d.innerHTML; + } + + function debounce(fn, delay) { + var timer; + return function () { + var args = arguments, ctx = this; + clearTimeout(timer); + timer = setTimeout(function () { fn.apply(ctx, args); }, delay); + }; + } + + // ------------------------------------------------------------------------- + // API + // ------------------------------------------------------------------------- + + function fetchCompounds(append) { + if (state.loading) return; + state.loading = true; + updateLoadMoreBtn(); + + var url = config.restUrl + + '?page=' + state.page + + '&per_page=' + config.perPage; + + if (state.search) url += '&search=' + encodeURIComponent(state.search); + if (state.category) url += '&category=' + encodeURIComponent(state.category); + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.setRequestHeader('X-WP-Nonce', config.nonce); + xhr.onload = function () { + state.loading = false; + if (xhr.status === 200) { + var data = JSON.parse(xhr.responseText); + if (append) { + state.compounds = state.compounds.concat(data.compounds); + } else { + state.compounds = data.compounds; + } + state.total = data.total; + state.totalPages = data.total_pages; + renderGrid(); + } else { + renderError(); + } + }; + xhr.onerror = function () { + state.loading = false; + renderError(); + }; + xhr.send(); + } + + function fetchCompoundDetail(id, callback) { + var url = config.restUrl + '?search=&per_page=1&page=1&fields=full'; + /* Find by iterating existing data first to avoid extra API call. */ + var existing = null; + for (var i = 0; i < state.compounds.length; i++) { + if (state.compounds[i].id === id) { + existing = state.compounds[i]; + break; + } + } + if (existing && existing.sequence !== undefined) { + callback(existing); + return; + } + /* Fallback: fetch from API (shouldn't normally be needed for full fields). */ + callback(existing); + } + + // ------------------------------------------------------------------------- + // Render — Shell + // ------------------------------------------------------------------------- + + function renderShell() { + root.innerHTML = + '
' + + '

' + esc(i18n.title) + '

' + + '

' + esc(i18n.subtitle) + '

' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '' + + ''; + + renderFilters(); + bindEvents(); + } + + // ------------------------------------------------------------------------- + // Render — Filters + // ------------------------------------------------------------------------- + + function renderFilters() { + var wrap = root.querySelector('.psa-dir-filters'); + var html = ''; + config.categories.forEach(function (cat) { + html += ''; + }); + wrap.innerHTML = html; + } + + // ------------------------------------------------------------------------- + // Render — Grid + // ------------------------------------------------------------------------- + + function renderGrid() { + var grid = root.querySelector('.psa-dir-grid'); + var empty = root.querySelector('.psa-dir-empty'); + var loader = root.querySelector('.psa-dir-loader'); + var coming = root.querySelector('.psa-dir-coming-soon'); + + loader.style.display = 'none'; + coming.style.display = 'none'; + + if (state.activeTab === 'protocols') { + grid.style.display = 'none'; + empty.style.display = 'none'; + coming.style.display = 'block'; + updateLoadMoreBtn(); + return; + } + + grid.style.display = ''; + + if (state.compounds.length === 0 && !state.loading) { + empty.style.display = 'block'; + grid.innerHTML = ''; + updateLoadMoreBtn(); + return; + } + + empty.style.display = 'none'; + + var html = ''; + state.compounds.forEach(function (c) { + var catBadge = ''; + if (c.categories && c.categories.length > 0) { + catBadge = '' + esc(c.categories[0].name) + ''; + } + + var metaHtml = ''; + if (c.half_life) { + metaHtml += '
' + esc(i18n.halfLife) + '' + esc(c.half_life) + '
'; + } + if (c.stability) { + metaHtml += '
' + esc(i18n.stability) + '' + esc(c.stability) + '
'; + } + + var extrasHtml = c.extras ? c.extras : ''; + + html += '
' + + '
' + + '

' + esc(c.name) + '

' + + catBadge + + '
' + + (metaHtml ? '
' + metaHtml + '
' : '') + + extrasHtml + + '' + + '
'; + }); + + grid.innerHTML = html; + updateLoadMoreBtn(); + } + + function updateLoadMoreBtn() { + var wrap = root.querySelector('.psa-dir-load-more-wrap'); + var btn = root.querySelector('.psa-dir-load-more'); + + if (state.activeTab === 'protocols' || state.page >= state.totalPages || state.compounds.length === 0) { + wrap.style.display = 'none'; + } else { + wrap.style.display = 'block'; + btn.textContent = state.loading ? i18n.loading : i18n.loadMore; + btn.disabled = state.loading; + } + } + + function renderError() { + state.loading = false; + var grid = root.querySelector('.psa-dir-grid'); + grid.innerHTML = '

Failed to load directory. Please try again.

'; + root.querySelector('.psa-dir-loader').style.display = 'none'; + updateLoadMoreBtn(); + } + + // ------------------------------------------------------------------------- + // Render — Modal + // ------------------------------------------------------------------------- + + function openModal(compound) { + state.modalData = compound; + var modal = root.querySelector('.psa-dir-modal'); + var content = root.querySelector('.psa-dir-modal__content'); + + var catBadges = ''; + if (compound.categories) { + compound.categories.forEach(function (cat) { + catBadges += '' + esc(cat.name) + ' '; + }); + } + + var dataGrid = ''; + var fields = [ + { label: i18n.solubility, value: compound.solubility }, + { label: i18n.vialSize, value: compound.vial_size_mg ? compound.vial_size_mg + ' mg' : '' }, + { label: i18n.storageLyo, value: compound.storage_lyophilized }, + { label: i18n.storageRecon, value: compound.storage_reconstituted }, + { label: i18n.halfLife, value: compound.half_life }, + { label: i18n.stability, value: compound.stability }, + { label: i18n.molecularWeight, value: compound.molecular_weight }, + { label: i18n.formula, value: compound.molecular_formula } + ]; + fields.forEach(function (f) { + if (f.value) { + dataGrid += '
' + + '' + esc(f.label) + '' + + '' + esc(f.value) + '' + + '
'; + } + }); + + var researchHtml = ''; + if (compound.typical_dose_mcg || compound.cycle_parameters) { + researchHtml = '
' + + '

' + esc(i18n.researchParams) + '

'; + if (compound.typical_dose_mcg) { + researchHtml += '

' + esc(i18n.typicalDose) + ': ' + esc(compound.typical_dose_mcg) + '

'; + } + if (compound.cycle_parameters) { + researchHtml += '

' + esc(i18n.cycleParams) + ': ' + esc(compound.cycle_parameters) + '

'; + } + researchHtml += '
'; + } + + var pubchemHtml = ''; + if (compound.pubchem_cid) { + pubchemHtml = '' + esc(i18n.pubchemLink) + ''; + } + + var sequenceHtml = ''; + if (compound.sequence) { + sequenceHtml = '
' + + '

' + esc(i18n.sequence) + '

' + + '
' + + '' + esc(compound.sequence) + '' + + '' + + '
' + + '
'; + } + + var mechanismHtml = ''; + if (compound.mechanism) { + mechanismHtml = '
' + + '

Mechanism of Action

' + + '

' + esc(compound.mechanism).replace(/\n\n/g, '

').replace(/\n/g, '
') + '

' + // Safe: esc() HTML-encodes all user content before structural tags are inserted. + + '
'; + } + + content.innerHTML = + '' + + '

' + esc(compound.name) + '

' + + '
' + catBadges + '
' + + (compound.description ? '

' + esc(compound.description) + '

' : '') + + sequenceHtml + + (dataGrid ? '
' + dataGrid + '
' : '') + + researchHtml + + mechanismHtml + + pubchemHtml + + '' + esc(i18n.viewFullPage) + ''; + + modal.style.display = 'flex'; + content.focus(); + + // Update URL hash for direct linking. + if (compound.slug) { + history.replaceState(null, '', '#' + compound.slug); + } + + // Fire integration hook for Peptide Community. + document.dispatchEvent(new CustomEvent('psa_after_peptide_detail', { + detail: { postId: compound.id } + })); + } + + function closeModal() { + var modal = root.querySelector('.psa-dir-modal'); + modal.style.display = 'none'; + state.modalData = null; + history.replaceState(null, '', window.location.pathname + window.location.search); + } + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + function bindEvents() { + // Search input. + var searchInput = root.querySelector('.psa-dir-search'); + var debouncedSearch = debounce(function () { + state.search = searchInput.value.trim(); + state.page = 1; + showLoader(); + fetchCompounds(false); + }, 400); + searchInput.addEventListener('input', debouncedSearch); + + // Category filter buttons. + root.querySelector('.psa-dir-filters').addEventListener('click', function (e) { + var btn = e.target.closest('.psa-dir-filter'); + if (!btn) return; + + root.querySelectorAll('.psa-dir-filter').forEach(function (b) { + b.classList.remove('psa-dir-filter--active'); + }); + btn.classList.add('psa-dir-filter--active'); + + state.category = btn.getAttribute('data-category'); + state.page = 1; + showLoader(); + fetchCompounds(false); + }); + + // Tab buttons. + root.querySelector('.psa-dir-tabs').addEventListener('click', function (e) { + var btn = e.target.closest('.psa-dir-tab'); + if (!btn) return; + + root.querySelectorAll('.psa-dir-tab').forEach(function (b) { + b.classList.remove('psa-dir-tab--active'); + b.setAttribute('aria-selected', 'false'); + }); + btn.classList.add('psa-dir-tab--active'); + btn.setAttribute('aria-selected', 'true'); + + state.activeTab = btn.getAttribute('data-tab'); + renderGrid(); + }); + + // Load More button. + root.querySelector('.psa-dir-load-more').addEventListener('click', function () { + state.page++; + fetchCompounds(true); + }); + + // Card "View Research Details" button — delegate on grid. + root.querySelector('.psa-dir-grid').addEventListener('click', function (e) { + var btn = e.target.closest('.psa-dir-card__btn'); + if (!btn) return; + var id = parseInt(btn.getAttribute('data-id'), 10); + fetchCompoundDetail(id, function (compound) { + if (compound) openModal(compound); + }); + }); + + // Modal close: X button, backdrop click, Escape key. + root.querySelector('.psa-dir-modal').addEventListener('click', function (e) { + if (e.target.classList.contains('psa-dir-modal__backdrop') || e.target.classList.contains('psa-dir-modal__close')) { + closeModal(); + } + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && state.modalData) { + closeModal(); + } + }); + + // Copy sequence button. + root.addEventListener('click', function (e) { + var copyBtn = e.target.closest('.psa-dir-modal__copy'); + if (!copyBtn) return; + var seq = copyBtn.getAttribute('data-seq'); + navigator.clipboard.writeText(seq).then(function () { + copyBtn.textContent = i18n.copied; + setTimeout(function () { copyBtn.textContent = i18n.copySequence; }, 2000); + }); + }); + + // Check URL hash on load for direct modal linking. + checkUrlHash(); + } + + function showLoader() { + var grid = root.querySelector('.psa-dir-grid'); + var loader = root.querySelector('.psa-dir-loader'); + var empty = root.querySelector('.psa-dir-empty'); + grid.innerHTML = ''; + empty.style.display = 'none'; + loader.style.display = 'block'; + } + + /** + * If URL has a hash like #bpc-157, find the matching compound and open modal. + */ + function checkUrlHash() { + var hash = window.location.hash.replace('#', ''); + if (!hash) return; + /* Wait for initial load, then look for matching slug. */ + var checkInterval = setInterval(function () { + if (state.loading) return; + clearInterval(checkInterval); + for (var i = 0; i < state.compounds.length; i++) { + if (state.compounds[i].slug === hash) { + openModal(state.compounds[i]); + return; + } + } + }, 200); + /* Safety: stop checking after 5 seconds. */ + setTimeout(function () { clearInterval(checkInterval); }, 5000); + } + + // ------------------------------------------------------------------------- + // Init + // ------------------------------------------------------------------------- + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + renderShell(); + showLoader(); + fetchCompounds(false); + } + +})(); diff --git a/includes/class-psa-admin-page.php b/includes/class-psa-admin-page.php new file mode 100644 index 0000000..4250995 --- /dev/null +++ b/includes/class-psa-admin-page.php @@ -0,0 +1,221 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'psa_batch_enrich' ), + ) ); + } + + // ── Field renderers ───────────────────────────────────────────────── + + public static function render_section_description(): void { + printf( + '

' . __( 'This plugin uses OpenRouter to access AI models.', 'peptide-search-ai' ) . '

', + esc_url( 'https://openrouter.ai/' ) + ); + } + + public static function render_api_key_field(): void { + if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) { + echo ' '; + echo '' . esc_html__( 'Configured via wp-config.php', 'peptide-search-ai' ) . ''; + echo '

' . esc_html__( 'The API key is securely set using the PSA_OPENROUTER_KEY constant.', 'peptide-search-ai' ) . '

'; + } else { + $value = PSA_Admin::get_setting( 'api_key', '' ); + $display_value = ! empty( $value ) ? '••••••••' : ''; + printf( + '', + esc_attr( $display_value ) + ); + printf( + '

%s %s.

', + esc_html__( 'Your OpenRouter API key.', 'peptide-search-ai' ), + esc_html__( 'Get one here', 'peptide-search-ai' ) + ); + } + } + + public static function render_model_field(): void { + $value = PSA_Admin::get_setting( 'ai_model', '' ); + printf( + '', + esc_attr( $value ), + esc_attr__( 'Leave blank for default', 'peptide-search-ai' ) + ); + echo '

' . esc_html__( 'Model for full content generation. Default: google/gemini-2.5-flash', 'peptide-search-ai' ) . '

'; + } + + public static function render_validation_model_field(): void { + $value = PSA_Admin::get_setting( 'validation_model', '' ); + printf( + '', + esc_attr( $value ), + esc_attr__( 'Leave blank for default', 'peptide-search-ai' ) + ); + echo '

' . esc_html__( 'Lighter model for name validation. Default: google/gemini-2.0-flash-001', 'peptide-search-ai' ) . '

'; + } + + public static function render_publish_field(): void { + $value = PSA_Admin::get_setting( 'auto_publish', 'draft' ); + echo ''; + } + + public static function render_pubchem_field(): void { + $value = PSA_Admin::get_setting( 'use_pubchem', '1' ); + printf( + '', + checked( $value, '1', false ), + esc_html__( 'Cross-reference with PubChem for verified molecular data', 'peptide-search-ai' ) + ); + } + + public static function render_budget_field(): void { + $value = PSA_Admin::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'Set to 0 for unlimited.', 'peptide-search-ai' ) . '

'; + } + + // ── Settings page ─────────────────────────────────────────────────── + + public static function render_settings_page(): void { + $migrate_url = wp_nonce_url( admin_url( 'options-general.php?page=peptide-search-ai&psa_action=migrate_categories' ), 'psa_migrate_categories' ); + ?> +
+

+ +
+ +
+ +
+

+ + +
+

+ + + + + + + + + +
+ +

+
+ + +

+ + +
+ +
+

+

[peptide_search] —

+

[peptide_directory] —

+ +
+

+

GET /wp-json/peptides/v1/search?q=BPC-157 —

+

GET /wp-json/peptide-search-ai/v1/compounds?search=&category=&page=1 —

+
+ +
+

+

+ $ + 0 ) : ?> + ⚠ includes estimates + +
+
+ +

+
+ +

+ + + + + + + + + + + + + + + token_source ) && 'estimated' === $log->token_source ); ?> + > + + + + + + + + + + +
created_at ); ?>model ); ?>~' : ''; ?>total_tokens ) ); ?>' : ''; ?>~$' : '$'; ?>estimated_cost_usd, 4 ) ); ?>' : ''; ?>request_type ); ?>peptide_name ); ?>success ) ? '✓' : '✗'; ?>
+ array( __CLASS__, 'sanitize_settings' ), + ) ); + + add_settings_section( 'psa_ai_section', __( 'OpenRouter API Settings', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_section_description' ), 'peptide-search-ai' ); + add_settings_field( 'api_key', __( 'API Key', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_api_key_field' ), 'peptide-search-ai', 'psa_ai_section' ); + add_settings_field( 'ai_model', __( 'Generation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_model_field' ), 'peptide-search-ai', 'psa_ai_section' ); + add_settings_field( 'validation_model', __( 'Validation Model', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_validation_model_field' ), 'peptide-search-ai', 'psa_ai_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( 'PSA_Admin_Page', 'render_publish_field' ), 'peptide-search-ai', 'psa_behavior_section' ); + add_settings_field( 'use_pubchem', __( 'PubChem Enrichment', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_pubchem_field' ), 'peptide-search-ai', 'psa_behavior_section' ); + add_settings_field( 'monthly_budget', __( 'Monthly API Budget (USD)', 'peptide-search-ai' ), array( 'PSA_Admin_Page', 'render_budget_field' ), 'peptide-search-ai', 'psa_behavior_section' ); + } + + /** + * Sanitize settings input. + * + * @param array $input Raw form input. + * @return array Sanitized settings. + */ + public static function sanitize_settings( array $input ): array { + $api_key_input = $input['api_key'] ?? ''; + $api_key_value = ''; + + if ( ! empty( $api_key_input ) && '••••••••' !== $api_key_input ) { + $sanitized = sanitize_text_field( $api_key_input ); + $encrypted = PSA_Encryption::encrypt( $sanitized ); + $api_key_value = ( false !== $encrypted ) ? $encrypted : ''; + } else { + $api_key_value = self::get_setting( 'api_key', '' ); + } + + return array( + 'api_key' => $api_key_value, + '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' ), + 'use_pubchem' => ! empty( $input['use_pubchem'] ) ? '1' : '0', + 'monthly_budget' => max( 0, floatval( $input['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ) ), + ); + } + + /** Handle admin actions (migration, re-enrich) via GET params with nonce verification. */ + public static function handle_admin_actions(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['psa_action'] ) || ! current_user_can( 'manage_options' ) ) { + return; + } + + $action = sanitize_text_field( wp_unslash( $_GET['psa_action'] ) ); + $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + + if ( 'migrate_categories' === $action && wp_verify_nonce( $nonce, 'psa_migrate_categories' ) ) { + self::run_category_migration(); + add_action( 'admin_notices', function () { + echo '

' . esc_html__( 'Category migration completed.', 'peptide-search-ai' ) . '

'; + } ); + } + + if ( 'reenrich' === $action && wp_verify_nonce( $nonce, 'psa_reenrich' ) ) { + $queued = self::queue_reenrichment(); + add_action( 'admin_notices', function () use ( $queued ) { + echo '

' . esc_html( sprintf( + __( '%d peptides queued for re-enrichment.', 'peptide-search-ai' ), $queued + ) ) . '

'; + } ); + } + } + + // ── Category migration ────────────────────────────────────────────── + + /** + * Assign peptide_category terms to existing posts from free-text meta. + * Idempotent — safe to run multiple times. + */ + private static function run_category_migration(): void { + $posts = get_posts( array( + 'post_type' => 'peptide', + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + ) ); + + foreach ( $posts as $post_id ) { + $terms = wp_get_object_terms( $post_id, 'peptide_category' ); + if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { + continue; + } + + $category = get_post_meta( $post_id, 'psa_category', true ); + $label = get_post_meta( $post_id, 'psa_category_label', true ); + $text = ! empty( $category ) ? $category : $label; + + if ( empty( $text ) ) { + continue; + } + + $slug = self::map_category_to_slug( $text ); + if ( ! empty( $slug ) ) { + wp_set_object_terms( $post_id, $slug, 'peptide_category' ); + } + } + } + + /** + * Map free-text category label to taxonomy slug. + * + * @param string $text Category label from AI or meta. + * @return string Taxonomy term slug, or empty if no match. + */ + private static function map_category_to_slug( string $text ): string { + $map = array( + 'gh secretagogues' => 'growth-hormone', + 'growth hormone' => 'growth-hormone', + 'healing & repair' => 'tissue-repair', + 'healing and repair' => 'tissue-repair', + 'tissue repair' => 'tissue-repair', + 'tissue healing' => 'tissue-repair', + 'cytoprotective' => 'tissue-repair', + 'melanocortin peptides' => 'dermatological', + 'melanocortin' => 'dermatological', + 'dermatological' => 'dermatological', + 'metabolic & anti-aging' => 'metabolic', + 'metabolic' => 'metabolic', + 'anti-aging' => 'aging-research', + 'aging research' => 'aging-research', + 'immunology' => 'immunology', + 'immune' => 'immunology', + 'nootropic' => 'immunology', + 'neuroprotective' => 'immunology', + 'lipid metabolism' => 'lipid-metabolism', + 'endocrine' => 'endocrine', + ); + + $lower = strtolower( trim( $text ) ); + if ( isset( $map[ $lower ] ) ) { + return $map[ $lower ]; + } + + // Fuzzy match against registered default categories. + foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $slug => $name ) { + if ( stripos( $text, $name ) !== false || stripos( $text, str_replace( '-', ' ', $slug ) ) !== false ) { + return $slug; + } + } + + return ''; + } + + // ── Re-enrichment ─────────────────────────────────────────────────── + + /** + * Queue re-enrichment for published peptides missing extended fields. + * Respects the daily generation cap to avoid cost overruns. + * + * @return int Number of peptides queued. + */ + private static function queue_reenrichment(): int { + $posts = get_posts( array( + 'post_type' => 'peptide', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => 'psa_half_life', 'compare' => 'NOT EXISTS' ), + array( 'key' => 'psa_half_life', 'value' => '' ), + ), + ) ); + + $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' ); + $daily_count = (int) get_transient( $daily_key ); + $queued = 0; + + foreach ( $posts as $post_id ) { + if ( ( $daily_count + $queued ) >= PSA_Config::DAILY_GENERATION_CAP ) { + break; + } + + $post = get_post( $post_id ); + if ( ! $post ) { + continue; + } + + update_post_meta( $post_id, 'psa_source', 'pending' ); + wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) ); + update_post_meta( $post_id, 'psa_generation_started', time() ); + + $args = array( $post_id, $post->post_title ); + if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) { + // Stagger 30s apart to avoid rate-limit hammering. + wp_schedule_single_event( time() + ( $queued * 30 ), 'psa_generate_peptide_background', $args ); + } + $queued++; + } + + set_transient( $daily_key, $daily_count + $queued, DAY_IN_SECONDS ); + if ( $queued > 0 ) { + spawn_cron(); + } + + return $queued; + } + + // ── Settings accessor ─────────────────────────────────────────────── + + /** + * Get a single setting with a default. + * + * @param string $key Setting key. + * @param mixed $default Default value. + * @return mixed + */ + public static function get_setting( string $key, $default = '' ) { + $options = get_option( 'psa_settings', array() ); + return $options[ $key ] ?? $default; + } + + // ── Backward-compatible proxies (rendering moved to PSA_Admin_Page) ─ + + /** @see PSA_Admin_Page::enqueue_batch_scripts() */ + public static function enqueue_batch_scripts( string $hook ): void { PSA_Admin_Page::enqueue_batch_scripts( $hook ); } + + /** @see PSA_Admin_Page::render_section_description() */ + public static function render_section_description(): void { PSA_Admin_Page::render_section_description(); } + + /** @see PSA_Admin_Page::render_api_key_field() */ + public static function render_api_key_field(): void { PSA_Admin_Page::render_api_key_field(); } + + /** @see PSA_Admin_Page::render_model_field() */ + public static function render_model_field(): void { PSA_Admin_Page::render_model_field(); } + + /** @see PSA_Admin_Page::render_validation_model_field() */ + public static function render_validation_model_field(): void { PSA_Admin_Page::render_validation_model_field(); } + + /** @see PSA_Admin_Page::render_publish_field() */ + public static function render_publish_field(): void { PSA_Admin_Page::render_publish_field(); } + + /** @see PSA_Admin_Page::render_pubchem_field() */ + public static function render_pubchem_field(): void { PSA_Admin_Page::render_pubchem_field(); } + + /** @see PSA_Admin_Page::render_budget_field() */ + public static function render_budget_field(): void { PSA_Admin_Page::render_budget_field(); } + + /** @see PSA_Admin_Page::render_settings_page() */ + public static function render_settings_page(): void { PSA_Admin_Page::render_settings_page(); } +} diff --git a/includes/class-psa-ai-content.php b/includes/class-psa-ai-content.php new file mode 100644 index 0000000..34c3e7d --- /dev/null +++ b/includes/class-psa-ai-content.php @@ -0,0 +1,228 @@ + $ai_data['sequence'] ?? '', + 'psa_molecular_weight' => ! empty( $pubchem_data['molecular_weight'] ) ? $pubchem_data['molecular_weight'] : ( $ai_data['molecular_weight'] ?? '' ), + 'psa_molecular_formula' => ! empty( $pubchem_data['molecular_formula'] ) ? $pubchem_data['molecular_formula'] : ( $ai_data['molecular_formula'] ?? '' ), + 'psa_aliases' => $ai_data['aliases'] ?? '', + 'psa_mechanism' => $ai_data['mechanism'] ?? '', + 'psa_overview' => $ai_data['overview'] ?? $ai_data['description'] ?? '', + 'psa_category_label' => $ai_data['category_label'] ?? '', + 'psa_origin' => $ai_data['origin'] ?? '', + 'psa_research_apps' => $ai_data['research_benefits'] ?? ( $ai_data['research_applications'] ?? '' ), + 'psa_safety_profile' => $ai_data['safety_side_effects'] ?? ( $ai_data['safety_profile'] ?? '' ), + 'psa_dosage_info' => $ai_data['administration_dosing'] ?? ( $ai_data['dosage_info'] ?? '' ), + 'psa_legal_regulatory' => $ai_data['legal_regulatory'] ?? '', + 'psa_references' => $ai_data['references'] ?? '', + 'psa_category' => $ai_data['category'] ?? '', + 'psa_source' => ! empty( $pubchem_data ) ? 'pubchem' : 'ai-generated', + 'psa_pubchem_cid' => $pubchem_data['cid'] ?? '', + 'psa_half_life' => $ai_data['half_life'] ?? '', + 'psa_stability' => $ai_data['stability'] ?? '', + 'psa_solubility' => $ai_data['solubility'] ?? '', + 'psa_vial_size_mg' => $ai_data['vial_size_mg'] ?? '', + 'psa_storage_lyophilized' => $ai_data['storage_lyophilized'] ?? '', + 'psa_storage_reconstituted' => $ai_data['storage_reconstituted'] ?? '', + 'psa_typical_dose_mcg' => $ai_data['typical_dose_mcg'] ?? '', + 'psa_cycle_parameters' => $ai_data['cycle_parameters'] ?? '', + 'psa_amino_acid_count' => $amino_acid_count, + ); + + $meta = apply_filters( 'psa_peptide_meta', $meta, $post_id, $ai_data, $pubchem_data ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $post_id, $key, $value ); + } + } + + /** + * Auto-assign a peptide_category taxonomy term based on AI-returned category. + * + * @param int $post_id The peptide post ID. + * @param array $ai_data AI-generated data containing 'category' or 'category_label'. + */ + public static function assign_category_term( int $post_id, array $ai_data ): void { + $ai_category = $ai_data['category'] ?? $ai_data['category_label'] ?? ''; + if ( empty( $ai_category ) ) { + return; + } + + // Why: AI returns varied labels ("GH Secretagogues", "Healing & Repair") that need + // to map to our controlled vocabulary of taxonomy terms. + $category_map = array( + 'gh secretagogues' => 'growth-hormone', + 'growth hormone' => 'growth-hormone', + 'healing & repair' => 'tissue-repair', + 'healing and repair' => 'tissue-repair', + 'tissue repair' => 'tissue-repair', + 'tissue healing' => 'tissue-repair', + 'cytoprotective' => 'tissue-repair', + 'melanocortin peptides' => 'dermatological', + 'melanocortin' => 'dermatological', + 'dermatological' => 'dermatological', + 'metabolic & anti-aging' => 'metabolic', + 'metabolic and anti-aging' => 'metabolic', + 'metabolic' => 'metabolic', + 'anti-aging' => 'aging-research', + 'aging research' => 'aging-research', + 'nootropic & neuroprotective' => 'immunology', + 'nootropic' => 'immunology', + 'neuroprotective' => 'immunology', + 'immunology' => 'immunology', + 'immune' => 'immunology', + 'lipid metabolism' => 'lipid-metabolism', + 'endocrine' => 'endocrine', + ); + + $lower = strtolower( trim( $ai_category ) ); + $slug = $category_map[ $lower ] ?? ''; + + // Fuzzy fallback: check if AI category contains any known slug keyword. + if ( empty( $slug ) ) { + foreach ( PSA_Post_Type::DEFAULT_CATEGORIES as $cat_slug => $cat_name ) { + if ( stripos( $ai_category, $cat_name ) !== false || stripos( $ai_category, str_replace( '-', ' ', $cat_slug ) ) !== false ) { + $slug = $cat_slug; + break; + } + } + } + + if ( ! empty( $slug ) ) { + wp_set_object_terms( $post_id, $slug, 'peptide_category' ); + } + } + + /** + * Derive amino acid count from sequence data or explicit AI field. + * + * @param array $ai_data AI-generated data. + * @return string Amino acid count as string, or empty. + */ + private static function derive_amino_acid_count( array $ai_data ): string { + $sequence = $ai_data['sequence'] ?? ''; + $count = ''; + + if ( ! empty( $sequence ) ) { + // Three-letter codes use dashes; single-letter codes are uppercase chars. + $count = ( strpos( $sequence, '-' ) !== false ) + ? (string) count( explode( '-', $sequence ) ) + : (string) strlen( preg_replace( '/[^A-Z]/', '', $sequence ) ); + } + + // AI may return this directly — prefer explicit value. + if ( ! empty( $ai_data['amino_acid_count'] ) ) { + $count = (string) intval( $ai_data['amino_acid_count'] ); + } + + return $count; + } +} diff --git a/includes/class-psa-ai-generator.php b/includes/class-psa-ai-generator.php new file mode 100755 index 0000000..7e683c9 --- /dev/null +++ b/includes/class-psa-ai-generator.php @@ -0,0 +1,282 @@ + false, 'canonical_name' => $name, 'reason' => 'Invalid characters in peptide name.' ); + } + + $options = self::get_settings(); + if ( empty( $options['api_key'] ) ) { + return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' ); + } + if ( PSA_Cost_Tracker::is_budget_exceeded() ) { + return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' ); + } + + // Transient cache to avoid repeated API calls for same term. + $cache_key = 'psa_validate_' . md5( strtolower( trim( $name ) ) ); + $cached = get_transient( $cache_key ); + if ( false !== $cached ) { + return $cached; + } + + $prompt = PSA_AI_Content::build_validation_prompt( $name ); + $prompt = apply_filters( 'psa_validation_prompt', $prompt, $name ); + $response = PSA_OpenRouter::send_request( + $options['api_key'], + $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001', + $prompt, + PSA_Config::VALIDATION_MAX_TOKENS, + 'validation', + $name + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = PSA_OpenRouter::parse_response( $response ); + if ( is_wp_error( $data ) ) { + return array( 'is_valid' => false, 'canonical_name' => $name, 'reason' => 'Could not verify this peptide name.' ); + } + + $result = array( + 'is_valid' => ! empty( $data['is_valid'] ) && true === $data['is_valid'], + 'canonical_name' => sanitize_text_field( $data['canonical_name'] ?? $name ), + 'reason' => sanitize_text_field( $data['reason'] ?? '' ), + ); + + set_transient( $cache_key, $result, DAY_IN_SECONDS ); + return $result; + } + + /** + * Background generation handler (called via WP-Cron or synchronously). + * + * @param int $post_id The placeholder post ID. + * @param string $peptide_name The peptide to research. + */ + public static function background_generate( int $post_id, string $peptide_name ): void { + $post = get_post( $post_id ); + if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) { + error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' ); + return; + } + + $source = get_post_meta( $post_id, 'psa_source', true ); + if ( 'pending' !== $source ) { + error_log( 'PSA: background_generate skipped — post ' . $post_id . ' source is "' . $source . '", not "pending".' ); + return; + } + + $options = self::get_settings(); + error_log( 'PSA: Generating content for "' . $peptide_name . '" (post ' . $post_id . ') using model: ' . ( $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash' ) ); + + do_action( 'psa_before_generation', $post_id, $peptide_name ); + + $result = self::generate_peptide_content( $peptide_name ); + if ( is_wp_error( $result ) ) { + error_log( 'PSA: Generation FAILED for "' . $peptide_name . '": ' . $result->get_error_message() ); + update_post_meta( $post_id, 'psa_generation_error', $result->get_error_message() ); + return; + } + + error_log( 'PSA: AI content generated successfully for "' . $peptide_name . '". Fields: ' . implode( ', ', array_keys( $result ) ) ); + + $post_content = $result['overview'] ?? $result['description'] ?? ''; + if ( empty( trim( $post_content ) ) ) { + error_log( 'PSA: WARNING — AI returned valid JSON but overview/description is empty for "' . $peptide_name . '".' ); + update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description. Fields: ' . implode( ', ', array_keys( $result ) ) ); + return; + } + + // PubChem enrichment for molecular data. + $pubchem_data = null; + if ( ! empty( $options['use_pubchem'] ) ) { + $pubchem_result = PSA_PubChem::lookup( $peptide_name ); + if ( is_wp_error( $pubchem_result ) ) { + error_log( 'PSA: PubChem lookup failed for "' . $peptide_name . '": ' . $pubchem_result->get_error_message() ); + } elseif ( is_array( $pubchem_result ) ) { + $pubchem_data = $pubchem_result; + error_log( 'PSA: PubChem data found for "' . $peptide_name . '" (CID: ' . ( $pubchem_data['cid'] ?? 'unknown' ) . ')' ); + } + } + + $post_status = ( 'publish' === $options['auto_publish'] ) ? 'publish' : 'draft'; + $update_result = wp_update_post( array( + 'ID' => $post_id, + 'post_title' => sanitize_text_field( $result['name'] ?? $peptide_name ), + 'post_content' => wp_kses_post( $post_content ), + 'post_status' => $post_status, + ), true ); + + if ( is_wp_error( $update_result ) ) { + error_log( 'PSA: wp_update_post FAILED for post ' . $post_id . ': ' . $update_result->get_error_message() ); + update_post_meta( $post_id, 'psa_generation_error', 'Failed to update post: ' . $update_result->get_error_message() ); + return; + } + + PSA_AI_Content::save_peptide_meta( $post_id, $result, $pubchem_data ); + PSA_AI_Content::assign_category_term( $post_id, $result ); + PSA_KB_Builder::create_article( $post_id, $result, $peptide_name, $post_status ); + + delete_post_meta( $post_id, 'psa_generation_started' ); + delete_post_meta( $post_id, 'psa_generation_error' ); + update_post_meta( $post_id, 'psa_generation_completed', time() ); + + do_action( 'psa_after_generation', $post_id, $result, $peptide_name ); + error_log( 'PSA: Successfully completed generation for "' . $peptide_name . '" (post ' . $post_id . ', status: ' . $post_status . ')' ); + } + + /** + * Generate full peptide content via AI. + * + * @param string $peptide_name The peptide to research. + * @return array|WP_Error Parsed peptide data or error. + */ + public static function generate_peptide_content( string $peptide_name ) { + $options = self::get_settings(); + if ( empty( $options['api_key'] ) ) { + return new WP_Error( 'no_api_key', 'OpenRouter API key is not configured.' ); + } + if ( PSA_Cost_Tracker::is_budget_exceeded() ) { + return new WP_Error( 'budget_exceeded', 'Monthly API budget reached.' ); + } + + $prompt = PSA_AI_Content::build_generation_prompt( $peptide_name ); + $prompt = apply_filters( 'psa_generation_prompt', $prompt, $peptide_name ); + $response = PSA_OpenRouter::send_request( + $options['api_key'], + $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash', + $prompt, + PSA_Config::GENERATION_MAX_TOKENS, + 'generation', + $peptide_name + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return PSA_OpenRouter::parse_response( $response ); + } + + /** + * Dry-run: estimate costs without making API calls. + * + * @param string $peptide_name The peptide name to estimate. + * @return array { validation: {tokens, cost}, generation: {tokens, cost}, total } + */ + public static function dry_run( string $peptide_name ): array { + $options = self::get_settings(); + $validation_model = $options['validation_model'] ? $options['validation_model'] : 'google/gemini-2.0-flash-001'; + $generation_model = $options['ai_model'] ? $options['ai_model'] : 'google/gemini-2.5-flash'; + + $val_prompt = PSA_AI_Content::build_validation_prompt( $peptide_name ); + $val_tokens = (int) ceil( strlen( $val_prompt ) / 4 ); + $val_cost = PSA_Cost_Tracker::estimate_cost( $validation_model, $val_tokens, PSA_Config::VALIDATION_MAX_TOKENS ); + + $gen_prompt = PSA_AI_Content::build_generation_prompt( $peptide_name ); + $gen_tokens = (int) ceil( strlen( $gen_prompt ) / 4 ); + $gen_cost = PSA_Cost_Tracker::estimate_cost( $generation_model, $gen_tokens, PSA_Config::GENERATION_MAX_TOKENS ); + + return array( + 'validation' => array( 'tokens' => $val_tokens + PSA_Config::VALIDATION_MAX_TOKENS, 'estimated_cost_usd' => $val_cost ), + 'generation' => array( 'tokens' => $gen_tokens + PSA_Config::GENERATION_MAX_TOKENS, 'estimated_cost_usd' => $gen_cost ), + 'total_estimated_cost_usd' => $val_cost + $gen_cost, + ); + } + + // ── Private helpers ───────────────────────────────────────────────── + + /** + * Get plugin settings with defaults, decrypted API key, and constant override. + * + * @return array + */ + private static function get_settings(): array { + $defaults = array( 'api_key' => '', 'ai_model' => '', 'validation_model' => '', 'auto_publish' => 'draft', 'use_pubchem' => '1' ); + $settings = wp_parse_args( get_option( 'psa_settings', array() ), $defaults ); + + if ( ! empty( $settings['api_key'] ) && ! ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) ) { + $decrypted = PSA_Encryption::decrypt( $settings['api_key'] ); + if ( false !== $decrypted ) { + $settings['api_key'] = $decrypted; + } + } + + // wp-config.php constant takes precedence for security. + if ( defined( 'PSA_OPENROUTER_KEY' ) && PSA_OPENROUTER_KEY ) { + $settings['api_key'] = PSA_OPENROUTER_KEY; + } + + return apply_filters( 'psa_ai_settings', $settings ); + } + + /** + * Validate peptide name input characters and block injection patterns. + * + * @param string $name The peptide name to validate. + * @return bool True if input looks legitimate. + */ + private static function validate_peptide_input( string $name ): bool { + if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) { + return false; + } + $blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' ); + $lower = strtolower( $name ); + foreach ( $blocked as $word ) { + if ( strpos( $lower, $word ) !== false ) { + return false; + } + } + return true; + } + + // ── Backward-compatible proxies ───────────────────────────────────── + + /** @see PSA_AI_Content::build_validation_prompt() */ + public static function build_validation_prompt( string $name ): string { return PSA_AI_Content::build_validation_prompt( $name ); } + + /** @see PSA_AI_Content::build_generation_prompt() */ + public static function build_generation_prompt( string $peptide_name ): string { return PSA_AI_Content::build_generation_prompt( $peptide_name ); } + + /** @see PSA_AI_Content::save_peptide_meta() */ + public static function save_peptide_meta( int $post_id, array $ai_data, ?array $pubchem_data ): void { PSA_AI_Content::save_peptide_meta( $post_id, $ai_data, $pubchem_data ); } + + /** @see PSA_AI_Content::assign_category_term() */ + public static function assign_category_term( int $post_id, array $ai_data ): void { PSA_AI_Content::assign_category_term( $post_id, $ai_data ); } +} diff --git a/includes/class-psa-batch-enrichment.php b/includes/class-psa-batch-enrichment.php new file mode 100755 index 0000000..32c4189 --- /dev/null +++ b/includes/class-psa-batch-enrichment.php @@ -0,0 +1,182 @@ + $total_published, + 'remaining' => count( $needs_enrichment ), + 'completed' => $total_published - count( $needs_enrichment ), + ) + ); + } + + /** + * AJAX: Process one peptide — set to draft, generate, republish. + * + * Response JSON on success: { post_id: int, name: string, status: 'ok', remaining: int } + * Response JSON on skip: { status: 'done', remaining: 0 } + * Response JSON on error: { post_id: int, name: string, status: 'error', message: string, remaining: int } + * + * Side effects: Updates post meta via AI generation, logs API cost. + */ + public static function ajax_process_next(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + check_ajax_referer( 'psa_batch_enrich', '_nonce' ); + + // Admin-initiated batch enrichment intentionally bypasses the daily cap. + // The cap exists to prevent runaway cron-based costs; AJAX enrichment is + // explicit, authenticated, rate-limited by the 2-second JS polling interval, + // and still respects the monthly budget check inside generate_peptide_content(). + + $ids = self::get_unenriched_ids(); + if ( empty( $ids ) ) { + wp_send_json_success( + array( + 'status' => 'done', + 'remaining' => 0, + ) + ); + } + + $post_id = $ids[0]; + $post = get_post( $post_id ); + if ( ! $post ) { + wp_send_json_error( 'Post not found: ' . $post_id, 404 ); + } + + $peptide_name = $post->post_title; + $original_status = $post->post_status; + + // Temporarily set to draft + pending so background_generate processes it. + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'draft', + ) + ); + update_post_meta( $post_id, 'psa_source', 'pending' ); + update_post_meta( $post_id, 'psa_generation_started', time() ); + + // Call the generator directly (synchronous — no cron dependency). + PSA_AI_Generator::background_generate( $post_id, $peptide_name ); + + // Check outcome: did generation succeed? + $error = get_post_meta( $post_id, 'psa_generation_error', true ); + $refreshed_post = get_post( $post_id ); + + // If still in draft (generation failed or auto_publish is 'draft'), force republish. + if ( $refreshed_post && 'draft' === $refreshed_post->post_status ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + } + + $remaining = count( self::get_unenriched_ids() ); + + if ( ! empty( $error ) ) { + wp_send_json_success( + array( + 'status' => 'error', + 'post_id' => $post_id, + 'name' => $peptide_name, + 'message' => $error, + 'remaining' => $remaining, + ) + ); + } + + wp_send_json_success( + array( + 'status' => 'ok', + 'post_id' => $post_id, + 'name' => $peptide_name, + 'remaining' => $remaining, + ) + ); + } + + /** + * Get IDs of published peptides missing the half_life extended field. + * + * @return int[] Post IDs. + */ + private static function get_unenriched_ids(): array { + return get_posts( + array( + 'post_type' => 'peptide', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'psa_half_life', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => 'psa_half_life', + 'value' => '', + ), + ), + ) + ); + } + + /** + * Get total published peptide count. + * + * @return int + */ + private static function get_total_published_count(): int { + $counts = wp_count_posts( 'peptide' ); + return (int) ( $counts->publish ?? 0 ); + } +} diff --git a/includes/class-psa-config.php b/includes/class-psa-config.php new file mode 100755 index 0000000..1fd1ec1 --- /dev/null +++ b/includes/class-psa-config.php @@ -0,0 +1,48 @@ +prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end + ) + ); + + return floatval( $result ); + } + + /** + * Get total token usage for a given month. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return int Total tokens. + */ + public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { + global $wpdb; + list( $start, $end ) = self::month_range( $year, $month ); + $table = $wpdb->prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COALESCE(SUM(total_tokens), 0) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end + ) + ); + + return (int) $result; + } + + /** + * Count API calls this month that used character-based token estimates. + * + * Why: Lets the admin UI flag when reported costs include approximations + * so operators can distinguish measured spend from estimated spend. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return int Number of estimated rows. + */ + public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { + global $wpdb; + list( $start, $end ) = self::month_range( $year, $month ); + $table = $wpdb->prefix . 'psa_api_logs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM ' . $table . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $start, + $end, + 'estimated' + ) + ); + + return (int) $result; + } + + /** + * Get the most recent API call logs. + * + * @param int $limit Maximum number of logs to return. + * @return array Array of log row objects. + */ + public static function get_recent_logs( int $limit = 20 ): array { + global $wpdb; + $table = $wpdb->prefix . 'psa_api_logs'; + $limit = max( 1, (int) $limit ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT * FROM ' . $table . ' ORDER BY created_at DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $limit + ) + ); + + return is_array( $results ) ? $results : array(); + } + + /** + * Build start/end datetime strings for a given month. + * + * @param int|null $year Year (defaults to current). + * @param int|null $month Month (defaults to current). + * @return array{0: string, 1: string} [start_date, end_datetime] + */ + private static function month_range( ?int $year, ?int $month ): array { + $year = $year ?? (int) gmdate( 'Y' ); + $month = max( 1, min( 12, $month ?? (int) gmdate( 'm' ) ) ); + + $start = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) ); + $end = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59'; + + return array( $start, $end ); + } +} diff --git a/includes/class-psa-cost-tracker.php b/includes/class-psa-cost-tracker.php new file mode 100755 index 0000000..6eec5d9 --- /dev/null +++ b/includes/class-psa-cost-tracker.php @@ -0,0 +1,171 @@ +prefix . 'psa_api_logs'; + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE $table ( + 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, + token_source VARCHAR(20) NOT NULL DEFAULT 'api', + INDEX idx_created_at (created_at), + INDEX idx_request_type (request_type) + ) $charset;"; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + 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, @type string $model, @type int $prompt_tokens, + * @type int $completion_tokens, @type int $total_tokens, @type string $request_type, + * @type string $peptide_name, @type bool $success, @type float $estimated_cost_usd, + * @type string $token_source 'api'|'estimated'|'none' + * } + * @return int|false Insert ID or false on error. + */ + public static function log_api_call( array $data ) { + global $wpdb; + + $table = $wpdb->prefix . 'psa_api_logs'; + $prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 ); + $completion_tokens = (int) ( $data['completion_tokens'] ?? 0 ); + $total_tokens = (int) ( $data['total_tokens'] ?? 0 ); + $estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 ); + + if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) { + $total_tokens = $prompt_tokens + $completion_tokens; + } + if ( 0 === $estimated_cost && $prompt_tokens > 0 ) { + $estimated_cost = self::estimate_cost( $data['model'] ?? '', $prompt_tokens, $completion_tokens ); + } + + $insert_data = array( + 'provider' => sanitize_text_field( $data['provider'] ?? 'openrouter' ), + 'model' => sanitize_text_field( $data['model'] ?? '' ), + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + 'estimated_cost_usd' => $estimated_cost, + 'request_type' => sanitize_text_field( $data['request_type'] ?? 'generation' ), + 'peptide_name' => sanitize_text_field( $data['peptide_name'] ?? '' ), + 'success' => ! empty( $data['success'] ) ? 1 : 0, + 'token_source' => sanitize_text_field( $data['token_source'] ?? 'api' ), + ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $result = $wpdb->insert( $table, $insert_data, array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' ) ); + + if ( false === $result ) { + error_log( 'PSA: Failed to insert API log: ' . $wpdb->last_error ); + return false; + } + + return $wpdb->insert_id; + } + + /** + * Check if current month's budget has been exceeded. Budget of 0 = unlimited. + * + * @return bool True if budget exceeded. + */ + public static function is_budget_exceeded(): bool { + $settings = get_option( 'psa_settings', array() ); + $budget = floatval( $settings['monthly_budget'] ?? PSA_Config::DEFAULT_MONTHLY_BUDGET ); + + if ( 0.0 === $budget ) { + return false; + } + + return PSA_Cost_Reporter::get_monthly_spend() >= $budget; + } + + /** + * Estimate cost for an API call based on model and token counts. + * + * @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( string $model, int $prompt_tokens, int $completion_tokens ): float { + // Per 1M tokens (input, output). Conservative default for unknown models. + $pricing = array( + 'google/gemini-2.5-flash' => array( 0.15, 0.60 ), + 'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ), + 'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ), + 'deepseek/deepseek-chat' => array( 0.27, 1.10 ), + 'deepseek/deepseek-r1' => array( 0.55, 2.19 ), + 'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ), + ); + + $model = sanitize_text_field( $model ); + if ( isset( $pricing[ $model ] ) ) { + list( $in, $out ) = $pricing[ $model ]; + } else { + $in = 1.0; + $out = 2.0; + } + + return (float) ( ( $in / 1000000 ) * $prompt_tokens + ( $out / 1000000 ) * $completion_tokens ); + } + + /** Drop the API logs table during uninstall. */ + public static function drop_table(): void { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'psa_api_logs' ); + } + + // ── Backward-compatible proxies (reporting moved to PSA_Cost_Reporter) ── + + /** @see PSA_Cost_Reporter::get_monthly_spend() */ + public static function get_monthly_spend( ?int $year = null, ?int $month = null ): float { return PSA_Cost_Reporter::get_monthly_spend( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_monthly_tokens() */ + public static function get_monthly_tokens( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_tokens( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_monthly_estimated_count() */ + public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int { return PSA_Cost_Reporter::get_monthly_estimated_count( $year, $month ); } + + /** @see PSA_Cost_Reporter::get_recent_logs() */ + public static function get_recent_logs( int $limit = 20 ): array { return PSA_Cost_Reporter::get_recent_logs( $limit ); } +} diff --git a/includes/class-psa-dependency-check.php b/includes/class-psa-dependency-check.php new file mode 100644 index 0000000..f3d4450 --- /dev/null +++ b/includes/class-psa-dependency-check.php @@ -0,0 +1,81 @@ += REQUIRED_PR_CORE_VERSION is active so PSA can rely + * on the `peptide` CPT + `peptide_category` taxonomy being registered. Renders + * an admin notice when the dependency is unmet. Serves as a boot-path gate: + * PSA surfaces that operate on the `peptide` CPT (meta boxes, admin columns, + * directory widget, KB renderer, search widget, template) call + * is_satisfied() and degrade gracefully — they do not fatal — when PR Core is + * missing or out of date. + * + * Who calls it: Main plugin bootstrap (`psa_init` + `psa_admin_init`) and each + * CPT-dependent class's `init()` method before hooking into WordPress. + * Dependencies: PR Core defines the PR_CORE_VERSION constant in its main + * plugin file; absence of the constant is treated as "not installed." + * + * @package PeptideSearchAI + * @since 4.5.0 + * @see includes/class-psa-post-type.php — Gated via init_admin(). + * @see includes/class-psa-directory.php — Gated via init(). + * @see includes/class-psa-template.php — Gated via init(). + * @see includes/class-psa-search.php — Gated via init(). + */ +declare( strict_types=1 ); + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class PSA_Dependency_Check { + + /** + * Minimum PR Core version that registers the `peptide` CPT + `peptide_category` + * taxonomy in the shape PSA depends on. Coordinated release: see + * `convo/peptidesearch/threads/2026-04-peptide-cpt-consolidation/`. + */ + const REQUIRED_PR_CORE_VERSION = '0.2.0'; + + /** Register the admin notice hook. Safe to call multiple times. */ + public static function init(): void { + add_action( 'admin_notices', array( __CLASS__, 'maybe_render_notice' ) ); + } + + /** + * Whether PR Core is installed, active, and at a version PSA accepts. + * + * Pure function — no side effects. Safe to call from any boot-path gate. + * + * @return bool True when PR_CORE_VERSION is defined and >= REQUIRED_PR_CORE_VERSION. + */ + public static function is_satisfied(): bool { + if ( ! defined( 'PR_CORE_VERSION' ) ) { + return false; + } + return version_compare( (string) constant( 'PR_CORE_VERSION' ), self::REQUIRED_PR_CORE_VERSION, '>=' ); + } + + /** + * Render the admin notice when the dependency is unmet. + * + * Hooked to `admin_notices`. Skips rendering when the dependency is + * satisfied and when the current user lacks the `activate_plugins` + * capability (the only role that can act on the notice). + */ + public static function maybe_render_notice(): void { + if ( self::is_satisfied() ) { + return; + } + if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) { + return; + } + + $msg = esc_html__( + 'Peptide Search AI requires Peptide Repo Core 0.2.0 or newer for peptide data (custom post type and taxonomy). Please install or update Peptide Repo Core — until then, peptide meta boxes, the directory widget, and the search widget are disabled.', + 'peptide-search-ai' + ); + + printf( '

%s

', $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg already escaped via esc_html__. + } +} diff --git a/includes/class-psa-directory-api.php b/includes/class-psa-directory-api.php new file mode 100644 index 0000000..1abbad7 --- /dev/null +++ b/includes/class-psa-directory-api.php @@ -0,0 +1,159 @@ + 'GET', + 'callback' => array( __CLASS__, 'rest_compounds' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'search' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ), + 'category' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ), + 'fields' => array( 'type' => 'string', 'enum' => array( 'basic', 'full' ), 'default' => 'full' ), + 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint' ), + 'per_page' => array( 'type' => 'integer', 'default' => PSA_Directory::PER_PAGE_DEFAULT, 'minimum' => 1, 'maximum' => PSA_Directory::PER_PAGE_MAX, 'sanitize_callback' => 'absint' ), + ), + ) ); + } + + /** + * REST callback for GET /v1/compounds. + * Supports search, category filtering, pagination, and basic/full field sets. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response + */ + public static function rest_compounds( $request ) { + $search = $request->get_param( 'search' ); + $category = $request->get_param( 'category' ); + $fields = $request->get_param( 'fields' ); + $page = max( 1, (int) $request->get_param( 'page' ) ); + $per_page = min( PSA_Directory::PER_PAGE_MAX, max( 1, (int) $request->get_param( 'per_page' ) ) ); + + $query_args = array( + 'post_type' => 'peptide', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'title', + 'order' => 'ASC', + ); + + if ( ! empty( $search ) ) { + $query_args['s'] = $search; + } + + if ( ! empty( $category ) ) { + $query_args['tax_query'] = array( array( + 'taxonomy' => 'peptide_category', + 'field' => 'slug', + 'terms' => $category, + ) ); + } + + $query = new WP_Query( $query_args ); + $post_ids = wp_list_pluck( $query->posts, 'ID' ); + if ( ! empty( $post_ids ) ) { + update_meta_cache( 'post', $post_ids ); + } + + $compounds = array(); + foreach ( $query->posts as $post ) { + $compounds[] = self::format_compound( $post, $fields ); + } + + $response = new WP_REST_Response( array( + 'compounds' => $compounds, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + 'page' => $page, + 'per_page' => $per_page, + ), 200 ); + + $response->header( 'X-WP-Total', (string) $query->found_posts ); + $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages ); + + return $response; + } + + /** + * Format a single compound for REST response. + * + * @param WP_Post $post The peptide post. + * @param string $fields 'basic' for lightweight data, 'full' for everything. + * @return array Formatted compound data. + */ + public static function format_compound( $post, string $fields = 'full' ): array { + $id = $post->ID; + + $terms = wp_get_object_terms( $id, 'peptide_category', array( 'fields' => 'all' ) ); + $categories = array(); + if ( ! is_wp_error( $terms ) ) { + foreach ( $terms as $term ) { + $categories[] = array( 'slug' => $term->slug, 'name' => $term->name ); + } + } + + $basic = array( + 'id' => $id, + 'name' => $post->post_title, + 'slug' => $post->post_name, + 'url' => get_permalink( $id ), + 'categories' => $categories, + 'half_life' => get_post_meta( $id, 'psa_half_life', true ), + 'stability' => get_post_meta( $id, 'psa_stability', true ), + 'source' => get_post_meta( $id, 'psa_source', true ), + 'extras' => apply_filters( 'psa_directory_card_extras', '', $id ), + ); + + if ( 'basic' === $fields ) { + $basic['vial_size_mg'] = get_post_meta( $id, 'psa_vial_size_mg', true ); + return $basic; + } + + $full = array_merge( $basic, array( + 'excerpt' => wp_trim_words( $post->post_content, 30 ), + 'description' => wp_trim_words( $post->post_content, 80 ), + 'sequence' => get_post_meta( $id, 'psa_sequence', true ), + 'molecular_weight' => get_post_meta( $id, 'psa_molecular_weight', true ), + 'molecular_formula' => get_post_meta( $id, 'psa_molecular_formula', true ), + 'aliases' => get_post_meta( $id, 'psa_aliases', true ), + 'mechanism' => get_post_meta( $id, 'psa_mechanism', true ), + 'research_apps' => get_post_meta( $id, 'psa_research_apps', true ), + 'safety_profile' => get_post_meta( $id, 'psa_safety_profile', true ), + 'dosage_info' => get_post_meta( $id, 'psa_dosage_info', true ), + 'references' => get_post_meta( $id, 'psa_references', true ), + 'pubchem_cid' => get_post_meta( $id, 'psa_pubchem_cid', true ), + 'solubility' => get_post_meta( $id, 'psa_solubility', true ), + 'vial_size_mg' => get_post_meta( $id, 'psa_vial_size_mg', true ), + 'storage_lyophilized' => get_post_meta( $id, 'psa_storage_lyophilized', true ), + 'storage_reconstituted' => get_post_meta( $id, 'psa_storage_reconstituted', true ), + 'typical_dose_mcg' => get_post_meta( $id, 'psa_typical_dose_mcg', true ), + 'cycle_parameters' => get_post_meta( $id, 'psa_cycle_parameters', true ), + 'amino_acid_count' => get_post_meta( $id, 'psa_amino_acid_count', true ), + ) ); + + return apply_filters( 'psa_rest_compound_data', $full, $id ); + } +} diff --git a/includes/class-psa-directory.php b/includes/class-psa-directory.php new file mode 100755 index 0000000..3a57d8c --- /dev/null +++ b/includes/class-psa-directory.php @@ -0,0 +1,114 @@ + 'peptide_category', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) ); + + $categories = array(); + if ( ! is_wp_error( $terms ) ) { + foreach ( $terms as $term ) { + $categories[] = array( 'slug' => $term->slug, 'name' => $term->name ); + } + } + + wp_localize_script( 'psa-directory', 'psaDirectory', array( + 'restUrl' => esc_url_raw( rest_url( 'peptide-search-ai/v1/compounds' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'categories' => $categories, + 'perPage' => self::PER_PAGE_DEFAULT, + 'i18n' => array( + 'title' => __( 'Peptide Directory', 'peptide-search-ai' ), + 'subtitle' => __( 'Browse reagents with detailed research parameters.', 'peptide-search-ai' ), + 'searchPlaceholder' => __( 'Search peptides...', 'peptide-search-ai' ), + 'showAll' => __( 'Show All', 'peptide-search-ai' ), + 'loadMore' => __( 'Load More', 'peptide-search-ai' ), + 'loading' => __( 'Loading...', 'peptide-search-ai' ), + 'noResults' => __( 'No peptides found matching your criteria.', 'peptide-search-ai' ), + 'viewDetails' => __( 'View Research Details', 'peptide-search-ai' ), + 'viewFullPage' => __( 'View Full Page', 'peptide-search-ai' ), + 'close' => __( 'Close', 'peptide-search-ai' ), + 'halfLife' => __( 'Half-Life', 'peptide-search-ai' ), + 'stability' => __( 'Stability', 'peptide-search-ai' ), + 'solubility' => __( 'Solubility', 'peptide-search-ai' ), + 'vialSize' => __( 'Vial Size', 'peptide-search-ai' ), + 'storageLyo' => __( 'Storage (Lyophilized)', 'peptide-search-ai' ), + 'storageRecon' => __( 'Storage (Reconstituted)', 'peptide-search-ai' ), + 'molecularWeight' => __( 'Molecular Weight', 'peptide-search-ai' ), + 'formula' => __( 'Formula', 'peptide-search-ai' ), + 'typicalDose' => __( 'Typical Dose', 'peptide-search-ai' ), + 'cycleParams' => __( 'Cycle Parameters', 'peptide-search-ai' ), + 'sequence' => __( 'Sequence', 'peptide-search-ai' ), + 'singleReagents' => __( 'Single Reagents', 'peptide-search-ai' ), + 'protocolModels' => __( 'Protocol Models', 'peptide-search-ai' ), + 'comingSoon' => __( 'Coming Soon', 'peptide-search-ai' ), + 'researchParams' => __( 'Research Parameters', 'peptide-search-ai' ), + 'communityObs' => __( 'Community Observations', 'peptide-search-ai' ), + 'pubchemLink' => __( 'View on PubChem', 'peptide-search-ai' ), + 'copySequence' => __( 'Copy', 'peptide-search-ai' ), + 'copied' => __( 'Copied!', 'peptide-search-ai' ), + ), + ) ); + + ob_start(); + ?> +
+ +
+ 'gh-secretagogues', + 'Healing & Repair' => 'healing-repair', + 'Melanocortin Peptides' => 'melanocortin-peptides', + 'Metabolic & Anti-Aging' => 'metabolic-anti-aging', + 'Nootropic & Neuroprotective' => 'nootropic-neuroprotective', + ); + + /** + * Create a matching Echo Knowledge Base article with full structured content. + * + * Checks for Echo KB plugin, avoids duplicates, builds structured HTML with + * h2/h3 headings, assigns KB category, and cross-references the peptide post. + * + * @param int $peptide_post_id The peptide post ID to cross-reference. + * @param array $ai_data The parsed AI data with all content fields. + * @param string $peptide_name The peptide name for logging and fallback title. + * @param string $post_status The post status ('draft' or 'publish'). + * @return void + */ + public static function create_article( int $peptide_post_id, array $ai_data, string $peptide_name, string $post_status = 'draft' ): void { + if ( ! post_type_exists( 'epkb_post_type_1' ) ) { + error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' ); + return; + } + + $title = sanitize_text_field( $ai_data['name'] ?? $peptide_name ); + $existing = get_posts( + array( + 'post_type' => 'epkb_post_type_1', + 'title' => $title, + 'post_status' => 'any', + 'numberposts' => 1, + ) + ); + + if ( ! empty( $existing ) ) { + error_log( 'PSA: KB article already exists for "' . $peptide_name . '".' ); + return; + } + + $content = self::build_article_content( $ai_data ); + $kb_post_id = wp_insert_post( + array( + 'post_title' => $title, + 'post_content' => wp_kses_post( $content ), + 'post_type' => 'epkb_post_type_1', + 'post_status' => $post_status, + ), + true + ); + + if ( is_wp_error( $kb_post_id ) ) { + error_log( 'PSA: Failed to create KB article: ' . $kb_post_id->get_error_message() ); + return; + } + + self::assign_category( $kb_post_id, $ai_data ); + + // Cross-reference both posts. + update_post_meta( $peptide_post_id, 'psa_kb_article_id', $kb_post_id ); + update_post_meta( $kb_post_id, 'psa_peptide_post_id', $peptide_post_id ); + + error_log( 'PSA: Created KB article #' . $kb_post_id . ' for "' . $peptide_name . '"' ); + } + + /** + * Build the full article HTML content from AI data. + * + * @param array $ai_data The parsed AI data. + * @return string HTML content for the KB article. + */ + private static function build_article_content( array $ai_data ): string { + // Build header meta line. + $cat_label = ! empty( $ai_data['category_label'] ) ? $ai_data['category_label'] : ''; + $origin = ! empty( $ai_data['origin'] ) ? $ai_data['origin'] : ''; + $sequence = ! empty( $ai_data['sequence'] ) ? $ai_data['sequence'] : ''; + + $header = ''; + if ( $cat_label ) { + $header .= 'Category: ' . esc_html( $cat_label ); + } + if ( $origin ) { + $header .= ' | Origin: ' . esc_html( $origin ); + } + if ( $sequence ) { + $header .= ' | Sequence: ' . esc_html( $sequence ); + } + + $c = $header ? '

' . $header . '

' . "\n\n" : ''; + + // Overview. + $c .= '

Overview

' . "\n"; + $c .= self::paragraphs( $ai_data['overview'] ?? $ai_data['description'] ?? '' ); + + // Mechanism of Action. + if ( ! empty( $ai_data['mechanism'] ) ) { + $c .= '

Mechanism of Action

' . "\n"; + $c .= self::paragraphs( $ai_data['mechanism'] ); + } + + // Research & Potential Benefits (with h3 subheadings). + if ( ! empty( $ai_data['research_benefits'] ) || ! empty( $ai_data['research_applications'] ) ) { + $c .= '

Research & Potential Benefits

' . "\n"; + $c .= '

The following potential benefits are based on animal studies and preclinical research. Human clinical trial data is extremely limited.

' . "\n"; + $research = $ai_data['research_benefits'] ?? $ai_data['research_applications'] ?? ''; + $c .= self::research_with_subheadings( $research ); + } + + // Administration & Dosing. + if ( ! empty( $ai_data['administration_dosing'] ) || ! empty( $ai_data['dosage_info'] ) ) { + $c .= '

Administration & Dosing (Research Context)

' . "\n"; + $c .= '

Important: The following dosing information is derived from animal studies and extrapolated research. This peptide is not approved for human use. This information is provided for educational purposes only.

' . "\n"; + $c .= self::paragraphs( $ai_data['administration_dosing'] ?? $ai_data['dosage_info'] ?? '' ); + } + + // Safety & Side Effects. + if ( ! empty( $ai_data['safety_side_effects'] ) || ! empty( $ai_data['safety_profile'] ) ) { + $c .= '

Safety & Side Effects

' . "\n"; + $c .= self::paragraphs( $ai_data['safety_side_effects'] ?? $ai_data['safety_profile'] ?? '' ); + } + + // Legal & Regulatory Status. + if ( ! empty( $ai_data['legal_regulatory'] ) ) { + $c .= '

Legal & Regulatory Status

' . "\n"; + $c .= self::paragraphs( $ai_data['legal_regulatory'] ); + } + + // References. + if ( ! empty( $ai_data['references'] ) ) { + $c .= '

Notable Research References

' . "\n"; + $c .= self::paragraphs( $ai_data['references'] ); + } + + // Medical Disclaimer. + $c .= "\n" . '

Medical Disclaimer: The information on this page is provided for educational and research purposes only. This peptide is not approved for human therapeutic use. Nothing on this page constitutes medical advice. Always consult a licensed healthcare professional before using any peptide, supplement, or research chemical.

'; + + return $c; + } + + /** + * Assign Echo KB category to a KB article based on AI-classified category. + * + * @param int $kb_post_id The KB article post ID. + * @param array $ai_data The parsed AI data containing 'category' field. + * @return void + */ + private static function assign_category( int $kb_post_id, array $ai_data ): void { + $cat_name = trim( $ai_data['category'] ?? '' ); + if ( isset( self::CATEGORY_MAP[ $cat_name ] ) ) { + $term = get_term_by( 'slug', self::CATEGORY_MAP[ $cat_name ], 'epkb_post_type_1_category' ); + if ( $term ) { + wp_set_object_terms( $kb_post_id, array( (int) $term->term_id ), 'epkb_post_type_1_category' ); + } + } + } + + /** + * Convert text to HTML paragraphs. + * + * Splits on double newlines and wraps each chunk in

tags. + * + * @param string $text Raw text content. + * @return string HTML paragraphs. + */ + public static function paragraphs( string $text ): string { + if ( empty( $text ) ) { + return ''; + } + $text = str_replace( "\r\n", "\n", $text ); + $parts = preg_split( '/\n{2,}/', trim( $text ) ); + $out = ''; + foreach ( $parts as $p ) { + $p = trim( $p ); + if ( '' === $p ) { + continue; + } + $out .= '

' . wp_kses_post( $p ) . '

' . "\n"; + } + return $out; + } + + /** + * Parse research text into h3 subheadings + paragraphs. + * + * Short lines (<80 chars, no period) become h3 subheadings. + * Longer blocks become regular paragraphs. + * + * @param string $text Research benefits text with embedded subheadings. + * @return string HTML with h3 subheadings and paragraphs. + */ + public static function research_with_subheadings( string $text ): string { + if ( empty( $text ) ) { + return ''; + } + $text = str_replace( "\r\n", "\n", trim( $text ) ); + $blocks = preg_split( '/\n{2,}/', $text ); + $out = ''; + foreach ( $blocks as $block ) { + $block = trim( $block ); + if ( '' === $block ) { + continue; + } + // If block is short (under 80 chars) and has no period, treat as a subheading. + if ( strlen( $block ) < 80 && strpos( $block, '.' ) === false ) { + $out .= '

' . esc_html( $block ) . '

' . "\n"; + } else { + // Check if first line is a subheading followed by content. + $first_nl = strpos( $block, "\n" ); + if ( false !== $first_nl ) { + $first_line = trim( substr( $block, 0, $first_nl ) ); + $rest = trim( substr( $block, $first_nl + 1 ) ); + if ( strlen( $first_line ) < 80 && strpos( $first_line, '.' ) === false ) { + $out .= '

' . esc_html( $first_line ) . '

' . "\n"; + $out .= '

' . wp_kses_post( $rest ) . '

' . "\n"; + continue; + } + } + $out .= '

' . wp_kses_post( $block ) . '

' . "\n"; + } + } + return $out; + } +} diff --git a/includes/class-psa-openrouter.php b/includes/class-psa-openrouter.php new file mode 100755 index 0000000..5c9c6bc --- /dev/null +++ b/includes/class-psa-openrouter.php @@ -0,0 +1,218 @@ +get_error_code() ) { + return $result; + } + + // Rate limited — retry with exponential backoff. + if ( $attempt < $max_retries ) { + $delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s + // Avoid blocking PHP workers during WP-Cron execution. + if ( defined( 'DOING_CRON' ) && DOING_CRON ) { + error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' ); + return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' ); + } + error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' ); + sleep( $delay ); + } + } + + error_log( 'PSA: All ' . $max_retries . ' attempts failed due to rate limiting.' ); + return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached after ' . $max_retries . ' retries. Consider adding credits at openrouter.ai or switching to a non-free model.' ); + } + + /** + * Single OpenRouter API call (no retry). + * + * @param string $api_key The OpenRouter API key. + * @param string $model The model identifier. + * @param string $prompt The prompt text. + * @param int $max_tokens Maximum tokens to generate. + * @param string $request_type 'validation' or 'generation' (default). + * @param string $peptide_name Peptide name for logging. + * @return string|WP_Error Response text or error. + */ + private static function send_request_once( string $api_key, string $model, string $prompt, int $max_tokens = 4096, string $request_type = 'generation', string $peptide_name = '' ) { + $site_url = get_bloginfo( 'url' ); + $site_name = get_bloginfo( 'name' ); + + $response = wp_remote_post( + self::API_URL, + array( + 'timeout' => 120, + 'headers' => array( + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $api_key, + 'HTTP-Referer' => $site_url, + 'X-Title' => $site_name, + ), + 'body' => wp_json_encode( + array( + 'model' => $model, + 'messages' => array( + array( + 'role' => 'system', + 'content' => 'You are a scientific database assistant. Return only valid JSON.', + ), + array( + 'role' => 'user', + 'content' => $prompt, + ), + ), + 'max_tokens' => $max_tokens, + 'temperature' => 0.3, + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( 'PSA: OpenRouter connection error: ' . $response->get_error_message() ); + return new WP_Error( 'api_error', 'Failed to connect to OpenRouter API: ' . $response->get_error_message() ); + } + + $code = wp_remote_retrieve_response_code( $response ); + $raw_body = wp_remote_retrieve_body( $response ); + $body = json_decode( $raw_body, true ); + + error_log( 'PSA: OpenRouter response code: ' . $code . ' for model: ' . $model ); + + if ( 200 !== $code ) { + if ( 429 === $code ) { + error_log( 'PSA: OpenRouter rate limited (429)' ); + return new WP_Error( 'rate_limited', 'OpenRouter rate limit reached. Please try again in a few moments.' ); + } + if ( 402 === $code ) { + error_log( 'PSA: OpenRouter insufficient credits (402)' ); + return new WP_Error( 'insufficient_credits', 'OpenRouter account has insufficient credits. Please add funds at openrouter.ai.' ); + } + $msg = $body['error']['message'] ?? ( 'Unknown error (HTTP ' . $code . ')' ); + error_log( 'PSA: OpenRouter API error (' . $code . '): ' . $msg ); + error_log( 'PSA: OpenRouter raw response: ' . substr( $raw_body, 0, 500 ) ); + return new WP_Error( 'api_error', 'OpenRouter API error: ' . $msg ); + } + + if ( empty( $body['choices'][0]['message']['content'] ) ) { + error_log( 'PSA: OpenRouter returned empty content. Raw: ' . substr( $raw_body, 0, 500 ) ); + return new WP_Error( 'api_error', 'OpenRouter API returned an empty response.' ); + } + + // Log API call with token usage. + $response_text = $body['choices'][0]['message']['content']; + $prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 ); + $completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 ); + $token_source = 'api'; + + // Diagnostic: log the raw usage structure once per model so we can verify + // whether the provider returns token counts. Remove after confirming. + if ( 0 === $prompt_tokens ) { + $usage_raw = isset( $body['usage'] ) ? wp_json_encode( $body['usage'] ) : 'missing'; + error_log( 'PSA: OpenRouter usage field for model ' . $model . ': ' . substr( $usage_raw, 0, 300 ) ); + } + + // Fallback: estimate tokens from character length when the API reports zero. + // Why: Some OpenRouter models (e.g. deepseek) omit the usage object entirely. + // Approximation: ~3.75 characters per token for English scientific text. + if ( 0 === $prompt_tokens && 0 === $completion_tokens ) { + $prompt_tokens = (int) ceil( strlen( $prompt ) / 3.75 ); + $completion_tokens = (int) ceil( strlen( $response_text ) / 3.75 ); + $token_source = 'estimated'; + + error_log( 'PSA: Token usage estimated from character length for ' . $peptide_name . ': prompt=' . $prompt_tokens . ', completion=' . $completion_tokens ); + } + + 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, + 'token_source' => $token_source, + ) + ); + + return $response_text; + } + + /** + * Parse AI response text into structured data. + * + * Strips thinking tags (e.g., DeepSeek) and markdown code fences, + * then decodes the JSON response. + * + * @param string $response_text Raw response text from OpenRouter. + * @return array|WP_Error Parsed data or error. + */ + public static function parse_response( string $response_text ) { + // Strip any ... tags (some models like DeepSeek include reasoning). + $cleaned = preg_replace( '/.*?<\/think>/s', '', $response_text ); + + // Strip markdown code fences if present. + $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $cleaned ); + $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned ); + $cleaned = trim( $cleaned ); + + $data = json_decode( $cleaned, true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + error_log( 'PSA: JSON parse error: ' . json_last_error_msg() . '. Raw (first 500 chars): ' . substr( $response_text, 0, 500 ) ); + return new WP_Error( + 'parse_error', + 'Failed to parse AI response as JSON: ' . json_last_error_msg() + ); + } + + return $data; + } +} diff --git a/includes/class-psa-post-type-meta.php b/includes/class-psa-post-type-meta.php new file mode 100644 index 0000000..7f21480 --- /dev/null +++ b/includes/class-psa-post-type-meta.php @@ -0,0 +1,140 @@ +'; + foreach ( PSA_Post_Type::META_FIELDS as $key => $label ) { + $value = get_post_meta( $post->ID, $key, true ); + echo ''; + echo ''; + echo ''; + + if ( 'psa_source' === $key ) { + $options = array( + '' => '— Select —', 'ai-generated' => 'AI Generated', + 'pubchem' => 'PubChem Verified', 'manual' => 'Manual Entry', 'pending' => 'Pending Generation', + ); + echo ''; + } elseif ( in_array( $key, $textarea_fields, true ) ) { + echo ''; + } else { + echo ''; + } + + echo ''; + } + echo ''; + } + + /** + * Render extended meta box fields (half-life, stability, storage, etc.). + * + * @param WP_Post $post The current post object. + */ + public static function render_extended_meta_box( $post ): void { + echo ''; + foreach ( PSA_Post_Type::EXTENDED_META_FIELDS as $key => $label ) { + $value = get_post_meta( $post->ID, $key, true ); + echo ''; + echo ''; + echo ''; + } + echo '
'; + + if ( 'psa_vial_size_mg' === $key ) { + echo ' mg'; + } elseif ( 'psa_amino_acid_count' === $key ) { + echo ''; + } else { + echo ''; + } + + echo '
'; + } + + /** + * Save meta field values (core + extended) on post save. + * + * @param int $post_id The post ID. + */ + public static function save_meta( int $post_id ): void { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + if ( ! isset( $_POST['psa_meta_nonce'] ) || + ! wp_verify_nonce( wp_unslash( $_POST['psa_meta_nonce'] ), 'psa_save_meta' ) ) { + return; + } + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return; + } + + $numeric_fields = array( 'psa_vial_size_mg', 'psa_amino_acid_count' ); + $all_fields = array_merge( PSA_Post_Type::META_FIELDS, PSA_Post_Type::EXTENDED_META_FIELDS ); + foreach ( $all_fields as $key => $label ) { + if ( isset( $_POST[ $key ] ) ) { + $raw = wp_unslash( $_POST[ $key ] ); + if ( in_array( $key, $numeric_fields, true ) ) { + $value = ( '' === trim( $raw ) ) ? '' : (string) max( 0, floatval( $raw ) ); + } else { + $value = sanitize_textarea_field( $raw ); + } + update_post_meta( $post_id, $key, $value ); + } + } + } +} diff --git a/includes/class-psa-post-type.php b/includes/class-psa-post-type.php new file mode 100755 index 0000000..6cc00b4 --- /dev/null +++ b/includes/class-psa-post-type.php @@ -0,0 +1,153 @@ += 0.2.0 — PSA no longer registers them. This + * class now wires PSA-owned admin surfaces (meta boxes, admin columns) onto + * the CPT that PR Core registers, and exposes the `psa_*` meta key namespace + * via constants for the rest of PSA to consume. + * + * What: Admin columns, meta key definitions, get_all_meta_keys() helper, and + * backward-compatible proxies to PSA_Post_Type_Meta. + * Who calls it: `psa_admin_init()` in the main plugin file, gated by + * PSA_Dependency_Check::is_satisfied() so admin surfaces don't bind against + * a missing CPT when PR Core is absent. + * Dependencies: PR Core >= 0.2.0 for the CPT itself (runtime); WordPress + * meta-box + admin-column APIs; PSA_Post_Type_Meta for meta box rendering. + * + * @package PeptideSearchAI + * @since 1.0.0 (CPT registration removed in 4.5.0) + * @see includes/class-psa-post-type-meta.php — Meta box rendering and save handlers. + * @see includes/class-psa-dependency-check.php — PR Core gate used by init_admin(). + * @see includes/class-psa-directory.php — Reads `peptide_category` terms for directory filtering. + */ +declare( strict_types=1 ); + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class PSA_Post_Type { + + /** + * Canonical slug → display-name map for the peptide categories PSA's + * AI and admin tools know how to assign terms to. This is a consumer + * reference list (used by PSA_AI_Content::assign_category_term() and + * the "Migrate Existing Peptides to Categories" admin button) — not a + * seed list. As of PSA v4.5.0 PR Core >= 0.2.0 owns the + * `peptide_category` taxonomy; the 8 terms below were seeded by PSA + * in pre-v4.5.0 releases and remain intact in `wp_term_taxonomy`. PSA + * no longer seeds them on activation. + */ + const DEFAULT_CATEGORIES = array( + 'tissue-repair' => 'Tissue Repair', + 'lipid-metabolism' => 'Lipid Metabolism', + 'aging-research' => 'Aging Research', + 'dermatological' => 'Dermatological', + 'metabolic' => 'Metabolic', + 'growth-hormone' => 'Growth Hormone', + 'immunology' => 'Immunology', + 'endocrine' => 'Endocrine', + ); + + /** Core meta field keys used throughout the plugin. */ + const META_FIELDS = array( + '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', + ); + + /** Extended meta fields added in v4.3.0 for the directory feature. */ + const EXTENDED_META_FIELDS = array( + 'psa_half_life' => 'Half-Life', + 'psa_stability' => 'Stability', + 'psa_solubility' => 'Solubility / Reconstitution', + 'psa_vial_size_mg' => 'Common Vial Size (mg)', + 'psa_storage_lyophilized' => 'Storage (Lyophilized)', + 'psa_storage_reconstituted' => 'Storage (Reconstituted)', + 'psa_typical_dose_mcg' => 'Typical Research Dose', + 'psa_cycle_parameters' => 'Cycle / Protocol Parameters', + 'psa_amino_acid_count' => 'Amino Acid Count', + ); + + /** + * Register admin-only hooks (meta boxes, save, admin columns). + * + * Caller (`psa_admin_init`) gates this behind + * PSA_Dependency_Check::is_satisfied() so meta boxes + admin columns + * don't attempt to bind against a `peptide` CPT that PR Core has not + * yet registered. + */ + public static function init_admin(): void { + add_action( 'add_meta_boxes', array( 'PSA_Post_Type_Meta', 'add_meta_boxes' ) ); + add_action( 'save_post_peptide', array( 'PSA_Post_Type_Meta', 'save_meta' ) ); + add_filter( 'manage_peptide_posts_columns', array( __CLASS__, 'add_admin_columns' ) ); + add_action( 'manage_peptide_posts_custom_column', array( __CLASS__, 'render_admin_column' ), 10, 2 ); + } + + /** + * Add custom columns to the peptide admin list. + * + * @param array $columns Existing columns. + * @return array Modified columns. + */ + public static function add_admin_columns( array $columns ): array { + $new = array(); + foreach ( $columns as $key => $label ) { + $new[ $key ] = $label; + if ( 'title' === $key ) { + $new['psa_source'] = __( 'Source', 'peptide-search-ai' ); + } + } + return $new; + } + + /** + * Render custom column content in the admin post list. + * + * @param string $column Column key. + * @param int $post_id Post ID. + */ + public static function render_admin_column( string $column, int $post_id ): void { + if ( 'psa_source' === $column ) { + $source = get_post_meta( $post_id, 'psa_source', true ); + $labels = array( + 'ai-generated' => 'AI', 'pubchem' => 'PubChem', + 'manual' => 'Manual', 'pending' => 'Pending', 'failed' => 'Failed', + ); + echo esc_html( $labels[ $source ] ?? '—' ); + } + } + + /** + * Get all meta field keys (core + extended) as a flat array. + * + * @return array Meta keys. + */ + public static function get_all_meta_keys(): array { + return array_merge( array_keys( self::META_FIELDS ), array_keys( self::EXTENDED_META_FIELDS ) ); + } + + // ── Backward-compatible proxies (meta boxes moved to PSA_Post_Type_Meta) ── + + /** @see PSA_Post_Type_Meta::add_meta_boxes() */ + public static function add_meta_boxes(): void { PSA_Post_Type_Meta::add_meta_boxes(); } + + /** @see PSA_Post_Type_Meta::render_meta_box() */ + public static function render_meta_box( $post ): void { PSA_Post_Type_Meta::render_meta_box( $post ); } + + /** @see PSA_Post_Type_Meta::render_extended_meta_box() */ + public static function render_extended_meta_box( $post ): void { PSA_Post_Type_Meta::render_extended_meta_box( $post ); } + + /** @see PSA_Post_Type_Meta::save_meta() */ + public static function save_meta( int $post_id ): void { PSA_Post_Type_Meta::save_meta( $post_id ); } +} diff --git a/includes/class-psa-pubchem.php b/includes/class-psa-pubchem.php new file mode 100755 index 0000000..9b8e6d5 --- /dev/null +++ b/includes/class-psa-pubchem.php @@ -0,0 +1,158 @@ + 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( int $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-handler.php b/includes/class-psa-search-handler.php new file mode 100644 index 0000000..ef778e0 --- /dev/null +++ b/includes/class-psa-search-handler.php @@ -0,0 +1,211 @@ + 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 = PSA_Search::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 = PSA_Search::find_pending_peptide( $query ); + if ( $pending ) { + self::handle_pending_retry( $pending ); + return; + } + + // 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 ); + } + + /** + * Handle a pending peptide — retry if timed out, else return pending status. + * + * Side effects: may update post meta, schedule cron, JSON response. + * + * @param WP_Post $pending The pending post. + */ + private static function handle_pending_retry( $pending ): void { + $started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true ); + $retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true ); + $timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT; + + if ( $timed_out ) { + if ( $retry_count >= PSA_Config::MAX_GENERATION_RETRIES ) { + 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; + } + + 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. + * + * @return bool True if under limit. + */ + private static function check_rate_limit(): bool { + $ip = psa_get_client_ip(); + $rate_key = 'psa_rate_' . md5( $ip ); + + $count_transient = (int) get_transient( $rate_key ); + $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' ); + $count = max( $count_transient, $count_cache ); + + return $count < PSA_Config::AJAX_RATE_LIMIT; + } + + /** + * Handle a new generation request — validate, create placeholder, and schedule. + * + * Side effects: AI validation call, post creation, rate limit update, cron schedule, JSON response. + * + * @param string $query The search query (peptide name). + */ + private static function handle_new_generation( string $query ): void { + // Global daily generation cap to prevent cost overruns. + $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' ); + $daily_count = (int) get_transient( $daily_key ); + 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; + } + + $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.', + ) ); + } + + // Valid 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 ); + 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. + set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS ); + + 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 ) ); + } + + /** + * Create a placeholder draft post for the pending peptide. + * + * @param string $peptide_name Canonical peptide name. + * @return int|WP_Error Post ID or error. + */ + public static function create_pending_placeholder( string $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() ); + do_action( 'psa_peptide_created', $post_id, $peptide_name ); + } + + return $post_id; + } + + /** + * Schedule background generation using WP-Cron (non-blocking). + * + * @param int $post_id Placeholder post ID. + * @param string $peptide_name Peptide to research. + */ + public static function schedule_background_generation( int $post_id, string $peptide_name ): void { + $args = array( $post_id, $peptide_name ); + if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) { + wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args ); + } + spawn_cron(); + } +} diff --git a/includes/class-psa-search.php b/includes/class-psa-search.php new file mode 100755 index 0000000..42bbc84 --- /dev/null +++ b/includes/class-psa-search.php @@ -0,0 +1,300 @@ +query( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE '_transient_psa_search_%' + OR option_name LIKE '_transient_timeout_psa_search_%'" + ); + } + + /** Register REST API route for external/headless access. */ + public static function register_rest_routes(): void { + 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. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response + */ + public static function rest_search( $request ) { + $ip = psa_get_client_ip(); + $rate_key = 'psa_rest_rate_' . md5( $ip ); + + $count_transient = (int) get_transient( $rate_key ); + $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' ); + $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 ); + } + + 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' ); + + 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 + ); + } + + return new WP_REST_Response( self::search_peptides( $query ), 200 ); + } + + /** + * Render the [peptide_search] shortcode. + * + * Uses class-based selectors (no IDs) to support multiple instances per page. + * + * @param array $atts Shortcode attributes. + * @return string HTML output. + */ + public static function render_search_form( $atts ): string { + 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(); + ?> +
+ + + + + + +
+ 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; + } + + /** + * Core search — queries CPT by title and aliases (published only). + * + * Includes transient caching with generation-counter invalidation. + * + * @param string $query Search term. + * @return array{results: array, total: int, query: string} + */ + public static function search_peptides( string $query ): array { + global $wpdb; + + $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_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 ); + + set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS ); + + return apply_filters( 'psa_search_results', $response, $query ); + } + + /** + * Format a peptide post for JSON response. + * + * @param int $post_id The post ID. + * @return array + */ + public static function format_peptide( int $post_id ): array { + $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 ), + ); + } + + // ── Backward-compatible proxies ───────────────────────────────────── + + /** @see PSA_Search_Handler::ajax_search() */ + public static function ajax_search(): void { PSA_Search_Handler::ajax_search(); } + + /** @see PSA_Search_Handler::create_pending_placeholder() */ + public static function create_pending_placeholder( string $peptide_name ) { return PSA_Search_Handler::create_pending_placeholder( $peptide_name ); } + + /** @see PSA_Search_Handler::schedule_background_generation() */ + public static function schedule_background_generation( int $post_id, string $peptide_name ): void { PSA_Search_Handler::schedule_background_generation( $post_id, $peptide_name ); } +} diff --git a/includes/class-psa-template.php b/includes/class-psa-template.php new file mode 100755 index 0000000..5383aaf --- /dev/null +++ b/includes/class-psa-template.php @@ -0,0 +1,224 @@ + $post_id, + 'source' => $source, + 'pubchem_cid' => $pubchem_cid, + ); + + $data = apply_filters( 'psa_template_data', $data, $post_id ); + + $post_id = $data['post_id'] ?? $post_id; + $source = $data['source'] ?? $source; + $pubchem_cid = $data['pubchem_cid'] ?? $pubchem_cid; + + $html = '
'; + + // Quick facts box. + $html .= self::render_quick_facts( $post_id, $pubchem_cid ); + + // Extended research data box (half-life, stability, storage, etc.). + $html .= self::render_extended_data( $post_id ); + + // Detailed sections. + $html .= self::render_detail_sections( $post_id ); + + // Source badge. + $html .= self::render_source_badge( $source ); + + // Integration hook: allow Peptide Community and other plugins to append content. + ob_start(); + do_action( 'psa_after_peptide_detail', $post_id ); + $html .= ob_get_clean(); + + $html .= '
'; + + return $content . $html; + } + + /** + * Render the quick facts table. + * + * @param int $post_id Post ID. + * @param string $pubchem_cid PubChem CID (may be empty). + * @return string HTML. + */ + private static function render_quick_facts( int $post_id, string $pubchem_cid ): string { + $weight = get_post_meta( $post_id, 'psa_molecular_weight', true ); + $formula = get_post_meta( $post_id, 'psa_molecular_formula', true ); + $seq = get_post_meta( $post_id, 'psa_sequence', true ); + $aliases = get_post_meta( $post_id, 'psa_aliases', true ); + + if ( ! $weight && ! $formula && ! $seq ) { + return ''; + } + + $html = '
'; + $html .= '

Quick Facts

'; + $html .= ''; + + if ( $aliases ) { + $html .= ''; + } + if ( $seq ) { + $html .= ''; + } + if ( $formula ) { + $html .= ''; + } + if ( $weight ) { + $html .= ''; + } + if ( $pubchem_cid ) { + $cid_int = intval( $pubchem_cid ); + $html .= ''; + } + + $html .= '
Also Known As' . esc_html( $aliases ) . '
Sequence' . esc_html( $seq ) . '
Molecular Formula' . esc_html( $formula ) . '
Molecular Weight' . esc_html( $weight ) . '
PubChem CID' . $cid_int . '
'; + return $html; + } + + /** + * Render the extended research data table (v4.3.0 fields). + * + * @param int $post_id Post ID. + * @return string HTML (empty string if no extended data). + */ + private static function render_extended_data( int $post_id ): string { + $fields = array( + 'psa_half_life' => 'Half-Life', + 'psa_stability' => 'Stability', + 'psa_solubility' => 'Solubility', + 'psa_vial_size_mg' => 'Vial Size', + 'psa_storage_lyophilized' => 'Storage (Lyophilized)', + 'psa_storage_reconstituted' => 'Storage (Reconstituted)', + 'psa_typical_dose_mcg' => 'Typical Research Dose', + 'psa_cycle_parameters' => 'Cycle Parameters', + 'psa_amino_acid_count' => 'Amino Acid Count', + ); + + $rows = ''; + foreach ( $fields as $key => $label ) { + $value = get_post_meta( $post_id, $key, true ); + if ( ! empty( $value ) ) { + $display = $value; + if ( 'psa_vial_size_mg' === $key ) { + $display = floatval( $value ) . ' mg'; + } + $rows .= '' . esc_html( $label ) . '' . esc_html( $display ) . ''; + } + } + + if ( empty( $rows ) ) { + return ''; + } + + return '
' . + '

Research Parameters

' . + '' . $rows . '
' . + '
'; + } + + /** + * Render detailed text sections (mechanism, research apps, safety, etc.). + * + * @param int $post_id Post ID. + * @return string HTML. + */ + private static function render_detail_sections( int $post_id ): string { + $detail_fields = array( + 'psa_mechanism' => 'Mechanism of Action', + 'psa_research_apps' => 'Research Applications', + 'psa_safety_profile' => 'Safety & Side Effects', + 'psa_dosage_info' => 'Dosage Information', + 'psa_references' => 'References', + ); + + $html = ''; + 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 .= '
'; + } + } + + return $html; + } + + /** + * Render the source badge. + * + * @param string $source The data source value. + * @return string HTML (empty if source is empty or 'pending'). + */ + private static function render_source_badge( string $source ): string { + if ( ! $source || 'pending' === $source ) { + return ''; + } + + $badges = array( + 'ai-generated' => array( + 'label' => 'AI-Generated Content', + 'class' => 'psa-badge-ai', + ), + 'pubchem' => array( + 'label' => 'PubChem Verified', + 'class' => 'psa-badge-verified', + ), + 'manual' => array( + 'label' => 'Manually Curated', + 'class' => 'psa-badge-manual', + ), + ); + $badge = $badges[ $source ] ?? $badges['ai-generated']; + + $html = '
'; + $html .= esc_html( $badge['label'] ); + if ( 'ai-generated' === $source ) { + $html .= ' — This entry was automatically generated and may contain inaccuracies. Please verify critical information with primary sources.'; + } + $html .= '
'; + + return $html; + } +} diff --git a/includes/class-psa-upgrade.php b/includes/class-psa-upgrade.php new file mode 100644 index 0000000..643fa2b --- /dev/null +++ b/includes/class-psa-upgrade.php @@ -0,0 +1,83 @@ += 0.2.0 owns `peptide` CPT + `peptide_category` taxonomy as + // of v4.5.0. Gate CPT-dependent surfaces so PSA degrades gracefully + // instead of binding hooks against an unregistered post type. + if ( ! PSA_Dependency_Check::is_satisfied() ) { + return; + } + + PSA_Search::init(); + PSA_AI_Generator::init(); + PSA_Batch_Enrichment::init(); + PSA_Template::init(); + PSA_Directory::init(); +} +add_action( 'init', 'psa_init' ); + +function psa_admin_init() { + PSA_Upgrade::maybe_run(); + + // Meta boxes + admin columns bind to the `peptide` CPT; only register + // when PR Core has provided it. + if ( ! PSA_Dependency_Check::is_satisfied() ) { + return; + } + + 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() { + $settings = get_option( 'psa_settings', array() ); + $kb_page = $settings['kb_page_id'] ?? 73; + return is_page( $kb_page ) || 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 + * - Any page/post containing the [peptide_directory] shortcode + */ +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; + } + + $has_search = has_shortcode( $post->post_content, 'peptide_search' ); + $has_directory = has_shortcode( $post->post_content, 'peptide_directory' ); + $is_peptide = is_singular( 'peptide' ); + + if ( ! $has_search && ! $has_directory && ! $is_peptide ) { + return; + } + + if ( $has_search || $is_peptide ) { + psa_enqueue_frontend_assets(); + } + + if ( $has_directory ) { + psa_enqueue_directory_assets(); + } +} +add_action( 'wp_enqueue_scripts', 'psa_maybe_enqueue_assets' ); + +/** + * Shared helper to enqueue search 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' ), + 'i18n' => array( + 'error' => __( 'Error:', 'peptide-search-ai' ), + 'searchFailed' => __( 'Search failed.', 'peptide-search-ai' ), + 'unexpected' => __( 'Unexpected response.', 'peptide-search-ai' ), + 'rateLimited' => __( 'Too many requests. Please try again later.', 'peptide-search-ai' ), + 'networkError' => __( 'Could not connect to the server. Please try again.', 'peptide-search-ai' ), + 'timeout' => __( 'Request timed out. The server took too long to respond. Please try again.', 'peptide-search-ai' ), + 'connectionError' => __( 'Network error. Please check your connection and try again.', 'peptide-search-ai' ), + 'serverError' => __( 'Server error. Please try again later.', 'peptide-search-ai' ), + 'unavailable' => __( 'Server is temporarily unavailable. Please try again later.', 'peptide-search-ai' ), + 'peptidesFound' => __( 'peptide(s) found', 'peptide-search-ai' ), + 'pendingMsg' => __( 'is currently being added to our database. Please check back again later.', 'peptide-search-ai' ), + 'invalidMsg' => __( 'does not appear to be a recognized peptide. Please check the spelling or try a different search term.', 'peptide-search-ai' ), + 'verified' => __( 'Verified', 'peptide-search-ai' ), + 'curated' => __( 'Curated', 'peptide-search-ai' ), + ), + ) + ); +} + +/** + * Enqueue directory-specific CSS and JS for the [peptide_directory] shortcode. + * Includes the search CSS as a base dependency (spinner styles, badge styles). + */ +function psa_enqueue_directory_assets() { + // Directory depends on search CSS for shared styles (spinner, badges). + wp_enqueue_style( + 'psa-styles', + PSA_PLUGIN_URL . 'assets/css/peptide-search.css', + array(), + PSA_VERSION + ); + + wp_enqueue_style( + 'psa-directory-styles', + PSA_PLUGIN_URL . 'assets/css/psa-directory.css', + array( 'psa-styles' ), + PSA_VERSION + ); + + wp_enqueue_script( + 'psa-directory', + PSA_PLUGIN_URL . 'assets/js/psa-directory.js', + array(), + PSA_VERSION, + true + ); + + // psaDirectory localization is handled in PSA_Directory::render_shortcode() + // because it needs to run after get_terms() to populate category data. +} + +/** + * On the KB page, hide Echo KB search and inject ours via wp_footer. + */ +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 ''; + + echo ''; +} +add_action( 'wp_footer', 'psa_replace_kb_search' ); + +function psa_activate() { + // As of PSA v4.5.0, PR Core (>= 0.2.0) owns the `peptide` CPT and + // `peptide_category` taxonomy — PSA no longer registers them here. The + // 8 default category terms were seeded on earlier PSA versions and + // remain in wp_term_taxonomy; PR Core inherits them by registering the + // same taxonomy name, so no re-seed is required. PSA only owns its + // own cost-tracker table and still needs to flush rewrite rules on + // activation so the `peptide` CPT permalinks resolve on this install. + PSA_Cost_Tracker::create_table(); + 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', + '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; +} diff --git a/readme.txt b/readme.txt new file mode 100755 index 0000000..254b9a1 --- /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.1.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. +