ART_database / app.py
udiksh's picture
Update app.py
55a2e60 verified
Raw
History Blame Contribute Delete
208 kB
import os
import gradio as gr
import pandas as pd
import numpy as np
import datetime
import urllib.parse
import urllib.request
import urllib.error
import json
import time
import re
import html as html_lib
from supabase import create_client, Client
# ── Helper: build query from terms and operators ──────────────
def build_boolean_query(term1, op1, term2, op2, term3):
"""
Build a boolean query from up to three terms with operators.
"""
terms = []
if term1 and term1.strip():
terms.append(term1.strip())
if term2 and term2.strip():
terms.append(term2.strip())
if term3 and term3.strip():
terms.append(term3.strip())
if len(terms) == 0:
return ""
if len(terms) == 1:
return terms[0]
# Build query with operators
query_parts = []
for i, t in enumerate(terms):
# Put quotes around phrases with spaces
if ' ' in t and not (t.startswith('"') and t.endswith('"')):
t = f'"{t}"'
query_parts.append(t)
# Insert operators between terms
if len(query_parts) == 2:
return f"{query_parts[0]} {op1} {query_parts[1]}"
else:
return f"{query_parts[0]} {op1} {query_parts[1]} {op2} {query_parts[2]}"
def normalize_boolean_query(query: str) -> str:
"""
Replace lowercase 'and' and 'or' with uppercase operators.
"""
if not query:
return query
pattern_and = re.compile(r'(?<!\w)and(?!\w)', re.IGNORECASE)
pattern_or = re.compile(r'(?<!\w)or(?!\w)', re.IGNORECASE)
query = pattern_and.sub('AND', query)
query = pattern_or.sub('OR', query)
return query
def _is_boolean_query(query: str) -> bool:
"""Return True if query contains AND or OR operators."""
q = normalize_boolean_query(query)
return ' AND ' in q or ' OR ' in q
# ── Admin authentication using session tracking ──────────────────
_admin_sessions = {} # session_id -> timestamp
def set_admin_active(session_id):
if session_id:
_admin_sessions[session_id] = time.time()
return True
def is_admin_active(session_id):
return session_id in _admin_sessions
# ── Supabase credentials ──────────────────────────────────────────
url: str = os.environ.get("SUPABASE_URL")
key: str = os.environ.get("SUPABASE_KEY")
AUTH_USER = os.environ.get("UPLOAD_USER")
AUTH_PASS = os.environ.get("UPLOAD_PASS")
if not url or not key:
print("Warning: Missing Supabase credentials in Space Secrets!")
supabase = None
else:
supabase: Client = create_client(url, key)
HOMEPAGE_FILE = "homepage_content.html"
HOME_CONTACT_HTML = """
<div style="font-family:'Segoe UI',system-ui,sans-serif;max-width:1100px;margin:32px auto 0;padding:0 16px 48px;">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:36px;">
<div style="flex:1;height:1px;background:linear-gradient(to right,#e2e8f0,#c7d2fe);"></div>
<span style="font-size:11px;font-weight:700;color:#94a3b8;letter-spacing:0.12em;text-transform:uppercase;">Our Team &amp; Contact</span>
<div style="flex:1;height:1px;background:linear-gradient(to left,#e2e8f0,#c7d2fe);"></div>
</div>
<!-- OUR TEAM -->
<div style="margin-bottom:48px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
<div style="width:36px;height:36px;border-radius:10px;background:#eff6ff;display:flex;align-items:center;justify-content:center;font-size:18px;"></div>
<h2 style="margin:0;font-size:20px;font-weight:800;color:#0f172a;">Our Team</h2>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(235px,1fr));gap:18px;">
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,0.05);position:relative;overflow:hidden;">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(to right,#2563eb,#7c3aed);"></div>
<div style="display:flex;align-items:center;gap:12px;margin:10px 0 14px;">
<div style="width:48px;height:48px;border-radius:50%;background:#eff6ff;border:2px solid #bfdbfe;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;line-height:1.3;">Dr. Bharati Pandey</div><div style="font-size:11px;font-weight:600;color:#2563eb;margin-top:2px;">Scientist</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#475569;line-height:1.5;"><span></span><span>Animal Biotechnology Division, NDRI, Karnal</span></div>
</div>
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,0.05);position:relative;overflow:hidden;">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(to right,#059669,#0891b2);"></div>
<div style="display:flex;align-items:center;gap:12px;margin:10px 0 14px;">
<div style="width:48px;height:48px;border-radius:50%;background:#f0fdf4;border:2px solid #bbf7d0;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;line-height:1.3;">Mr. Udiksh Malik</div><div style="font-size:11px;font-weight:600;color:#059669;margin-top:2px;">Intern</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#475569;line-height:1.5;"><span></span><span>B.Sc. Biotechnology (Hons), Amity University, Noida</span></div>
</div>
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,0.05);position:relative;overflow:hidden;">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(to right,#7c3aed,#db2777);"></div>
<div style="display:flex;align-items:center;gap:12px;margin:10px 0 14px;">
<div style="width:48px;height:48px;border-radius:50%;background:#faf5ff;border:2px solid #ddd6fe;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;line-height:1.3;">Dr. Manoj Kumar Singh</div><div style="font-size:11px;font-weight:600;color:#7c3aed;margin-top:2px;">Principal Scientist</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#475569;line-height:1.5;"><span></span><span>Animal Biotechnology Division, NDRI, Karnal</span></div>
</div>
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,0.05);position:relative;overflow:hidden;">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(to right,#d97706,#dc2626);"></div>
<div style="display:flex;align-items:center;gap:12px;margin:10px 0 14px;">
<div style="width:48px;height:48px;border-radius:50%;background:#fffbeb;border:2px solid #fde68a;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;line-height:1.3;">Dr. Naresh Selokar</div><div style="font-size:11px;font-weight:600;color:#d97706;margin-top:2px;">Senior Scientist</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#475569;line-height:1.5;"><span></span><span>Animal Biotechnology Division, NDRI, Karnal</span></div>
</div>
</div>
</div>
<!-- CONTACT US -->
<div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:24px;">
<div style="width:36px;height:36px;border-radius:10px;background:#f0fdf4;display:flex;align-items:center;justify-content:center;font-size:18px;"></div>
<h2 style="margin:0;font-size:20px;font-weight:800;color:#0f172a;">Contact Us</h2>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:18px;">
<!-- Dr. Bharati Pandey -->
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,0.05);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;">
<div style="width:40px;height:40px;border-radius:50%;background:#eff6ff;border:2px solid #bfdbfe;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;">Dr. Bharati Pandey</div><div style="font-size:11px;color:#2563eb;font-weight:600;">Scientist &middot; NDRI</div></div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<a href="mailto:bharati.pandey@icar.org.in" style="display:flex;align-items:center;gap:10px;text-decoration:none;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;">
<span style="font-size:16px;flex-shrink:0;"></span>
<div><div style="font-size:10px;font-weight:700;color:#94a3b8;letter-spacing:0.06em;text-transform:uppercase;">Email</div><div style="font-size:12.5px;color:#2563eb;font-weight:500;">bharati.pandey@icar.org.in</div></div>
</a>
<a href="tel:+919560421766" style="display:flex;align-items:center;gap:10px;text-decoration:none;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;">
<span style="font-size:16px;flex-shrink:0;"></span>
<div><div style="font-size:10px;font-weight:700;color:#94a3b8;letter-spacing:0.06em;text-transform:uppercase;">Phone</div><div style="font-size:12.5px;color:#0f172a;font-weight:500;">+91&nbsp;9560421766</div></div>
</a>
<div style="display:flex;align-items:center;gap:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;">
<span style="font-size:16px;flex-shrink:0;"></span>
<div><div style="font-size:10px;font-weight:700;color:#94a3b8;letter-spacing:0.06em;text-transform:uppercase;">Address</div><div style="font-size:12.5px;color:#0f172a;font-weight:500;">Animal Biotechnology Division, NDRI, Karnal</div></div>
</div>
</div>
</div>
<!-- Udiksh Malik -->
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,0.05);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px;">
<div style="width:40px;height:40px;border-radius:50%;background:#f0fdf4;border:2px solid #bbf7d0;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;"></div>
<div><div style="font-size:14px;font-weight:800;color:#0f172a;">Udiksh Malik</div><div style="font-size:11px;color:#059669;font-weight:600;">Intern &middot; Amity University</div></div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<a href="mailto:malikudiksh@gmail.com" style="display:flex;align-items:center;gap:10px;text-decoration:none;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;">
<span style="font-size:16px;flex-shrink:0;"></span>
<div><div style="font-size:10px;font-weight:700;color:#94a3b8;letter-spacing:0.06em;text-transform:uppercase;">Email</div><div style="font-size:12.5px;color:#2563eb;font-weight:500;">malikudiksh@gmail.com</div></div>
</a>
<div style="display:flex;align-items:center;gap:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;">
<span style="font-size:16px;flex-shrink:0;"></span>
<div><div style="font-size:10px;font-weight:700;color:#94a3b8;letter-spacing:0.06em;text-transform:uppercase;">Address</div><div style="font-size:12.5px;color:#0f172a;font-weight:500;">B.Sc. Biotechnology (Hons) with Research, Amity University, Noida</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
"""
# ── Paper Alert Configuration ─────────────────────────────────────────────────
PAPER_ALERT_TOPIC = "bovine oocyte transcriptomics"
PAPER_ALERT_DAYS = 30
PAPER_ALERT_MAX = 5
# ─────────────────────────────────────────────────────────────────────────────
EXACT_MATCH_COLUMNS = ["Accession", "Organism", "Type", "Platform", "Samples", "Study Title"]
ALL_UNIQUE_COLUMNS = EXACT_MATCH_COLUMNS + ["tissue", "dataset"]
BLANK_TEMPLATE_DF = pd.DataFrame(columns=ALL_UNIQUE_COLUMNS)
GEO_TOOLS_DATA = [
{"Name": "GEO2R", "URL": "https://www.ncbi.nlm.nih.gov/geo/geo2r/", "Type": "Web-based (Free)", "Feature": "Official NCBI tool; differential gene expression between sample groups; volcano plots, Venn diagrams."},
{"Name": "GEOexplorer", "URL": "https://geoexplorer.rosalind.kcl.ac.uk/", "Type": "Web-based (Free)", "Feature": "End-to-end gene expression analysis; EDA, DGEA, gene enrichment analysis (GEA); interactive plots."},
{"Name": "shinyGEO", "URL": "http://gdancik.github.io/shinyGEO/", "Type": "Web-based / Docker (Free)", "Feature": "Download expression and sample data from GEO; differential expression and survival analysis."},
{"Name": "iDEP", "URL": "http://bioinformatics.sdstate.edu/idep/", "Type": "Web-based (Free)", "Feature": "Integrated analysis platform; DEG analysis (DESeq2, edgeR, limma); PCA, heatmaps, pathway enrichment."},
{"Name": "TidyGEO", "URL": "https://tidygeo.uams.edu/", "Type": "Web-based (Free)", "Feature": "Downloads and reformats GEO series; tidies and standardises sample-level annotations."},
{"Name": "GeneCloudOmics", "URL": "https://abiotrans.ait.ie/", "Type": "Web-based (Free)", "Feature": "23 data analytics and bioinformatics tasks; PCA, clustering, t-SNE, DEG analysis, pathway enrichment."},
{"Name": "BART (Bioinformatics Array Research Tool)", "URL": "http://bart.salk.edu/", "Type": "Web-based (Free)", "Feature": "Automates microarray download and analysis from GEO; batch effect correction; differential expression; functional enrichment."},
{"Name": "GEOquery (R/Bioconductor)", "URL": "https://bioconductor.org/packages/GEOquery/", "Type": "Standalone - R package (Free)", "Feature": "Downloads GEO datasets directly into R; parses GEO SOFT/MINiML formats into ExpressionSet objects."},
{"Name": "GEOfetch", "URL": "https://github.com/pepkit/geofetch", "Type": "Standalone - CLI (Free)", "Feature": "Command-line tool for downloading GEO data and metadata; outputs standardised PEP format."},
{"Name": "GEOmetadb (R/Bioconductor)", "URL": "https://bioconductor.org/packages/GEOmetadb/", "Type": "Standalone - R package (Free)", "Feature": "SQLite database of all GEO metadata; enables advanced SQL queries; faster than NCBI API."},
{"Name": "NCBI Datasets (GEO)", "URL": "https://www.ncbi.nlm.nih.gov/datasets/", "Type": "Web-based + CLI (Free)", "Feature": "Official NCBI data retrieval portal; download GEO datasets, metadata, and associated sequences."},
{"Name": "Correlation Engine (formerly NextBio)", "URL": "https://www.illumina.com/products/by-type/informatics-products/basespace-correlation-engine.html", "Type": "Web-based (Freemium)", "Feature": "Cross-study meta-analysis; correlation of gene expression signatures across GEO studies; disease/drug association analysis; biomarker discovery."},
{"Name": "Phantasus", "URL": "https://genome.ifmo.ru/phantasus/", "Type": "Web-based (Free)", "Feature": "Interactive visual analysis of GEO datasets; heatmaps, PCA, k-means clustering, DESeq2/limma."},
{"Name": "ExpressionAtlas", "URL": "https://www.ebi.ac.uk/gxa/", "Type": "Web-based (Free)", "Feature": "Curated gene expression across species and conditions; differential and baseline expression data."},
{"Name": "Enrichr", "URL": "https://maayanlab.cloud/Enrichr/", "Type": "Web-based (Free)", "Feature": "Gene set enrichment analysis; 200+ gene set libraries; interactive visualisations; accepts gene lists."}
]
SRA_TOOLS_DATA = [
{"Name": "SRA Toolkit (NCBI)", "URL": "https://github.com/ncbi/sra-tools", "Type": "Standalone – CLI (Free)", "Feature": "Official NCBI toolkit; prefetch (downloads SRA files), fasterq-dump (converts to FASTQ, multi-threaded); fastq-dump, sam-dump, vdb-config; supports SRA/dbGaP/ADSP data; handles paired-end and single-end reads."},
{"Name": "SRA Explorer", "URL": "https://sra-explorer.info/", "Type": "Web-based (Free)", "Feature": "Browser-based search of SRA by accession/keyword; generates batch download scripts (curl/wget/Aspera); finds ENA FTP direct download URLs; exports metadata; no installation required."},
{"Name": "pysradb", "URL": "https://github.com/saketkc/pysradb", "Type": "Standalone – Python (Free)", "Feature": "Python package for SRA and GEO metadata retrieval; converts between GSE/RSP/SRX/SRR accessions; downloads FASTQ files; LLM-based metadata extraction; batch operations; integrates with Entrez API."},
{"Name": "Kingfisher", "URL": "https://github.com/wwood/kingfisher-download", "Type": "Standalone – CLI (Free)", "Feature": "Fast flexible SRA/ENA/AWS/GCP download; tries multiple download methods in sequence (ENA FTP, Aspera, AWS, GCP, prefetch); FASTQ/FASTA/SRA/GZIP output; usually faster than SRA Toolkit."},
{"Name": "ffq (Find FASTQ)", "URL": "https://github.com/pachterlab/ffq", "Type": "Standalone – CLI (Free)", "Feature": "Retrieves metadata and download links for SRA, GEO, ENA, DDBJ, EMBL datasets by accession; outputs JSON with full metadata; converts between SRA/study/sample/run accessions; no data download (links only)."},
{"Name": "SRAdownloader", "URL": "https://github.com/s-andrews/sradownloader", "Type": "Standalone – CLI (Free)", "Feature": "Takes SRA Run Selector annotation table as input; retrieves FASTQ files from ENA or NCBI; assigns meaningful filenames based on metadata; batch processing; simpler interface than SRA Toolkit."},
{"Name": "nf-core/fetchngs", "URL": "https://nf-co.re/fetchngs/", "Type": "Standalone – Nextflow (Free)", "Feature": "Nextflow pipeline for downloading raw sequencing data from SRA/ENA/DDBJ/GEO; automatically downloads FASTQ and metadata; outputs standardised samplesheets for other nf-core pipelines; supports HPC/cloud."},
{"Name": "parallel-fastq-dump", "URL": "https://github.com/rvalieris/parallel-fastq-dump", "Type": "Standalone – CLI (Free)", "Feature": "Parallelised wrapper for fastq-dump; splits SRA file and downloads in chunks simultaneously; faster than standard fastq-dump; supports gzip compression; retains all fastq-dump options."},
{"Name": "ENA Browser / ENA FTP", "URL": "https://www.ebi.ac.uk/ena/browser/", "Type": "Web-based + FTP (Free)", "Feature": "European mirror of SRA data; often faster downloads than NCBI for non-US users; direct FTP/Aspera access to FASTQ files; metadata search; API access; direct wget/curl download of FASTQ without conversion."},
{"Name": "Entrez Direct (EDirect)", "URL": "https://www.ncbi.nlm.nih.gov/books/NBK179288/", "Type": "Standalone – CLI (Free)", "Feature": "NCBI command-line utilities for querying all NCBI databases including SRA; esearch, efetch, elink, efilter commands; powerful text-based queries; metadata retrieval; scripting-friendly."},
{"Name": "Trim Galore", "URL": "https://github.com/FelixKrueger/TrimGalore", "Type": "Standalone – CLI (Free)", "Feature": "Quality trimming of FASTQ reads from SRA downloads; adapter auto-detection; integrates FastQC reports; supports paired-end; RRBS and small RNA modes; simple one-command usage."},
{"Name": "Trimmomatic", "URL": "http://www.usadellab.org/cms/?page=trimmomatic", "Type": "Standalone – Java (Free)", "Feature": "Flexible adapter trimming and quality filtering; LEADING, TRAILING, SLIDINGWINDOW, MINLEN, AVGQUAL, HEADCROP parameters; paired-end and single-end support; widely used in standard pipelines."},
{"Name": "fastp", "URL": "https://github.com/OpenGene/fastp", "Type": "Standalone – CLI (Free)", "Feature": "Ultra-fast FASTQ QC and trimming; automatic adapter detection; duplication analysis; per-base quality correction; HTML/JSON reports; supports paired-end; very fast due to multithreading."},
{"Name": "FastQC", "URL": "https://www.bioinformatics.babraham.ac.uk/projects/fastqc/", "Type": "Standalone + Web (Free)", "Feature": "Per-base quality scores; sequence content; GC content; adapter contamination; overrepresented sequences; duplication levels; HTML QC report; GUI and command-line modes; supports many formats."},
{"Name": "MultiQC", "URL": "https://multiqc.info/", "Type": "Standalone – CLI (Free)", "Feature": "Aggregates QC reports from FastQC, Trimmomatic, fastp, STAR, BWA, Bowtie2, featureCounts, and 100+ other tools into a single interactive HTML report; essential for multi-sample SRA studies."},
{"Name": "Galaxy (UseGalaxy.org)", "URL": "https://usegalaxy.org/", "Type": "Web-based (Free)", "Feature": "Browser-based bioinformatics platform; SRA import via accession; quality control; alignment; variant calling; assembly; annotation; 1,000+ integrated tools; no programming required; workflow sharing."},
{"Name": "NCBI Run Selector", "URL": "https://www.ncbi.nlm.nih.gov/Traces/study/", "Type": "Web-based (Free)", "Feature": "Web interface to browse and filter SRA experiments; filter by organism, platform, library strategy, read length, metadata; download accession lists and metadata tables for batch downloading."},
]
# ── Helper functions ──────────────────────────────────────────────────────────
def sanitize_dataframe(df):
df = df.replace({np.nan: None, 'nan': None, 'NaN': None})
return df.astype(object).where(pd.notnull(df), None)
def normalize_val(v):
if v is None or pd.isna(v) or str(v).lower().strip() in ['nan', 'none', '<na>', 'n/a']:
return ""
return str(v).lower().strip()
BOS_TAURUS_ALIASES = {"bos taurus", "cattle"}
def normalize_organism(v):
val = normalize_val(v)
return "bos taurus" if val in BOS_TAURUS_ALIASES else val
def read_saved_homepage():
if os.path.exists(HOMEPAGE_FILE):
try:
with open(HOMEPAGE_FILE, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
return ""
def save_homepage_content(html_content):
try:
with open(HOMEPAGE_FILE, "w", encoding="utf-8") as f:
f.write(html_content)
return " Homepage saved successfully!"
except Exception as e:
return f" Error saving: {str(e)}"
def generate_view_html():
saved_content = read_saved_homepage()
if not saved_content.strip():
return '<div style="min-height:500px;">' + HOMEPAGE_DEFAULT + '</div>'
return f"""<div style="border:1px solid #e2e8f0;border-radius:8px;padding:24px;background:#ffffff;min-height:500px;color:#1e293b;font-family:system-ui,sans-serif;">{saved_content}</div>"""
def generate_editor_html():
saved_content = read_saved_homepage()
return f"""<div style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;background:#ffffff;min-height:500px;display:flex;flex-direction:column;gap:12px;font-family:system-ui,sans-serif;">
<div id="homepage-canvas-editor" contenteditable="true" style="flex-grow:1;min-height:450px;outline:none;padding:16px;border:1px dashed #cbd5e1;border-radius:6px;overflow-y:auto;color:#1e293b;background:#f8fafc;">{saved_content}</div>
</div>"""
# ── Paper Alert HTML generator ────────────────────────────────────────────────
def generate_paper_alert_html(
topic=PAPER_ALERT_TOPIC,
days=PAPER_ALERT_DAYS,
max_results=PAPER_ALERT_MAX
):
topic_js = topic.replace("'", "\\'")
return f"""
<div id="paper-alert-overlay"
style="display:none;position:fixed;inset:0;background:rgba(15,23,42,0.55);
z-index:99999;align-items:center;justify-content:center;">
<div id="paper-alert-box"
style="background:#ffffff;border-radius:12px;padding:28px 32px;
max-width:640px;width:92%;max-height:82vh;overflow-y:auto;
position:relative;border:1px solid #e2e8f0;
box-shadow:0 16px 48px rgba(0,0,0,0.18);font-family:system-ui,sans-serif;">
<button onclick="closePaperAlert()"
style="position:absolute;top:14px;right:16px;background:none;border:none;
font-size:20px;cursor:pointer;color:#64748b;line-height:1;padding:4px 8px;
border-radius:4px;"
aria-label="Close paper alert"></button>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;">
<div style="width:40px;height:40px;border-radius:50%;background:#eff6ff;
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<span style="font-size:20px;"></span>
</div>
<div>
<h2 style="margin:0;font-size:16px;font-weight:700;color:#0f172a;">
Recent Publications Alert
</h2>
<p style="margin:0;font-size:12px;color:#64748b;">
Last {days} days &nbsp;&bull;&nbsp; Topic:&nbsp;<em style="color:#2563eb;">{topic}</em>
</p>
</div>
</div>
<div id="paper-alert-list">
<div style="display:flex;align-items:center;gap:10px;padding:12px 0;color:#64748b;font-size:13px;">
<span style="display:inline-block;width:16px;height:16px;border:2px solid #2563eb;
border-top-color:transparent;border-radius:50%;
animation:pa-spin 0.8s linear infinite;"></span>
Fetching latest papers from PubMed&hellip;
</div>
</div>
<div style="margin-top:18px;display:flex;justify-content:space-between;align-items:center;
border-top:1px solid #f1f5f9;padding-top:14px;">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#64748b;cursor:pointer;">
<input type="checkbox" id="pa-no-show" style="accent-color:#2563eb;">
Don&rsquo;t show again this session
</label>
<button onclick="closePaperAlert()"
style="background:#2563eb;color:#fff;border:none;border-radius:6px;
padding:8px 22px;font-size:13px;font-weight:600;cursor:pointer;">
Got it
</button>
</div>
</div>
</div>
<style>
@keyframes pa-spin {{
to {{ transform: rotate(360deg); }}
}}
</style>
<script>
(function() {{
var TOPIC = '{topic_js}';
var DAYS = {days};
var MAX_RESULTS = {max_results};
function closePaperAlert() {{
var overlay = document.getElementById('paper-alert-overlay');
if (overlay) overlay.style.display = 'none';
if (document.getElementById('pa-no-show') &&
document.getElementById('pa-no-show').checked) {{
try {{ sessionStorage.setItem('pa_dismissed', '1'); }} catch(e) {{}}
}}
}}
window.closePaperAlert = closePaperAlert;
function getMinDate() {{
var d = new Date();
d.setDate(d.getDate() - DAYS);
var mm = String(d.getMonth() + 1).padStart(2, '0');
var dd = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '/' + mm + '/' + dd;
}}
function escapeHtml(str) {{
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}}
function renderPapers(articles) {{
var list = document.getElementById('paper-alert-list');
if (!list) return;
if (!articles || articles.length === 0) {{
list.innerHTML =
'<div style="padding:14px 0;color:#64748b;font-size:13px;text-align:center;">' +
' No new papers found in the last ' + DAYS + ' days for this topic.</div>';
return;
}}
var countLabel = articles.length === 1
? '1 new paper found on PubMed'
: articles.length + ' new papers found on PubMed';
var html = '<p style="font-size:12px;color:#475569;margin:0 0 12px;">&nbsp;' +
countLabel + ':</p>';
articles.forEach(function(a) {{
html +=
'<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px;' +
'margin-bottom:10px;background:#f8fafc;">' +
'<a href="https://pubmed.ncbi.nlm.nih.gov/' + escapeHtml(a.uid) + '/" ' +
'target="_blank" rel="noopener noreferrer" ' +
'style="font-size:13px;font-weight:600;color:#2563eb;text-decoration:none;' +
'line-height:1.5;display:block;margin-bottom:5px;">' +
escapeHtml(a.title) +
'</a>' +
'<p style="margin:0;font-size:11.5px;color:#64748b;">' +
escapeHtml(a.authors) +
(a.journal ? ' &bull; <em>' + escapeHtml(a.journal) + '</em>' : '') +
(a.date ? ' &bull; ' + escapeHtml(a.date) : '') +
'</p>' +
'</div>';
}});
list.innerHTML = html;
}}
function renderError(msg) {{
var list = document.getElementById('paper-alert-list');
if (!list) return;
list.innerHTML =
'<div style="padding:12px;background:#fef2f2;border:1px solid #fecaca;' +
'border-radius:6px;color:#dc2626;font-size:13px;">&nbsp;' +
escapeHtml(msg) + '</div>';
}}
function fetchPapers() {{
var minDate = getMinDate();
var termRaw = TOPIC + ' AND ("' + minDate +
'"[Date - Publication] : "3000"[Date - Publication])';
var term = encodeURIComponent(termRaw);
var searchURL =
'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi' +
'?db=pubmed&retmax=' + MAX_RESULTS + '&retmode=json&sort=date&term=' + term;
fetch(searchURL)
.then(function(r) {{
if (!r.ok) throw new Error('PubMed search request failed (' + r.status + ')');
return r.json();
}})
.then(function(data) {{
var ids = data.esearchresult && data.esearchresult.idlist;
if (!ids || ids.length === 0) {{
renderPapers([]);
return null;
}}
var summaryURL =
'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi' +
'?db=pubmed&retmode=json&id=' + ids.join(',');
return fetch(summaryURL);
}})
.then(function(r) {{
if (!r) return null;
if (!r.ok) throw new Error('PubMed summary request failed (' + r.status + ')');
return r.json();
}})
.then(function(data) {{
if (!data) return;
var result = data.result;
var uids = result.uids || [];
var articles = uids.map(function(uid) {{
var a = result[uid] || {{}};
var rawAuth = a.authors || [];
var names = rawAuth.slice(0, 3).map(function(x) {{ return x.name || ''; }});
if (rawAuth.length > 3) names.push('et al.');
return {{
uid: uid,
title: a.title || 'Untitled',
journal: a.fulljournalname || a.source || '',
date: a.pubdate || '',
authors: names.join(', ') || 'Unknown authors'
}};
}});
renderPapers(articles);
}})
.catch(function(err) {{
renderError('Could not fetch papers. Please check your connection. (' + err.message + ')');
}});
}}
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', init);
}} else {{
setTimeout(init, 600);
}}
}})();
</script>
"""
# ══════════════════════════════════════════════════════════════════════════════
# ── Multi-Database Literature Search Engine ───────────────────────────────────
# Searches: PubMed · Europe PMC · CrossRef · Semantic Scholar · bioRxiv/medRxiv
# ══════════════════════════════════════════════════════════════════════════════
def _safe_request(url, headers=None, timeout=12):
"""Make an HTTP GET request and return parsed JSON, or None on failure."""
try:
default_headers = {'User-Agent': 'Mozilla/5.0 (BioinformaticsHub/1.0)'}
if headers:
default_headers.update(headers)
req = urllib.request.Request(url, headers=default_headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode('utf-8'))
except Exception:
return None
def _fetch_pubmed(query, since_date_str, max_results=200):
"""Fetch from NCBI PubMed using boolean query (AND/OR) with separate date range."""
results = []
try:
query = normalize_boolean_query(query)
query_enc = urllib.parse.quote(query)
search_url = (
f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
f"?db=pubmed&retmax={max_results}&retmode=json&sort=date"
f"&term={query_enc}&mindate={since_date_str}&maxdate=3000"
)
search_data = _safe_request(search_url)
if not search_data:
return results, "PubMed: request failed"
ids = search_data.get('esearchresult', {}).get('idlist', [])
if not ids:
return results, "PubMed: 0 results"
summary_url = (
f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
f"?db=pubmed&retmode=json&id={','.join(ids)}"
)
summary_data = _safe_request(summary_url)
if not summary_data:
return results, f"PubMed: retrieved {len(ids)} IDs but summary failed"
result_dict = summary_data.get('result', {})
uids = result_dict.get('uids', [])
for uid in uids:
a = result_dict.get(uid, {})
raw_auth = a.get('authors', [])
names = [x.get('name', '') for x in raw_auth[:3]]
if len(raw_auth) > 3:
names.append('et al.')
results.append({
'title': a.get('title', 'Untitled'),
'authors': ', '.join(names) if names else 'Unknown Authors',
'journal': a.get('fulljournalname', a.get('source', '')),
'date': a.get('pubdate', 'N/A'),
'url': f"https://pubmed.ncbi.nlm.nih.gov/{uid}/",
'doi': a.get('elocationid', '').replace('doi: ', '').strip(),
'source': 'PubMed',
'source_color': '#dc2626',
'source_bg': '#fef2f2',
})
return results, f"PubMed: {len(results)} results"
except Exception as e:
return results, f"PubMed: error – {str(e)}"
def _fetch_europe_pmc(query, since_date_str, max_results=200):
"""Fetch from Europe PMC using boolean query with dateFrom parameter."""
results = []
try:
query = normalize_boolean_query(query)
since_epmc = since_date_str.replace('/', '-')
query_enc = urllib.parse.quote(query)
url = (
f"https://www.ebi.ac.uk/europepmc/webservices/rest/search"
f"?query={query_enc}&resultType=core&pageSize={max_results}&format=json&sort=P_PDATE_D%20desc"
f"&dateFrom={since_epmc}"
)
data = _safe_request(url)
if not data:
return results, "Europe PMC: request failed"
articles = data.get('resultList', {}).get('result', [])
for a in articles:
authors_list = a.get('authorList', {}).get('author', [])
names = [au.get('fullName', '') for au in authors_list[:3]]
if len(authors_list) > 3:
names.append('et al.')
doi = a.get('doi', '')
pmid = a.get('pmid', '')
article_url = (
f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid
else (f"https://doi.org/{doi}" if doi else f"https://europepmc.org/article/{a.get('source','')}/{a.get('id','')}")
)
results.append({
'title': a.get('title', 'Untitled').rstrip('.'),
'authors': ', '.join(names) if names else 'Unknown Authors',
'journal': a.get('journalTitle', a.get('bookOrReportDetails', {}).get('publisher', '')),
'date': a.get('firstPublicationDate', a.get('pubYear', 'N/A')),
'url': article_url,
'doi': doi,
'source': 'Europe PMC',
'source_color': '#7c3aed',
'source_bg': '#f5f3ff',
})
return results, f"Europe PMC: {len(results)} results"
except Exception as e:
return results, f"Europe PMC: error – {str(e)}"
def _fetch_crossref(query, since_date_str, max_results=100):
"""Fetch from CrossRef Works API – supports simple phrase queries."""
results = []
try:
since_cr = since_date_str.replace('/', '-')
query_enc = urllib.parse.quote(query)
url = (
f"https://api.crossref.org/works"
f"?query={query_enc}&filter=from-pub-date:{since_cr}"
f"&rows={max_results}&sort=published&order=desc"
f"&select=DOI,title,author,container-title,published,type"
f"&mailto=biohub@example.com"
)
data = _safe_request(url)
if not data:
return results, "CrossRef: request failed"
items = data.get('message', {}).get('items', [])
for item in items:
item_type = item.get('type', '')
if item_type not in ('journal-article', 'proceedings-article', 'posted-content'):
continue
titles = item.get('title', ['Untitled'])
title = titles[0] if titles else 'Untitled'
authors_raw = item.get('author', [])
names = []
for au in authors_raw[:3]:
fn = au.get('given', '')
ln = au.get('family', '')
names.append(f"{fn} {ln}".strip() if fn or ln else au.get('name', ''))
if len(authors_raw) > 3:
names.append('et al.')
journals = item.get('container-title', [])
journal = journals[0] if journals else ''
doi = item.get('DOI', '')
pub_date_parts = item.get('published', {}).get('date-parts', [[]])
parts = pub_date_parts[0] if pub_date_parts else []
pub_date = '-'.join(str(p) for p in parts) if parts else 'N/A'
results.append({
'title': title,
'authors': ', '.join(n for n in names if n) or 'Unknown Authors',
'journal': journal,
'date': pub_date,
'url': f"https://doi.org/{doi}" if doi else '',
'doi': doi,
'source': 'CrossRef',
'source_color': '#059669',
'source_bg': '#ecfdf5',
})
return results, f"CrossRef: {len(results)} results"
except Exception as e:
return results, f"CrossRef: error – {str(e)}"
def _fetch_semantic_scholar(query, since_date_str, max_results=100):
"""Fetch from Semantic Scholar – simple query string."""
results = []
try:
since_ss = since_date_str.replace('/', '-')
query_enc = urllib.parse.quote(query)
url = (
f"https://api.semanticscholar.org/graph/v1/paper/search"
f"?query={query_enc}"
f"&publicationDateOrYear={since_ss}:"
f"&fields=title,authors,venue,year,publicationDate,externalIds,openAccessPdf"
f"&limit={min(max_results, 100)}"
f"&sort=publicationDate:desc"
)
data = _safe_request(url, headers={'x-api-key': ''})
if not data:
return results, "Semantic Scholar: request failed"
papers = data.get('data', [])
for p in papers:
authors_raw = p.get('authors', [])
names = [a.get('name', '') for a in authors_raw[:3]]
if len(authors_raw) > 3:
names.append('et al.')
ext_ids = p.get('externalIds', {})
doi = ext_ids.get('DOI', '')
paper_id = p.get('paperId', '')
pdf_info = p.get('openAccessPdf') or {}
article_url = (
pdf_info.get('url') or
(f"https://doi.org/{doi}" if doi else '') or
(f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else '')
)
pub_date = p.get('publicationDate') or str(p.get('year', 'N/A'))
results.append({
'title': p.get('title', 'Untitled'),
'authors': ', '.join(n for n in names if n) or 'Unknown Authors',
'journal': p.get('venue', ''),
'date': pub_date,
'url': article_url,
'doi': doi,
'source': 'Semantic Scholar',
'source_color': '#d97706',
'source_bg': '#fffbeb',
})
return results, f"Semantic Scholar: {len(results)} results"
except Exception as e:
return results, f"Semantic Scholar: error – {str(e)}"
def _normalize_doi(doi):
"""Normalize common DOI forms to a bare DOI string."""
doi = (doi or "").strip()
doi = re.sub(r'^(?:https?://)?(?:dx\.)?doi\.org/', '', doi, flags=re.IGNORECASE)
doi = doi.strip().rstrip('.,;')
return doi.lower()
def _extract_doi(doi_text):
"""Extract a DOI from provider strings such as 'pii: ..., doi: 10.xxxx/yyy'."""
raw = str(doi_text or '').strip()
if not raw:
return ''
# Prefer an actual DOI-looking substring over provider prefixes such as pii:/doi:.
match = re.search(r'(10\.\d{4,9}/[^\s,;]+)', raw, flags=re.IGNORECASE)
if match:
return _normalize_doi(match.group(1))
return _normalize_doi(raw)
def _doi_html_result(doi, title, authors='', journal='', date='', url='', source='DOI lookup'):
doi = _extract_doi(doi)
doi_display = doi
url = url or f"https://doi.org/{doi}"
is_reviewed = doi in load_reviewed_dois() if doi else False
reviewed_badge = (
'<span style="background:#dcfce7;color:#15803d;padding:3px 8px;border-radius:9999px;'
'font-size:10px;font-weight:700;"> Read</span>'
if is_reviewed else ''
)
return f"""
<div style=\"font-family:system-ui,sans-serif;border:1px solid #e2e8f0;border-radius:10px;
padding:18px;background:#ffffff;box-shadow:0 1px 3px rgba(0,0,0,0.04);">
<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;\">
<span style=\"background:#ecfdf5;color:#059669;padding:3px 8px;border-radius:9999px;font-size:10px;font-weight:700;\">{source}</span>
<span style=\"font-size:11px;color:#94a3b8;\">DOI</span>
{reviewed_badge}
</div>
<a href=\"{url}\" target=\"_blank\" rel=\"noopener noreferrer\"
style=\"font-size:15px;font-weight:700;color:#2563eb;text-decoration:none;line-height:1.5;display:block;margin-bottom:10px;\">
{title}
</a>
<div style=\"font-size:12px;color:#475569;line-height:1.7;\">
{f'<div><strong>Authors:</strong> {authors}</div>' if authors else ''}
{f'<div><strong>Journal:</strong> <em>{journal}</em></div>' if journal else ''}
{f'<div><strong>Date:</strong> {date}</div>' if date else ''}
<div><strong>DOI:</strong> <a href=\"{url}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#2563eb;word-break:break-all;\">{doi_display}</a></div>
</div>
</div>
"""
def search_by_doi(doi):
"""Look up a DOI across CrossRef, Europe PMC and PubMed."""
doi = _extract_doi(doi)
if not doi or not re.match(r'^10\.\d{4,9}/\S+$', doi):
return "<div style='color:#dc2626;font-size:13px;padding:12px;border:1px solid #fecaca;border-radius:8px;'>Please enter a valid DOI, for example <code>10.1093/nar/gkac123</code>.</div>"
# CrossRef — primary DOI metadata source
cr_url = "https://api.crossref.org/works/" + urllib.parse.quote(doi, safe='')
cr = _safe_request(cr_url)
if cr and cr.get('message'):
item = cr['message']
titles = item.get('title') or ['Untitled']
title = titles[0] if titles else 'Untitled'
auth = item.get('author') or []
names = []
for a in auth[:5]:
name = ' '.join(x for x in [a.get('given',''), a.get('family','')] if x).strip() or a.get('name','')
if name:
names.append(name)
if len(auth) > 5:
names.append('et al.')
journals = item.get('container-title') or []
journal = journals[0] if journals else ''
date_parts = (item.get('published-print') or item.get('published-online') or item.get('published') or {}).get('date-parts', [[]])
parts = date_parts[0] if date_parts else []
date = '-'.join(str(x) for x in parts) if parts else ''
url = item.get('URL') or f"https://doi.org/{doi}"
return _doi_html_result(doi, title, ', '.join(names), journal, date, url, 'CrossRef')
# Europe PMC fallback
epmc = _safe_request(
"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=" +
urllib.parse.quote(f'DOI:"{doi}"') + "&resultType=core&pageSize=5&format=json"
)
if epmc:
items = epmc.get('resultList', {}).get('result', [])
if items:
item = items[0]
authors_list = item.get('authorList', {}).get('author', [])
names = [a.get('fullName','') for a in authors_list[:5] if a.get('fullName')]
if len(authors_list) > 5:
names.append('et al.')
pmid = item.get('pmid','')
url = f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else f"https://doi.org/{doi}"
return _doi_html_result(
doi,
item.get('title','Untitled'),
', '.join(names),
item.get('journalTitle',''),
item.get('firstPublicationDate', str(item.get('pubYear',''))),
url,
'Europe PMC'
)
# PubMed final fallback
pm_url = (
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&retmode=json&retmax=5&term=" +
urllib.parse.quote(f'"{doi}"[AID] OR "{doi}"[DOI]')
)
pm = _safe_request(pm_url)
ids = (pm or {}).get('esearchresult', {}).get('idlist', [])
if ids:
sm = _safe_request(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&id=" + ','.join(ids)
)
if sm:
result = sm.get('result', {})
uid = result.get('uids', [ids[0]])[0]
item = result.get(uid, {})
raw = item.get('authors', [])
names = [a.get('name','') for a in raw[:5] if a.get('name')]
if len(raw) > 5:
names.append('et al.')
return _doi_html_result(
doi,
item.get('title','Untitled'),
', '.join(names),
item.get('fulljournalname', item.get('source','')),
item.get('pubdate',''),
f"https://pubmed.ncbi.nlm.nih.gov/{uid}/",
'PubMed'
)
return "<div style='padding:20px;background:#ffffff;border:1px dashed #cbd5e1;border-radius:8px;text-align:center;color:#64748b;'> No publication metadata found for <strong style='color:#2563eb;'>" + doi + "</strong>.</div>"
def get_reviewed_table_df():
"""Return all reviewed DOIs as a DataFrame for the admin console."""
columns = ['DOI', 'Reviewed At', 'Link']
rows = []
if _check_reviewed_table():
try:
res = supabase.table('reviewed_articles').select('doi,reviewed_at').order('reviewed_at', desc=True).execute()
for r in (res.data or []):
doi = (r.get('doi') or '').strip()
if doi:
rows.append({'DOI': doi, 'Reviewed At': r.get('reviewed_at') or '', 'Link': f'https://doi.org/{doi}'})
except Exception:
pass
db_ids = {r['DOI'].lower() for r in rows}
for doi in sorted(_reviewed_cache):
if doi not in db_ids:
rows.append({'DOI': doi, 'Reviewed At': 'Session only', 'Link': f'https://doi.org/{doi}'})
return pd.DataFrame(rows, columns=columns)
def reviewed_row_selection(evt: gr.SelectData, current_df):
try:
row_idx = evt.index[0]
doi = str(current_df.iloc[row_idx].get('DOI', '')).strip()
return doi, f'Selected reviewed DOI: {doi}' if doi else 'No DOI selected.'
except Exception as e:
return '', f'Selection error: {e}'
def remove_selected_reviewed_doi(selected_doi, request: gr.Request):
session_hash = getattr(request, 'session_hash', None)
if not session_hash or session_hash not in _admin_sessions:
return ' Admin login required. Please log in first.', get_reviewed_table_df(), ''
if not selected_doi or not selected_doi.strip():
return ' Select a marked article first.', get_reviewed_table_df(), ''
msg = remove_reviewed_article(selected_doi)
return msg, get_reviewed_table_df(), ''
def _fetch_biorxiv(query, since_date_str, max_results=100):
"""Fetch from bioRxiv/medRxiv – basic keyword filtering (simple AND on split terms)."""
results = []
try:
today_str = datetime.date.today().strftime("%Y-%m-%d")
since_bxr = since_date_str.replace('/', '-')
# Extract keywords (remove quotes and split)
keywords = [w.strip('"').lower() for w in query.split() if len(w) > 2 and w not in ('AND', 'OR', 'NOT')]
# If query has quotes, keep them as phrase?
# We'll keep it simple: use the raw query as a phrase for title/abstract checking.
for server in ['biorxiv', 'medrxiv']:
url = f"https://api.biorxiv.org/details/{server}/{since_bxr}/{today_str}/0/json"
data = _safe_request(url)
if not data:
continue
collection = data.get('collection', [])
for item in collection:
title = item.get('title', 'Untitled')
abstract = item.get('abstract', '').lower()
# Check if all keywords appear (simple AND)
match = True
for kw in keywords:
if kw not in title.lower() and kw not in abstract:
match = False
break
if not match:
continue
doi = item.get('doi', '')
authors_str = item.get('authors', 'Unknown Authors')
author_list = [a.strip() for a in authors_str.split(';')]
names = author_list[:3]
if len(author_list) > 3:
names.append('et al.')
results.append({
'title': title,
'authors': '; '.join(names),
'journal': f"{server.capitalize()} (Preprint)",
'date': item.get('date', 'N/A'),
'url': f"https://doi.org/{doi}" if doi else f"https://www.{server}.org/",
'doi': doi,
'source': server.capitalize(),
'source_color': '#0891b2',
'source_bg': '#ecfeff',
})
if len(results) >= max_results:
break
if len(results) >= max_results:
break
return results, f"bioRxiv/medRxiv: {len(results)} results"
except Exception as e:
return results, f"bioRxiv/medRxiv: error – {str(e)}"
def _deduplicate(all_results):
"""Remove duplicates by DOI (if present) or by normalised title."""
seen_dois = set()
seen_titles = set()
unique = []
for r in all_results:
doi = r.get('doi', '').strip().lower()
title_key = r.get('title', '').strip().lower()[:80]
if doi and doi in seen_dois:
continue
if title_key and title_key in seen_titles:
continue
if doi:
seen_dois.add(doi)
if title_key:
seen_titles.add(title_key)
unique.append(r)
return unique
def _sort_results(results):
"""Sort results by date descending, with unparseable dates last."""
def date_key(r):
d = r.get('date', '') or ''
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%Y %b %d', '%Y %b', '%Y'):
try:
return datetime.datetime.strptime(d.strip()[:len(fmt)+2], fmt)
except Exception:
pass
parts = d.strip().split()
if parts:
try:
return datetime.datetime(int(parts[0]), 1, 1)
except Exception:
pass
return datetime.datetime.min
return sorted(results, key=date_key, reverse=True)
def _render_source_badge(source, color, bg):
return (
f'<span style="background:{bg};color:{color};padding:2px 7px;'
f'border-radius:9999px;font-size:10px;font-weight:700;'
f'border:1px solid {color}22;white-space:nowrap;">{source}</span>'
)
def _run_multi_db_search(query, selected_sources, since_date, since_date_dash, window_label):
"""
Shared engine for running a multi-database search given an explicit
'since' date window, and rendering the resulting HTML.
"""
if not query or not query.strip():
return "<div style='color:#dc2626;font-size:13px;padding:12px;'>Please enter a valid search query.</div>"
# If query contains boolean operators, force only PubMed
if _is_boolean_query(query):
selected_sources = ['PubMed'] # override
all_results = []
source_logs = []
source_map = {
'PubMed': lambda: _fetch_pubmed(query, since_date),
'Europe PMC': lambda: _fetch_europe_pmc(query, since_date_dash),
'CrossRef': lambda: _fetch_crossref(query, since_date_dash),
'Semantic Scholar': lambda: _fetch_semantic_scholar(query, since_date_dash),
'bioRxiv / medRxiv': lambda: _fetch_biorxiv(query, since_date_dash),
}
for src_name, fetch_fn in source_map.items():
if src_name not in selected_sources:
continue
try:
res, log = fetch_fn()
all_results.extend(res)
source_logs.append(log)
except Exception as e:
source_logs.append(f"{src_name}: unexpected error – {str(e)}")
if not all_results:
log_html = ' &nbsp;|&nbsp;'.join(source_logs)
return f"""
<div style='padding:28px;background:#ffffff;border:1px dashed #cbd5e1;
border-radius:8px;text-align:center;color:#64748b;
font-family:system-ui,sans-serif;'>
No papers found for <strong style="color:#2563eb;">"{query}"</strong>
{window_label}.<br>
<span style="font-size:11px;margin-top:8px;display:block;color:#94a3b8;">{log_html}</span>
</div>"""
unique_results = _deduplicate(all_results)
sorted_results = _sort_results(unique_results)
source_counts = {}
for r in sorted_results:
src = r.get('source', 'Unknown')
source_counts[src] = source_counts.get(src, 0) + 1
badge_row = ' '.join(
f'<span style="background:#f1f5f9;color:#475569;padding:3px 9px;'
f'border-radius:9999px;font-size:11px;font-weight:600;">'
f'{src}: {cnt}</span>'
for src, cnt in source_counts.items()
)
log_html = ' &nbsp;|&nbsp;'.join(source_logs)
reviewed_dois = load_reviewed_dois()
html = f"""
<div style="font-family:system-ui,sans-serif;">
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;
padding:14px 18px;margin-bottom:18px;">
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#0f172a;">
{len(sorted_results)} unique publications retrieved
<span style="font-size:12px;color:#64748b;font-weight:400;">
({window_label} · deduplicated)
</span>
</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;">{badge_row}</div>
<p style="margin:0;font-size:11px;color:#94a3b8;">{log_html}</p>
</div>
"""
# No reviewed DOIs or mark buttons - removed entirely
for r in sorted_results:
src_badge = _render_source_badge(r['source'], r['source_color'], r['source_bg'])
title = r.get('title', 'Untitled')
url = r.get('url', '')
authors = r.get('authors', 'Unknown Authors')
journal = r.get('journal', '')
date = r.get('date', 'N/A')
doi = _extract_doi(r.get('doi', ''))
reviewed_badge = (
'<span style="background:#dcfce7;color:#15803d;padding:2px 8px;border-radius:9999px;'
'font-weight:700;font-size:11px;"> Read</span>'
if doi and doi in reviewed_dois else ''
)
doi_link = (
f'<a href="https://doi.org/{doi}" target="_blank" rel="noopener noreferrer" '
f'style="color:#64748b;font-size:11px;text-decoration:none;">DOI: {doi}</a>'
if doi else ''
)
title_html = (
f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
f'style="font-size:13.5px;font-weight:600;color:#2563eb;text-decoration:none;'
f'line-height:1.5;display:block;margin-bottom:7px;">{title}</a>'
if url else
f'<span style="font-size:13.5px;font-weight:600;color:#1e293b;'
f'display:block;margin-bottom:7px;">{title}</span>'
)
html += f"""
<div class="art-result-card" style="border:1px solid #e2e8f0;
border-radius:8px;padding:14px 16px;
margin-bottom:10px;background:#ffffff;
box-shadow:0 1px 2px rgba(0,0,0,0.03);">
{title_html}
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:12px;color:#475569;">
{src_badge}
<span><strong>Authors:</strong> {authors}</span>
{'<span>&bull;</span><em style="color:#64748b;">' + journal + '</em>' if journal else ''}
<span style="background:#eff6ff;color:#1e40af;padding:2px 8px;
border-radius:4px;font-weight:600;font-size:11px;"> {date}</span>
{reviewed_badge}
{doi_link}
</div>
</div>
"""
html += "</div>"
return html
def fetch_multi_db_papers(query, selected_sources):
"""
Entry-point for the recent (past 6 months) multi-database literature search.
"""
six_months_ago = datetime.date.today() - datetime.timedelta(days=180)
since_date = six_months_ago.strftime("%Y/%m/%d")
since_date_dash = six_months_ago.strftime("%Y-%m-%d")
return _run_multi_db_search(query, selected_sources, since_date, since_date_dash, "past 6 months")
# ── Paged All-Time Literature Search ─────────────────────────────────────────
LS_FETCH_SIZE = 50
LS_PAGE_SIZE = 20
LS_YEAR_MIN = 1900
LS_YEAR_MAX = datetime.date.today().year
def _fetch_pubmed_paged(query, start_year, end_year, offset):
results, exhausted = [], False
try:
query = normalize_boolean_query(query)
query_enc = urllib.parse.quote(query)
search_url = (
f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
f"?db=pubmed&retmax={LS_FETCH_SIZE}&retstart={offset}"
f"&retmode=json&sort=date&term={query_enc}"
f"&mindate={start_year}/01/01&maxdate={end_year}/12/31"
)
search_data = _safe_request(search_url)
if not search_data:
return results, True
esresult = search_data.get('esearchresult', {})
total = int(esresult.get('count', 0))
ids = esresult.get('idlist', [])
if not ids:
return results, True
exhausted = (offset + len(ids)) >= total
summary_url = (
f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
f"?db=pubmed&retmode=json&id={','.join(ids)}"
)
summary_data = _safe_request(summary_url)
if not summary_data:
return results, exhausted
result_dict = summary_data.get('result', {})
for uid in result_dict.get('uids', []):
a = result_dict.get(uid, {})
raw_auth = a.get('authors', [])
names = [x.get('name', '') for x in raw_auth[:3]]
if len(raw_auth) > 3:
names.append('et al.')
results.append({
'title': a.get('title', 'Untitled'),
'authors': ', '.join(names) if names else 'Unknown Authors',
'journal': a.get('fulljournalname', a.get('source', '')),
'date': a.get('pubdate', 'N/A'),
'url': f"https://pubmed.ncbi.nlm.nih.gov/{uid}/",
'doi': a.get('elocationid', '').replace('doi: ', '').strip(),
'source': 'PubMed',
'source_color': '#dc2626', 'source_bg': '#fef2f2',
})
return results, exhausted
except Exception:
return results, True
def _fetch_europe_pmc_paged(query, start_year, end_year, offset):
results, exhausted = [], False
try:
query = normalize_boolean_query(query)
page_num = offset // LS_FETCH_SIZE
query_enc = urllib.parse.quote(query)
url = (
f"https://www.ebi.ac.uk/europepmc/webservices/rest/search"
f"?query={query_enc}&resultType=core&pageSize={LS_FETCH_SIZE}"
f"&page={page_num}&format=json&sort=P_PDATE_D%20desc"
f"&dateFrom={start_year}-01-01&dateTo={end_year}-12-31"
)
data = _safe_request(url)
if not data:
return results, True
hit_count = int(data.get('hitCount', 0))
articles = data.get('resultList', {}).get('result', [])
if not articles:
return results, True
exhausted = (offset + len(articles)) >= hit_count
for a in articles:
auth_list = a.get('authorList', {}).get('author', [])
names = [au.get('fullName', '') for au in auth_list[:3]]
if len(auth_list) > 3:
names.append('et al.')
doi = a.get('doi', '')
pmid = a.get('pmid', '')
article_url = (
f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid
else (f"https://doi.org/{doi}" if doi
else f"https://europepmc.org/article/{a.get('source','')}/{a.get('id','')}")
)
results.append({
'title': a.get('title', 'Untitled').rstrip('.'),
'authors': ', '.join(names) if names else 'Unknown Authors',
'journal': a.get('journalTitle', ''),
'date': a.get('firstPublicationDate', a.get('pubYear', 'N/A')),
'url': article_url, 'doi': doi,
'source': 'Europe PMC',
'source_color': '#7c3aed', 'source_bg': '#f5f3ff',
})
return results, exhausted
except Exception:
return results, True
def _fetch_crossref_paged(query, start_year, end_year, offset):
results, exhausted = [], False
try:
query_enc = urllib.parse.quote(query)
url = (
f"https://api.crossref.org/works"
f"?query={query_enc}"
f"&filter=from-pub-date:{start_year}-01-01,until-pub-date:{end_year}-12-31"
f"&rows={LS_FETCH_SIZE}&offset={offset}&sort=published&order=desc"
f"&select=DOI,title,author,container-title,published,type"
f"&mailto=biohub@example.com"
)
data = _safe_request(url)
if not data:
return results, True
msg = data.get('message', {})
total = msg.get('total-results', 0)
items = msg.get('items', [])
if not items:
return results, True
exhausted = (offset + len(items)) >= total
for item in items:
if item.get('type', '') not in ('journal-article', 'proceedings-article', 'posted-content'):
continue
titles = item.get('title', ['Untitled'])
title = titles[0] if titles else 'Untitled'
authors_raw = item.get('author', [])
names = []
for au in authors_raw[:3]:
fn = au.get('given', ''); ln = au.get('family', '')
names.append(f"{fn} {ln}".strip() if fn or ln else au.get('name', ''))
if len(authors_raw) > 3:
names.append('et al.')
journals = item.get('container-title', [])
journal = journals[0] if journals else ''
doi = item.get('DOI', '')
parts = (item.get('published', {}).get('date-parts', [[]])[0] or [])
pub_date = '-'.join(str(p) for p in parts) if parts else 'N/A'
results.append({
'title': title,
'authors': ', '.join(n for n in names if n) or 'Unknown Authors',
'journal': journal, 'date': pub_date,
'url': f"https://doi.org/{doi}" if doi else '',
'doi': doi, 'source': 'CrossRef',
'source_color': '#059669', 'source_bg': '#ecfdf5',
})
return results, exhausted
except Exception:
return results, True
def _fetch_semantic_scholar_paged(query, start_year, end_year, offset):
results, exhausted = [], False
try:
query_enc = urllib.parse.quote(query)
url = (
f"https://api.semanticscholar.org/graph/v1/paper/search"
f"?query={query_enc}"
f"&publicationDateOrYear={start_year}:{end_year}"
f"&fields=title,authors,venue,year,publicationDate,externalIds,openAccessPdf"
f"&limit={min(LS_FETCH_SIZE, 100)}&offset={offset}"
)
data = _safe_request(url, headers={'x-api-key': ''})
if not data:
return results, True
total = data.get('total', 0)
papers = data.get('data', [])
if not papers:
return results, True
exhausted = (offset + len(papers)) >= total
for p in papers:
authors_raw = p.get('authors', [])
names = [a.get('name', '') for a in authors_raw[:3]]
if len(authors_raw) > 3:
names.append('et al.')
ext_ids = p.get('externalIds', {})
doi = ext_ids.get('DOI', '')
paper_id = p.get('paperId', '')
pdf_info = p.get('openAccessPdf') or {}
article_url = (
pdf_info.get('url') or
(f"https://doi.org/{doi}" if doi else '') or
(f"https://www.semanticscholar.org/paper/{paper_id}" if paper_id else '')
)
pub_date = p.get('publicationDate') or str(p.get('year', 'N/A'))
results.append({
'title': p.get('title', 'Untitled'),
'authors': ', '.join(n for n in names if n) or 'Unknown Authors',
'journal': p.get('venue', ''), 'date': pub_date,
'url': article_url, 'doi': doi,
'source': 'Semantic Scholar',
'source_color': '#d97706', 'source_bg': '#fffbeb',
})
return results, exhausted
except Exception:
return results, True
def _fetch_biorxiv_paged(query, start_year, end_year, cursor):
results, exhausted = [], False
try:
start_dt = f"{start_year}-01-01"
end_dt = f"{end_year}-12-31"
# Use normalized query for keyword extraction
norm_query = normalize_boolean_query(query)
keywords = [w.strip('"').lower() for w in norm_query.split() if len(w) > 2 and w not in ('AND', 'OR', 'NOT')]
for server in ['biorxiv', 'medrxiv']:
url = f"https://api.biorxiv.org/details/{server}/{start_dt}/{end_dt}/{cursor}/json"
data = _safe_request(url)
if not data:
continue
collection = data.get('collection', [])
if not collection:
exhausted = True
continue
if len(collection) < 100:
exhausted = True
for item in collection:
title = item.get('title', 'Untitled')
abstract = item.get('abstract', '').lower()
match = True
for kw in keywords:
if kw not in title.lower() and kw not in abstract:
match = False
break
if not match:
continue
doi = item.get('doi', '')
authors_str = item.get('authors', 'Unknown Authors')
author_list = [a.strip() for a in authors_str.split(';')]
names = author_list[:3]
if len(author_list) > 3:
names.append('et al.')
results.append({
'title': title,
'authors': '; '.join(names),
'journal': f"{server.capitalize()} (Preprint)",
'date': item.get('date', 'N/A'),
'url': f"https://doi.org/{doi}" if doi else f"https://www.{server}.org/",
'doi': doi,
'source': server.capitalize(),
'source_color': '#0891b2', 'source_bg': '#ecfeff',
})
return results, exhausted
except Exception:
return results, True
def _ls_fetch_batch(state):
"""Fetch one batch from all non-exhausted sources and merge into pool."""
topic = state['topic']
start_year = state['start_year']
end_year = state['end_year']
sources = state['sources']
exhausted = set(state['exhausted'])
seen_dois = set(state['seen_dois'])
seen_titles = set(state['seen_titles'])
fetcher_map = {
'PubMed': lambda off: _fetch_pubmed_paged(topic, start_year, end_year, off),
'Europe PMC': lambda off: _fetch_europe_pmc_paged(topic, start_year, end_year, off),
'CrossRef': lambda off: _fetch_crossref_paged(topic, start_year, end_year, off),
'Semantic Scholar': lambda off: _fetch_semantic_scholar_paged(topic, start_year, end_year, off),
'bioRxiv / medRxiv': lambda off: _fetch_biorxiv_paged(topic, start_year, end_year, off),
}
new_results = []
for src_name, fetcher in fetcher_map.items():
if src_name not in sources or src_name in exhausted:
continue
offset = state['offsets'].get(src_name, 0)
try:
res, is_exhausted = fetcher(offset)
if is_exhausted:
exhausted.add(src_name)
state['offsets'][src_name] = offset + LS_FETCH_SIZE
new_results.extend(res)
except Exception:
exhausted.add(src_name)
for r in new_results:
doi = r.get('doi', '').strip().lower()
title_key = r.get('title', '').strip().lower()[:80]
if doi and doi in seen_dois:
continue
if title_key and title_key in seen_titles:
continue
if doi:
seen_dois.add(doi)
if title_key:
seen_titles.add(title_key)
state['pool'].append(r)
state['pool'] = _sort_results(state['pool'])
state['exhausted'] = list(exhausted)
state['seen_dois'] = list(seen_dois)
state['seen_titles'] = list(seen_titles)
def _ls_render_page(state):
# No reviewed DOIs or mark buttons - removed entirely
page = state['page']
pool = state['pool']
start = (page - 1) * LS_PAGE_SIZE
end = start + LS_PAGE_SIZE
items = pool[start:end]
if not items:
return ("<div style='padding:40px;text-align:center;color:#64748b;"
"font-size:13px;border:1px dashed #e2e8f0;border-radius:8px;"
"background:#ffffff;'>No results on this page.</div>")
reviewed_dois = load_reviewed_dois()
html = ""
for r in items:
src_badge = _render_source_badge(r['source'], r['source_color'], r['source_bg'])
title = r.get('title', 'Untitled')
url = r.get('url', '')
authors = r.get('authors', 'Unknown Authors')
journal = r.get('journal', '')
date = r.get('date', 'N/A')
doi = _extract_doi(r.get('doi', ''))
reviewed_badge = (
'<span style="background:#dcfce7;color:#15803d;padding:2px 8px;border-radius:9999px;'
'font-weight:700;font-size:11px;"> Read</span>'
if doi and doi in reviewed_dois else ''
)
doi_link = (
f'<a href="https://doi.org/{doi}" target="_blank" rel="noopener noreferrer" '
f'style="color:#64748b;font-size:11px;text-decoration:none;">DOI: {doi}</a>'
if doi else ''
)
title_html = (
f'<a href="{url}" target="_blank" rel="noopener noreferrer" '
f'style="font-size:13.5px;font-weight:600;color:#2563eb;text-decoration:none;'
f'line-height:1.5;display:block;margin-bottom:7px;">{title}</a>'
if url else
f'<span style="font-size:13.5px;font-weight:600;color:#1e293b;'
f'display:block;margin-bottom:7px;">{title}</span>'
)
html += f"""<div class="art-result-card" style="border:1px solid #e2e8f0;
border-radius:8px;padding:14px 16px;
margin-bottom:10px;background:#ffffff;
box-shadow:0 1px 2px rgba(0,0,0,0.03);">
{title_html}
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:12px;color:#475569;">
{src_badge}
<span><strong>Authors:</strong> {authors}</span>
{'<span>&bull;</span><em style="color:#64748b;">' + journal + '</em>' if journal else ''}
<span style="background:#eff6ff;color:#1e40af;padding:2px 8px;border-radius:4px;
font-weight:600;font-size:11px;"> {date}</span>
{reviewed_badge}
{doi_link}
</div>
</div>"""
return html
def _ls_render_nav(state):
page = state['page']
pool = state['pool']
exhausted = state['exhausted']
sources = state['sources']
total_pool = len(pool)
max_known_page = max(1, (total_pool + LS_PAGE_SIZE - 1) // LS_PAGE_SIZE)
all_exhausted = all(s in exhausted for s in sources) if sources else True
has_more = not all_exhausted
pages_to_show = list(range(1, min(max_known_page, 10) + 1))
show_ellipsis = (max_known_page > 10) or has_more
btns = ""
for p in pages_to_show:
if p == page:
btns += (f'<span style="display:inline-flex;align-items:center;justify-content:center;'
f'min-width:32px;height:32px;padding:0 8px;border-radius:6px;'
f'background:#2563eb;color:#fff;font-size:12px;font-weight:700;">{p}</span>')
else:
btns += (f'<span style="display:inline-flex;align-items:center;justify-content:center;'
f'min-width:32px;height:32px;padding:0 8px;border-radius:6px;'
f'background:#f1f5f9;color:#475569;font-size:12px;">{p}</span>')
if show_ellipsis:
btns += ('<span style="display:inline-flex;align-items:center;justify-content:center;'
'width:32px;height:32px;color:#94a3b8;font-size:16px;">…</span>')
src_counts = {}
for r in pool:
s = r.get('source', '')
src_counts[s] = src_counts.get(s, 0) + 1
badges = ' '.join(
f'<span style="background:#f1f5f9;color:#475569;padding:2px 8px;border-radius:9999px;'
f'font-size:11px;font-weight:600;">{s}: {c}</span>'
for s, c in src_counts.items()
)
status_line = (
f'<div style="font-size:11px;color:#94a3b8;text-align:center;margin-bottom:6px;">'
f'Page {page} · {total_pool} results loaded'
+ (' · fetching more…' if has_more else ' · all sources loaded')
+ f'</div>'
)
return f"""<div style="font-family:system-ui,sans-serif;padding:4px 0 8px;">
{status_line}
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:4px;margin-bottom:8px;">
{btns}
</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:5px;">{badges}</div>
</div>"""
def _ls_empty_state():
return {
'topic': '', 'sources': [], 'start_year': LS_YEAR_MIN, 'end_year': LS_YEAR_MAX,
'page': 1, 'pool': [], 'offsets': {}, 'exhausted': [],
'seen_dois': [], 'seen_titles': [],
}
def ls_init_search(query, sources, start_year, end_year):
start_year = int(start_year) if start_year else LS_YEAR_MIN
end_year = int(end_year) if end_year else LS_YEAR_MAX
if not query or not query.strip():
err = ("<div style='color:#dc2626;font-size:13px;padding:12px;"
"border:1px solid #fecaca;border-radius:8px;'>"
" Please enter a valid search query.</div>")
return err, "", _ls_empty_state()
# If boolean query, restrict to PubMed
if _is_boolean_query(query):
sources = ['PubMed']
state = {
'topic': query.strip(), 'sources': list(sources),
'start_year': start_year, 'end_year': end_year,
'page': 1, 'pool': [], 'offsets': {}, 'exhausted': [],
'seen_dois': [], 'seen_titles': [],
}
_ls_fetch_batch(state)
if len(state['pool']) < LS_PAGE_SIZE:
_ls_fetch_batch(state)
if not state['pool']:
empty_html = (
"<div style='padding:28px;background:#ffffff;border:1px dashed #cbd5e1;"
"border-radius:8px;text-align:center;color:#64748b;"
"font-family:system-ui,sans-serif;'>"
" No papers found for <strong style='color:#2563eb;'>&ldquo;"
+ query +
"&rdquo;</strong> in the selected year range across chosen databases.</div>"
)
return empty_html, "", state
return _ls_render_page(state), _ls_render_nav(state), state
def ls_prev_page(state):
if not state or state['page'] <= 1:
return _ls_render_page(state), _ls_render_nav(state), state
state = {**state, 'page': state['page'] - 1}
return _ls_render_page(state), _ls_render_nav(state), state
def ls_next_page(state):
if not state:
return "", "", state
state = dict(state)
next_page = state['page'] + 1
needed = next_page * LS_PAGE_SIZE
max_batches = 6
batches_done = 0
all_exhausted = lambda: all(s in state['exhausted'] for s in state['sources'])
while len(state['pool']) < needed and not all_exhausted() and batches_done < max_batches:
_ls_fetch_batch(state)
batches_done += 1
if len(state['pool']) >= (next_page - 1) * LS_PAGE_SIZE + 1:
state['page'] = next_page
return _ls_render_page(state), _ls_render_nav(state), state
# ── Database functions ────────────────────────────────────────────────────────
def search_supabase(species, tissue, tool, accession=""):
if not supabase:
return pd.DataFrame({"Error": ["Supabase keys are missing. Connection failed."]})
try:
table_name = "sra_database" if tool == "SRA" else "geo_database"
response = supabase.table(table_name).select("*").execute()
raw_data = response.data
if not raw_data:
return pd.DataFrame({"Status": [f"No records found in {table_name}."]})
df = pd.DataFrame(raw_data)
if 'created_at' in df.columns:
df = df.drop(columns=['created_at'])
if accession and accession.strip():
if "Accession" in df.columns:
df = df[df["Accession"].astype(str).str.lower().str.strip() == accession.lower().strip()]
if df.empty:
return pd.DataFrame({"Status": [f"No matching record for Accession '{accession}'."]})
else:
if "Organism" in df.columns:
df = df[df["Organism"].apply(normalize_organism) == normalize_organism(species)]
if "tissue" in df.columns:
df = df[df["tissue"].astype(str).str.lower().str.strip() == tissue.lower().strip()]
if df.empty:
return pd.DataFrame({"Status": [f"No records for '{species}' in {tool}."]})
display_cols = [c for c in ALL_UNIQUE_COLUMNS if c in df.columns]
return df[display_cols].fillna("N/A")
except Exception as e:
return pd.DataFrame({"Database Error": [f"Failed to fetch data: {str(e)}"]})
def load_entire_table():
if not supabase:
return pd.DataFrame({"Error": ["Supabase connection is inactive."]})
try:
sra_res = supabase.table("sra_database").select("*").execute()
df_sra = pd.DataFrame(sra_res.data) if sra_res.data else pd.DataFrame()
if 'created_at' in df_sra.columns:
df_sra = df_sra.drop(columns=['created_at'])
geo_res = supabase.table("geo_database").select("*").execute()
df_geo = pd.DataFrame(geo_res.data) if geo_res.data else pd.DataFrame()
if 'created_at' in df_geo.columns:
df_geo = df_geo.drop(columns=['created_at'])
if df_sra.empty and df_geo.empty:
return pd.DataFrame(columns=ALL_UNIQUE_COLUMNS)
combined_df = pd.concat([df_sra, df_geo], ignore_index=True)
ordered_cols = [c for c in ALL_UNIQUE_COLUMNS if c in combined_df.columns]
return combined_df[ordered_cols].fillna("N/A")
except Exception as e:
return pd.DataFrame({"Error": [str(e)]})
def generate_tools_launchpad_html(dataset_type="GEO"):
tools = SRA_TOOLS_DATA if dataset_type == "SRA" else GEO_TOOLS_DATA
icon = "" if dataset_type == "SRA" else ""
label = "SRA Download & QC" if dataset_type == "SRA" else "GEO Analytical"
accent = "#0891b2" if dataset_type == "SRA" else "#2563eb"
html = f"""<div style="margin-top:16px;font-family:system-ui,sans-serif;">
<p style="color:#1e293b;font-weight:600;margin-bottom:16px;font-size:15px;">{icon} Biological Data Located. Launch a <strong>{label}</strong> Suite below:</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">"""
for tool in tools:
html += f"""<div style="border:1px solid #e2e8f0;border-radius:8px;padding:16px;background:#ffffff;display:flex;flex-direction:column;justify-content:space-between;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:8px;">
<strong style="color:{accent};font-size:14px;font-weight:700;">{tool['Name']}</strong>
<span style="font-size:11px;background:#f1f5f9;color:#475569;padding:3px 8px;border-radius:9999px;font-weight:500;border:1px solid #e2e8f0;white-space:nowrap;">{tool['Type']}</span>
</div>
<p style="font-size:12.5px;color:#475569;margin:0 0 14px;line-height:1.6;">{tool['Feature']}</p>
</div>
<a href="{tool['URL']}" target="_blank" style="display:block;text-align:center;background:{accent};color:#ffffff;text-decoration:none;padding:8px 12px;font-size:13px;font-weight:600;border-radius:6px;">Go to Tool</a>
</div>"""
html += "</div></div>"
return html
def handle_search_and_box(species, tissue, tool, accession):
df_result = search_supabase(species, tissue, tool, accession)
tools_launchpad = generate_tools_launchpad_html(dataset_type=tool)
return df_result, gr.Column(visible=True), gr.HTML(value=f"<div>{tools_launchpad}</div>")
def check_upload_credentials_generator(username, password, request: gr.Request):
global _admin_active
session_hash = getattr(request, "session_hash", None)
if not AUTH_USER or not AUTH_PASS:
_admin_active = False
yield (gr.Column(visible=True), gr.Column(visible=False), "Configuration Error: Secrets missing.", BLANK_TEMPLATE_DF, gr.Column(visible=True), gr.Column(visible=False), gr.HTML(value=generate_editor_html()), False, gr.Column(visible=False), gr.Column(visible=False), get_reviewed_table_df(), get_pubcrawler_terms_df(), get_pubcrawler_articles_html(request), gr.TabItem(visible=False))
return
if username == AUTH_USER and password == AUTH_PASS:
_admin_active = True
if session_hash:
_admin_sessions[session_hash] = time.time()
yield (gr.Column(visible=False), gr.Column(visible=True), "Authenticated. Loading data...", BLANK_TEMPLATE_DF, gr.Column(visible=False), gr.Column(visible=True), gr.HTML(value=generate_editor_html()), True, gr.Column(visible=True), gr.Column(visible=True), get_reviewed_table_df(), get_pubcrawler_terms_df(), get_pubcrawler_articles_html(request), gr.TabItem(visible=True))
entire_table_df = load_entire_table()
yield (gr.Column(visible=False), gr.Column(visible=True), "Admin Dashboard Ready.", entire_table_df, gr.Column(visible=False), gr.Column(visible=True), gr.HTML(value=generate_editor_html()), True, gr.Column(visible=True), gr.Column(visible=True), get_reviewed_table_df(), get_pubcrawler_terms_df(), get_pubcrawler_articles_html(request), gr.TabItem(visible=True))
else:
_admin_active = False
yield (gr.Column(visible=True), gr.Column(visible=False), "Invalid credentials. Access denied.", BLANK_TEMPLATE_DF, gr.Column(visible=True), gr.Column(visible=False), gr.HTML(value=generate_view_html()), False, gr.Column(visible=False), gr.Column(visible=False), get_reviewed_table_df(), get_pubcrawler_terms_df(), get_pubcrawler_articles_html(request), gr.TabItem(visible=False))
def upload_data_to_supabase(file_obj, species, tissue, dataset_type):
if not supabase:
return "Configuration Error: Supabase connection is inactive."
if file_obj is None:
return "Please upload a valid CSV or Excel file first."
try:
if file_obj.name.endswith('.csv'):
uploaded_df = pd.read_csv(file_obj.name)
elif file_obj.name.endswith(('.xls', '.xlsx')):
uploaded_df = pd.read_excel(file_obj.name)
else:
return "Unsupported format. Please upload a .csv or .xlsx file."
uploaded_df.columns = [c.strip() for c in uploaded_df.columns]
table_name = "sra_database" if dataset_type == "SRA" else "geo_database"
for target_col in EXACT_MATCH_COLUMNS:
if target_col not in uploaded_df.columns:
uploaded_df[target_col] = None
db_ready_df = uploaded_df[EXACT_MATCH_COLUMNS].copy().dropna(subset=["Accession"])
canonical_organism = "Bos taurus" if normalize_organism(species) == "bos taurus" else species.strip()
db_ready_df["Organism"] = canonical_organism
db_ready_df["tissue"] = tissue.strip()
db_ready_df["dataset"] = dataset_type.strip()
db_ready_df = sanitize_dataframe(db_ready_df)
new_records = db_ready_df.to_dict(orient="records")
if not new_records:
return "No rows with valid Accession numbers found."
db_response = supabase.table(table_name).select("Accession,Organism,tissue").execute()
existing = db_response.data if db_response.data else []
exact_keys = set()
accession_only = {}
for r in existing:
acc = normalize_val(r.get("Accession"))
org = normalize_organism(r.get("Organism", ""))
tis = normalize_val(r.get("tissue", ""))
exact_keys.add((acc, org, tis))
if acc not in accession_only:
accession_only[acc] = []
accession_only[acc].append((r.get("Organism", ""), r.get("tissue", "")))
incoming_org_norm = normalize_organism(canonical_organism)
incoming_tis_norm = normalize_val(tissue)
true_dupes = []
cross_category_warnings = []
final_records = []
for r in new_records:
acc = normalize_val(r.get("Accession"))
key = (acc, incoming_org_norm, incoming_tis_norm)
if key in exact_keys:
true_dupes.append(acc)
else:
if acc in accession_only:
for (ex_org, ex_tis) in accession_only[acc]:
cross_category_warnings.append(
f" • {acc} already exists as Organism='{ex_org}', Tissue='{ex_tis}'"
)
final_records.append(r)
msg_parts = []
if cross_category_warnings:
warn_lines = "\n".join(cross_category_warnings)
msg_parts.append(
f" Warning — are you sure you inputted the right parameters?\n"
f"The following accessions already exist in a different category:\n{warn_lines}"
)
if true_dupes:
msg_parts.append(f"Skipped {len(true_dupes)} exact duplicate(s) (same accession + organism + tissue).")
if not final_records:
msg_parts.append("No new records to insert after duplicate check.")
return "\n".join(msg_parts)
supabase.table(table_name).insert(final_records).execute()
msg_parts.append(f" Inserted {len(final_records)} record(s) into {table_name}.")
return "\n".join(msg_parts)
except Exception as e:
return f"Upload failed: {str(e)}"
def handle_row_selection(evt: gr.SelectData, current_df):
try:
row_idx = evt.index[0]
if "Accession" in current_df.columns:
accession_id = str(current_df.iloc[row_idx].get("Accession", "")).strip()
if accession_id and accession_id != "N/A":
return accession_id, f"Selected: {accession_id}"
return "", "Selected row has no valid Accession ID."
except Exception as e:
return "", f"Selection error: {str(e)}"
def delete_record_from_supabase(accession_id, current_df):
if not supabase or not accession_id.strip() or accession_id == "N/A":
return "No valid record selected.", current_df, accession_id
try:
response = supabase.table("sra_database").delete().eq("Accession", accession_id.strip()).execute()
if not response.data:
response = supabase.table("geo_database").delete().eq("Accession", accession_id.strip()).execute()
if response.data:
return f"Deleted: {accession_id}", load_entire_table(), ""
return f"Record {accession_id} not found.", current_df, accession_id
except Exception as e:
return f"Deletion error: {str(e)}", current_df, accession_id
def _df_to_html_table(df):
if df is None or df.empty:
return "<p style=\"color:#64748b;font-size:13px;\">No records found.</p>"
cols = list(df.columns)
header = "".join(f'<th style="background:#f8fafc;color:#475569;font-weight:600;font-size:12px;padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:left;white-space:nowrap;">{c}</th>' for c in cols)
rows = ""
for i, row in df.iterrows():
bg = "#ffffff" if i % 2 == 0 else "#f8fafc"
cells = "".join(f'<td style="padding:9px 12px;font-size:12px;color:#334155;border-bottom:1px solid #f1f5f9;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{str(v)}</td>' for v in row)
rows += f'<tr style="background:{bg};">{cells}</tr>'
return f'''<div style="overflow-x:auto;border:1px solid #e2e8f0;border-radius:8px;">
<table style="width:100%;border-collapse:collapse;font-family:system-ui,sans-serif;">
<thead><tr>{header}</tr></thead>
<tbody>{rows}</tbody>
</table></div>'''
def update_homepage_string(html_content):
log_msg = save_homepage_content(html_content)
return log_msg, generate_view_html()
# ── Homepage Default Content ──────────────────────────────────────────────────
HOMEPAGE_DEFAULT = """
<div style="font-family:'Inter',system-ui,sans-serif;color:#0f172a;max-width:900px;margin:0 auto;padding:20px 0 30px;">
<!-- Welcome Section -->
<div style="text-align:center;margin-bottom:40px;background:linear-gradient(135deg,#eff6ff,#dbeafe);border-radius:16px;padding:32px 24px 28px;border:1px solid #bfdbfe;">
<div style="display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:#ffffff;box-shadow:0 4px 14px rgba(37,99,235,0.15);margin-bottom:16px;">
<span style="font-size:32px;"></span>
</div>
<h1 style="font-size:28px;font-weight:900;color:#1e3a8a;margin:0 0 8px;letter-spacing:-0.02em;">
Welcome to ReproOmics Hub
</h1>
<p style="font-size:18px;font-weight:600;color:#2563eb;margin:0 0 12px;">
Integrated Bioinformatics Platform for Animal Reproduction Research
</p>
<p style="font-size:14px;color:#475569;max-width:700px;margin:0 auto;line-height:1.7;">
ReproOmics Hub is an integrated bioinformatics platform developed to support research in
<strong>Animal Reproductive Technologies (ART)</strong> with a primary focus on cattle and buffalo.
The portal provides seamless access to public omics datasets, literature resources,
AI-assisted analytical tools, and bioinformatics workflows to accelerate research in
reproductive biology, embryo development, fertility, and livestock improvement.
</p>
</div>
<!-- About Section -->
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:28px 24px;box-shadow:0 2px 8px rgba(0,0,0,0.04);margin-bottom:32px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<span style="font-size:20px;"></span>
<h2 style="font-size:18px;font-weight:800;color:#0f172a;margin:0;">About ReproOmics Hub</h2>
</div>
<p style="font-size:13.5px;color:#475569;line-height:1.8;margin:0;">
The portal integrates biological databases, literature resources, and computational tools
into a single platform for researchers working in:
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px;margin-top:12px;">
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Animal Reproduction Technologies (ART)</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Embryo Development</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Oocyte Biology</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Sperm Biology</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Fertility Research</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Reproductive Genomics</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Transcriptomics</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Epigenomics</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Single-cell RNA sequencing</span>
<span style="background:#f8fafc;padding:6px 12px;border-radius:6px;font-size:12px;color:#1e293b;border:1px solid #e2e8f0;"> Functional Genomics</span>
</div>
<p style="font-size:13px;color:#475569;line-height:1.7;margin-top:14px;padding:12px 16px;background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0;">
<span style="font-weight:600;"> Researchers can</span> search public repositories such as
<strong>GEO</strong> and <strong>SRA</strong>, explore reproductive omics datasets,
perform downstream analyses, and access AI-assisted resources through a unified interface.
</p>
</div>
<!-- Quick Links -->
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;">
<a href="/?__tab=1" style="text-decoration:none;background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;transition:all 0.15s;box-shadow:0 1px 3px rgba(0,0,0,0.04);display:flex;align-items:center;gap:10px;">
<span style="font-size:22px;"></span>
<span style="font-size:13px;font-weight:600;color:#0f172a;">Query Database Hub</span>
</a>
<a href="/?__tab=2" style="text-decoration:none;background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;transition:all 0.15s;box-shadow:0 1px 3px rgba(0,0,0,0.04);display:flex;align-items:center;gap:10px;">
<span style="font-size:22px;"></span>
<span style="font-size:13px;font-weight:600;color:#0f172a;">Literature Finder</span>
</a>
<a href="/?__tab=3" style="text-decoration:none;background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;transition:all 0.15s;box-shadow:0 1px 3px rgba(0,0,0,0.04);display:flex;align-items:center;gap:10px;">
<span style="font-size:22px;"></span>
<span style="font-size:13px;font-weight:600;color:#0f172a;">Literature Search (All Time)</span>
</a>
<a href="/?__tab=4" style="text-decoration:none;background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;transition:all 0.15s;box-shadow:0 1px 3px rgba(0,0,0,0.04);display:flex;align-items:center;gap:10px;">
<span style="font-size:22px;"></span>
<span style="font-size:13px;font-weight:600;color:#0f172a;">AI / ML Tools</span>
</a>
</div>
</div>
"""
# ── Reviewed-Article helpers (kept for admin DOI box) ──────────────────────────────────
_admin_active = False
_reviewed_cache: set = set()
_supabase_reviewed_ok: bool | None = None
def _check_reviewed_table():
global _supabase_reviewed_ok
if _supabase_reviewed_ok is not None:
return _supabase_reviewed_ok
if not supabase:
_supabase_reviewed_ok = False
return False
try:
supabase.table("reviewed_articles").select("doi").limit(1).execute()
_supabase_reviewed_ok = True
except Exception:
_supabase_reviewed_ok = False
return _supabase_reviewed_ok
def load_reviewed_dois():
if _check_reviewed_table():
try:
res = supabase.table("reviewed_articles").select("doi").execute()
return {r['doi'].strip().lower() for r in (res.data or []) if r.get('doi')}
except Exception:
pass
return set(_reviewed_cache)
def save_reviewed_article(article_id):
article_id = (article_id or "").strip().lower()
if not article_id:
return " No identifier provided"
if _check_reviewed_table():
try:
supabase.table("reviewed_articles").upsert({"doi": article_id}).execute()
_reviewed_cache.add(article_id)
return f" Marked as reviewed"
except Exception:
pass
_reviewed_cache.add(article_id)
return " Marked as reviewed (session only — create reviewed_articles table in Supabase for persistence)"
def remove_reviewed_article(article_id):
article_id = (article_id or "").strip().lower()
if not article_id:
return " No identifier provided"
if _check_reviewed_table():
try:
supabase.table("reviewed_articles").delete().eq("doi", article_id).execute()
_reviewed_cache.discard(article_id)
return "Removed from reviewed"
except Exception:
pass
_reviewed_cache.discard(article_id)
return "Removed from reviewed (session only)"
def mark_article_api(doi, action, session_hash=None):
if not session_hash or session_hash not in _admin_sessions:
return " Admin login required. Please log in first."
article_id = (doi or "").strip().lower()
if not article_id:
return " No identifier provided"
if action == "mark":
return save_reviewed_article(article_id)
elif action == "unmark":
return remove_reviewed_article(article_id)
else:
return " Unknown action"
def list_reviewed_html():
rows = []
if _check_reviewed_table():
try:
res = supabase.table("reviewed_articles").select("doi,reviewed_at").order("reviewed_at", desc=True).execute()
rows = res.data or []
except Exception:
pass
db_ids = {r.get('doi', '').lower() for r in rows}
cache_only = [c for c in _reviewed_cache if c not in db_ids]
if not rows and not cache_only:
note = "" if _check_reviewed_table() else (
"<p style='font-size:10px;color:#f59e0b;margin:6px 0 0;'>"
" Create a <code>reviewed_articles (doi TEXT PRIMARY KEY, reviewed_at TIMESTAMPTZ DEFAULT NOW())</code>"
" table in Supabase for persistent storage. Currently using session memory.</p>"
)
return "<p style='color:#94a3b8;font-size:12px;'>No articles marked yet.</p>" + note
html = ""
for r in rows:
article_id = r.get('doi', '')
at = (r.get('reviewed_at') or '')[:10]
display = article_id[:80] + ('...' if len(article_id) > 80 else '')
link = f"https://doi.org/{article_id}" if not article_id.startswith('http') else article_id
html += (
f"<div style='display:flex;align-items:center;gap:8px;padding:5px 8px;"
f"margin-bottom:5px;background:#f0fdf4;border:1px solid #86efac;"
f"border-radius:6px;font-size:11px;'>"
f"<span style='color:#16a34a;font-weight:700;flex-shrink:0;'></span>"
f"<a href='{link}' target='_blank' rel='noopener noreferrer' "
f"style='color:#2563eb;text-decoration:none;flex:1;word-break:break-all;'>{display}</a>"
+ (f"<span style='color:#94a3b8;white-space:nowrap;'>{at}</span>" if at else "") +
f"</div>"
)
for c in cache_only:
display = c[:80] + ('...' if len(c) > 80 else '')
html += (
f"<div style='display:flex;align-items:center;gap:8px;padding:5px 8px;"
f"margin-bottom:5px;background:#fefce8;border:1px solid #fde68a;"
f"border-radius:6px;font-size:11px;'>"
f"<span style='color:#d97706;font-weight:700;flex-shrink:0;'>*</span>"
f"<span style='flex:1;word-break:break-all;color:#78350f;'>{display}</span>"
f"<span style='color:#94a3b8;white-space:nowrap;font-size:10px;'>session</span>"
f"</div>"
)
return html
def manual_mark_doi(doi, request: gr.Request):
"""Manually mark a DOI as read for the current authenticated Gradio session."""
if not doi or not doi.strip():
return " Please enter a valid DOI."
session_hash = getattr(request, "session_hash", None)
if not session_hash:
return " Could not determine your session. Please reload the page and log in again."
return mark_article_api(doi.strip(), "mark", session_hash)
def manual_mark_doi_and_refresh(doi, request: gr.Request):
msg = manual_mark_doi(doi, request)
return msg, get_reviewed_table_df()
# ── Theme ─────────────────────────────────────────────────────────────────────
light_theme = gr.themes.Base(
primary_hue=gr.themes.colors.blue,
neutral_hue=gr.themes.colors.slate,
).set(
body_background_fill="#f3f6fa",
body_background_fill_dark="#f3f6fa",
body_text_color="#132238",
body_text_color_dark="#132238",
block_background_fill="#ffffff",
block_background_fill_dark="#ffffff",
block_border_color="#dbe3ed",
block_border_color_dark="#dbe3ed",
block_border_width="1px",
block_label_text_color="#475569",
block_label_text_color_dark="#475569",
block_label_text_weight="600",
block_label_text_size="13px",
block_title_text_color="#132238",
block_title_text_color_dark="#132238",
block_title_text_weight="700",
block_shadow="0 1px 2px rgba(15,23,42,.04)",
input_background_fill="#ffffff",
input_background_fill_dark="#ffffff",
input_border_color="#cfd9e5",
input_border_color_dark="#cfd9e5",
input_border_width="1px",
input_placeholder_color="#94a3b8",
input_placeholder_color_dark="#94a3b8",
input_shadow="none",
input_shadow_focus="0 0 0 3px rgba(37,99,235,.10)",
checkbox_background_color="#ffffff",
checkbox_background_color_dark="#ffffff",
checkbox_border_color="#cfd9e5",
checkbox_border_color_hover="#94a3b8",
checkbox_border_color_selected="#2563eb",
checkbox_background_color_selected="#2563eb",
button_primary_background_fill="#2563eb",
button_primary_background_fill_dark="#2563eb",
button_primary_background_fill_hover="#1d4ed8",
button_primary_text_color="#ffffff",
button_primary_text_color_dark="#ffffff",
button_primary_border_color="transparent",
button_secondary_background_fill="#ffffff",
button_secondary_background_fill_dark="#ffffff",
button_secondary_background_fill_hover="#f8fafc",
button_secondary_text_color="#334155",
button_secondary_text_color_dark="#334155",
button_secondary_border_color="#cfd9e5",
button_secondary_border_color_dark="#cfd9e5",
button_secondary_border_color_hover="#b8c6d8",
button_large_padding="10px 18px",
button_small_padding="6px 12px",
button_large_text_size="13px",
button_small_text_size="12px",
table_even_background_fill="#ffffff",
table_even_background_fill_dark="#ffffff",
table_odd_background_fill="#f8fafc",
table_odd_background_fill_dark="#f8fafc",
table_border_color="#dbe3ed",
table_border_color_dark="#dbe3ed",
border_color_primary="#dbe3ed",
color_accent="#2563eb",
color_accent_soft="#eff6ff",
)
css = r"""
:root {
--bg: #eef2f7;
--bg-deep: #e9eef5;
--surface: rgba(255,255,255,.96);
--surface-solid: #ffffff;
--surface-soft: #f7f9fc;
--surface-tint: #f3f7ff;
--line: #dde4ee;
--line-strong: #cbd6e4;
--text: #122033;
--text-2: #334155;
--muted: #66758a;
--muted-2: #94a3b8;
--accent: #2563eb;
--accent-2: #4f46e5;
--accent-soft: #eef4ff;
--success: #16803a;
--success-soft: #edf9f1;
--danger: #b42318;
--danger-soft: #fff1f1;
--warning-soft: #fff8e8;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--shadow-xs: 0 1px 2px rgba(15,23,42,.04);
--shadow-sm: 0 2px 8px rgba(15,23,42,.05), 0 1px 2px rgba(15,23,42,.04);
--shadow-md: 0 12px 30px rgba(15,23,42,.07), 0 2px 8px rgba(15,23,42,.04);
--shadow-lg: 0 22px 50px rgba(15,23,42,.10), 0 4px 14px rgba(15,23,42,.05);
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0 !important; background: var(--bg) !important; }
body {
color: var(--text) !important;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
background:
radial-gradient(circle at 10% 0%, rgba(79,70,229,.05), transparent 32%),
radial-gradient(circle at 92% 5%, rgba(37,99,235,.05), transparent 28%),
var(--bg) !important;
}
.gradio-container {
max-width: 1540px !important;
margin: 0 auto !important;
padding: 20px 30px 48px !important;
background: transparent !important;
}
.gradio-container h1,
.gradio-container h2,
.gradio-container h3,
.gradio-container h4,
.gradio-container strong,
.gradio-container b {
color: var(--text) !important;
}
.gradio-container p,
.gradio-container label,
.gradio-container .block label span {
color: var(--muted) !important;
}
/* App header */
.app-header {
position: relative;
overflow: hidden;
margin: 0 0 18px;
padding: 20px 22px;
border: 1px solid rgba(203,214,228,.9);
border-radius: 20px;
background:
linear-gradient(135deg, rgba(255,255,255,.99) 0%, rgba(246,249,255,.98) 62%, rgba(243,247,255,.98) 100%);
box-shadow: var(--shadow-md);
}
.app-header::after {
content: "";
position: absolute;
left: 22px; right: 22px; bottom: 0;
height: 2px;
background: linear-gradient(90deg, transparent, #7aa2ff, #8b7cf6, transparent);
opacity: .6;
}
.app-header-row { display:flex; align-items:center; justify-content:space-between; gap:22px; }
.app-brand { display:flex; align-items:center; gap:14px; min-width:0; }
.app-mark {
width: 48px; height: 48px; flex: 0 0 48px;
display:flex; align-items:center; justify-content:center;
border-radius:14px;
color:#fff; font-size:16px; font-weight:800; letter-spacing:.02em;
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
border: 1px solid rgba(37,99,235,.25);
box-shadow: 0 8px 18px rgba(37,99,235,.20);
}
.app-title { margin:0; font-size:21px; font-weight:850; letter-spacing:-.035em; }
.app-subtitle { margin:4px 0 0; font-size:12px; color:var(--muted); }
.app-meta { display:flex; align-items:center; gap:7px; flex-wrap:wrap; justify-content:flex-end; }
.app-meta-chip {
border:1px solid #d9e1ec;
background:rgba(255,255,255,.82);
color:#475569;
padding:6px 10px;
border-radius:999px;
font-size:10.5px;
font-weight:700;
letter-spacing:.01em;
box-shadow: var(--shadow-xs);
}
/* Navigation */
.tabs { gap: 10px !important; }
.tab-nav {
position: sticky;
top: 10px;
z-index: 20;
display:flex !important;
gap:6px !important;
width:fit-content;
max-width:100%;
margin:0 auto 22px !important;
padding:6px !important;
border:1px solid rgba(203,214,228,.92) !important;
border-radius:14px !important;
background:rgba(255,255,255,.90) !important;
box-shadow:0 10px 28px rgba(15,23,42,.08) !important;
backdrop-filter:blur(14px);
}
.tab-nav button {
border:1px solid transparent !important;
background:transparent !important;
color:#64748b !important;
border-radius:9px !important;
padding:9px 13px !important;
font-size:11.5px !important;
font-weight:750 !important;
transition:background .18s ease, color .18s ease, box-shadow .18s ease, transform .18s ease !important;
}
.tab-nav button:hover {
background:#f4f7fb !important;
color:#1e40af !important;
transform:translateY(-1px);
}
.tab-nav button.selected {
color:#173b9e !important;
background:linear-gradient(180deg,#eef4ff,#e7efff) !important;
border-color:#c9d8fb !important;
box-shadow:0 3px 10px rgba(37,99,235,.10) !important;
}
/* Base component surfaces */
.gradio-container .block,
.gradio-container .form,
.gradio-container .panel,
.gradio-container [data-testid="block-wrap"] {
border-color: transparent !important;
box-shadow: none !important;
background: transparent !important;
}
.gradio-container .block:hover,
.gradio-container .form:hover,
.gradio-container .panel:hover,
.gradio-container [data-testid="block-wrap"]:hover {
box-shadow:none !important;
background:transparent !important;
}
.clean-card,
.admin-section {
background:var(--surface-solid);
border:1px solid var(--line);
border-radius:var(--radius-md);
box-shadow:var(--shadow-sm);
}
.clean-card { padding:18px; }
.clean-section-title { margin:0 0 6px; font-size:15px; font-weight:800; letter-spacing:-.015em; }
.clean-section-note { margin:0; font-size:12px; color:var(--muted); line-height:1.65; }
.clean-kicker {
margin-bottom:7px;
color:#64748b;
font-size:9.5px;
font-weight:850;
text-transform:uppercase;
letter-spacing:.13em;
}
.home-hero {
position:relative;
overflow:hidden;
padding:28px 28px;
border:1px solid #d7e2f0;
border-radius:20px;
background:
radial-gradient(circle at 100% 0%, rgba(79,70,229,.07), transparent 30%),
linear-gradient(140deg,#ffffff,#f7faff);
box-shadow:var(--shadow-md);
}
.home-hero::before {
content:"";
position:absolute;
width:180px; height:180px;
right:-70px; top:-75px;
border-radius:50%;
background:rgba(37,99,235,.05);
}
/* Form controls */
.gradio-container input,
.gradio-container textarea,
.gradio-container select,
.gradio-container .wrap input,
.gradio-container .wrap textarea {
background:#fff !important;
border:1px solid var(--line-strong) !important;
border-radius:10px !important;
box-shadow:none !important;
color:var(--text) !important;
font-size:13px !important;
transition:border-color .16s ease, box-shadow .16s ease, background .16s ease !important;
}
.gradio-container input:hover,
.gradio-container textarea:hover,
.gradio-container select:hover {
border-color:#bac7d7 !important;
}
.gradio-container input:focus,
.gradio-container textarea:focus,
.gradio-container select:focus {
border-color:#78a0ee !important;
box-shadow:0 0 0 3px rgba(37,99,235,.09) !important;
outline:none !important;
}
/* The component wrapper stays visually neutral; only the actual control responds. */
.gradio-container .block:has(input):hover,
.gradio-container .block:has(textarea):hover,
.gradio-container .block:has(select):hover {
background:transparent !important;
box-shadow:none !important;
border-color:transparent !important;
}
/* Checkbox / radio groups */
.gradio-container .checkbox-group .wrap,
.gradio-container .radio-group .wrap { gap:8px !important; }
.gradio-container .checkbox-group label,
.gradio-container .radio-group label {
min-height:36px;
border:1px solid var(--line-strong) !important;
background:#fff !important;
border-radius:10px !important;
padding:8px 11px !important;
color:#475569 !important;
transition:border-color .15s ease, background .15s ease, transform .15s ease !important;
}
.gradio-container .checkbox-group label:hover,
.gradio-container .radio-group label:hover {
background:#f8fbff !important;
border-color:#b8c6d9 !important;
transform:translateY(-1px);
}
.gradio-container .checkbox-group label.selected,
.gradio-container .radio-group label.selected {
background:linear-gradient(180deg,#f0f5ff,#eaf1ff) !important;
border-color:#94b1eb !important;
color:#17408f !important;
}
/* Buttons */
.gradio-container button {
min-height:40px !important;
border-radius:10px !important;
box-shadow:none !important;
font-size:12px !important;
font-weight:760 !important;
letter-spacing:.005em !important;
transition:transform .16s ease, box-shadow .16s ease, background .16s ease, border-color .16s ease !important;
}
.gradio-container button.primary {
color:#fff !important;
background:linear-gradient(135deg,#2563eb,#315bdc) !important;
border:1px solid #2563eb !important;
box-shadow:0 7px 16px rgba(37,99,235,.18) !important;
}
.gradio-container button.primary:hover {
background:linear-gradient(135deg,#1d4ed8,#4338ca) !important;
transform:translateY(-1px);
box-shadow:0 10px 20px rgba(37,99,235,.22) !important;
}
.gradio-container button.secondary {
color:#334155 !important;
background:#fff !important;
border:1px solid var(--line-strong) !important;
}
.gradio-container button.secondary:hover {
background:#f8fafc !important;
border-color:#b8c6d7 !important;
transform:translateY(-1px);
box-shadow:0 5px 12px rgba(15,23,42,.06) !important;
}
.gradio-container button.stop {
color:#b42318 !important;
background:#fff !important;
border:1px solid #f0c9c6 !important;
}
.gradio-container button.stop:hover {
background:#fff7f6 !important;
border-color:#e7aaa5 !important;
}
/* Dataframes */
.gradio-container .table-wrap,
.gradio-container .dataframe {
overflow:hidden !important;
border:1px solid var(--line) !important;
border-radius:12px !important;
background:#fff !important;
box-shadow:var(--shadow-xs) !important;
}
.gradio-container .dataframe table { font-size:12px !important; }
.gradio-container .dataframe th {
padding:10px 12px !important;
background:#f6f8fb !important;
color:#475569 !important;
font-weight:750 !important;
border-bottom:1px solid var(--line) !important;
}
.gradio-container .dataframe td {
padding:9px 12px !important;
color:#334155 !important;
border-color:#edf1f5 !important;
}
.gradio-container .dataframe tr:hover td { background:#f8fbff !important; }
/* Result area */
.results-shell { display:flex; flex-direction:column; gap:12px; }
.results-summary {
padding:14px 16px;
border:1px solid #d9e3ef;
border-radius:12px;
background:linear-gradient(135deg,#ffffff,#f8fbff);
box-shadow:var(--shadow-xs);
}
.results-summary-title { font-size:13px; font-weight:800; color:var(--text); }
.results-summary-note { font-size:11px; color:var(--muted); margin-top:3px; }
.source-pills { display:flex; gap:6px; flex-wrap:wrap; }
.source-pill {
padding:4px 8px;
border-radius:999px;
background:#f4f7fb;
border:1px solid #dfe6ee;
font-size:10px;
color:#475569;
font-weight:700;
}
/* AI tools */
.ai-tools-section-title {
margin:28px 0 12px;
padding:0 0 9px;
border-bottom:1px solid var(--line);
color:#334155 !important;
font-size:11px !important;
font-weight:850 !important;
text-transform:uppercase;
letter-spacing:.12em;
}
.ai-tools-grid {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(280px,1fr));
gap:15px;
}
.ai-tool-card {
display:flex;
flex-direction:column;
gap:9px;
padding:17px 18px;
background:#fff !important;
border:1px solid var(--line) !important;
border-radius:14px !important;
text-decoration:none !important;
color:inherit !important;
box-shadow:var(--shadow-sm) !important;
transition:transform .18s ease, border-color .18s ease, box-shadow .18s ease !important;
}
.ai-tool-card:hover {
transform:translateY(-3px) !important;
border-color:#b9c9e0 !important;
box-shadow:var(--shadow-lg) !important;
}
.ai-tool-card .card-name { color:#1d4ed8 !important; font-size:13px; font-weight:780; line-height:1.4; }
.ai-tool-card .card-desc { color:#64748b !important; font-size:11.5px; line-height:1.62; flex:1; }
.ai-tool-card .card-footer { display:flex; flex-wrap:wrap; gap:6px; margin-top:3px; }
.card-badge { font-size:9.5px; font-weight:700; padding:3px 7px; border-radius:999px; }
.badge-type { background:#eef4ff !important; color:#1d4ed8 !important; border:1px solid #c5d7ff !important; }
.badge-spp { background:#edf9f1 !important; color:#166534 !important; border:1px solid #c9ebd2 !important; }
.badge-link { background:#f7f9fc !important; color:#64748b !important; border:1px solid #e2e7ef !important; margin-left:auto; }
/* PubCrawler */
.crawler-banner {
position:relative;
overflow:hidden;
border:1px solid #d2def1;
background:linear-gradient(135deg,#ffffff,#f3f7ff 72%,#f2efff 100%);
border-radius:18px;
padding:22px 22px;
margin-bottom:16px;
box-shadow:var(--shadow-md);
}
.crawler-banner::after {
content:"";
position:absolute;
width:160px; height:160px;
right:-55px; top:-70px;
border-radius:50%;
background:rgba(79,70,229,.05);
}
.crawler-banner h2 { margin:0 0 6px; font-size:20px; letter-spacing:-.025em; }
.crawler-banner p { margin:0; font-size:12px; max-width:820px; line-height:1.65; }
.crawler-stat { color:#1d4ed8; font-weight:800; }
.crawler-grid { align-items:stretch !important; }
/* Admin */
.admin-login {
max-width:570px;
margin:34px auto;
}
.admin-login > div {
background:linear-gradient(145deg,#ffffff,#f7faff) !important;
border:1px solid #d7e2ef !important;
border-radius:18px !important;
box-shadow:var(--shadow-lg) !important;
padding:20px !important;
}
.admin-dashboard-title { margin:0 0 4px; font-size:18px; font-weight:800; }
.admin-section { padding:17px; }
.admin-section + .admin-section { margin-top:13px; }
/* Homepage editor */
#homepage-canvas-editor {
background:#fbfcfe !important;
border:1px solid #d6dee9 !important;
border-radius:12px !important;
}
/* Reduce excessive default Gradio whitespace */
.gradio-container .gap { gap:12px !important; }
.gradio-container .form { gap:10px !important; }
/* Mobile */
@media (max-width: 1100px) {
.gradio-container { padding:14px 16px 34px !important; }
.app-header-row { align-items:flex-start; flex-direction:column; }
.app-meta { justify-content:flex-start; }
.tab-nav {
width:100%;
overflow-x:auto;
justify-content:flex-start;
margin-left:0 !important;
margin-right:0 !important;
}
.tab-nav button { flex:0 0 auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition:none !important; animation:none !important; }
}
"""
# ── PubCrawler term management (admin-only) ──────────────────────────────────
def _pubcrawler_admin_ok(request: gr.Request):
session_hash = getattr(request, 'session_hash', None)
return bool(session_hash and session_hash in _admin_sessions)
def get_pubcrawler_terms_df():
"""Load the terms stored in the Supabase `pubcrawler` table."""
columns = ['ID', 'Term']
if not supabase:
return pd.DataFrame(columns=columns)
try:
res = (
supabase.table('pubcrawler')
.select('id,term')
.order('id', desc=True)
.execute()
)
rows = []
for row in (res.data or []):
rows.append({
'ID': row.get('id'),
'Term': row.get('term', ''),
})
return pd.DataFrame(rows, columns=columns)
except Exception:
return pd.DataFrame(columns=columns)
def save_pubcrawler_term(term, request: gr.Request):
"""Insert a crawler term for an authenticated admin."""
if not _pubcrawler_admin_ok(request):
return ' Admin login required. Please log in first.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
term = ' '.join(str(term or '').split())
if not term:
return ' Enter a term before saving.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
if not supabase:
return ' Supabase connection is unavailable.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
try:
# Prevent accidental duplicate terms without requiring a unique DB constraint.
existing = (
supabase.table('pubcrawler')
.select('id,term')
.eq('term', term)
.limit(1)
.execute()
)
if existing.data:
return 'Note: That term is already in PubCrawler.', get_pubcrawler_terms_df(), term, get_pubcrawler_articles_html(request)
supabase.table('pubcrawler').insert({'term': term}).execute()
return f' Saved PubCrawler term: **{term}**', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
except Exception as e:
return f' Could not save term: {e}', get_pubcrawler_terms_df(), term, get_pubcrawler_articles_html(request)
def pubcrawler_row_selection(evt: gr.SelectData, current_df):
try:
row_idx = evt.index[0]
row = current_df.iloc[row_idx]
row_id = str(row.get('ID', '')).strip()
term = str(row.get('Term', '')).strip()
return row_id, f'Selected term: {term}' if term else 'No term selected.'
except Exception as e:
return '', f'Selection error: {e}'
def get_pubcrawler_articles_html(request: gr.Request):
"""Search every saved PubCrawler term across the literature sources for the past 30 days."""
if not _pubcrawler_admin_ok(request):
return "<div style=\"padding:18px;border:1px solid #fecaca;background:#fef2f2;border-radius:8px;color:#b91c1c;font-size:13px;\"> Admin login required. Please log in first.</div>"
if not supabase:
return "<div style=\"padding:18px;border:1px solid #fecaca;background:#fef2f2;border-radius:8px;color:#b91c1c;font-size:13px;\"> Supabase connection is unavailable.</div>"
terms_df = get_pubcrawler_terms_df()
terms = []
if not terms_df.empty:
terms = [str(x).strip() for x in terms_df['Term'].tolist() if str(x).strip()]
if not terms:
return "<div style=\"padding:28px;border:1px dashed #cbd5e1;background:#ffffff;border-radius:10px;text-align:center;color:#64748b;font-family:system-ui,sans-serif;\"> No PubCrawler terms have been saved yet.</div>"
since = datetime.date.today() - datetime.timedelta(days=30)
since_date = since.strftime('%Y/%m/%d')
since_dash = since.strftime('%Y-%m-%d')
all_results = []
term_logs = []
for term in terms:
source_map = {
'PubMed': lambda t=term: _fetch_pubmed(t, since_date, max_results=200),
'Europe PMC': lambda t=term: _fetch_europe_pmc(t, since_dash, max_results=200),
'CrossRef': lambda t=term: _fetch_crossref(t, since_dash, max_results=100),
'Semantic Scholar': lambda t=term: _fetch_semantic_scholar(t, since_dash, max_results=100),
'bioRxiv / medRxiv': lambda t=term: _fetch_biorxiv(t, since_dash, max_results=100),
}
for source_name, fetch_fn in source_map.items():
try:
results, log = fetch_fn()
for result in results:
result = dict(result)
result['_crawler_term'] = term
all_results.append(result)
if results:
term_logs.append(f"{term}: {source_name} {len(results)}")
except Exception as e:
term_logs.append(f"{term}: {source_name} error")
if not all_results:
return f"""
<div style=\"padding:28px;border:1px dashed #cbd5e1;background:#ffffff;border-radius:10px;text-align:center;color:#64748b;font-family:system-ui,sans-serif;\">
No publications matching the saved PubCrawler terms were found in the past 30 days.
</div>"""
# Deduplicate while retaining all saved terms that matched each article.
merged = {}
for r in all_results:
doi = _extract_doi(r.get('doi', ''))
title_key = str(r.get('title', '')).strip().lower()
key = ('doi', doi.lower()) if doi else ('title', title_key[:140])
if key not in merged:
merged[key] = dict(r)
merged[key]['_crawler_terms'] = [r.get('_crawler_term', '')]
else:
hit = r.get('_crawler_term', '')
if hit and hit not in merged[key]['_crawler_terms']:
merged[key]['_crawler_terms'].append(hit)
if not merged[key].get('doi') and doi:
merged[key]['doi'] = doi
results = _sort_results(list(merged.values()))
reviewed_dois = load_reviewed_dois()
source_counts = {}
for r in results:
src = r.get('source', 'Unknown')
source_counts[src] = source_counts.get(src, 0) + 1
count_badges = ''.join(
f"<span style=\"background:#f1f5f9;color:#475569;padding:3px 9px;border-radius:9999px;font-size:11px;font-weight:600;\">{html_lib.escape(src)}: {cnt}</span>"
for src, cnt in source_counts.items()
)
html = f"""
<div style=\"font-family:system-ui,sans-serif;\">
<div style=\"background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px 18px;margin-bottom:16px;\">
<div style=\"display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;\">
<div style=\"font-size:14px;font-weight:700;color:#0f172a;\"> {len(results)} unique publications · past 30 days</div>
<div style=\"display:flex;gap:6px;flex-wrap:wrap;\">{count_badges}</div>
</div>
<div style=\"font-size:11px;color:#94a3b8;margin-top:8px;\">Searching {len(terms)} saved PubCrawler term(s); duplicates merged.</div>
</div>
"""
for r in results:
title = html_lib.escape(str(r.get('title', 'Untitled')))
url = html_lib.escape(str(r.get('url', '') or ''))
authors = html_lib.escape(str(r.get('authors', 'Unknown Authors')))
journal = html_lib.escape(str(r.get('journal', '')))
date = html_lib.escape(str(r.get('date', 'N/A')))
doi = _extract_doi(r.get('doi', ''))
doi_esc = html_lib.escape(doi)
src = html_lib.escape(str(r.get('source', 'Unknown')))
src_color = r.get('source_color', '#2563eb')
src_bg = r.get('source_bg', '#eff6ff')
terms_hit = r.get('_crawler_terms', []) or []
term_html = ''.join(
f"<span style=\"background:#eef2ff;color:#4338ca;padding:2px 7px;border-radius:9999px;font-size:10px;font-weight:600;\">{html_lib.escape(t)}</span>"
for t in terms_hit
)
reviewed_badge = (
'<span style=\"background:#dcfce7;color:#15803d;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;\"> Read</span>'
if doi and doi in reviewed_dois else ''
)
title_html = (
f'<a href=\"{url}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"font-size:14px;font-weight:700;color:#2563eb;text-decoration:none;line-height:1.5;display:block;margin-bottom:8px;\">{title}</a>'
if url else f'<div style=\"font-size:14px;font-weight:700;color:#0f172a;line-height:1.5;margin-bottom:8px;\">{title}</div>'
)
html += f"""
<div style=\"border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;margin-bottom:10px;background:#ffffff;\">
{title_html}
<div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-bottom:7px;\">
<span style=\"background:{src_bg};color:{src_color};padding:2px 7px;border-radius:9999px;font-size:10px;font-weight:700;\">{src}</span>
{reviewed_badge}
{term_html}
<span style=\"font-size:11px;color:#64748b;\"> {date}</span>
</div>
<div style=\"font-size:12px;color:#475569;line-height:1.6;\">
<strong>Authors:</strong> {authors}
{f' <span style=\"color:#94a3b8;\">•</span> <em>{journal}</em>' if journal else ''}
</div>
{f'<div style=\"font-size:11px;color:#64748b;margin-top:5px;\">DOI: <a href=\"https://doi.org/{doi_esc}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#2563eb;text-decoration:none;\">{doi_esc}</a></div>' if doi else ''}
</div>
"""
html += "</div>"
return html
def refresh_pubcrawler_articles(request: gr.Request):
"""Refresh the PubCrawler article feed for the authenticated admin."""
return get_pubcrawler_articles_html(request)
def delete_pubcrawler_term(selected_id, request: gr.Request):
"""Delete a selected PubCrawler term for an authenticated admin."""
if not _pubcrawler_admin_ok(request):
return ' Admin login required. Please log in first.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
if not selected_id:
return ' Select a term first.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
if not supabase:
return ' Supabase connection is unavailable.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
try:
supabase.table('pubcrawler').delete().eq('id', int(selected_id)).execute()
return 'PubCrawler term removed.', get_pubcrawler_terms_df(), '', get_pubcrawler_articles_html(request)
except Exception as e:
return f' Could not remove term: {e}', get_pubcrawler_terms_df(), str(selected_id), get_pubcrawler_articles_html(request)
# ── Layout ────────────────────────────────────────────────────────────────────
ALL_SOURCES = ['PubMed', 'Europe PMC', 'CrossRef', 'Semantic Scholar', 'bioRxiv / medRxiv']
with gr.Blocks() as demo:
# ── Institute header ──────────────────────────────────────────────────────
gr.HTML("""
<div style="background:#ffffff;border-bottom:2px solid #1e3a8a;margin-bottom:4px;padding:16px 32px 14px;font-family:'Segoe UI',system-ui,sans-serif;">
<div style="display:flex;align-items:center;justify-content:center;gap:40px;">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAADICAIAAABqJeQMAAAQAElEQVR4AdS9B4AVRbY/fE5Vdfe9kxgYco6SMyogKApiwowRc1hX1w2GVXcNa845RxSzmCMqCioiSbLknPPkmbu7u8L36xlEVHzrfv/dt/b/aM3Wrq06dOrmqq3k+Ua2j2OhsdWi1qzRRlY2z2WwcRqitc8a6yLqs1SHaxgA3zlRnM1Wxi7ImE8VVOs64OIpjU4sZOxvHYc2kMIqrwzADstkwds5Z52ITGaerM2FWxxprVFcYnTU2iynoN7HVkbE1xf3Xlyg22hrjbKasroyq0c5kqiy0Bf6/Zz6CkmxoTawNdFBtXFSeqciaMJuNokhDIaEzWWuhHa11ZVVpNlsNCnFUbaMMGsZp0MdoHGrgx7HBlGwYRzrMZEJrdbXWkXVVcSg8FliXmTHHknGs/SCIdIzaWMNMksgZYkfOOWA6wV4qiMka4RwTMzuWxoJ+gkkogp1zcRxLpciTkdGeUuAS8yULY0yQ8jwphRAqSLEUsTaowzAWihM0UPh/AYQkwQJiCk+B36wOfd93ltEGQAOoBTNBX8xRDOmUdsYLVKIpIqVEolEoFjhAJfJUYKWDXYWS7CU0yTqMAA89hAcpsnFkPEtSqJRfVR0JgsYiTyqBlnAW9tDkJk766rMvJ06dM8PL8a2zIEEOXJASJNlJFujB88Spkz6c8MnMBfMIs4m0iTwlYACYEggJGnNVdeaj8R9/8MmHTlHsrFIJWzChgvRAAjCDp6UrV7w//uMV69YKX1pHyvcw8v8EwITgU0peu37N+Anjp82alo1iZrZWE8XWWhhSKD/WBMNEzoz7YvzYd9+qzFQrzPEUO2LiRH/WSSJmlp7K6Gjc+E/f/uDDlWs3ZDMR1AUizDBC8mstGcWTZ00Z9/Vn4z4bl8r1peQAEcGcBKJUyTNLeuOt1487+YQRgw5YvXldZGNZq3FLUL3VCFMmIYUKvp425YQzRh161OHEClHrqYAZ3gMsUgjaZFXWzt56950jRx7/xvtvGQEDJaxIKeEu8F8IgIYK1LzFC08YOfKRp59wLLNxCB7o/53iyCGjfDDuw2NHHjd1+lTPYyJBgmNt0Q85IqOdcCzEvIULTjj15NNPH5XOSUcxNpbYWAYCwkIxa6DB+mzGfznxuGOPPnHUSeUVpX4q8DzPGAM0CwMqGcbZNVs3HHXiCUePPGbsW2ONs9nqagwJsIFIBapzCfaZ55zl56bzGhdO+naaE9IhwaKfk6GELUswA4geOGRogyZNbODPWTDX832QI0IWTdAcUeI2zHmFBX0G9PfrFy5etcyxDU1oTUwEPAqUNDY25OCh+w7oL3OCqrBak4YvYS79v1NiGyKBLVuzKrcor0uPriSFtRruiwZDEdZ5CBJKHHrjti05BQVDRhxKxNYYqFEKYJBg4Wws0MlUbfXnU76uv1eb4084vlWbltg5iUghh8E2TlvYIsWz58/mlPIK0rAUrBN4SL5k4SpKCaBKxYJdp06dWrdt1bB50zFjXw45MuRYWGJYyMGKcAg0Qbpnz56FdesGfmrm3LkRczYKYTsgIMiIrOAkXRDxYYcdykpMnT1ja0UxCyGVcvAcp8G+McYlmCLISdXr1n3RsqU7yosxvZY+/b9QjDUsVETRxs0bvMAvbFAHwgFkYkPPwXMtkUCSMob1tFnTIpcNHX4IJPM92EFAfrR1HEvpARfhua10x7Q53zoluvbqkZeT6/s+qDnELDOC0gnGWeSNd98raVC3foNGMIFkIRAPxggkSESSoxiqB9G6fv6pp566buvmOYvmzZ431yahIVElOOArsaaLopCNPnPUKN8Tr779eqUL/TSyP1gmP/EMOFfSBnqLZs3CMPPtnNnl1RWCHSzHQhB7zIkvkgOHBlIOHLT/wsXf7SgtISkS44KP/xdAaw0ta3Kr1q/WwrInhRBK+Y5lFGGDJCAoyoYQFSnn6WdHQ4d9evbyyIcWEakYZSLP87SOgArfX7dhw/KVy4q3bjr51FMtwdktlIPp0D/0Addft379nO/mV1dXH3XM0Z7nQ8PIbkp4lGRxiRBhqBjzMG1An31h4Yjs5CmTiS0iWVuHftBlSWh4vgTR4UOGlpeWTpo+dduO7VmdpAiEEUINliHB2CzZmebNWpxz5jlkoxmzZtSygqgFhdhoZumzJ5zL8YMe3boVFxeXlVVI5HAkcFD/fwEkiiAmsXLlyq5duzes38wiOh0hUwYKtoHXuiAIDLlMlKkoL23RrHmLJs20To6U1mooPHECW7sNQcPy/XHv59ctrNOgQVGdQiLyBGKUrCOYH8j4XbB04fbi7Zi1/8D9PWIdRkoJS2yMExqJUkiykq1TxHv36OezX6+w7suvvKhJg6maRMeCkQSAasjoNIvWDesfOGBQUVHd195+IyW96qqKOIqwNrFll5xI2Yk0+53atq+urpwza7oj0Lae9AUEJ4Izwq7YVjySrZu1cJHZunlDDcfSwUXpv6uA1VqGEBCW8ORQ40wB/W7esqGyvKJZvab1cusa6MZpgiM6I7CrMMWRk+R9PP5THFW679W1bZMWDiokYvaYJWIXPi2UF7kYYfPmu+9F1Zk//eGiPBkEJEE/CSHnSAglmMi8N+69Rs0a2Gz2oIEDfZKBlyLBmshKHB8JNBBMjN0xW1GliP9+2RWl20qWrFoyb/lCQ+yBJhGYF0LAEaSUWLtequ6Bg4aUlpfNmj+3ymZz83MUCyElMdZDxTa2IDVkv8GC6dvZM6ujCrBjrY7jUAoFLcBa6XQujNqqRSup6JPx443TUibTsdp/FTD/lCt4ITiULEpKd/hSNaxXPy/IdRpblIB1oCIiC5CKs3G0buOG2NlO7TtAWB+qZ8ZGiD+4A0KICf+T0+d+W1xR5mKzT7d+IOscG7gMQevAcqGNMtmKt997Y+vWzaeMPDGXUxZv2cboGAYxipQIhPMUkUAocm5+Hlxg2N4D6+XlV2/R9L1Jn2cpzpo4oeSc1caTgp2QDGu5IYP3t9ZO/uabeUu+ix2xpwxim5OFITAwhbNdO3ZBdl25bs3CJYtJIvydSDZkC1nhaJiFhRs3bZJfr3DKjGlCYF7yh5//UoB23U7WhIDWaMnCRaEmKiwsFOTJGh9kZ43BvuhIIDChu8zixYuJaPDgwYnWoEZnHejIhJBKspKxZOYuXhDk59bLrXPg3oOzUehYAAfKASliK6T35fRpQkrkxZHHHA86UmJLBUhfCWuQUbUWNfqTMsmwZGivNm379+6bzs35bMLnpZlyT3jWEDIB0BySLglyyczunbvVyS+Momjugu8MC5ygmFkQg2N2JD1Vm2NPOuXU6urMxo0bEWqGyThWMsHR1kkhsVzrFi0bNW4c6kxsNAuD6f9PgNZIZrR9e7HyZdcu3Z0zfs2p0lpbYwAESCJfcUXphAkjAs/vvFdHR4nlCBZiJgBBG1o7jRflsW++Ya095qhjmUgKzzikKoktVjLeJqEX8+G4D5o2bdqvR6/uXXowSeAQplvDxDAcYiM50YIjTUKyCKRvY/e7s87buGr97KkzvvtuvhWOk3QJI5KDfTCbiJ0goj9f/Md0Oj36+TExQSZHnGRKUZMwNTzOJs+nHHMCafPSay8knmApirOJ/M5iLWwvioTPsk3bVlu3b1m+ZrmGVUH3vwssUQIOG95ujEFxeFq/fj203759e2a8Vjv0QKfYm2CE0FhLZv36tRVlZccecUxBfj6SmRKCKQkCa0ySWTGLaeu27TOnz4iqMqefdIokK6WXGJFgaEa44a5n+bpV48Z/Vl1R3a/n3niPUOQRCjMlViRyJJhZCJwOjXTwAIesmPbSXTp3bdqwSZPGjZ956mlYBna0zsFQnPBJxFIoT1Kwd88+mcrqZcuWrtu+nkgwSQJNi3SdkPICH2mjRePmjRs0fu+jD7GO76u0n4acAj5gkQPgjDC169R7ra7KypUrV1tiCxL/9eCci+MYbOIlSvpB8xZNhUvuIEUimMAozGAdPJlnzJrtYtelQ0dPwoI1Q5AY5sX2AueQUIT4YNxHdeoWIljT6Rytk0iBZzATiBncAzm3ZtOGHVWl23eUnH7yaZKkhN0o8XeJJhExgRisB9UJgg2sUwI4riAn/+wzzwmz8ZzvvttStr3cZLMmhO8QzpGUFOMSew7st0/HVi1h+GnfTrcUa06cAKEmJTIBS4SwVW1ate3csZPnBTPmfiuYITwyCurAU9gV4NGY1X/v/lJzeVmFAA8J+f+qP2gG2mciNHYyhlDzPLm1ZMfyVSslthzfz7oszuoYdkmB7ML3fC2i2TNnsra9u/WSJJDRkg1PkFLCI9CUGt8kSE/+ZqpkuW+/fZo2apzEsWMUQ5TNRgZmZPePO25t0Lxln+59mjZo7OD9BO07ScSMKjGpICLMCUMYCcbHezeOrUiXfODAwfj8UVxVNm7ipzBHDfUa8okx0QATcQ6lhh0wNJVKffHVRJxiiIhxjnVYgMklvinZeSR79+xByn4xeSLB+4BDEtSgFEQ5zgLM3KR5M+No85ZNceJh9F9eYCZwaMhUVFauWrO6b9++PhIfPuxgh0jUKiAR1GysgU6WLF8SZqOD9h8qWbAT0DoAwmqLaA6V8pesWjl7wWzo/6CDB9aQdRIbonOwn5BJTl6+YsnG4k1l5eUnjjyhIFWA02otA46Ag2hKnkCa2BHeT2ufhBBgwiPRs2v3gpzcVEHOa++MhanRyVLE1hkH0iwdzjlSEI887vjIVr/+ztiq6lJ8aQOawL5oSQmRiMbYTOL+/QemC3IXLFqID4uYgvmWGCczixaMSSx9L69ewSeff6YtvpdpuBUivQasAwEL9SQcw+oO6oGsRGjgAyVyAxroABYaAGu1S3hEjSfMimt6nLUWnAM0NgYiPGJWDUGjrSHrjIkxEQ14mqspGAXEDlzCaxklYQz+CYD4rHaUlGJf7Nixs8esiCkJL+c4IcBMQtC8hfNXbVjTtVdP0AmrQwPFU3J7TAK4goSEqDMWzCnJlFtr9ts3SUgijoUjWVM0GxV4cxbOyWSrslVlfXvi6kfk5uYjoImwHgOLmQUn9Gj3gl48MhHi7OLzfx/bePLkL7eUbs/GWYPZGGawacCsILS4ZbPmnTrsVZCX99mkSXCF2FipPMkiUauxzByQ36FdG5bqk4mfb96xFclEoLDQzkqBy6oISmzWtOngwQd8O2smVFZjPFTggsBG8kOJnzFLqik1Gk4q5Se3GzV9SYUuLEcCFRMzujgpmCXQxpqCBQCSw4QMBihZxVpSQhr4CZBqwMTJ9QWm4omJUVv8UeI3Nb8JM+iBu1mrsdu1atFcAM1YxFPNLAwCEtzVG9aUlJWeecYZUJmXTmEhZ9kjZCiG13jKI7LPvvBcfn7+0UeMyGFfSemrNMPS2WpLhpXMcvjEc0+lfb9ti1YDuvVNfD8h/NM/QQT4US8zFMZEetCgQXE2zq1Xb8yrL/p+CkiJpojJQVVEzNbo/Jy8kcccofQaYgAAEABJREFUX1mZee+j96JkYfRaHRlOVERoWG0a12047MCDyqsq5i1coNlEYYat87ACEZQLmnleTtO6jWykq8rKLci7ZDITCngTDj2M1Wq2/aRKHuDdLtEnTkMOeFCHSwSxcDVjsKcQyFqHWVAsA0HrZAZaeMaixCSJmRhGBT56hFBxjBdekoGPEUeE0eSZvi/MXNMEP/hFRl25YrkOI/gxGQsKOekczBLEkkWkw5jsN19PVkr26tbdWusLj6Ebh1RqgACLZsPqqrBy0aJFJSUlRx56uDOWWMKnwTlIeZzcsWyrLPt2/tzqyuqTTzhRkNUapwgs/lMQzI65lr2dY8zJIzvVvVP3wQMG5gQ502bP3BFWkEDKZke1NcGLsfUqEv377WPjaMGSxUvXr8xGGWOxxYKCZSlwqPHIlxz067N32s8Z99nHjoQFm85BWmet9BC5floFRXXqRpUVy5cvtxaWgDZ2MlP7A2SYwRHIJh3GGq0jbWLYEeG10z7OQAUQFQpNkASMD/WSdQxgIZzDfZUDMkajONK4X3TQXIwex8TMeOFjAWQEqbHOWuOAKQiLJA2ixDN4Z5OEUAuXLkFX/fr1PZlYCJoBDgAEBUmgjx07tlmTZk0aNvKFnyxFBKdxzuBwF4YZFahp386IhFHTxs36dO8lGUuRkNAoQ5LqMOtT8OkXE3Lycm1ohg8+COktpXCZjhV+Cphp0QcZAGgAoGTGPswucO6ooYdkyyu/mjpl9dZ18D7gQMvAgWTENghSJtT9uvXNDXLWb1qPbUD5PqZiW4YqIS/YhRbTLthvn8GguWz5yixlvACXjMZYeL6BK4CaT2LAPvuy4UWLF0MFgF0awahJzEEMM6DhXBxrQRx4viSsYGFR3Dwkw1JG1oRks2xxd6Kx+SNQJVnGvgeDxAaPRJDWklGCjYlDijM6YljWGkgNbplhW9hVYAnJpPAMDgjGgqFrWjUVHrD29BnfpnPyWjZtobVONmmiTDYDOkTWKiqtqCgrLuvbu1+b1i3hdJinjTOOWXqwup8KYnxymDYV6bRdu3YtGjXwhazBARbD0nlBGu434asvU3n5Qwcf2Kt9NxNBLAbOz0Hs3sU15fseIVgdOXx4XJUpKMh/9vnn4cvGxDXmAavOGPCWPAric846G4782uuvGmehU4txoZ21nvRBUrPpvld3E9l1G9YuRbQxIW0SS+kpSsgJwmGqG64/3Ib16zG1FsAGGqglIQAcuNHOZaKQGIag0GrjtHMGRpK+NGRjinDiqjbZzSVbN2zDHcKK75YswJFq6aplq9fib/mKVUtXrF+FLwPLVq1cu30DNumS6lJSUkuLPAWVCSVDjZ0da2JvIG3jMMwSAhkWo8SQycD3jYwNN2zdXFBYr25hkSSJrGOMkVJaa8G2dhZvgUFOuk2bNp4LgCAEMScvWiCibRSTLq2uePWNV2H+804/GxlLYBUHCgrZAFrR1q3etPabb6ZC7Xv36uexyE3nQLeOQeCnIH7aQcliWA+ag5Xq5db78x8uxuOHH4+LsbdKuLAhSMwWKykcQxXYU/sNGJTJVn7yyfiqKOOncqBNl/gxsySZfIBysMQJxx6He7gNm9dra0gKSriGRUAbbDt8diYiHPmggl2AHkDCNnhnQ5hIcA3OEF65tfZElmG6aN3m9eMnjHvw8Yevv+2Giy75w7EnjTzk6EN779OnT68effDdq1Onjp06dOnWpXunzp06tOvTq3unXt0GH3rQoccfcdIZp176t0tuv/euF8e++s2MKaWZCnAeSUKUIB9UxxnlyyQTMEEbADADQAOdSAgZEzdr0byosAgMSyEFCiX6EcyOxarVa0Fk0ICBmrRQyliBudJLvgRIKQXJTcVbVm9Yp5TXrnW7ZIgFW5ZoESEYSIjFixdnyzLbVq4+/tjjkFqqsxmllOMajB9XCekf9xB8Aj3Qbhq5z/F+++xTtr14S/G2TyZ8ClpCCAsmSQEH/hnhSlvHfXv06rJXVzD/zfRpiDyCiZwjZqIkbhQrSfKE407UOnr/ow8tkyFkMWxUCUvAYEa+FAcfcdjq1auhESKBTmgKw1BEshAEI4elVVoaijdv3zxp2jePPv3EYUcdVlS/QYe2bY44YsRfr7x8zJgxO3bswL3z2Wedcdtttz338svvfvrp2++//9Z7H447uP3P/34rfff+3ttv78fb7+38MEJ2M1nNGlY5cNBy5bVc892hvkXrn0AjzBhGIq2xtp3E7gGjwLCs0d3wsuC5B+jrx/h9kGBI2L/9OXpIWI4dMxogKXhYaOC4qO29V2kLIZlTpq7X2/fvWZtrBd1UzGErR2By1bp6N6GJgJ5lq6ZW1f2Do6sBQqJSSR1WpkRxQNpGzAhhBgy2SCeTM9+6q+V+dL3V6en2x2z6CgQxKx93LUPO+7kv/TCrv3Cb+9wB3x0Dw+3A0NTd3T29DSEIIBzC5kxZJ2h3Y93bf+8fjz9KpxKjV6+g6bU//M5Pr9/KMptD83zRMyuy6ThWbhj8f8/FryOOH4hcHwFz4jGDCwH+m7SkfSIGURwQ6x4w3NMCysWMn/74r/zrP/8p3/lvd7LyiWfZfuAg02fP4b33t/L6G79h397dbNmyheGRYR59/DE8z2PJkiWUUolhAgF2NpICzQfNAZAuyrVlUkggI6S2dQhCMIMbjgNHlg/9M6S0fBmCYBs7jLjRqjRzSsd5KRrSPvE4zlKPRxgfDxIM3J7p2NzzI1oRo/8LSUxhxIgRIx4vWC0R47SGVnOkFgmj9WgbBYzXxL2P/5ytn76HdrrO3PmTWWbfRipu02i0GBocsnz9yCN89uE29u/ZyxVXXMEVV1zBggUL0FqTzWZpaWlBd6n0UQKEQQoDMjSwL2wASiRCUjxg2sDuhx9/YrRS6E/Jc01bBAVQvBZp7rWUBiEgBqJFcskOGGxzx4itOjpExESK+G8ULeU0zCJHjBgx4nhS97GkkTE2Mjt+9HtyiKgoLHgTdoHAzKEDzP/PH/POm7/kD//ifzL/nBNZtHwRaZ4VW1d91LvNFlKOZebUqSxaupQFixYxY8YM+vr6aG1txXEc6SMEeYxSckh7ge6eUUIQaAOgMLcPzs4QIzWDT3sYrxpTrnyByVo+go2cLYZgUYIRYZm6xO4nPqOUtaL6mQw3FDZ9NjoMxCGGMTYxP8Y/b0aMGDFiRKl0gUDuUchhjBawAgspUSUJgxLTH/k2TTNMMW/w9rtvsXH929y89o+ZN384B7aWSWUSxF1D2nXRWqA8BQuYOP0MNrj0ci64+GLOO+885s6dS3t7O67rEo9Fq5TxPgQzQS0BgoUMIAEQikrJp6MLmDUnT58+zW9ef439n+0T58I2ABAzchP0pUDF1YBhBqQZogBldxsFYTgCwWC0RRhZidS2sWSEIG3KbAsRLIbUSeIZJ/+aMWPGOBqOvI6Cx7QZ9kbY6zXmww8+4C8f+TkfuQ8wMLWfZSvPZcHieQivQa2Rx3Utu9UqtMoFWsUyqWxrEJSLbNoG7VpJ66yLmHXm2Vx+2UpWLF9OuZAg5YJTU0VHcWIKStmID5S2WslEXBGNKYQ6lXZJxgUZGBzBhQpB4Gnb20UojHAQ0iGfTWIDQVr0f3mSdJKlT7s6DGUSmnaBmA3CSuGxYcQgIoP1mPj4jq/GGDFixBihECR4R5pcIBICywK7JqM1+QY9n+6gL6epFSM4rY1sbb2PvzSAAAsyE84CBZA0tNar9PVMZPrkXk4++VRmTJ9BW2sLqVSCmMqgI0HEi2SifgQEPEBLRkrZJgBcCtJzGQn3HCtdNXDGC1a2fPtDTPqV7fj9b3/Iy199RcbOUBZpkqk4rR0dRBIF0rU4qZyFVmBTYkH6sR3TbPfeC1rI92NWjxEjRoxAgWCGlFkijDhKxRFtIeLEycW8/52PeO/953Bjo4TDc/j8yRbqCWEpJZWADePuqQgpCMcgaTdk09PP4MzzzmbhwoU01dvAEB6ELR87s4nzGGw2IEIdpQQ2jgAQQZhaKc0Q8cGxyDajPKHZ8NuDh/nOf9zL9p1PMm3mNL71P/6CZZcfT9KT2HJCaWJMQGRHrL+7BIPUBB6d3S2j2ezQ1dqNhSCXq3HW5Rdw7113I12Jo2Ok0zFkX0A8HcOPRDZktRjAjxIVIOWxY8dzBSy4IGTpFCM2KXacASM24mS17BYjRowYMT5XQyBIlCAooW1s65GiiGqoKbP/9h/cZBqPFrZtYtnf/4OjHx4iZjIYJkkezN5OvrGd3Bt/ZNyplifZ0Rz29Y55nHwqCxfOY9rUqTQ1NxGJhqWhRiWamGNJNSAjJG1IEkMRZ5GPNCdjB1bLaAqVkJZvQ6NY1Fzzldv43vfuY9PmlxgfP4G+Pqje8RJFnPzov75xDyedDZEC1iLB8UeEMaM70hFzRkK3toL8uLw3RmtMUkyhx7G/O4hxMUPHcTz3yKfzQY34zmj2/ywOM7Z7gPIP+pjHYnKOGDFi/AhHWg7vdfgjPmGSxPJmzRzHOhtWf43pFdrvKfbufwnje5gIJCIFnsYxrQhdxPUklhC4tkA5AluC1tDdb1j90VuQjGMWIas/eRNHc0tPr3DBTS/S3tGCEgG8jY2ABUgs0BZKCLS2kIFF5YECrdHGAa2Ix+3gbdq4/v7Xv+L3f/FrrFNXseL8Rbz39uscGr6LH/zT9+jL5TCawr8DawrDkkAnFosLODhjPyi3UYa7ErS1dPLRb7YhI5JtL7vqSv7k+7/HC2yMhsWqFdv9se8iJ3+Y8cCzqjXh80mjAwL5YStpjBgxYhwDEBygSAvUFkTRGmsHxBQH2Xbwu3nlvYfZfvgtcVxPSVIltCuIBtwL9ybOZcRFIKIbLQpSlpKkwYhR9r2/g1GRRGctPjz0Li3yTGoNR3PmKtAJhKMVwjGgNYGqUsJ2UxEmak1UIf8EQnUAaIMjwPZceag6/ezqJzn/8gs5a9Ustn+yhdpIMZhm0OeLcQleRmqDA2QAmzaP8M72bdSqjXiMTfzmvjtYMC+N4zVwIqCNoO52kZCFEQsIJa5hGCA5a0VgkdtlPKQQBAiAsTScMsdoHyVEocdG8bFy6f2j8+lYAuP3kNAjgP/RTYVqAhX/5aZcKoy+53jFHKUczA99c6Cb3v2yjFbw+CBvO5Z4OytJSN86zuQn4mKZfeQlP46X8bMIbPzotf47wB8n/Chz8Jc/1ncXxtieR47wnHqJueR6RqaRkC3Sf/k0CJowMFSrWEFo0NGMpa1NkJRpUlogAm3aFjJmO0poVQhLmnxSYILsX79KyMZJaG5N8PMX/w86DWM6AWGYuKSsYhsOBFn6hQYFa+79OktWnMqyFfMxuw9z6FCDThPHR3hAJmLp6S2bSPjs3jVCoixICahOGJdHHn6Q02e3IgSBdE9iTUuITiTAGy/u9dHHrzP13KRtIFGGpSsmU6oNookT2B/jSdIgBdKJo1UaaUUoCXGIRGOgBRgplVnJBEhF5OauN5FnJpzE7335UpatOjE4sxEShJGECUdyY5MjhI8SAQiB1gKsJDHN0RZDiBgcT3jqyc/weyZTSYKrB/BDJmiPLUFQ/6mHZb0DtVETTfI9gaSMOIJJBScM4gEjcCw8Mvgh0mPLhlgEDYLRRrLh+H8f6J9Fy4jPG2mEBAwsOQyBZDZ8kmeVCLbVqInFPyrS0OD5uNP2UNJPEKXCsBqDA1ZgCRcBkUOKx4oQ6Z34mB64uQoeGzj2tyPmmc7y2P9vQBL8sdqTvvUSfwQa9i3GRpqMmFhUosM2QlKJQyUdF3pBYq9i2dQkHD6JcT1NHFeTRs8CQ/zzBh8E/sd3FmX7QdKbIYQQy1iGdpE3S29//WuBVVKQIQH9kAAOBNbY7OAMidmWIXFgVIvC2sFiQBD0d4x4gB9JhK3gGB05J1AEi6s5tD9u3QYhUEqjXShVTAEW4AggpWKqOMcYJKBQP/mna6nvifG/rnwcYgtUAKWrqK7BBwOOpL97H+9/9T5/8LWbWbhsJsdLX2Vw1D4KHEFQh8IgIrT3T6KjtZ3vf/NB/uKP7yEVM+AIPGBAIrCslCSPNDuTqGRBCA3WgHRxJbimyVuv7OQrH7wIUYeaI8j4MZoLSTy3xKfP7uPW+3/CaUu0GKkApbTdpYww4CJAiEA/iL8OUFasPJk775xm00O9OCeBbWUcNri3hJVUyxYHLQxYcCTu1hcP9fCMEW04L5SsjCOxwZ6q5p47/pLTVsMPbx2Cg9ExxkU6lhVbJQHAt8a1Ag9NV0f48C3NZ2+99ta1TngP3RgNlP/Z+UthowDk2XqUURwVhyIqjvws5AP/+JQmQ46mTgoo3UpoHUIkoYk5wYUDqSgol9MgdUPGYUEiBEqJIOsI09pKfF/n1M90ILs8AlmNlJGgPYgAFzCrR79RGDwRThSaqJz3RN5BlpKkMccic9/XeIjSCoMHIhZwuEPgOiCWl4Ntv/yA5tTTlIfC8cO0GqsiQW5RSfn2jbJgYVJQycbY+vo9eE1J+se386v335bmARAkMQjlLK0TmjFGguDxX36HO26cwSVnT8YvNWAtEElLDaKjwzYHg0uJ/Z+9Q/qcXgYdmD/jTKYeP5mIJ+2fFIQ3BCEWG2T3E5KkP4xIjPDNR/eRSWdQCenQkqwt7J0JoQhSLFzLcKqVH3bvn3rhOZ792T/ZsySqYbw5VYyQIG1osW2IqG4hI/Gk1K6YIzARpO+FRyJvSzyhAEscS08igH1ryN88z/y9W7nTlRntdC7wS1c8PYpnGgI9BGWnEI68KpZp9mEYY/HH/LdCiK3YtQrCTNkGtF/9spB//o7BIDxcfuYICgyI4LhMWOeFy2LBhBgU1G+1g9VfIg3xIA7T2j4SA2gQYSNnKZE2SGaB2JzOcJIGlTtYqR4A/yoSWQFBnl8BXaD8f6dwP/nb6gIHeSRZ0ZHs/31C2KH3SLhZEqaVXySEiGYU9ZahK6MRoIUZUFDBQzspPPHAzpyh5FmgA/RJ0JzJfjx3EEsGUUZqTtptp7x1mKFnx0nmDDMzOkxS1pAihnRc0m4cSweOcEAKtBcBJZDCwaoIjrohKZHa1Q5N1fM4UDP865OP4LS3o7I5jJRULJdEo0YgxLKW24OB3fzzr77PbVdvo3VcK45MhpmuFkhGEY0b/PYXv8JrS9DS0sKJZx1H0tHc/dM/5d++82sm9rYihCFUCkIh3Nbfm29O9HAROsK4sY38jwe/TPQ6vjHjae5957kA6SI4DjMtkL9eTWAPPH3b7/neU4cYkadIeRWixhCxvhzDnTIiTgQ0umWi24gXgSS+8VzJvJkO585PovwwxieBtCwS1teMCiAUmgmswff2P/0jOfdcNICJ26FjNU9KQ+W4MRyNjZfH0qkIYg0CNs2onB3hQ9K2BOWxY7bUllAwQZ/2BcLRmJYWLMDT6iMBT1lZRSsyFSOdOoUnn+5GmowcHMRRQ4p0hsb4TqJtLZSk51DBaQSRXyFNldmhP7w0DE04WkIBFeCLzIt/izbMESeNMZSjW35O+BdGm7A8tzVjXKjQRs9Rl4kAXrxjVWodJi+BCGv2Y54XqCFJGxA2EDooP/dBmjrYWwi2RdAf6EDY9sj9WRAziFjA2iPEVlBJz1IcypK2UhgLtTHAjsm7MR+JCEKdJoaU9e7BNo4M74h09ujbEMLpBqRRSBejLb4KoUSUMfIzwzv5i0cf5tSleYhXMN76H9pLUsaQgWn9sHeAGHOkRI/rIe1UWmRMoI3B1IcsMBK9X2CHzCKDcR3hhaB4YjtNky+EGLQQx1Ea3wkAVHp0hMrse5skX/TBGSn2fLSfJOGsK3j/nP1gFCMF3PXNJ/jd7+dR+5u7GF49jXIYzR5x5dKIG4yPm5MxWrcN4PzF3+GrA8xdu4GNn/iocB1CjK7l/96AfvQ9x+PG+9Xf/Du/3SrDIZnCyKBzSQUGamw+0mUR7G1HlDRn5A7uWv0YbsEQa84E+2UZbE9UO4GGDuVToBmsohQWTxucKLhJQaPdoD6YpkkYDC1CgR8CjzJ1IuDBz6Brl+SrIA2rYbQoIMMs8bIqsgkr2qRN4pLGN0Vq2C6VafZNijzNjO/LILxRVQlBK1TSSHRgDzACuQUVLFJs/h5A8HrYHjLQm2X3F9G22SAGQk0hBMJYsC9QKmehQhC2yRZglaWVLLHB1mFkFCrKS/dThLcUqkSODoHtFKFboq2lA2mhgRcQqRKWbCHLk0//BX5M0zY+ibItrYQYrLJG4Mn3MBVsrOZIE9PxExdx3MJTefO3W/jx9/47O3/zN6TtZfgWyp6cTIt2mK+ltZR9n2jQrmTooCcRwkNrjfY06UjAQVppP3eeM3Lmstby1d/LdQ0YH5SDVVEJKBvkUzPggM/HWzO88L0/MHboI96o1igFa2wtlcjzYwncbK2IMppWp5+XqDixNZPRkRRAIGuPRplKNsM4S5Wrr17Mt1b3IOVwUO0FCl0C2aKytCC7Cj6ADd8+6ySVlVcT6mhGhLQ4+s8/Y+NghH3hA1wTbHVHcHKb3YzY/z/75NucsKhiJ9+7C9SxLs5DsPnjTdwy6T7SmLhgniFzkrA5tJphhG05d/RnP2QOZJx3yWuMW9rktCEQFhLTAs07Cjl1BYol1QSCUgRHuCAtRhbBhuXnyFzaAN10DHFN2NN2ZPYbIUCGAliEe00hC75iWeRYmCGUA9qFmA+uFwQ6Hkkmzq5Z01cYgTQ2aU8rYSij0LEjbYkR5zAKgTi+2P6P69ZQWqPjijTe4iCGGaE2SJa9pcBxHB59/CccN66Z3lwLGOmH4J2pCCiBdUCEbRrVY1L39BFI6Bf6yI1iwmaA7xgaLy9lX+QehM2FhEZEc7ixpKM6yCOqZRzZBlDWNgoHC/40fBKqKv9DBE80ylx4eTrmR3BcOwJgHYU1yjJcfJTK0dfTPH5+GkI4nJxV1Bsc5w2EMJY1VAsPjZYgbGntjMlK0lKQDJHBsT/yQ9+PR/GxGpylwAmDIsWgxS/QOh9kcY6YyLEhR64Rw4yOMPQNU7UtiSwDL4tVKiP9PkI9HBLjPLn5DMPxDShVwa3K5poId5GtP8N65Q4xM9p4W8TjIBoVGldiBbOYxZKBkIHk2ZXU7dyfQkE7M6hqEZGhYeSUJqM0DiNcPw/JNEg3GHA+Bp/R4ZRceP8ZMr/4X1FFHaJhkZ3MLKcWp8iKNCcufCkCdyOowq6MkKoWIaJZ2tr6+eDOb/Hqy6/T2dXFI//6z0w3JYaF7ZR20r4YpSksNm6xdU3Khk1ZjNDBypM+0rU4CLTwCS8S2JzgYYWz9NNyA59azPaXhoiYKEJ18RcHv+DZPzgoGUKmJBfM7yacJ3g4MScgbCGkBEkEphKkkNdujF0/cPm//uXnbPj1O9hIhoR0ifhFEDVIQrGStx2T4Kdf/warFoBv/GC+CEfP6i/WnID1e/8CYZI0tzexe8fOYCvxQUDraikHS+JBApRrw4UKQ9UiJGCz0CEwz3cgYvt0jvTwUpcGm/7T99/7S4iIo9KNSJk1DhS0F6xJs8tJHYPRS1Z8l0rLFB57+gfMnteHI8Tg5juadY/0n5HB58HBDTjECCFDaMPP4/07gQxLaGOt7GYOCMKYwLgxXmEfPLR0uBk8zU++9y2m5Q3IBjoCSpXQxqA0CG1IlQwixnCC3hHwfBJgG8AhM/t5Do22+WpYSaRr8AeP0N/nMjxqE7X5wGp0m3DEbcJR2rdbJQ+OyUEC1fY6yVnHR/szWi1++3qf9BxiFJddcA1nXNGA6R3AbQFcAdqB9tb1NFk+0jRoAY6RUjsk+OA+WbAGpR1qNWPDcLQy4IyBRg86klKriPQC1zToLEV3VhfUFrJhRIQKLP/IYUu4IRjS0myByZBEFF1De1sHBwYPMzAwwG233qZR9oKQ2VlMBLmSBiwWvMFfsOWHTzFv3kR+9mSHiL5gjgLwIwpXsuaXb/P6f32H//XLBUzMx7CxtrA9awCaVkEpQjUzyuCgRjXN4Jq/58BgDpeDEHfp61C0tbfxxn/fRH+/wljBsGgIxLxh4L+StOdw7ZVP8Df/9TaZXGCAksJRzzNYN+1Sc4LzB9s7hLAsvfhscoMf8dJf/xFzNTZ0z4ywm0BAUs4pADVpsVUcV/DFkQLBNDDucOqEMhMnGJIGUi0SoyVe4CQD7QHS1MAlwVDEI3jqSvnwROAQFSjPOgLVAHZzMtkZLBzTw1vvfMT0+Z3CMdTatSF2QVRuGOgoFQd3hhk1JZSAf1Is6XjZzBI6MipRFTxJIC2AG2lNp3UZmvQbXk3gsA8yAYSyAL6zCqUTg+8KvyHxVQKjc/j1bXXqQyFiU1FmZbB6a0BaWpcYnbxMfYBo1YpUIIQkRCLrRASRg59Jgw1H6TSOIJY0jPQ28lQQNcGjpx9DWRjIuIh4T+wYwd49H+kmpjIFVJG/0A4dLT01Y4k1JULmGVnU0Hjh4A2oTEYI/GcFEK+gVT7rAAAiVElEQVQoKONjhCZu4N0d+3j5xmBc4hR4N0W8HZ1sGFdKiIRl3S93cP3cZi657AJenmhZEDV0FhXpRAPz0x8YxJEY4+pI4iBBnQYh8EmREga0CQoF+j18V5OJuLR0JEmFEjS1a3B9UJGAVSQHj4LAtCIESjjkHA3TkgDTEeSff2EQazBYlUE47SGqo6CwEq0atJv11gshXKCAG61iRdTgGVvVx3YcowKfzVJ0lAYlXTzTjAHzx/faO6+3zffsa+u96Hr20gXz2WJj+BhwsFhV6+EqsE9EIOVAAOXeGHSE8ZXy4ECd9p5W7FFobLJELnRniGmOjdgPj4C9ny4bq7f+gZ3OEU0iI2X3k2fq99q3QIQCMRhuMcYI5p4Itkl1jYq7zIEOL7z2V3jGJ+WCiICMEkCpzOHUwMBTv6Sy3x5A9sIBT8pNxoeiHJyWpOKEBPR5CjfS8Klb5ZYbbuSk7AL6NJj2s4EVYVIE9doPFrG1NWKKed5bfIGvBD99aD+ffxbBjSiiyR6yuj7FqXwePBgLui6ABa0gHhc0xSJoK4imEzSnIa2TjFQNzztcUT6xBo4T5i7uGpUIw2JE6E4M2I1eLCLt1c9VhBN6fUxbKBgZbSsK+ZEXyxtLWVmH5d7w+/erxNfMue0zjmMkqMfv4Yd/fB2fO4qKcKK46Ix9PHJrgtYWiSd1wLIi3KqA7hUYJ+Sz7B3Fv1++ixnT4XDR1KUprEDbJhEtIDw1uF6LLI70zqoZakfFyT48m8Uvaq68+2cM7NuNNgFEbZRBLOLC4f1iL6/aCmoY7Xui+Qp6rBMYhZAdnNw6yPQ+3AxUyoYkCpcIjm7lwXkJztDgGzWrAI3Sveag8zD/j5JhL+JaJHRHRnjKbKFeUcJznhtAEQphkDbC4/f/hBhQKTmoaGVzV5V0K1VnVIzovLxDCw9/711yWXDi7gDDKMzZCPbCQW8HENb4J4Xm0D9ggwXgQCN9Pt+35IJN4r5H2p2gXhQQmRWDz+4iFA6eTrBnt4skEFJGSjBeZcYzPPlE50gZ76FvGnbqCtzvrD1aFED7QjVM5PTiME69j3GcEkgRE2TqNbKxDD7JESeEMH/PVkq3d08E4o4MEEmWNBwP/3s/cjtQC8i3BtBfhDwoVsvb7lGBEIdYMEWcd/ZK3BX/EC4X61U6LJpOBGoa9cLwmKB6JvgPz5Y4Y8IcCrkivg4s8rhAaaLZJmiF9hnj9pPp3Mmexw+xlCmMv1ODSOOK4LtkMMhMOKDRBhsRHDgwzOPP/ITT+wS6ZkA7nDyi+da7Bxh6Yh3RrCQnDToJm4cU8yYpRixI0+cFR31bN//iZx8x9/gItaxCRuBzswivL1CVcK65Ymmw2Czv4QJAmH+JEp2p9ND95JEPzhFQO/rBQ9h/1MKp3TgF0DGBShuGnhxGjKQEhjFks80cHtxHPDaDnBgKpr+s3okpN+4KZq1dVyPbtQh29xCi3qPwRjj5zBRZnWS/yyKzQSKTETTVv/LP6+hp82mUJYSQFMUGp8+aKU7eB6tSO8FgM40H+8Fj58QKCSxHCJghj+TnAl9Y2mIGd8XbHGzJge+CVdDWluaee3/AjJltWKGw3hESUEkNYqgF2dZnnPInPPvsAxhZx4RIvgnOBcPSpSaV7sNlnKkq/73Hd4LTfC6yDJ6K9kSu+nn6G+DLZUCctBbmz5vM5DtfxTVhjVaChjZEShATiDALMrTQg8li6pO4afCPRLDI0MVsRFLh3K7DUiJBYyrB/RmhmlJ6AEOPWyiVHnzxuK9+hZJU/YHQ4w8Hhj0aEMx5wB0yxYj7/rGHmHf8OIyiSCjwhEeZOmI7GcKTMDxhEU3l+d6DM+g2ho4eP9AlVkSxrgMjKEF1paRTBFGtzXHjovtZvTizB6H3cY1i5tVpAksmmPk9L5MttzA/0kbKj2D9JE1tbhCnoEw08B8tBHNcMSPoExw6pPj+Q79maHGeOJD1IhRtBZ76oMya+RfTe7wBdMRz3AD/6EhiCcznT2pOLhWsTOZQx4ZpMYsBLIYI2CAduFmCVDYEMYxYgGi2jM5d+9nWchjrpXB9yaTcIJ5dN0HJWWKh2Y8aSBY9nVcEuFpNQlO6YE8g5E0g7VXglaSWIAQJiUgbgoQBTQQVl5TLAqMleQvZbjJigJiRjFThxRQ2rVj76KcU6kaHRiybBU0kFUYSeDUQhQikMqA7PjSLLlOBN2hIDV9Uo/zmH25h1cVxRuKKmNbggm8dquUOTuwyOEqgUwZPtOJg0OYwopYkagW4M9mYQIjAiKgy/ejHx8jQyNAwI2/dx6Rz4og1ChMPHDoKjY3JLWvNWw9uZvKkfj7evpP5M03gCqI1OL0jJZkySRPj1mdHqH/77gP+3cFNS6JhDd5wX33wA/o75/L51kk0mgiJhKLR8BkpC5o7NP1ze2pNp8SMsQyC1nLZ4nLO+e1BFYrW0dMPnyy8Gz/5tAGdFyFEhs/lnivf584fP0+bTcO4iRKTISrqK5kPpj40z+9d87DQIZWZEEw1B2jKMrl9vriJuxjE+UmWTngAJxdBJ6oMDY+DWlDQ1x8I+rwlsB0ev7VgguWLqT1BPXhDR9vJNEg/WM9H3EOnE9hkO+gmcKSXrdYNZIFCE4B1aK0KhTqiwrQmklD2a2f0aJ6RxZ/LyCjFEUrHmKqAqeXqS0O7U4P5s+vOgT0JcifCfRSbmO1gAgpS4hkVhI1QoRJRgT1wcLjGs6//jfPOUUynwPddH2p5zSk/vA/XnSpQBYDFhEY3I6jAlxUcT9ESU+SdOrRz5mRJ0rXheS4sW8q8uMIVNZ3d7wR9DRdIso7Ca4lgyk0ojsZYMiaG7xeRoAhXnePjMvam/xFFV3TzmMP9b7j0SZASFy2wytGQ4MWzM6y8uBmhUqPDAI7t2y9gy1Nn9WfxbRbhIg2D7m8x5Z4X5tbsrgKClhDmWtX0kANm6hJGB2KiRP8mCP8YAf2elNYzggmMg01jIfTJ2klExOE4HnPq6OBvHjXE9CDuJhqxFEYXA2jIxgJkLbIKpXk6PHh8ISl9iTAHW2jVrJlyvFDoz6k3ScsW4o4HGvxsk2UlJk2MBFUBgneOZkrF4HqHvLwHxPSTgBMBrg+9nQkSiYNIMsRJCiqj5AAN5k55jFwO/LLBimZUTMk2i9HuyiDKYwwKxKJjB9v3AEvIC+BYSb3sEQ9HfPjZekSpkylnTBozTwpB7REvCKiQ/h73APqBOWOLPR/Szu8YvZ4zqptCBRrhKPF5v//+Wn5+67/5+7hXnc7Vct6nS2m/MlRQOoYgX7mOivvRoZsf6n+Hj4VhzHMPE3igIHI+4OtxTbjx/2EdoxNWFHFW/gHcunBQo0IKQ/ALzX1/ewvuHfPoFvP5zS/FVvOaeh9Z6Ur/T9lb8Trh+4CJA2OUf6JnyI2Bo/vCj5xH/WQde4xl2Hl3rw4+Ct/qCJ47Kg73zBHgjRb6qAnHjxNURJ8agwOGVEWwG/7ng9Jv1o8D/QY4xv5N7U9HXo6j7d9+HcHnI54eA0d++5i7WxGsm9IKh2Y8l5RX5f/52b0s6W/GQ1OLqYCplggn4ghnA67MQG4eKQPPbvmIez47He1OQvY00ZxXZsaJt2hNAqM9tD7A4m5Nj0OIM3PxGWfKht73PsIclWVnJ7YSSIW6fw6DA4KLojKcOBbMchJpNEo36HZKki2GWbPGsWSKQSdT4fx6eR9SQUxmGXGfjI6QUDGmDz6NC1TaP2tWY6K+8Wp1A3Vb4bLz27n36RK6HMAfoJ3J0u5U2PLqOj793VV86YwTkMqA7SAma0Rlj2VnRKR4yZc/46Z/LfnkfQftQNCnI4byjBAhK5tgpgyijI0C9ePAAytQcN3kMj1V76utVgM15imKsd+C7z7P++4T9n96GblhA0KMiJh1KzmpAVU5Cy5qYsVlY1FyZQwEFMhhiz2yCCRptZZaM+C57/0bPxxWpIoxqgnwjEckVscKxYoLOrh6dTdYCbYVHF2i/R9lGXmX/XstkUSWc1M3cuUJX5BtaCJLioQ1FGsmg1hTtCeAVC6BHJjM+Cl/lX2H8jz0yzupF/owMoEyOaQIbPZIGAlCjJWlIyEea+HkC3hHnM2Z0DxElVDPK+jQxydpaRyBSUOXFMVjXhWJZYHx0T6EcYG/ECpLWVG1mtBUsYiaEDm8gCXrpBYj1CCpRekpppVgfWnRjoeygoTMUylbtFR4/mVn0dLXhRADYJuIWQcQAbtI6VCFBk2DAm4Nl3Yx/MHdP7/1UnNC1M+Luyu93RVRhGiDri9LEH8a2hUw4WBjEDWDmMsBslOyzAfFEnYXyGojT2pU7WqlsY7jOwgLrlSEkp6fz/0Fe7Y/zsHBvyRiGjnxtB68kTqTp6U559Jufu9jVidRfT7Xw9Fc90vU/WYVbH3pPnbtz8O+SdT8POVMhJhDlkUn9XLJeUtZ0J8DUWQUg+oIV+9j7fMIMiL8RjFw6h/vn1JRe1mCV3/9NyQrNvp6IL/DRaQcSqUkWpaIGEDg+ZpNW5/iy+unY4IozmN2xRqw+dbB/YeRmSEQU8g3Jfni3a/ojbjq2m/zm6d72PbwT9nz0rco9Q+QnxGjVmpiUj6F9Ayi7e2c/42/YcJ9/5tTxIdclDM0NMNrHmmth8P2gC+7GPy0pVYM0qMiiHiWkWYP8x74Hjtg9y1XduFIhRElLlxzH199b51wLh9RUTP2MT5+UYJYRLN3d4wI4Kq4ECcF6bJBKMHwMNxw8+VctSJPzITpwwghZVCwQBhGNgjthDHSQYvAZ+QzDxyO4aKcfL3Yl+06lBKKXRS3UzO6NWiwWK91j3S4hkC2HnHZIfAfqVHHKdIniFgE5Q6jJSSE5fN9MW6+4Qvs/PRx9u36E7zRJMmSx5Tuldi2Xh599m+4dGG1T6i1BD1ROcL6RRk31i7LadLWL77Fz353KyMDT5DY2Uz+gzbMzjc55dQzOePqC+mc14DdO7ja74Td6hBiVST0B3LkA9OxMvQY7vQcGV1VrhxwVCmhDHjjM3jUkg/Os/M/0/PUWcE8VcQwjVKNW3/0eyy7sJV4eBRxA4dx1SP29TGRH37F0a8kf7kY5TeU7Gv+yNF1B5/SSJkHnyG/6X+4/TNr3OG1nD91QkQ16syZAn/x0CPc9sE9+BxyMLtyS1dLTayxGx8jtRRTjZPN5lnU9wx/szL6AJtSKRtxTZCLj4WpQYvZ37OfN587RH1Kiig1pFjC+JSzgo8/vpW6/QUnXrwJp47T32dBlEATIaZHEEanIEgj4ZAdQeJJIMIMKfccfCVxRIoP9uV58EebOZAqMUt1cOq5s/jmX76OjMPZsw6y7/gb3PLFswH6wAtL7qOZgs9l3RYAJRQBKseYc+E4gXPHUUgHzs96hGXzL1nLiUsM3/g//5uS+jH//mhKsG3AZWLeZ2l1e2/SUz0iR27okC76D42N2eOoTQvUnxCNBv09eEfzXSiZRRnL3h93sHnDOrq6klx69VnMXn1vF/uuXbJ9o6d5HYk1bG1Q6ToYE9QyCdsD60/6wHlC/H+0tZWP3/gWm9Lwo1/fycQ+Rea8Hn7yiTM49/yPOHhYZ2/OrK+VY0TC5JYjY05kFyj8XCBHW4gCJh7uPfwEX/l1L//xXVVfJEYc5tOCOELjjfZgf9GMfXRQDMkG0wH8zN/8jN79eRwDOgI6bvB9S6SmAyNFdRDMdRSDBTt/3MeZ7RNh2Xl4cUVnto1Lf/4nPLFhB6aYoh4vYVOaGRPa2PqTb/AHvb38yXn3kEuUuHxmDOzkYEfYmpbRzzs+BGD8wcH22OYxz9u60M85/QQ6nvg1KhKIsngEHcsi1iCr9qL5rA2JVIYtXpKrvnYrcz86wLlmhJhEoj0hI2L53GE3cpcQPh8lGhfWUoEqM0RYv/kwG7d8G73Rzql9F/LpX55OrqUd3xzGxCIkigXytXLwVIMgKzg4gIgGfXwQx8AY30dYBrQoQTrYJbA88pM/Inb4ADG7kOzl0wzkBfISe+tXVxzeP1SwtGZ4lH8n5eXJ8MEXOVYEG6r+8UPgRjHGUq30CLOoX1N7Nqxncb4LLuHn+MH/+v3JEpZ7n7oIkyF/lYJc/2rH6QISRTdkD4/x44THJ2j5TjyEQssPwU2HRiSxlh7m1aKd7nvf14B2SLRXBd9/R7SSfB9D2E8KlrQqDVw8PCaKAYzJ/7WfCDLcA+9Yh9GhKKKlQy0f/LJp7RoHxXp4+9Kx+BkGyRSLr6VOKaBMZaYoQnTAiEhxGg7x0XUS6eOpzNmiA8n/duvx9RQR9hUw9IxAq4nMCFTrE6zNffBuAWn8NqKb2PqkIxFq3U3zPLvCnl6LdIDJb7+hbW0HFhM3q3wZOsRjN/C/EWKtZdezoxzH2d41PNK2v6Zvwnb8+JYHREfG/w+GNou7B/7I8XhN1lurF/LTd9cDk7BKA9/R6R8MIc98vzBpiCDYVsrG1Pj46x+7FmmN1WxCFTbcYa+9iP0VhPqfwQlSU2w2DpELRaFZQK6ErB+GKoL/QRMstamgYkAkYkZSBtLS2iCJN0JCaxK8tC7a2FUiLCKG9LgYrxhZm+TJN1BGH+4dPqeV7h16ymte1qTq1VCKmyApxQ2WkAkZDsbGiUT2mC9BtaOGlj5slk0Fz6FvS/Q//Q0xExJTIrMxyC7F7WjUOqMZPYYOaaUSKvxvJpaUzYMDhpa8gr33f+vnL/mnPONjVJPRxn1inT4ZVo8Qw0Dfi0zHAA4GJu5knt1/tCJk/0KoCxq38Xc9ae7juyggBCyCGmC51ahWq4Rk1GUEIACzETBxcuiDdS2boT4MOGO9g8PC0yaUHz21X9n/vM/4F9/ZAhDk0ETsU11l17LpkaFjTf8gBPnPsq2wz1WkM1/uaCj+5+dubT78V6LOPBv5bBw60NPc8vz9wV1I5ysKdD2jqC7GXy07YxEWmdIihTHnTifv13SyXndb2JcOIid0K1MHDvE0Hm/MFoXlylSB3hVrrz8MhJPrQdktKQZ3a2CFl7zQvJg63S9UAG9oHj5nX+je0ov8gvpxdPw2O3x8U55nGXo7U/1a9kKB+pBzPmYj1jfuY3e6Qm/cbDQ8u2f7OStx3/CRTMnSMifDfZNDHzn1w/4MduAYNl8cePjvPjERuKG+vfMkxHn/7gDszH+5A/n8mcaS0wLTKpA7Z/30/8lw0S8wP2qUdx9byv+tbO54ZyLqUcBR9AZcH6pI7iG1/8T+4sjlIYy/er+jlVnnMv0PzuPzbt2B5qU7ibqCEHwR05gX7D4RuCJOK/ueh9Bkq6mw5iSKL5h8w/v4IwtG9idGEIJh1Ldo1HwBcPz3ED7SBFMZgCFi5+uSQ2skeKJX17L5BnjUCcphATbH8N2oR91qmxU1OgtCk6TIFMYIeK/wmhLvjUO3iH+7olxvLLjMTpmduP3Sd5e8X/9+2/fbjw3UGE5dcZ0fVHkqSjO/7aY2EdmX9w7jPgdCH0Nvjx1qN6SUxhpqJc0M+ZM55b/vJNlr/7A2V7rSP8Gv/rI6EHAjIIMckFtsg+smdH7L0bvRByuY8C4lHOVc9Ux/yEYgRjqP2ML7Lbl5yPphuIouBdWx/0YfRR9R5w7Iof6u8b2PfWtH3Uv3Bs/1mbU1X1AdehXZIXCoQba3iv9+2MO5/87ulNEBZSYIhKJfM7a+0YtH16i5wTH4Ugo+H2TuwuykKFRh/WT22D3B0/iFgbxWxRoXWQk2eBvX/wHc8+5LPRZifI07u1wCOH3n4DQ2xGkHArQPtIIdIDpJYhPJ1npIl1LAjA4A10Nx6uOcR2EGyGXAxzM/2Bfhs73p6NvWMVg+pQqtT+qK3Lkg2zBQx8dRtd7vj7fzj6dE+Kav8r7//hYh1A4wX5CrTeO9I16f/6bOqe2HEaHbfMR1C19bUiwTYTHRCJwHrg0gJMY4eL1iR4cy7v7f8z3Mv2u3f1dEn6cXc3tIf/Sf5ONX/RKLKcQsQSdM3rBeBhH4KiA0cWQ/3EGtnn/9T3O3b2D/v27KMRqAghCCwvSQr4uonOU0vUc2gjRhxZgQ6St76GNcmlw4hWTiyvMIX7y1Hq8lA47fJgPq9/ZMFc0+uxRJq/WO4QQOBF/cc/uKv2z+tCRAA8PoyPoSJbbOPodnnkGz7Id4yk9OPXJ+3l+96/xplfJxBrEogVSwyVGTUwAalKUE2VsXUGzgBItXV2w6Dhoqad44He/4uO9H6MjHtKTWC1x0OQn0ZPcml4bVYau7bnwOFhPkKmlaZQ1ieYWjAtSpa3uByRfA4oQG/zQaRzLxx8URw1hVJvR4dhJN18F7RRl9piilL9WxNY1vPLYJVTjA8TMOGr1DPoTx2dLxU1pew72Jz7m0v7v+Jd+6yg8H7blIeMl+bE5Xtfx6xSUjyU+SWDE1uE1N2ImKjCz5/DEj/6BSWrYSzlyLQ1h+8BYQAmMcA8fNCMY9QYhTDDzSGG5Mp5lv8qw79A+2t0BLpdiZBW7igMTDCiPWhDO5X14Y+J2BTbG3N7/8m/vTG/YaVPVdPzgubmeO//AfseL8Y99bPxI/zsKZ5zfOvb5evYQ3JNf0cA8+pcBEd3kH1vLLa/fgYzXwxhB+QDSyLxkibjRSOPmKbn4nBtkS6eSyLhYO5LaPpDYu3fuQda7xU9ad47f/dWXnG6HSKWC8h9jc4QYq0GOuLF/Vr/ZjS87qTv9KMYIUplZ3dkrIp/CYUhKUSCI2OBm3Qj9akn29pPIGVHcFakRA2dKYcIooIhq9CrA4hH+H6iwZ+X/yf/4E3XCExdTzV7W0sWb6EnS/+ipm9m0i2XkBnS5Zb5n9bWLUg/24aG7Vz/3QfKewMhEDR+7W/EuMxgi9Db3GppcKaL//KO+NfT+fWwh0qD+Wk4lnLnFVnU/nxU9D8OaRQGWcQ8oz0q5tQBoiF2QUbJx0kZcMEID/iwmEQA1ki6AoyTl3CmlPOYDjRzQgr8DKK0wT0bvmUoZVCrf0d6A7fM7/wWzNFBSHEgeXevvU5fnn3WtxoPZg4CQmeZ3Gc2gBdC2DRDKZxXj+9z/2i1I0P9WYv1JNchaxHWTz06D1Muvd7nPGjFNf+tI2hk1wMGbApkPmwWqNkGzQL20nR0tLCnIvn8t2p2jc/j6//OR9/hEYlRrytSTQGJ4mO15ByBj0qGY5w9D28qBsK+8kHw+JkF3pR0ZouUj38mW1uL27mF1SJhj3V3l06AtvGvPTSM77lzNDvIDJShKGhUe0mE1lH5ROrQtxenD8nWgHHBLFgGcHTlNNp8LFADtvH9w/d99sdWp+tafnzRvD7Gm/K5+EPHil/fPWP7PrLn6PdyeFg+LC0CR31kAIcTe/oX3/nsBk9b0Prd1GzBhSH6HEfc9qPl9H83Cm8MJRh4Y6p9cazws3i54rI3k4SCY+ESuBo7MgB+HOvwQZSOG2+wwsez5S56LpYkvsLOVJ1hI+REqQmJtK4SPTn9rEHktFfsN2Vb8KlDPYl2zRap2jkm4KsKElhUjRklhG7G2P3o7dKGA31diLpoCMvHnzyfr7ytdPpGp/vsdl2IKYlVmVI1WFF7i12zNa4cVoLpWjcsjXh3fjhOWofUsZLjYk8Meb/+bu+iTQfFjv+4a2stM/Bozr2jTZuf/MhTl4+jolzZoDXgjCmURmCSK3jrxosvSEm1mY4Lx6a1un/ZNfBQS448UwYVCOmMnM9ARWn4MRL9IIYjST6P1tjqFzPRMpBa+ENgZEmpBURsWhpC8vY+i/ql3PPcDc/+cY8ZvdthdiUIPUFAdPYKg3zyo5eZFgUuydzLeHxT9/LfJWvPmeUwRoL/QOLMffnSU5tFRQHozhOgqxrg3rLMrfH2UDB/tC5dceAJv/wMpX09pFtBq1G9LhFEOtJoHsyE6v04vN/Pp/l6UcYP1IlGp2I73jBnkM+3tcm+uQwWVHD9Uf4m71OkV55F9m/bMGV0oARNRpK0a3aKEtUFt0UqZPChsQQqlmCjpCXRJp7ntLfEttBk7aVjkkmr2E6Qq+0IhYbqKEdc+6o5/CbFTJazjLmMeJjC7MROYZz5BzGx7eSOdCDfzDYozg9qHy9QKppfIscY7TRwCqRwEbB7mFJJfCZFgZ8sWiHCGAOxt7/0e+uUcY4nP/ys65Pz8b/9vVZXW9/6hkCj6fVw0wKBn3d/PiB/8UFK05h0hcvpv4+7Hnz34Jt8OP4KKbe7A/7fnOYr/74NtxcC1P+4i58e4h/+u63ENvf4LJ0C1YEsUJGGKb/UYqJi6bxd2clOjz9m/U+l6ZFEt9cfM8dvBpn/q4GpAMVn6FLLuYNP4GMe1DKDn2u5Ocuv9SAcPFieUZKMZx0GEkb4rHm8tDTbdLJGEL6zJt1EzH7NL1C0J2TrHnqEQYe2MqVvS1MZxetY7+0R0e/+8nT67zj73a2xYQhlu/mq//8ZyxKleUr/50W0c7D5VdiLY7OUqjt5ZovfY/r5mRolGQAfzHhQvAK9mdcz0dvfUhLTiBlDPS6Gv13PhCFAViRwRCG10UEadZZGcwVk3+IX9vrmIxE9Vj9w//Pmbf+iT4kRd0GJXHr+u7O1Yp/Pg+/zYkZQ40NqtdjaxaRy6aXvsUtyZ/xo6+r+kCYFPO8cd4v4icgXFWcCN8XNEyS53/7d5z1F7/j1E++xW+//I/sCwslt47tY8f3PcrN19Oqfn77T1m8/FSe/eB5hI3RqDaIJoT7xS0Vudj72we5f/qDfHnZ0aiKRifkmEuwFtrKz/ZneP79rTQnvkci26DmFgM7uYbSGu0CsuYHrV3IqMOPBbr7MPzT8y8gig1c8E8/50AJpC8oZgbxxTBjbv8l/2fipYQjF4/YTZBOULMuC9rOsPGp/1/DyL5HOa9pCjbSSmtM0d1UIPrCFTw7s4X/r9f+zbdOPfLhyLix4MpS/vdKPsM/Tx6vcfkFg0x9fTOxXMSLzZnPb5rpfHul+b+9hKZ6+x2H3vmnP/td79dzuSGEfPkvv+7/S9HtR5PzAsRjJZfESrP7xjO1HrrdDDS8h+N7Qn6kCyaYlqM/t/9IdA3c0j3SRf9DvsPP/uBLaAnj2jqJ9CwWt/6GzR9uwHg/1Ff+0D9Rr1uGziz1yfVf1TXbCN51xPP+6WvP0b46S/BEug7GUOBpoiiWLo/7LsuHDOViMpDVhH1t9YOBUw2c7XvHrYdlYWP/l33H9T1oJ/R8S4KqD8XQefio8+iRztiLfUiw4bc7OfeU6XRMOYu5d70FuOvVz/8gZ+z4jtX6z+7xsfnweFg6sBx09nJwx7bB7l2FNq34WtAy7xrWnn7RqIEt+Jv+03tZ+txGZqezxI3FaAO2WfD8C1pJ2npNScdNEHfjQbTrEJ1THb3OPkY6yAMdyS6dhU4D6lzK45tRbiIYOgPj5CKY0RIuCEJNIfjhzR0qG0RZ7CjKJXj9mYeYd3BfdRrH+5DNGxYyQRDSt7GcIxbOipc+ZXpnM9N6P6cxUme46uA3YOVdLpUdwwD9+cxG/uZfq2JlnM03//PGI1T0xmnTz6Ej2cWtP15H1z09gf1F1PlTM/zqdw28/p8Rkkk3lGPMv/VP2f/dJzjjkw2O9U/tvZ/+1L3H/P7TVnp30Ua8hn/ov2/RSiI1IPc5++K8w6+7lPtf2Kzu31D2Hzr3P1zx8O/4wc/Sf7Ztx4i0/+Gp95UfCfC8C6Kkxw6d6f4x9rYfnvEdPq1Tr/Gj8zksG+5fchRXeM21lXkWeBMNg49pGxrNx2fBTGqL8JUiV8bFz78fAelHfHLVfPX+J+zA37ryHd9+wyjHCyP+t4+pUjF2HiK4f+in4v/H2CVDp4V3e8ebC+e1m9m+wHrH9lmX0Hu0OSfPhM5iVAg8MDPnQqV3X3P43h//uYw8MUhOEnhV9zxT+zp0+3W3nv/Vk7w1uYs3DltdH2zvWm5lb/X9PTz7d1MbOnuC//rBz3n4hS3UCi1oO4yO1pnb8nM2Dxme+/ul5tKPd7m/+O67fphKCHR7Rae2tdPG61dsT+z53Z0cjG2j4bk0LcHksfTAxdrvOjSpy/oM9D28wuW/brL5nEyb7nnpDp3uNltbK3kfue58s/Lh87/+88cHA3l5uVKEMLcD3afAj+wfKb38jnu3b23yWcMlf7Nzuy6F8uTkDd01vXYB5f/KhXJ56RZ7l/eT2n7Mx+Nj1cG29x3Yb/h3v36xX1YlHv2l/4Bhx++S1NfAvm2VemkbdR/1vSloWVhlK/KpybN0fCJkCbaLqDs6m2zC/kMT4B1dJz9azOkYspy+dLQ6pcXo7w3B43Uo8w5NqgqPeIhauE8xQSFvL1b4IlC7BF2/QgpFAYfPp/iWbcXHkgyLbQW7wH+a1O93Mn2nIlcE0sWK2PCMHvI7+L65SD+9PMO1hqZGFk8UMTqDb/LV/jO0TXQGYWQRXqM2RihofSsz0E9/66t8L/8vvNk/gBKhT7g0SFlA0y2KQmhZAVJC3FZw3uW30d7Xjq9LwTzU9YJf+fn6o/4K26HnXz4spHWFS3P/+MEj+PHNwnae63//ji2q/Ln2kH7sfXQ0WX7o7P1/6zPPZ2H+Z8nRa9W3FjzPYPFEG+8vEdZCGcHaX7+E3YisIKOYrkwpGutdd6Ge8YLnaYcn6//Lfz+kj36v9Lv88dfmfbyXXydH7xnsvP3+SbB/MgY15ePEvuCuH/2SLL1W1UaDE3t0LJc2bqX5haX6dCz71t+oLYC1RcPisWkd7O/HNDbctIga3X7tjTf+2jmhSyfbk5O8kbWt55/3bGv+WeD3wJhtB0cA1WAl8gc3k7pzP87AIPmuKA03h3QlWui/L6PpDMbDi7bAP28HsRWNfPN8g1tPJj6Bh/eaq1D79HdN3iBj5dhHxq6JhXWCBjLCJZbHtw9c8YirJZx8gtYpmx/qJZdz8dQx5tL/9pP32s+5nDfKgsdf1x/4trPxMwVp/UjXVEXHHCnP6Mn/vHFYLazn1UfCJj71Hz8l1Jj1q/2Rco/OkfPqD9s+Vt7PwzpKbhyqZOPAjj2P8kX5NDlRwVNx/PE65n0uZP7/A8n5ss85kK8LAAAAAElFTkSuQmCC" style="height:90px;width:auto;object-fit:contain;flex-shrink:0;" alt="ICAR Logo"/>
<div style="text-align:center;min-width:200px;">
<div style="font-size:16px;font-weight:700;color:#1a6b35;margin-bottom:3px;">
भाकृअनुप-राष्ठ्रीय डेरी अनुसंधान संस्थान
</div>
<div style="font-size:22px;font-weight:900;color:#1e3a8a;line-height:1.2;margin-bottom:4px;">
ICAR – National Dairy Research Institute
</div>
<div style="font-size:12px;font-weight:600;color:#475569;letter-spacing:0.1em;margin-bottom:6px;">
KARNAL, HARYANA
</div>
<div style="border-top:1.5px solid #1e3a8a;margin:0 auto 6px;width:85%;"></div>
<div style="font-size:11px;color:#94a3b8;letter-spacing:0.04em;">
ReproOmics Hub — Bioinformatics Hub for Animal Reproduction Research
</div>
</div>
</div>
</div>
""")
gr.HTML("""
<div class="app-header">
<div class="app-header-row">
<div class="app-brand">
<div class="app-mark">RH</div>
<div>
<div class="app-title">ReproOmics Hub</div>
<div class="app-subtitle">Research workspace for animal reproduction data, literature, and analysis</div>
</div>
</div>
<div class="app-meta">
<span class="app-meta-chip">GEO</span>
<span class="app-meta-chip">SRA</span>
<span class="app-meta-chip">Literature</span>
<span class="app-meta-chip">PubCrawler</span>
</div>
</div>
</div>
""")
# ── Paper Alert ──
gr.HTML(value=generate_paper_alert_html())
# ── Shared state ──
is_authenticated = gr.State(False)
selected_accession = gr.State("")
hidden_canvas_data = gr.Textbox(visible=False)
# ── Hidden textbox for session_hash ──
session_hash_state = gr.Textbox(visible=False, value="")
# ── JavaScript to set session_hash_state ──
gr.HTML("""
<script>
(function() {
if (!window.__gradioSessionHash) {
window.__gradioSessionHash = (Math.random().toString(36) + Date.now().toString(36)).slice(2);
}
// Find the hidden textbox and set its value
function setSessionHash() {
var inputs = document.querySelectorAll('input[type="text"]');
for (var i = 0; i < inputs.length; i++) {
if (inputs[i].id && inputs[i].id.includes('session_hash_state')) {
inputs[i].value = window.__gradioSessionHash;
// Trigger input event so Gradio registers the change
var event = new Event('input', { bubbles: true });
inputs[i].dispatchEvent(event);
break;
}
}
}
// Wait a bit for the DOM to render
setTimeout(setSessionHash, 500);
})();
</script>
""")
with gr.Tabs() as app_tabs:
# ── TAB 1: Home ────────────────────────────────────────────────────────
with gr.TabItem("Home"):
with gr.Column(visible=True) as home_locked_panel:
home_view_component = gr.HTML(value=generate_view_html())
gr.HTML(HOME_CONTACT_HTML)
with gr.Column(visible=False) as home_editor_panel:
gr.HTML("""
<div class="clean-card" style="margin-bottom:12px;">
<div class="clean-kicker">Site editor</div>
<h2 class="clean-section-title">Homepage Content</h2>
<p class="clean-section-note">Edit the homepage content below. Changes are saved to the local homepage file.</p>
</div>
""")
home_editor_component = gr.HTML(value=generate_editor_html())
with gr.Row():
save_home_btn = gr.Button("Save Homepage", variant="primary")
home_save_status = gr.Markdown("")
# ── TAB 2: Query Database Hub ──────────────────────────────────────────
with gr.TabItem("Query Database Hub"):
with gr.Column(elem_id="qt-search-panel", visible=True) as qt_search_panel:
gr.HTML("""
<div class="home-hero" id="qt-hero">
<div class="clean-kicker">Dataset search</div>
<h2 class="clean-section-title" style="font-size:20px;">Query Biological Records</h2>
<p class="clean-section-note">Search your GEO and SRA records using animal, tissue, source, and accession filters.</p>
</div>
""")
with gr.Row():
with gr.Column(scale=1, min_width=300):
species_input = gr.Dropdown(choices=["Bos taurus", "Buffalo"], label="Animal Type", value="Bos taurus")
tissue_input = gr.Dropdown(choices=["Oocyte", "Blastocyst", "Sperm"], label="Tissue Type", value="Oocyte")
tool_input = gr.Radio(choices=["GEO", "SRA"], label="Source", value="SRA")
accession_input = gr.Textbox(label="Accession Number", placeholder="Optional: SRX12345, GSM6789...")
search_btn = gr.Button("Search Records", variant="primary")
with gr.Column(scale=2, min_width=450):
gr.HTML("""
<div class="clean-card" style="min-height:190px;">
<div class="clean-kicker">How it works</div>
<div class="clean-section-title">Search and inspect stored records</div>
<p class="clean-section-note">Results appear in a structured table. After a match is found, the analysis launchpad becomes available below it.</p>
</div>
""")
with gr.Column(elem_id="qt-results-panel", visible=False) as qt_results_panel:
with gr.Row():
new_search_btn = gr.Button("New Search", variant="secondary")
with gr.Row():
with gr.Column(scale=1, min_width=240):
species_input2 = gr.Dropdown(choices=["Bos taurus", "Buffalo"], label="Animal Type", value="Bos taurus")
tissue_input2 = gr.Dropdown(choices=["Oocyte", "Blastocyst", "Sperm"], label="Tissue Type", value="Oocyte")
tool_input2 = gr.Radio(choices=["GEO", "SRA"], label="Source", value="SRA")
accession_input2 = gr.Textbox(label="Accession Number", placeholder="Optional: SRX12345, GSM6789...")
search_btn2 = gr.Button("Search Again", variant="primary")
with gr.Column(scale=3, min_width=500):
output_table = gr.Dataframe(value=BLANK_TEMPLATE_DF, interactive=False, wrap=False)
with gr.Column(visible=False) as query_box_container:
gr.HTML("""
<div class="clean-card" style="margin-top:12px;">
<div class="clean-kicker">Analysis</div>
<div class="clean-section-title">Available analysis tools</div>
<div class="clean-section-note">Launch the relevant GEO or SRA workflow for the returned record.</div>
</div>
""")
query_custom_box = gr.HTML()
def do_search(species, tissue, tool, accession):
df = search_supabase(species, tissue, tool, accession)
tools_html = generate_tools_launchpad_html(dataset_type=tool)
return (
gr.Column(visible=False),
gr.Column(visible=True),
df,
gr.Column(visible=True),
gr.HTML(value=tools_html),
species, tissue, tool, accession
)
def do_reset():
return gr.Column(visible=True), gr.Column(visible=False)
search_btn.click(
fn=do_search,
inputs=[species_input, tissue_input, tool_input, accession_input],
outputs=[qt_search_panel, qt_results_panel, output_table,
query_box_container, query_custom_box,
species_input2, tissue_input2, tool_input2, accession_input2]
)
search_btn2.click(
fn=do_search,
inputs=[species_input2, tissue_input2, tool_input2, accession_input2],
outputs=[qt_search_panel, qt_results_panel, output_table,
query_box_container, query_custom_box,
species_input2, tissue_input2, tool_input2, accession_input2]
)
new_search_btn.click(fn=do_reset, inputs=[], outputs=[qt_search_panel, qt_results_panel])
# ── TAB 3: Literature Finder ────────────────────────────────────────────
with gr.TabItem("Literature Finder"):
gr.HTML("""
<div class="home-hero">
<div class="clean-kicker">Literature discovery</div>
<h2 class="clean-section-title" style="font-size:20px;">Multi-Database Literature Search</h2>
<p class="clean-section-note">Search the most recent six months across PubMed, Europe PMC, CrossRef, Semantic Scholar, and bioRxiv/medRxiv. Results are deduplicated and sorted by date.</p>
</div>
""")
with gr.Row():
with gr.Column(scale=1, min_width=290):
pm_topic_input = gr.Textbox(label="Search Topic / Keywords", placeholder="e.g. bovine oocyte transcriptomics, blastocyst CRISPR")
pm_sources_input = gr.CheckboxGroup(choices=ALL_SOURCES, value=ALL_SOURCES, label="Databases")
with gr.Row():
pm_search_btn = gr.Button("Search Literature", variant="primary")
gr.HTML("""
<div class="clean-card">
<div class="clean-kicker">Direct lookup</div>
<div class="clean-section-title">Search by DOI</div>
<div class="clean-section-note">Look up one DOI across CrossRef, Europe PMC, and PubMed.</div>
</div>
""")
pm_doi_search_input = gr.Textbox(label="DOI", placeholder="10.1016/j.example.2026.123456")
pm_doi_search_btn = gr.Button("Search DOI", variant="secondary")
with gr.Column(scale=3, min_width=480):
pm_output_html = gr.HTML(value="<div class='clean-card' style='text-align:center;padding:70px 20px;color:#64748b;'>Enter a topic or DOI to begin.</div>")
with gr.Column(scale=1, min_width=220, visible=False) as pm_doi_col:
with gr.Group():
gr.HTML("""
<div class="clean-kicker">Admin action</div>
<div class="clean-section-title">Mark DOI as Read</div>
<p class="clean-section-note">Store a reviewed DOI in Supabase.</p>
""")
pm_doi_input = gr.Textbox(label="DOI", placeholder="10.1093/nar/gkac123")
pm_doi_btn = gr.Button("Mark as Read", variant="primary")
pm_doi_status = gr.Markdown("")
pm_search_btn.click(fn=fetch_multi_db_papers, inputs=[pm_topic_input, pm_sources_input], outputs=pm_output_html)
pm_doi_search_btn.click(fn=search_by_doi, inputs=[pm_doi_search_input], outputs=pm_output_html)
def manual_mark_doi_only(doi, request: gr.Request):
return manual_mark_doi(doi, request)
pm_doi_btn.click(fn=manual_mark_doi_only, inputs=[pm_doi_input], outputs=pm_doi_status)
# ── TAB 4: Literature Search ────────────────────────────────────────────
with gr.TabItem("Literature Search"):
gr.HTML("""
<div class="home-hero">
<div class="clean-kicker">Full archive</div>
<h2 class="clean-section-title" style="font-size:20px;">Literature Search — All Time</h2>
<p class="clean-section-note">Search by topic, database, and year range. Results are loaded page by page and deduplicated.</p>
</div>
""")
ls_state = gr.State(_ls_empty_state())
with gr.Row():
with gr.Column(scale=1, min_width=290):
ls_topic_input = gr.Textbox(label="Search Topic / Keywords", placeholder="e.g. CRISPR gene editing, bovine embryo transfer")
with gr.Row():
with gr.Column():
ls_year_from = gr.Slider(minimum=1900, maximum=datetime.date.today().year, value=1900, step=1, label="From year")
with gr.Column():
ls_year_to = gr.Slider(minimum=1900, maximum=datetime.date.today().year, value=datetime.date.today().year, step=1, label="To year")
ls_sources_input = gr.CheckboxGroup(choices=ALL_SOURCES, value=ALL_SOURCES, label="Databases")
ls_search_btn = gr.Button("Search Archive", variant="primary")
gr.HTML("<div class='clean-card'><div class='clean-kicker'>Direct lookup</div><div class='clean-section-title'>Search by DOI</div><p class='clean-section-note'>Open a publication directly from its DOI.</p></div>")
ls_doi_search_input = gr.Textbox(label="DOI", placeholder="10.1016/j.example.2026.123456")
ls_doi_search_btn = gr.Button("Search DOI", variant="secondary")
with gr.Column(scale=3, min_width=480):
ls_nav_html = gr.HTML(value="")
ls_output_html = gr.HTML(value="<div class='clean-card' style='text-align:center;padding:70px 20px;color:#64748b;'>Enter a topic and click Search to begin.</div>")
with gr.Row():
ls_prev_btn = gr.Button("Previous", variant="secondary")
ls_next_btn = gr.Button("Next", variant="primary")
with gr.Column(scale=1, min_width=220, visible=False) as ls_doi_col:
with gr.Group():
gr.HTML("<div class='clean-kicker'>Admin action</div><div class='clean-section-title'>Mark DOI as Read</div><p class='clean-section-note'>Store a reviewed DOI in Supabase.</p>")
ls_doi_input = gr.Textbox(label="DOI", placeholder="10.1093/nar/gkac123")
ls_doi_btn = gr.Button("Mark as Read", variant="primary")
ls_doi_status = gr.Markdown("")
ls_search_btn.click(fn=ls_init_search, inputs=[ls_topic_input, ls_sources_input, ls_year_from, ls_year_to], outputs=[ls_output_html, ls_nav_html, ls_state])
ls_doi_search_btn.click(fn=search_by_doi, inputs=[ls_doi_search_input], outputs=ls_output_html)
ls_prev_btn.click(fn=ls_prev_page, inputs=[ls_state], outputs=[ls_output_html, ls_nav_html, ls_state])
ls_next_btn.click(fn=ls_next_page, inputs=[ls_state], outputs=[ls_output_html, ls_nav_html, ls_state])
ls_doi_btn.click(fn=manual_mark_doi_only, inputs=[ls_doi_input], outputs=ls_doi_status)
# ── TAB 5: AI Tools Directory ──────────────────────────────────────────
with gr.TabItem("AI Tools"):
gr.HTML("""
<div style="text-align:center;padding:28px 0 20px;font-family:'Inter',system-ui,sans-serif;">
<div style="display:inline-flex;align-items:center;justify-content:center;
width:52px;height:52px;border-radius:16px;
background:linear-gradient(135deg,#fefce8,#fde68a);
box-shadow:0 4px 14px rgba(217,119,6,.2);margin-bottom:14px;">
<span style="font-size:26px;line-height:1;"></span>
</div>
<h2 style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 6px;letter-spacing:-.02em;">
AI / ML / DL Tools
</h2>
<p style="font-size:13px;color:#64748b;margin:0 auto 4px;max-width:520px;">
Curated AI, Machine Learning &amp; Deep Learning tools for cattle &amp; buffalo reproduction research.
Click any card to open the tool or source paper.
</p>
</div>
<style>
.ai-tools-section-title {
font-size: 13px;
font-weight: 800;
color: #94a3b8;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 24px 0 10px;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 16px;
}
.ai-tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
margin-bottom: 8px;
}
.ai-tool-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px 18px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
transition: box-shadow 0.15s, border-color 0.15s, transform 0.12s;
cursor: pointer;
}
.ai-tool-card:hover {
box-shadow: 0 4px 16px rgba(37,99,235,0.12);
border-color: #93c5fd;
transform: translateY(-2px);
text-decoration: none;
}
.ai-tool-card .card-name {
font-size: 13.5px;
font-weight: 700;
color: #2563eb;
line-height: 1.4;
}
.ai-tool-card .card-desc {
font-size: 12px;
color: #475569;
line-height: 1.55;
flex-grow: 1;
}
.ai-tool-card .card-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
}
.card-badge {
font-size: 10.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 9999px;
white-space: nowrap;
}
.badge-type { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
.badge-spp { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.badge-link { background: #f8fafc; color: #64748b; border: 1px solid #e2e8f0; margin-left: auto; font-size: 11px; }
</style>
<div style="padding: 20px 4px 0; font-family: system-ui, sans-serif;">
<div style="text-align:center; margin-bottom: 28px;">
<div style="font-size:26px; margin-bottom:6px;"></div>
<h2 style="font-size:18px; font-weight:800; color:#0f172a; margin:0 0 6px;">AI / ML / DL Tools for Cattle & Buffalo Reproduction</h2>
<p style="font-size:13px; color:#64748b; margin:0;">Click any card to open the source paper or tool.</p>
</div>
<!-- ── OOCYTE ASSESSMENT / GRADING ───────────────────────────────────── -->
<div class="ai-tools-section-title"> Oocyte Assessment / Grading</div>
<div class="ai-tools-grid">
<a class="ai-tool-card" href="https://pubmed.ncbi.nlm.nih.gov/37679441/" target="_blank" rel="noopener noreferrer">
<div class="card-name">DeepLabV3Plus + SqueezeNet Pipeline</div>
<div class="card-desc">Combines DeepLabV3Plus segmentation with a refined SqueezeNet classifier; 96% validation accuracy for oocyte meiotic-stage classification.</div>
<div class="card-footer">
<span class="card-badge badge-type">CNN Segmentation + Classification</span>
<span class="card-badge badge-spp">Human</span>
<span class="card-badge badge-link">Targosz et al., 2023 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://pubmed.ncbi.nlm.nih.gov/37271037/" target="_blank" rel="noopener noreferrer">
<div class="card-name">CNN-Based Oocyte Competence Scoring</div>
<div class="card-desc">Semi-automatic CNN-based system for bovine oocyte competence, labelling images post hoc by resulting blastocyst outcomes.</div>
<div class="card-footer">
<span class="card-badge badge-type">Semi-Automatic CNN</span>
<span class="card-badge badge-spp">Bovine</span>
<span class="card-badge badge-link">Costa et al., 2023 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.nature.com/articles/s41598-025-00001-0" target="_blank" rel="noopener noreferrer">
<div class="card-name">ML + Microfluidic Integration</div>
<div class="card-desc">Multiple supervised ML models (RF, XGBoost, SVM, LightGBM, KNN, Naïve Bayes, LR) evaluated for oocyte quality prediction integrated with microfluidic chip data.</div>
<div class="card-footer">
<span class="card-badge badge-type">RF · XGBoost · SVM · LightGBM</span>
<span class="card-badge badge-spp">Human</span>
<span class="card-badge badge-link">Sci. Reports, 2025 ↗</span>
</div>
</a>
</div>
<!-- ── EMBRYO / BLASTOCYST GRADING ───────────────────────────────────── -->
<div class="ai-tools-section-title"> Embryo / Blastocyst Grading</div>
<div class="ai-tools-grid">
<a class="ai-tool-card" href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6308431/" target="_blank" rel="noopener noreferrer">
<div class="card-name">Blasto3Q</div>
<div class="card-desc">Fully automated ANN-based bovine blastocyst grading per IETS standard (Grade 1/2/3); 76.4% accuracy; MATLAB + multiplatform web interface.</div>
<div class="card-footer">
<span class="card-badge badge-type">Genetic Algorithm + ANN</span>
<span class="card-badge badge-spp">Bovine</span>
<span class="card-badge badge-link">PMC6308431 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.vitrolife.com/products/time-lapse-systems/idascore/" target="_blank" rel="noopener noreferrer">
<div class="card-name">iDAScore (Vitrolife)</div>
<div class="card-desc">Fully automated time-lapse analysis; ranks embryos by likelihood of fetal heartbeat at day 2/3/blastocyst stage. Widely cited in bovine ET literature.</div>
<div class="card-footer">
<span class="card-badge badge-type">Deep Learning · Time-Lapse</span>
<span class="card-badge badge-spp">Human (cited in bovine ET)</span>
<span class="card-badge badge-link">Vitrolife ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC10414680/" target="_blank" rel="noopener noreferrer">
<div class="card-name">EmbryoScope / Primo Vision / Eeva</div>
<div class="card-desc">Time-lapse incubation systems with integrated kinetics and cleavage-symmetry evaluation software; Primo Vision used to record bovine blastulation timing (tSB, tB).</div>
<div class="card-footer">
<span class="card-badge badge-type">Built-in DL/ML + Imaging Hardware</span>
<span class="card-badge badge-spp">Human · Bovine IVP</span>
<span class="card-badge badge-link">PMC10414680 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.jivf.com/article/S2405-8394(25)00001-0/fulltext" target="_blank" rel="noopener noreferrer">
<div class="card-name">ML Embryo Stage / Transferability Classifier</div>
<div class="card-desc">81.7% agreement with expert embryologists on developmental stage; 95.2% agreement on transferability decision.</div>
<div class="card-footer">
<span class="card-badge badge-type">Multi-class ML Classifier</span>
<span class="card-badge badge-spp">Bovine</span>
<span class="card-badge badge-link">J. IVF-Worldwide, 2025 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://arxiv.org/abs/1908.09637" target="_blank" rel="noopener noreferrer">
<div class="card-name">Multi-Task DL + Dynamic Programming</div>
<div class="card-desc">2D CNN per time-lapse frame with dynamic-programming post-processing enforcing monotonic cell-count progression for embryo cell-stage classification.</div>
<div class="card-footer">
<span class="card-badge badge-type">CNN + Dynamic Programming</span>
<span class="card-badge badge-spp">Bovine · Mouse</span>
<span class="card-badge badge-link">arXiv:1908.09637 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://arxiv.org/abs/2502.07360" target="_blank" rel="noopener noreferrer">
<div class="card-name">Supervised Contrastive Learning Model</div>
<div class="card-desc">Benchmarked on public Bovine Embryo CS dataset and NYU Mouse Embryo dataset for cell-stage classification using contrastive CNN.</div>
<div class="card-footer">
<span class="card-badge badge-type">Contrastive CNN</span>
<span class="card-badge badge-spp">Bovine · Mouse</span>
<span class="card-badge badge-link">arXiv:2502.07360 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.nature.com/articles/s41598-025-00001-0" target="_blank" rel="noopener noreferrer">
<div class="card-name">CNN Oocyte / Embryo Scoring (Mana et al.)</div>
<div class="card-desc">CNN-based scoring of 269 oocytes and 269 corresponding embryos from 104 women; preliminary high-quality embryo classification study.</div>
<div class="card-footer">
<span class="card-badge badge-type">CNN</span>
<span class="card-badge badge-spp">Human</span>
<span class="card-badge badge-link">Sci. Reports, 2025 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.nature.com/articles/s41598-025-00001-0" target="_blank" rel="noopener noreferrer">
<div class="card-name">ML Live-Birth Prediction (Miyagi et al.)</div>
<div class="card-desc">Multiple ML algorithms compared (LR, Naïve Bayes, KNN, RF, Neural Network, SVM) for predicting probability of live birth from blastocyst-stage images.</div>
<div class="card-footer">
<span class="card-badge badge-type">LR · NB · KNN · RF · SVM · NN</span>
<span class="card-badge badge-spp">Human</span>
<span class="card-badge badge-link">Sci. Reports, 2025 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.nature.com/articles/s41598-025-00001-0" target="_blank" rel="noopener noreferrer">
<div class="card-name">AI Pregnancy-Probability Platform (Khosravi et al.)</div>
<div class="card-desc">AI platform trained on embryologist-scored blastocyst images; predicted pregnancy chance 13.8–66.3% depending on blastocyst/patient factors.</div>
<div class="card-footer">
<span class="card-badge badge-type">Deep Learning</span>
<span class="card-badge badge-spp">Human</span>
<span class="card-badge badge-link">Sci. Reports, 2025 ↗</span>
</div>
</a>
</div>
<!-- ── SPERM / SEMEN ANALYSIS ─────────────────────────────────────────── -->
<div class="ai-tools-section-title"> Sperm / Semen Analysis (CASA + AI)</div>
<div class="ai-tools-grid">
<a class="ai-tool-card" href="https://pubmed.ncbi.nlm.nih.gov/36244251/" target="_blank" rel="noopener noreferrer">
<div class="card-name">BGM — Open-Access CASA Software</div>
<div class="card-desc">Open-access CASA software validated against commercial Hamilton-Thorne (HTM) system for cattle and buffalo sperm motility/kinematics. Most directly relevant tool for cattle + buffalo work.</div>
<div class="card-footer">
<span class="card-badge badge-type">Open-Source CASA (ImageJ-based)</span>
<span class="card-badge badge-spp">Cattle · Buffalo</span>
<span class="card-badge badge-link">PubMed 36244251 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.researchgate.net/publication/356100000" target="_blank" rel="noopener noreferrer">
<div class="card-name">BSPMCsvm3casa</div>
<div class="card-desc">SVM-based bull sperm motility classifier using three CASA kinematic parameters (VCL, VSL, LIN); outperforms prior static-threshold classification methods.</div>
<div class="card-footer">
<span class="card-badge badge-type">SVM</span>
<span class="card-badge badge-spp">Bovine (Bull)</span>
<span class="card-badge badge-link">ResearchGate, 2021 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.researchgate.net/publication/356100001" target="_blank" rel="noopener noreferrer">
<div class="card-name">Tracking-Grid + Mean-Angle Motion Tracker</div>
<div class="card-desc">Predicts position of sperm with failed detection using mean motion angle + Tracking-Grid; 5% fewer ID-switches and +15.6 MOTAL points vs Deep SORT baseline.</div>
<div class="card-footer">
<span class="card-badge badge-type">Custom MOT Algorithm</span>
<span class="card-badge badge-spp">Bovine (Bull)</span>
<span class="card-badge badge-link">ResearchGate, 2021 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.hamiltonthorne.com/" target="_blank" rel="noopener noreferrer">
<div class="card-name">Hamilton-Thorne CASA (IVOS / HTM Series)</div>
<div class="card-desc">Industry-standard CASA combining camera, microscope, and pixel-recognition software for motility/velocity/morphology evaluation across cattle and buffalo.</div>
<div class="card-footer">
<span class="card-badge badge-type">Commercial CASA Hardware + Software</span>
<span class="card-badge badge-spp">Cattle · Buffalo · Multi-species</span>
<span class="card-badge badge-link">hamiltonthorne.com ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.biorxiv.org/content/10.1101/2025.01.01.000000" target="_blank" rel="noopener noreferrer">
<div class="card-name">DL Bovine Sperm Morphology Classifier (IFC)</div>
<div class="card-desc">Deep learning morphology analysis trained on ~1.8 million imaging flow cytometry images across 6 bulls, fresh and frozen-thawed semen.</div>
<div class="card-footer">
<span class="card-badge badge-type">CNN on Label-Free IFC Images</span>
<span class="card-badge badge-spp">Bovine</span>
<span class="card-badge badge-link">Frontiers Vet Sci, 2026 ↗</span>
</div>
</a>
<a class="ai-tool-card" href="https://www.sciencedirect.com/search?query=AI+bull+sperm+morphology" target="_blank" rel="noopener noreferrer">
<div class="card-name">AI Bull Sperm Morphology Evaluation System</div>
<div class="card-desc">AI algorithm benchmarked against manual morphology assessment in bull semen analysis; confirmed potential applicability for automated sperm morphology evaluation.</div>
<div class="card-footer">
<span class="card-badge badge-type">Deep Learning Image Classifier</span>
<span class="card-badge badge-spp">Bovine (Bull)</span>
<span class="card-badge badge-link">ScienceDirect, 2025 ↗</span>
</div>
</a>
</div>
<!-- ── ANIMAL IDENTIFICATION ──────────────────────────────────────────── -->
<div class="ai-tools-section-title"> Animal Identification (Cattle / Buffalo)</div>
<div class="ai-tools-grid">
<a class="ai-tool-card" href="https://www.arch-anim-breed.net/" target="_blank" rel="noopener noreferrer">
<div class="card-name">CNN Muzzle-Pattern Identification</div>
<div class="card-desc">Deep-learning-based buffalo identification through muzzle pattern images using AlexNet, SqueezeNet, GoogLeNet and ResNet101; cattle precedent achieved ~98.99% identification accuracy.</div>
<div class="card-footer">
<span class="card-badge badge-type">CNN · AlexNet · ResNet101</span>
<span class="card-badge badge-spp">Buffalo · Cattle</span>
<span class="card-badge badge-link">Arch. Anim. Breed., 2025 ↗</span>
</div>
</a>
</div>
<div style="height: 32px;"></div>
</div>
""")
# ── TAB 6: PubCrawler ────────────────────────────────────────────────────
with gr.TabItem("PubCrawler", visible=False) as pubcrawler_tab:
gr.HTML("""
<div class="crawler-banner">
<div class="clean-kicker">Admin research monitoring</div>
<h2>PubCrawler</h2>
<p>Maintain a list of watch terms in the <code>pubcrawler</code> Supabase table and search the literature for publications from the past 30 days.</p>
</div>
""")
with gr.Row(elem_classes=["crawler-grid"]):
with gr.Column(scale=2, min_width=300):
gr.HTML("<div class='clean-kicker'>Watch list</div><div class='clean-section-title'>Add a search term</div><p class='clean-section-note'>Terms are saved to Supabase and used for the next crawl.</p>")
pubcrawler_term_input = gr.Textbox(label="Term", placeholder="e.g. bovine oocyte CRISPR")
with gr.Row():
pubcrawler_save_btn = gr.Button("Save Term", variant="primary")
pubcrawler_refresh_btn = gr.Button("Reload", variant="secondary")
pubcrawler_status = gr.Markdown("")
with gr.Column(scale=3, min_width=420):
gr.HTML("<div class='clean-kicker'>Stored terms</div><div class='clean-section-title'>PubCrawler Watch List</div><p class='clean-section-note'>Select a row to remove that term.</p>")
pubcrawler_table = gr.Dataframe(value=get_pubcrawler_terms_df(), headers=['ID', 'Term'], datatype=['number', 'str'], interactive=False, wrap=True, max_height=240)
selected_pubcrawler_id = gr.State("")
with gr.Row():
pubcrawler_delete_btn = gr.Button("Remove Selected Term", variant="stop")
pubcrawler_selection_status = gr.Markdown("")
gr.HTML("<div class='admin-section' style='margin-top:14px;'><div class='clean-kicker'>Literature feed</div><div class='clean-section-title'>Articles Matching Saved Terms</div><p class='clean-section-note'>Every saved term is searched across the literature sources for the past 30 days. Duplicates are merged.</p></div>")
with gr.Row():
pubcrawler_articles_refresh_btn = gr.Button("Refresh Articles", variant="secondary")
pubcrawler_articles_status = gr.Markdown("")
pubcrawler_articles_html = gr.HTML(value="<div class='clean-card' style='text-align:center;padding:55px 20px;color:#64748b;'>Save one or more terms, then refresh the article feed.</div>")
# ── TAB 7: Administrative Portal ───────────────────────────────────────
with gr.TabItem("Administrative Portal"):
with gr.Column(visible=True, elem_classes=["admin-login"]) as login_panel:
gr.HTML("""
<div class="clean-card">
<div class="clean-kicker">Restricted access</div>
<h2 class="clean-section-title" style="font-size:18px;">Administrative Portal</h2>
<p class="clean-section-note">Sign in to manage database records, reviewed articles, PubCrawler terms, and homepage content.</p>
</div>
""")
user_box = gr.Textbox(label="Username", placeholder="Enter username")
pass_box = gr.Textbox(label="Password", type="password", placeholder="Enter password")
login_btn = gr.Button("Sign In", variant="primary")
with gr.Column(visible=False) as upload_panel:
gr.HTML("<div class='home-hero'><div class='clean-kicker'>Administration</div><h2 class='clean-section-title' style='font-size:20px;'>Admin Console</h2><p class='clean-section-note'>Manage dataset records, reviewed DOIs, and site content from one workspace.</p></div>")
with gr.Row():
with gr.Column(scale=1, min_width=290):
with gr.Group(elem_classes=["admin-section"]):
gr.HTML("<div class='clean-kicker'>Data ingestion</div><div class='clean-section-title'>Upload Records</div><p class='clean-section-note'>Upload a CSV or Excel dataset and assign its biological metadata.</p>")
file_input = gr.File(label="CSV or Excel", file_types=[".csv", ".xlsx"])
upload_species = gr.Dropdown(choices=["Bos taurus", "Buffalo"], label="Animal Type", value="Bos taurus")
upload_tissue = gr.Dropdown(choices=["Oocyte", "Blastocyst", "Sperm"], label="Tissue Type", value="Oocyte")
upload_dataset = gr.Radio(choices=["GEO", "SRA"], label="Dataset Type", value="SRA")
submit_btn = gr.Button("Upload Records", variant="primary")
with gr.Group(elem_classes=["admin-section"]):
gr.HTML("<div class='clean-kicker'>Record removal</div><div class='clean-section-title'>Delete Selected Record</div><p class='clean-section-note'>Select a row from the database table before deleting.</p>")
delete_btn = gr.Button("Delete Selected Row", variant="stop")
status_output = gr.Textbox(label="Console Log", placeholder="Awaiting action...")
with gr.Column(scale=2, min_width=520):
with gr.Group(elem_classes=["admin-section"]):
gr.HTML("<div class='clean-kicker'>Database browser</div><div class='clean-section-title'>Filter and Browse Records</div>")
admin_species = gr.Dropdown(choices=["Bos taurus", "Buffalo"], label="Animal Type", value="Bos taurus")
admin_tissue = gr.Dropdown(choices=["Oocyte", "Blastocyst", "Sperm"], label="Tissue Type", value="Oocyte")
admin_tool = gr.Radio(choices=["GEO", "SRA"], label="Source", value="SRA")
with gr.Row():
admin_search_btn = gr.Button("Run Filter", variant="secondary")
admin_reset_btn = gr.Button("Reload All", variant="secondary")
admin_table_view = gr.Dataframe(value=BLANK_TEMPLATE_DF, interactive=False, wrap=False)
with gr.Group(elem_classes=["admin-section"]):
gr.HTML("<div class='clean-kicker'>Review management</div><div class='clean-section-title'>Mark DOI as Read</div><p class='clean-section-note'>Store a reviewed DOI in Supabase.</p>")
with gr.Row():
manual_doi_input = gr.Textbox(label="DOI", placeholder="10.1093/nar/gkac123")
manual_doi_btn = gr.Button("Mark as Read", variant="primary")
manual_doi_status = gr.Markdown("")
with gr.Group(elem_classes=["admin-section"]):
gr.HTML("<div class='clean-kicker'>Review history</div><div class='clean-section-title'>Marked Articles</div><p class='clean-section-note'>Select an article, then remove it to clear the reviewed status.</p>")
reviewed_table_view = gr.Dataframe(value=get_reviewed_table_df(), headers=['DOI', 'Reviewed At', 'Link'], datatype=['str', 'str', 'str'], interactive=False, wrap=True, max_height=300)
selected_reviewed_doi = gr.State("")
with gr.Row():
remove_reviewed_btn = gr.Button("Remove Selected Article", variant="stop")
reviewed_status = gr.Markdown("")
login_btn.click(
fn=check_upload_credentials_generator,
inputs=[user_box, pass_box],
outputs=[login_panel, upload_panel, status_output, admin_table_view,
home_locked_panel, home_editor_panel, home_editor_component, is_authenticated,
pm_doi_col, ls_doi_col, reviewed_table_view, pubcrawler_table, pubcrawler_articles_html, pubcrawler_tab]
)
pass_box.submit(
fn=check_upload_credentials_generator,
inputs=[user_box, pass_box],
outputs=[login_panel, upload_panel, status_output, admin_table_view,
home_locked_panel, home_editor_panel, home_editor_component, is_authenticated,
pm_doi_col, ls_doi_col, reviewed_table_view, pubcrawler_table, pubcrawler_articles_html, pubcrawler_tab]
)
pubcrawler_save_btn.click(
fn=save_pubcrawler_term,
inputs=[pubcrawler_term_input],
outputs=[pubcrawler_status, pubcrawler_table, pubcrawler_term_input, pubcrawler_articles_html]
)
pubcrawler_refresh_btn.click(
fn=lambda request: (get_pubcrawler_terms_df(), get_pubcrawler_articles_html(request)),
inputs=[],
outputs=[pubcrawler_table, pubcrawler_articles_html]
)
pubcrawler_term_input.submit(
fn=save_pubcrawler_term,
inputs=[pubcrawler_term_input],
outputs=[pubcrawler_status, pubcrawler_table, pubcrawler_term_input, pubcrawler_articles_html]
)
pubcrawler_table.select(
fn=pubcrawler_row_selection,
inputs=[pubcrawler_table],
outputs=[selected_pubcrawler_id, pubcrawler_selection_status]
)
pubcrawler_delete_btn.click(
fn=delete_pubcrawler_term,
inputs=[selected_pubcrawler_id],
outputs=[pubcrawler_status, pubcrawler_table, selected_pubcrawler_id, pubcrawler_articles_html]
)
pubcrawler_articles_refresh_btn.click(
fn=refresh_pubcrawler_articles,
inputs=[],
outputs=[pubcrawler_articles_html]
)
save_home_btn.click(
fn=update_homepage_string,
inputs=[hidden_canvas_data],
outputs=[home_save_status, home_view_component],
js="() => { const ed = document.getElementById('homepage-canvas-editor'); return [ed ? ed.innerHTML : '']; }"
)
admin_table_view.select(
fn=handle_row_selection,
inputs=[admin_table_view],
outputs=[selected_accession, status_output]
)
admin_search_btn.click(fn=search_supabase, inputs=[admin_species, admin_tissue, admin_tool], outputs=admin_table_view)
admin_reset_btn.click(fn=load_entire_table, inputs=[], outputs=admin_table_view)
def post_upload_refresh(file, species, tissue, dataset):
log_msg = upload_data_to_supabase(file, species, tissue, dataset)
return log_msg, load_entire_table()
submit_btn.click(fn=post_upload_refresh, inputs=[file_input, upload_species, upload_tissue, upload_dataset], outputs=[status_output, admin_table_view])
delete_btn.click(fn=delete_record_from_supabase, inputs=[selected_accession, admin_table_view], outputs=[status_output, admin_table_view, selected_accession])
manual_doi_btn.click(fn=manual_mark_doi_and_refresh, inputs=[manual_doi_input], outputs=[manual_doi_status, reviewed_table_view])
reviewed_table_view.select(fn=reviewed_row_selection, inputs=[reviewed_table_view], outputs=[selected_reviewed_doi, reviewed_status])
remove_reviewed_btn.click(fn=remove_selected_reviewed_doi, inputs=[selected_reviewed_doi], outputs=[reviewed_status, reviewed_table_view, selected_reviewed_doi])
# ── Public API ──────────────────────────────────────────────────────────
with gr.Row(visible=False):
_api_in_doi = gr.Textbox(value="", elem_id="api-doi")
_api_in_action = gr.Textbox(value="", elem_id="api-action")
_api_out = gr.Textbox(value="", elem_id="api-out")
_api_btn = gr.Button("api", elem_id="api-btn")
_api_btn.click(fn=mark_article_api, inputs=[_api_in_doi, _api_in_action], outputs=[_api_out], api_name="mark_article_api")
# Add parameters directly to launch()
demo.launch(css=css, theme=light_theme)