Spaces:
Running
Running
| 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 & 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 · 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 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 · 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 • Topic: <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… | |
| </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’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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| }} | |
| 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;"> ' + | |
| 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 ? ' • <em>' + escapeHtml(a.journal) + '</em>' : '') + | |
| (a.date ? ' • ' + 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;"> ' + | |
| 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 = ' | '.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 = ' | '.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>•</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>•</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;'>“" | |
| + query + | |
| "”</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,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" 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 & Deep Learning tools for cattle & 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) |