Merge branch 'develop'

This commit is contained in:
Alexis
2026-05-07 13:56:15 +02:00
64 changed files with 8543 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# --- GLOBAL ---
.env
*.env
.env.*
!.env.example
# --- PYTHON BACKEND ---
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
*.db
*.log
# Virtual environments
venv/
.venv/
env/
ENV/
# FastAPI / Uvicorn
*.pid
# --- NODE FRONTEND ---
node_modules/
dist/
build/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Vite cache
.vite/
vite.config.ts.timestamp*
vite.config.js.timestamp*
# --- DOCKER ---
# Avoid local volumes or generated files
docker-data/
postgres_data/
redis_data/
# --- OS / EDITOR ---
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
.cursorrules
+19
View File
@@ -0,0 +1,19 @@
ORCID_CLIENT_ID=123412341234
ORCID_CLIENT_SECRET=123412341234
API_KEY_NAME=X-API-Key
API_KEY_VALUE=123412341234
DATABASE_URL=postgresql://postgres:postgres@db:5432/orcid_db
REDIS_URL=redis://redis:6379/0
BASE_URL=http://localhost:8000/api
# JWT (login ORCID)
JWT_SECRET=change_me
JWT_ALGORITHM=HS256
JWT_EXPIRES_MINUTES=720
# ORCID OAuth 3-legged (authorization code)
# Debe coincidir exactamente con el redirect URI configurado en tu app ORCID.
ORCID_REDIRECT_URI=http://localhost:8000/api/auth/orcid/callback
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+109
View File
@@ -0,0 +1,109 @@
import httpx
import os
from pathlib import Path
from dotenv import load_dotenv
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.db.models import Researcher
from app.db.session import get_db
from app.schema.auth import OrcidLoginResponseSchema
from app.security.jwt import create_access_token
from app.services.orcid_client import ORCIDClient
from app.utils.orcid_validator import is_valid_orcid
# Asegura que al ejecutar `uvicorn` local también se carga `backend/.env`.
_ENV_PATH = Path(__file__).resolve().parents[2] / ".env"
load_dotenv(dotenv_path=_ENV_PATH, override=False)
router = APIRouter(prefix="/auth", tags=["auth"])
def _extract_display_name(record: dict) -> str | None:
person = (record or {}).get("person") or {}
name = person.get("name") or {}
given = ((name.get("given-names") or {}).get("value")) if isinstance(name.get("given-names"), dict) else None
family = ((name.get("family-name") or {}).get("value")) if isinstance(name.get("family-name"), dict) else None
full = " ".join([p for p in [given, family] if p])
return full or None
def _orcid_redirect_uri() -> str:
# Debe coincidir con el `redirect_uri` registrado en tu integración ORCID.
return os.getenv("ORCID_REDIRECT_URI") or "http://localhost:8000/api/auth/orcid/callback"
def _complete_oauth_login(*, code: str, db: Session) -> OrcidLoginResponseSchema:
"""
Completa el login OAuth:
1) intercambio del `code` en ORCID (server-side)
2) crea/actualiza el investigador
3) emite nuestro JWT
"""
if not code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing ORCID authorization code")
client = ORCIDClient()
redirect_uri = _orcid_redirect_uri()
try:
token_data = client.exchange_authorization_code(code=code, redirect_uri=redirect_uri)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"ORCID token error ({exc.response.status_code})",
)
except httpx.TimeoutException:
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="ORCID timeout")
except Exception:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ORCID unavailable")
orcid_id = (token_data.get("orcid") or "").strip()
if not is_valid_orcid(orcid_id):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid ORCID returned by OAuth")
display_name = token_data.get("name")
if not display_name:
# Fallback si ORCID no devuelve `name` en el token response.
try:
record = client.fetch_record(orcid_id)
display_name = _extract_display_name(record)
except Exception:
display_name = None
researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first()
if not researcher:
researcher = Researcher(orcid_id=orcid_id, name=display_name, authenticated=True)
db.add(researcher)
else:
researcher.authenticated = True
if display_name and not researcher.name:
researcher.name = display_name
db.commit()
db.refresh(researcher)
token = create_access_token(subject=orcid_id, extra={"rid": str(researcher.id)})
return OrcidLoginResponseSchema(access_token=token)
@router.get("/orcid/authorize")
def authorize_orcid():
"""
Inicia el flujo OAuth 3-legged (authorization code) hacia ORCID.
"""
client = ORCIDClient()
authorize_url = client.build_authorize_url(
redirect_uri=_orcid_redirect_uri(),
# Solo necesitamos el Authenticated iD del usuario.
scope="/authenticate",
)
return RedirectResponse(authorize_url)
@router.get("/orcid/callback", response_model=OrcidLoginResponseSchema)
def orcid_callback(code: str, db: Session = Depends(get_db)):
return _complete_oauth_login(code=code, db=db)
+167
View File
@@ -0,0 +1,167 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy.orm import Session
from uuid import UUID
from app.db.session import get_db
from app.db.models import Publication, Researcher, PublicationDownload
from app.security.api_key import get_api_key_optional
from app.security.jwt import get_optional_current_researcher
from app.services.sword_generator import SWORDGenerator
from app.services.zip_generator import ZIPGenerator
router = APIRouter(prefix="/export")
def validate_uuid_list(pub_ids: list[str]) -> list[UUID]:
valid_ids = []
for pid in pub_ids:
try:
valid_ids.append(UUID(pid))
except Exception:
raise HTTPException(
status_code=400,
detail=f"Invalid publication ID (not UUID): {pid}"
)
return valid_ids
@router.post("/sword/publications")
async def export_multiple_sword(
pub_ids: list[str],
db: Session = Depends(get_db),
api_key: str | None = Depends(get_api_key_optional),
current: Researcher | None = Depends(get_optional_current_researcher),
):
if not api_key and not current:
raise HTTPException(status_code=401, detail="Missing credentials")
validate_uuid_list(pub_ids)
pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all()
if not pubs:
raise HTTPException(status_code=404, detail="No publications found")
researcher = db.query(Researcher).filter_by(id=pubs[0].researcher_id).first()
xml_bytes = SWORDGenerator.generate_feed_xml(researcher, pubs)
# Registrar descarga solo si hay usuario logueado
if current:
for p in pubs:
exists = (
db.query(PublicationDownload)
.filter(
PublicationDownload.researcher_id == current.id,
PublicationDownload.publication_id == p.id,
)
.first()
)
if not exists:
db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id))
db.commit()
return Response(content=xml_bytes, media_type="application/xml")
@router.get("/sword/researcher/{orcid_id}")
async def export_researcher_sword(
orcid_id: str,
db: Session = Depends(get_db),
api_key: str | None = Depends(get_api_key_optional),
current: Researcher | None = Depends(get_optional_current_researcher),
):
if not api_key and not current:
raise HTTPException(status_code=401, detail="Missing credentials")
researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first()
if not researcher:
raise HTTPException(status_code=404, detail="Researcher not found")
pubs = db.query(Publication).filter_by(researcher_id=researcher.id).all()
if not pubs:
raise HTTPException(status_code=404, detail="No publications found for this researcher")
xml_bytes = SWORDGenerator.generate_feed_xml(researcher, pubs)
if current:
for p in pubs:
exists = (
db.query(PublicationDownload)
.filter(
PublicationDownload.researcher_id == current.id,
PublicationDownload.publication_id == p.id,
)
.first()
)
if not exists:
db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id))
db.commit()
return Response(content=xml_bytes, media_type="application/xml")
@router.post("/zip/publications")
async def export_multiple_zip(
pub_ids: list[str],
db: Session = Depends(get_db),
api_key: str | None = Depends(get_api_key_optional),
current: Researcher | None = Depends(get_optional_current_researcher),
):
if not api_key and not current:
raise HTTPException(status_code=401, detail="Missing credentials")
validate_uuid_list(pub_ids)
pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all()
if not pubs:
raise HTTPException(status_code=404, detail="No publications found")
researcher = db.query(Researcher).filter_by(id=pubs[0].researcher_id).first()
zip_bytes = ZIPGenerator.generate_zip(researcher, pubs)
if current:
for p in pubs:
exists = (
db.query(PublicationDownload)
.filter(
PublicationDownload.researcher_id == current.id,
PublicationDownload.publication_id == p.id,
)
.first()
)
if not exists:
db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id))
db.commit()
return Response(content=zip_bytes, media_type="application/zip")
@router.get("/zip/researcher/{orcid_id}")
async def export_researcher_zip(
orcid_id: str,
db: Session = Depends(get_db),
api_key: str | None = Depends(get_api_key_optional),
current: Researcher | None = Depends(get_optional_current_researcher),
):
if not api_key and not current:
raise HTTPException(status_code=401, detail="Missing credentials")
researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first()
if not researcher:
raise HTTPException(status_code=404, detail="Researcher not found")
pubs = db.query(Publication).filter_by(researcher_id=researcher.id).all()
if not pubs:
raise HTTPException(status_code=404, detail="No publications found for this researcher")
zip_bytes = ZIPGenerator.generate_zip(researcher, pubs)
if current:
for p in pubs:
exists = (
db.query(PublicationDownload)
.filter(
PublicationDownload.researcher_id == current.id,
PublicationDownload.publication_id == p.id,
)
.first()
)
if not exists:
db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id))
db.commit()
return Response(content=zip_bytes, media_type="application/zip")
+311
View File
@@ -0,0 +1,311 @@
from datetime import datetime
from typing import List
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.models import Publication, Researcher
from app.db.session import get_db
from app.schema.researcher import (
ResearcherBatchSearchRequestSchema,
ResearcherBatchSearchResponseSchema,
ResearcherSearchErrorSchema,
ResearcherStatsSchema,
ResearcherWithPublicationsSchema,
)
from app.services.normalizer import PublicationNormalizer
from app.services.orcid_client import get_works_summary, get_work_detail
from app.schema.publication import PublicationSchema
from app.db.models import PublicationDownload
from app.security.jwt import get_optional_current_researcher
router = APIRouter(prefix="/researchers", tags=["researchers"])
# ---------------------------------------------------------
# Función auxiliar: detectar si una publicación ha cambiado
# ---------------------------------------------------------
def publication_changed(existing: Publication, data: dict) -> bool:
fields = [
"title", "subtitle", "type", "journal",
"pub_year", "pub_month", "pub_day",
"doi", "url", "short_description",
"citation_type", "citation_value",
"language_code", "country",
"external_ids", "contributors"
]
for f in fields:
if getattr(existing, f) != data[f]:
return True
return False
def build_researcher_stats(publications: list) -> ResearcherStatsSchema:
publication_types: dict[str, int] = {}
for publication in publications:
pub_type = getattr(publication, "type", None) or "unknown"
publication_types[pub_type] = publication_types.get(pub_type, 0) + 1
return ResearcherStatsSchema(
total_publications=len(publications),
publication_types=publication_types,
)
def _upsert_researcher_publications(
researcher: Researcher,
orcid_id: str,
db: Session,
) -> List[Publication]:
works = get_works_summary(orcid_id)
groups = works.get("group", [])
publications: List[Publication] = []
for g in groups:
summaries = g.get("work-summary") or []
if not summaries:
continue
summary = summaries[0]
put_code = summary.get("put-code")
if put_code is None:
continue
try:
detail = get_work_detail(orcid_id, put_code)
except Exception:
detail = None
data = PublicationNormalizer.normalize(summary, detail)
existing = (
db.query(Publication)
.filter(
Publication.researcher_id == researcher.id,
Publication.put_code == data["put_code"],
)
.first()
)
if existing:
for field in [
"title", "subtitle", "type", "journal",
"pub_year", "pub_month", "pub_day",
"doi", "url", "short_description",
"citation_type", "citation_value",
"language_code", "country",
"external_ids", "contributors"
]:
setattr(existing, field, data[field])
existing.last_modified = datetime.utcnow()
existing.status = None
publications.append(existing)
else:
pub = Publication(
researcher_id=researcher.id,
**data,
last_modified=datetime.utcnow(),
)
pub.status = None
db.add(pub)
publications.append(pub)
researcher.last_sync_at = datetime.utcnow()
db.commit()
db.refresh(researcher)
return publications
def _decorate_downloaded_by_me(
*,
db: Session,
current: Researcher | None,
publications: List[Publication],
) -> List[PublicationSchema] | List[Publication]:
if not current:
return publications
downloaded_ids = {
row[0]
for row in (
db.query(PublicationDownload.publication_id)
.filter(PublicationDownload.researcher_id == current.id)
.all()
)
}
out: List[PublicationSchema] = []
for p in publications:
out.append(
PublicationSchema.model_validate(p).model_copy(update={"downloaded_by_me": p.id in downloaded_ids})
)
return out
def build_search_response(orcid_id: str, db: Session, current: Researcher | None) -> ResearcherWithPublicationsSchema:
researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first()
if not researcher:
researcher = Researcher(
orcid_id=orcid_id,
name=None,
authenticated=False,
last_sync_at=None,
)
db.add(researcher)
db.flush()
publications = _upsert_researcher_publications(researcher, orcid_id, db)
publications_out = _decorate_downloaded_by_me(db=db, current=current, publications=publications)
stats = build_researcher_stats(publications_out)
return ResearcherWithPublicationsSchema(
researcher=researcher,
publications=publications_out,
stats=stats,
new_records=0,
updated_records=0,
unchanged_records=0,
total_records=len(publications_out),
)
# ---------------------------------------------------------
# ENDPOINT 1: SEARCH + SYNC (sin contadores)
# ---------------------------------------------------------
@router.post("/search", response_model=ResearcherBatchSearchResponseSchema, response_model_exclude_none=True)
def search_and_sync_researchers(
payload: ResearcherBatchSearchRequestSchema,
db: Session = Depends(get_db),
current: Researcher | None = Depends(get_optional_current_researcher),
):
results: List[ResearcherWithPublicationsSchema] = []
errors: List[ResearcherSearchErrorSchema] = []
# Evita llamadas duplicadas a ORCID conservando el orden de entrada.
unique_orcid_ids = list(dict.fromkeys(payload.orcid_ids))
for orcid_id in unique_orcid_ids:
try:
results.append(build_search_response(orcid_id, db, current))
except httpx.HTTPStatusError as exc:
db.rollback()
errors.append(
ResearcherSearchErrorSchema(
orcid_id=orcid_id,
detail=f"ORCID devolvió {exc.response.status_code} para {orcid_id}.",
)
)
except Exception as exc:
db.rollback()
errors.append(
ResearcherSearchErrorSchema(
orcid_id=orcid_id,
detail=str(exc),
)
)
return ResearcherBatchSearchResponseSchema(
results=results,
errors=errors,
total_requested=len(unique_orcid_ids),
total_processed=len(results),
)
# ---------------------------------------------------------
# ENDPOINT 2: SYNC COMPLETO (con contadores + status)
# ---------------------------------------------------------
@router.post("/{orcid_id}/sync", response_model=ResearcherWithPublicationsSchema, response_model_exclude_none=True)
def sync_researcher(
orcid_id: str,
db: Session = Depends(get_db),
current: Researcher | None = Depends(get_optional_current_researcher),
):
researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first()
if not researcher:
raise HTTPException(status_code=404, detail="Researcher not found")
works = get_works_summary(orcid_id)
groups = works.get("group", [])
publications_output = []
new_count = 0
updated_count = 0
unchanged_count = 0
for g in groups:
summaries = g.get("work-summary") or []
if not summaries:
continue
summary = summaries[0]
put_code = summary.get("put-code")
if put_code is None:
continue
try:
detail = get_work_detail(orcid_id, put_code)
except Exception:
detail = None
data = PublicationNormalizer.normalize(summary, detail)
existing = (
db.query(Publication)
.filter(
Publication.researcher_id == researcher.id,
Publication.put_code == data["put_code"],
)
.first()
)
if existing:
if publication_changed(existing, data):
# updated
for field in data:
setattr(existing, field, data[field])
existing.last_modified = datetime.utcnow()
existing.status = "updated"
updated_count += 1
else:
# unchanged
existing.status = "unchanged"
unchanged_count += 1
pub = existing
else:
# new
pub = Publication(
researcher_id=researcher.id,
**data,
last_modified=datetime.utcnow(),
)
pub.status = "new"
db.add(pub)
new_count += 1
db.flush()
publications_output.append(pub)
researcher.last_sync_at = datetime.utcnow()
db.commit()
db.refresh(researcher)
publications_out = _decorate_downloaded_by_me(db=db, current=current, publications=publications_output)
return ResearcherWithPublicationsSchema(
researcher=researcher,
publications=publications_out,
stats=build_researcher_stats(publications_out),
new_records=new_count,
updated_records=updated_count,
unchanged_records=unchanged_count,
total_records=new_count + updated_count + unchanged_count,
)
+3
View File
@@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()
+83
View File
@@ -0,0 +1,83 @@
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
import uuid
from datetime import datetime
from app.db.session import Base
class Researcher(Base):
__tablename__ = "researchers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
orcid_id = Column(String, unique=True, index=True, nullable=False)
name = Column(String, nullable=True)
authenticated = Column(Boolean, default=False)
last_sync_at = Column(DateTime, nullable=True)
publications = relationship("Publication", back_populates="researcher", cascade="all, delete-orphan")
class Publication(Base):
__tablename__ = "publications"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
researcher_id = Column(UUID(as_uuid=True), ForeignKey("researchers.id"), nullable=False)
researcher = relationship("Researcher", back_populates="publications")
# ORCID core
put_code = Column(Integer, index=True, nullable=False)
title = Column(String, nullable=True)
subtitle = Column(String, nullable=True)
type = Column(String, nullable=True)
# Journal / container
journal = Column(String, nullable=True)
# Dates
pub_year = Column(Integer, nullable=True)
pub_month = Column(Integer, nullable=True)
pub_day = Column(Integer, nullable=True)
# Identifiers / links
doi = Column(String, nullable=True)
url = Column(String, nullable=True)
# Description / citation
short_description = Column(String, nullable=True)
citation_type = Column(String, nullable=True)
citation_value = Column(String, nullable=True)
# Language / country
language_code = Column(String, nullable=True)
country = Column(String, nullable=True)
# Extra structured data
external_ids = Column(JSONB, nullable=True) # lista de external-id normalizados
contributors = Column(JSONB, nullable=True) # lista de autores/roles
# Tu campo existente
hash_fingerprint = Column(String, nullable=True)
last_modified = Column(DateTime, nullable=True, default=None)
# Legacy: descargado global (deprecado). Mantener por compatibilidad de DB.
downloaded = Column(Boolean, nullable=False, default=False)
class PublicationDownload(Base):
"""
Marca de descarga por usuario (researcher) sobre cualquier publicación.
Una fila por (researcher_id, publication_id).
"""
__tablename__ = "publication_downloads"
__table_args__ = (
UniqueConstraint("researcher_id", "publication_id", name="uq_publication_download"),
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
researcher_id = Column(UUID(as_uuid=True), ForeignKey("researchers.id"), nullable=False, index=True)
publication_id = Column(UUID(as_uuid=True), ForeignKey("publications.id"), nullable=False, index=True)
downloaded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
@@ -0,0 +1,66 @@
from sqlalchemy.orm import Session
from app.db.models import Publication
class PublicationRepository:
@staticmethod
def get_by_put_code(db: Session, researcher_id: str, put_code: int):
"""
Devuelve una publicación existente por put_code (único en ORCID).
"""
return (
db.query(Publication)
.filter(
Publication.researcher_id == researcher_id,
Publication.put_code == put_code
)
.first()
)
@staticmethod
def create(db: Session, researcher_id: str, data: dict):
"""
Crea una nueva publicación normalizada.
"""
pub = Publication(
researcher_id=researcher_id,
put_code=data["put_code"],
title=data["title"],
journal=data["journal"],
doi=data["doi"],
pub_year=data["pub_year"],
type=data["type"],
hash_fingerprint=data["hash_fingerprint"]
)
db.add(pub)
db.commit()
db.refresh(pub)
return pub
@staticmethod
def update(db: Session, publication: Publication, data: dict):
"""
Actualiza una publicación existente si ORCID ha cambiado algo.
"""
publication.title = data["title"]
publication.journal = data["journal"]
publication.doi = data["doi"]
publication.pub_year = data["pub_year"]
publication.type = data["type"]
publication.hash_fingerprint = data["hash_fingerprint"]
db.commit()
db.refresh(publication)
return publication
@staticmethod
def list_by_researcher(db: Session, researcher_id: str):
"""
Lista todas las publicaciones de un investigador.
"""
return (
db.query(Publication)
.filter(Publication.researcher_id == researcher_id)
.order_by(Publication.pub_year.desc().nullslast())
.all()
)
@@ -0,0 +1,25 @@
from sqlalchemy.orm import Session
from app.db.models import Researcher
from sqlalchemy.sql import func
class ResearcherRepository:
@staticmethod
def get_by_orcid(db: Session, orcid_id: str):
return db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first()
@staticmethod
def create(db: Session, orcid_id: str, name: str = None):
researcher = Researcher(orcid_id=orcid_id, name=name)
db.add(researcher)
db.commit()
db.refresh(researcher)
return researcher
@staticmethod
def update_last_sync(db: Session, researcher: Researcher):
researcher.last_sync_at = func.now()
db.commit()
db.refresh(researcher)
return researcher
@@ -0,0 +1,28 @@
from sqlalchemy.orm import Session
from app.db.models import SyncJob
from sqlalchemy.sql import func
class SyncJobRepository:
@staticmethod
def start_job(db: Session, researcher_id: str):
job = SyncJob(
researcher_id=researcher_id,
status="running",
started_at=func.now()
)
db.add(job)
db.commit()
db.refresh(job)
return job
@staticmethod
def finish_job(db: Session, job: SyncJob, new_records: int, updated_records: int):
job.status = "finished"
job.new_records = new_records
job.updated_records = updated_records
job.finished_at = func.now()
db.commit()
db.refresh(job)
return job
+63
View File
@@ -0,0 +1,63 @@
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker, declarative_base
import os
from dotenv import load_dotenv
# Cargar variables del .env para ejecuciones locales (en Docker ya vendrán por entorno).
load_dotenv()
# -----------------------------
# DATABASE URL
# -----------------------------
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(
DATABASE_URL,
future=True,
echo=False
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
Base = declarative_base()
# -----------------------------
# DB SESSION DEPENDENCY
# -----------------------------
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# -----------------------------
# INIT DB (CREA TABLAS)
# -----------------------------
def init_db():
# Importa modelos para que SQLAlchemy los registre
import app.db.models # noqa
# Crea todas las tablas si no existen
Base.metadata.create_all(bind=engine)
# Pequeñas migraciones "best-effort" para entornos sin Alembic.
# (create_all no altera tablas existentes)
_ensure_columns()
def _ensure_columns():
insp = inspect(engine)
if "publications" in insp.get_table_names():
cols = {c["name"] for c in insp.get_columns("publications")}
if "downloaded" not in cols:
with engine.begin() as conn:
conn.execute(
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
)
+68
View File
@@ -0,0 +1,68 @@
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from app.db.session import init_db
from app.db.session import get_db
from app.api.researchers import router as researchers_router
from app.api.export import router as export_router
from app.api.auth import router as auth_router
from app.api.auth import _complete_oauth_login
from app.schema.auth import OrcidLoginResponseSchema
from app.scheduler.sync_scheduler import start_scheduler
# ---------------------------------------------------------
# Crear instancia principal de FastAPI
# ---------------------------------------------------------
app = FastAPI(
title="ORCID SWORD Backend",
description="Backend para sincronización ORCID y exportación SWORD",
version="1.0.0"
)
# ---------------------------------------------------------
# Crear tablas al iniciar la aplicación
# ---------------------------------------------------------
@app.on_event("startup")
def startup_event():
init_db() # 🔥 CREA TABLAS
start_scheduler() # 🔥 INICIA SCHEDULER
# ---------------------------------------------------------
# Healthcheck
# ---------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/callback", response_model=OrcidLoginResponseSchema)
def oauth_callback_root(code: str, db: Session = Depends(get_db)):
"""
Alias para probar redirect URIs como `https://127.0.0.1/callback` en local.
Intercambia el code con ORCID y emite el JWT.
"""
return _complete_oauth_login(code=code, db=db)
# ---------------------------------------------------------
# Registrar routers
# ---------------------------------------------------------
app.include_router(researchers_router, prefix="/api")
app.include_router(export_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
# ---------------------------------------------------------
# CORS
# ---------------------------------------------------------
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # en producción limitar
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
+43
View File
@@ -0,0 +1,43 @@
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from app.db.session import SessionLocal
from app.db.repositories.researcher_repository import ResearcherRepository
from dotenv import load_dotenv
import os
# Cargar variables del .env
load_dotenv()
API_KEY = os.getenv("API_KEY_VALUE")
BASE_URL = os.getenv("BASE_URL")
def run_monthly_sync():
db = SessionLocal()
researchers = ResearcherRepository.get_all(db)
for r in researchers:
try:
url = f"{BASE_URL}/researchers/{r.orcid_id}/sync"
response = requests.post(
url,
headers={"X-API-Key": API_KEY}
)
if response.status_code != 200:
print(f"[ERROR] Sync failed for {r.orcid_id}: {response.text}")
else:
print(f"[OK] Synced {r.orcid_id}")
except Exception as e:
print(f"[EXCEPTION] Error syncing {r.orcid_id}: {e}")
db.close()
def start_scheduler():
scheduler = BackgroundScheduler()
scheduler.add_job(run_monthly_sync, "cron", day=1, hour=3) # día 1 a las 03:00
scheduler.start()
+12
View File
@@ -0,0 +1,12 @@
from pydantic import BaseModel, Field
class OrcidLoginRequestSchema(BaseModel):
# `code` is the authorization code returned by ORCID OAuth after the user signs in.
# Exchanging it for tokens must happen server-side.
code: str = Field(..., examples=["Q70Y3A"])
class OrcidLoginResponseSchema(BaseModel):
access_token: str
token_type: str = "bearer"
+29
View File
@@ -0,0 +1,29 @@
from pydantic import BaseModel
from uuid import UUID
from typing import Optional, List, Any
from datetime import datetime
class PublicationSchema(BaseModel):
id: UUID
put_code: int | None = None
title: str | None = None
subtitle: str | None = None
journal: str | None = None
doi: str | None = None
pub_year: int | None = None
pub_month: int | None = None
pub_day: int | None = None
type: str | None = None
url: str | None = None
short_description: str | None = None
citation_type: str | None = None
citation_value: str | None = None
language_code: str | None = None
country: str | None = None
external_ids: List[Any] | None = None
contributors: List[Any] | None = None
hash_fingerprint: str | None = None
last_modified: datetime | None = None
status: str | None = None
downloaded_by_me: bool | None = None
model_config = {"from_attributes": True}
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel, Field
from uuid import UUID
from typing import Optional, List, Dict
from datetime import datetime
from app.schema.publication import PublicationSchema
class ResearcherSchema(BaseModel):
id: UUID
orcid_id: str
name: Optional[str]
authenticated: bool
last_sync_at: Optional[datetime]
model_config = {"from_attributes": True}
class ResearcherStatsSchema(BaseModel):
total_publications: int
publication_types: Dict[str, int]
class ResearcherWithPublicationsSchema(BaseModel):
researcher: ResearcherSchema
publications: List[PublicationSchema]
stats: ResearcherStatsSchema
new_records: int
updated_records: int
unchanged_records: int
total_records: int
model_config = {"from_attributes": True}
class ResearcherBatchSearchRequestSchema(BaseModel):
orcid_ids: List[str] = Field(min_length=1)
class ResearcherSearchErrorSchema(BaseModel):
orcid_id: str
detail: str
class ResearcherBatchSearchResponseSchema(BaseModel):
results: List[ResearcherWithPublicationsSchema]
errors: List[ResearcherSearchErrorSchema]
total_requested: int
total_processed: int
+43
View File
@@ -0,0 +1,43 @@
import os
from dotenv import load_dotenv
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
# Cargar variables del .env
load_dotenv()
API_KEY_NAME = os.getenv("API_KEY_NAME")
API_KEY_VALUE = os.getenv("API_KEY_VALUE")
if not API_KEY_NAME:
raise RuntimeError("ERROR: La variable API_KEY_NAME no está definida en el .env")
if not API_KEY_VALUE:
raise RuntimeError("ERROR: La variable API_KEY_VALUE no está definida en el .env")
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
def get_api_key(api_key: str = Depends(api_key_header)):
if api_key != API_KEY_VALUE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key inválida o ausente."
)
return api_key
def get_api_key_optional(api_key: str = Depends(api_key_header)) -> str | None:
"""
Devuelve la API key si está presente y es correcta.
- Si no está presente: None
- Si está presente pero incorrecta: 401
"""
if api_key is None:
return None
if api_key != API_KEY_VALUE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key inválida."
)
return api_key
+75
View File
@@ -0,0 +1,75 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from dotenv import load_dotenv
from app.db.models import Researcher
from app.db.session import get_db
load_dotenv()
_bearer = HTTPBearer(auto_error=False)
def _settings() -> tuple[str, str, int]:
# Fallback de desarrollo para evitar 500 por configuración ausente.
secret = os.getenv("JWT_SECRET") or "change_me"
algorithm = os.getenv("JWT_ALGORITHM") or "HS256"
expires_minutes = int(os.getenv("JWT_EXPIRES_MINUTES") or "720")
return secret, algorithm, expires_minutes
def create_access_token(*, subject: str, extra: dict[str, Any] | None = None) -> str:
secret, algorithm, expires_minutes = _settings()
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=expires_minutes)).timestamp()),
}
if extra:
payload.update(extra)
return jwt.encode(payload, secret, algorithm=algorithm)
def get_current_researcher(
creds: HTTPAuthorizationCredentials = Depends(_bearer),
db: Session = Depends(get_db),
) -> Researcher:
if not creds or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
secret, algorithm, _ = _settings()
try:
payload = jwt.decode(creds.credentials, secret, algorithms=[algorithm])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
orcid_id = payload.get("sub")
if not isinstance(orcid_id, str) or not orcid_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject")
researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first()
if not researcher or not researcher.authenticated:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Researcher not authenticated")
return researcher
def get_optional_current_researcher(
creds: HTTPAuthorizationCredentials = Depends(_bearer),
db: Session = Depends(get_db),
) -> Researcher | None:
"""
Devuelve el investigador autenticado si hay Bearer token.
Si no hay token, devuelve None.
Si hay token inválido, lanza 401.
"""
if not creds or not creds.credentials:
return None
return get_current_researcher(creds=creds, db=db)
+111
View File
@@ -0,0 +1,111 @@
from typing import List
def _get(d: dict | None, *keys, default=None):
cur = d or {}
for k in keys:
if not isinstance(cur, dict):
return default
cur = cur.get(k)
if cur is None:
return default
return cur
class PublicationNormalizer:
@staticmethod
def normalize(summary: dict, detail: dict | None = None) -> dict:
"""
summary: work-summary de ORCID
detail: work completo (puede ser None si la llamada falla)
"""
# --- Core desde summary ---
put_code = summary.get("put-code")
title = _get(summary, "title", "title", "value")
type_ = summary.get("type")
journal = _get(summary, "journal-title", "value")
year = _get(summary, "publication-date", "year", "value")
month = _get(summary, "publication-date", "month", "value")
day = _get(summary, "publication-date", "day", "value")
url = _get(summary, "url", "value")
short_description = summary.get("short-description")
# DOI desde summary (external-ids)
doi = None
external_ids_list: List[dict] = _get(
summary, "external-ids", "external-id", default=[]
) or []
for ext in external_ids_list:
if ext.get("external-id-type") == "doi":
doi = ext.get("external-id-value")
break
# --- Si tenemos detail, enriquecemos ---
subtitle = None
citation_type = None
citation_value = None
language_code = None
country = None
external_ids_full: List[dict] | None = None
contributors: List[dict] | None = None
if detail:
# Subtitle
subtitle = _get(detail, "title", "subtitle", "value") or subtitle
# Citation
citation_type = _get(detail, "citation", "citation-type")
citation_value = _get(detail, "citation", "citation-value")
# Language
language_code = detail.get("language-code")
# Country
country = _get(detail, "country", "value")
# External IDs completos
external_ids_full = _get(
detail, "external-ids", "external-id", default=[]
) or []
# Contributors
raw_contributors = _get(
detail, "contributors", "contributor", default=[]
) or []
contributors = []
for c in raw_contributors:
contributors.append(
{
"name": _get(c, "credit-name", "value"),
"orcid": _get(c, "contributor-orcid", "path"),
"role": _get(
c, "contributor-attributes", "contributor-role"
),
}
)
return {
"put_code": put_code,
"title": title,
"subtitle": subtitle,
"type": type_,
"journal": journal,
"pub_year": int(year) if year is not None else None,
"pub_month": int(month) if month is not None else None,
"pub_day": int(day) if day is not None else None,
"doi": doi,
"url": url,
"short_description": short_description,
"citation_type": citation_type,
"citation_value": citation_value,
"language_code": language_code,
"country": country,
"external_ids": external_ids_full,
"contributors": contributors,
"hash_fingerprint": None,
}
+150
View File
@@ -0,0 +1,150 @@
import os
import urllib.parse
from pathlib import Path
from typing import Any, Optional
from dotenv import load_dotenv
import httpx
TOKEN_URL_SANDBOX = "https://sandbox.orcid.org/oauth/token"
AUTHORIZATION_URL_SANDBOX = "https://sandbox.orcid.org/oauth/authorize"
BASE_URL_SANDBOX = "https://pub.sandbox.orcid.org/v3.0"
# Si en algún momento pasas a producción, cambiarías a:
# TOKEN_URL_PROD = "https://orcid.org/oauth/token"
# BASE_URL_PROD = "https://pub.orcid.org/v3.0"
class ORCIDClient:
def __init__(self):
# Asegura que al ejecutar `uvicorn` local también se carga `backend/.env`.
# (En docker `ORCID_REDIRECT_URI` y secretos llegan por env_file, así que esto no molesta.)
_env_path = Path(__file__).resolve().parents[2] / ".env"
load_dotenv(dotenv_path=_env_path, override=False)
self.client_id = os.getenv("ORCID_CLIENT_ID")
self.client_secret = os.getenv("ORCID_CLIENT_SECRET")
self._token_cache: Optional[str] = None
self.token_url = TOKEN_URL_SANDBOX
self.authorization_url = AUTHORIZATION_URL_SANDBOX
self.base_url = BASE_URL_SANDBOX
# ---------------------------------------------------------
# 1. Obtener token público
# ---------------------------------------------------------
def get_public_token(self) -> str:
if self._token_cache:
return self._token_cache
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
"scope": "/read-public",
}
with httpx.Client(timeout=20.0) as client:
response = client.post(self.token_url, data=data)
response.raise_for_status()
token = response.json()["access_token"]
self._token_cache = token
return token
# ---------------------------------------------------------
# Headers comunes
# ---------------------------------------------------------
def _headers(self) -> dict:
token = self.get_public_token()
return {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
# ---------------------------------------------------------
# 2. Consultar /record
# ---------------------------------------------------------
def fetch_record(self, orcid_id: str) -> dict:
url = f"{self.base_url}/{orcid_id}/record"
with httpx.Client(timeout=20.0) as client:
response = client.get(url, headers=self._headers())
response.raise_for_status()
return response.json()
# ---------------------------------------------------------
# 3. Consultar /works (summary)
# ---------------------------------------------------------
def fetch_works(self, orcid_id: str) -> dict:
url = f"{self.base_url}/{orcid_id}/works"
with httpx.Client(timeout=20.0) as client:
response = client.get(url, headers=self._headers())
response.raise_for_status()
return response.json()
# ---------------------------------------------------------
# 4. Consultar /work/{put_code} (detalle)
# ---------------------------------------------------------
def fetch_work_detail(self, orcid_id: str, put_code: int) -> dict | None:
url = f"{self.base_url}/{orcid_id}/work/{put_code}"
with httpx.Client(timeout=20.0) as client:
response = client.get(url, headers=self._headers())
if response.status_code != 200:
return None
return response.json()
# ---------------------------------------------------------
# OAuth 3-legged (authorization code)
# ---------------------------------------------------------
def build_authorize_url(
self,
*,
redirect_uri: str,
scope: str = "/authenticate",
state: str | None = None,
) -> str:
"""
Creates the ORCID authorization URL (user signs in at ORCID and returns an auth code).
"""
params: dict[str, Any] = {
"client_id": self.client_id,
"response_type": "code",
# Scope(s) are space-separated in the authorize URL.
"scope": scope,
"redirect_uri": redirect_uri,
}
if state:
params["state"] = state
return f"{self.authorization_url}?{urllib.parse.urlencode(params)}"
def exchange_authorization_code(
self,
*,
code: str,
redirect_uri: str,
) -> dict:
"""
Server-side code exchange. Response includes at least `orcid` and usually `name`.
"""
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
}
with httpx.Client(timeout=20.0) as client:
response = client.post(self.token_url, data=data, headers={"Accept": "application/json"})
response.raise_for_status()
return response.json()
# -------------------------------------------------------------------
# Funciones de módulo usadas en researchers.py
# -------------------------------------------------------------------
def get_works_summary(orcid_id: str) -> dict:
client = ORCIDClient()
return client.fetch_works(orcid_id)
def get_work_detail(orcid_id: str, put_code: int) -> dict | None:
client = ORCIDClient()
return client.fetch_work_detail(orcid_id, put_code)
+112
View File
@@ -0,0 +1,112 @@
from datetime import datetime
from xml.etree.ElementTree import Element, SubElement, tostring
from app.db.models import Publication, Researcher
ATOM_NS = "http://www.w3.org/2005/Atom"
DC_NS = "http://purl.org/dc/elements/1.1/"
EXTRA_NS = "http://example.org/orcid-extra" # namespace para campos extendidos
class SWORDGenerator:
@staticmethod
def generate_feed_xml(researcher: Researcher, publications: list[Publication]) -> bytes:
feed = Element("feed", {
"xmlns": ATOM_NS,
"xmlns:dc": DC_NS,
"xmlns:extra": EXTRA_NS
})
SubElement(feed, "title").text = f"Publications for {researcher.orcid_id}"
author = SubElement(feed, "author")
SubElement(author, "name").text = researcher.name or "Unknown"
SubElement(feed, "updated").text = datetime.utcnow().isoformat() + "Z"
SubElement(feed, "id").text = f"urn:uuid:{researcher.id}"
for pub in publications:
entry = SubElement(feed, "entry")
SubElement(entry, "id").text = f"urn:uuid:{pub.id}"
SubElement(entry, "updated").text = datetime.utcnow().isoformat() + "Z"
# Title
SubElement(entry, f"{{{DC_NS}}}title").text = pub.title or "Untitled"
# Subtitle
if pub.subtitle:
SubElement(entry, f"{{{EXTRA_NS}}}subtitle").text = pub.subtitle
# DOI
if pub.doi:
SubElement(entry, f"{{{DC_NS}}}identifier").text = f"doi:{pub.doi}"
# Journal
if pub.journal:
SubElement(entry, f"{{{DC_NS}}}source").text = pub.journal
# URL
if pub.url:
SubElement(entry, f"{{{DC_NS}}}relation").text = pub.url
# Short description
if pub.short_description:
SubElement(entry, f"{{{DC_NS}}}description").text = pub.short_description
# Citation
if pub.citation_value:
cit = SubElement(entry, f"{{{EXTRA_NS}}}citation")
SubElement(cit, "type").text = pub.citation_type or "unknown"
SubElement(cit, "value").text = pub.citation_value
# Language
if pub.language_code:
SubElement(entry, f"{{{DC_NS}}}language").text = pub.language_code
# Country
if pub.country:
SubElement(entry, f"{{{EXTRA_NS}}}country").text = pub.country
# External IDs
if pub.external_ids:
ext_ids_el = SubElement(entry, f"{{{EXTRA_NS}}}external_ids")
for ext in pub.external_ids:
ext_el = SubElement(ext_ids_el, "external_id")
for k, v in ext.items():
if isinstance(v, dict) and "value" in v:
SubElement(ext_el, k).text = v["value"]
else:
SubElement(ext_el, k).text = str(v)
# Contributors
if pub.contributors:
contribs_el = SubElement(entry, f"{{{EXTRA_NS}}}contributors")
for c in pub.contributors:
c_el = SubElement(contribs_el, "contributor")
SubElement(c_el, "name").text = c.get("name")
SubElement(c_el, "orcid").text = c.get("orcid")
SubElement(c_el, "role").text = c.get("role")
# Date
if pub.pub_year:
date_str = str(pub.pub_year)
if pub.pub_month:
date_str += f"-{pub.pub_month:02d}"
if pub.pub_day:
date_str += f"-{pub.pub_day:02d}"
SubElement(entry, f"{{{DC_NS}}}date").text = date_str
# Type
if pub.type:
SubElement(entry, f"{{{DC_NS}}}type").text = pub.type
# Status (new / updated / unchanged)
if hasattr(pub, "status") and pub.status:
SubElement(entry, f"{{{EXTRA_NS}}}status").text = pub.status
# Last modified
if pub.last_modified:
SubElement(entry, f"{{{EXTRA_NS}}}last_modified").text = pub.last_modified.isoformat()
return tostring(feed, encoding="utf-8", xml_declaration=True)
+136
View File
@@ -0,0 +1,136 @@
from sqlalchemy.orm import Session
import httpx
from app.services.orcid_client import ORCIDClient
from app.services.normalizer import PublicationNormalizer
from app.db.repositories.researcher_repository import ResearcherRepository
from app.db.repositories.publication_repository import PublicationRepository
from app.db.repositories.syncjob_repository import SyncJobRepository
class SyncService:
def __init__(self):
self.orcid_client = ORCIDClient()
def sync_researcher(self, db: Session, orcid_id: str):
"""
Sincroniza las publicaciones de un investigador con manejo robusto de errores.
"""
try:
researcher = ResearcherRepository.get_by_orcid(db, orcid_id)
if not researcher:
record = self.orcid_client.fetch_record(orcid_id)
name = (
record.get("person", {})
.get("name", {})
.get("given-names", {})
.get("value")
)
researcher = ResearcherRepository.create(db, orcid_id, name)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return {
"status": "error",
"code": 404,
"message": f"El ORCID {orcid_id} no existe en ORCID."
}
return {
"status": "error",
"code": e.response.status_code,
"message": f"Error al consultar ORCID: {str(e)}"
}
except Exception as e:
return {
"status": "error",
"code": 500,
"message": f"Error interno durante la sincronización: {str(e)}"
}
job = SyncJobRepository.start_job(db, researcher.id)
try:
works_raw = self.orcid_client.fetch_works(orcid_id)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
SyncJobRepository.finish_job(db, job, 0, 0)
ResearcherRepository.update_last_sync(db, researcher)
return {
"status": "ok",
"message": "El ORCID existe pero no tiene publicaciones públicas.",
"new_records": 0,
"updated_records": 0,
"total": 0
}
return {
"status": "error",
"code": e.response.status_code,
"message": f"Error al obtener works de ORCID: {str(e)}"
}
except Exception as e:
return {
"status": "error",
"code": 500,
"message": f"Error interno al obtener works: {str(e)}"
}
groups = works_raw.get("group", [])
new_records = 0
updated_records = 0
for group in groups:
summary = group["work-summary"][0]
normalized = PublicationNormalizer.normalize_work(summary)
existing = PublicationRepository.get_by_put_code(
db, researcher.id, normalized["put_code"]
)
if existing:
PublicationRepository.update(db, existing, normalized)
updated_records += 1
else:
PublicationRepository.create(db, researcher.id, normalized)
new_records += 1
SyncJobRepository.finish_job(db, job, new_records, updated_records)
ResearcherRepository.update_last_sync(db, researcher)
return {
"status": "ok",
"message": "Sincronización completada correctamente.",
"researcher_id": researcher.id,
"new_records": new_records,
"updated_records": updated_records,
"total": new_records + updated_records
}
def sync_and_get_full(self, db: Session, orcid_id: str):
"""
Sincroniza (si es necesario) y devuelve investigador + publicaciones.
Pensado para el buscador: una sola petición.
"""
sync_result = self.sync_researcher(db, orcid_id)
if sync_result.get("status") == "error":
return sync_result
researcher = ResearcherRepository.get_by_orcid(db, orcid_id)
if not researcher:
return {
"status": "error",
"code": 500,
"message": "Error interno: investigador no encontrado tras sincronización."
}
publications = PublicationRepository.list_by_researcher(db, researcher.id)
return {
"status": "ok",
"researcher": researcher,
"publications": publications
}
+165
View File
@@ -0,0 +1,165 @@
import io
import zipfile
import json
from datetime import datetime
from xml.etree.ElementTree import Element, SubElement, tostring
from app.db.models import Publication, Researcher
from app.services.sword_generator import SWORDGenerator
class ZIPGenerator:
# ---------------------------------------------------------
# MANIFEST.TXT — más completo
# ---------------------------------------------------------
@staticmethod
def generate_manifest(researcher, publications):
lines = [
"SWORD Deposit Package",
"----------------------",
f"Researcher ORCID: {researcher.orcid_id}",
f"Researcher Name: {researcher.name}",
f"Researcher UUID: {researcher.id}",
f"Total Publications: {len(publications)}",
f"Generated At: {datetime.utcnow().isoformat()}Z",
"",
"Publications:",
]
for pub in publications:
year = pub.pub_year or "Unknown"
lines.append(
f"- {pub.title} ({year}) | DOI={pub.doi} | TYPE={pub.type}"
)
return "\n".join(lines)
# ---------------------------------------------------------
# METADATA.JSON — ahora con TODOS los campos
# ---------------------------------------------------------
@staticmethod
def generate_metadata_json(researcher, publications):
data = {
"researcher": {
"orcid_id": researcher.orcid_id,
"name": researcher.name,
"id": str(researcher.id),
"last_sync_at": researcher.last_sync_at.isoformat() if researcher.last_sync_at else None,
},
"generated_at": datetime.utcnow().isoformat() + "Z",
"publications": [],
}
for pub in publications:
data["publications"].append({
"id": str(pub.id),
"put_code": pub.put_code,
"title": pub.title,
"subtitle": pub.subtitle,
"doi": pub.doi,
"journal": pub.journal,
"type": pub.type,
"url": pub.url,
"short_description": pub.short_description,
"citation_type": pub.citation_type,
"citation_value": pub.citation_value,
"language_code": pub.language_code,
"country": pub.country,
"pub_year": pub.pub_year,
"pub_month": pub.pub_month,
"pub_day": pub.pub_day,
"external_ids": pub.external_ids,
"contributors": pub.contributors,
"hash_fingerprint": pub.hash_fingerprint,
"last_modified": pub.last_modified.isoformat() if pub.last_modified else None,
"status": getattr(pub, "status", None),
})
return json.dumps(data, indent=4)
# ---------------------------------------------------------
# METS.XML — ampliado con más metadatos
# ---------------------------------------------------------
@staticmethod
def generate_mets_xml(researcher, publications):
mets = Element("mets", xmlns="http://www.loc.gov/METS/")
header = SubElement(mets, "metsHdr")
agent = SubElement(header, "agent", ROLE="CREATOR", TYPE="OTHER")
SubElement(agent, "name").text = "ORCID Exporter System"
dmd_sec = SubElement(mets, "dmdSec", ID="dmd1")
md_wrap = SubElement(dmd_sec, "mdWrap", MDTYPE="DC")
xml_data = SubElement(md_wrap, "xmlData")
for pub in publications:
# Title
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}title").text = pub.title
# Subtitle
if pub.subtitle:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}description").text = pub.subtitle
# DOI
if pub.doi:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}identifier").text = f"doi:{pub.doi}"
# Journal
if pub.journal:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}source").text = pub.journal
# URL
if pub.url:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}relation").text = pub.url
# Description
if pub.short_description:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}description").text = pub.short_description
# Citation
if pub.citation_value:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}bibliographicCitation").text = pub.citation_value
# Language
if pub.language_code:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}language").text = pub.language_code
# Country
if pub.country:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}coverage").text = pub.country
# Date
if pub.pub_year:
date_str = str(pub.pub_year)
if pub.pub_month:
date_str += f"-{pub.pub_month:02d}"
if pub.pub_day:
date_str += f"-{pub.pub_day:02d}"
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}date").text = date_str
# Type
if pub.type:
SubElement(xml_data, "{http://purl.org/dc/elements/1.1/}type").text = pub.type
return tostring(mets, encoding="utf-8", xml_declaration=True)
# ---------------------------------------------------------
# ZIP FINAL
# ---------------------------------------------------------
@staticmethod
def generate_zip(researcher, publications):
xml_bytes = SWORDGenerator.generate_feed_xml(researcher, publications)
manifest = ZIPGenerator.generate_manifest(researcher, publications)
metadata_json = ZIPGenerator.generate_metadata_json(researcher, publications)
mets_xml = ZIPGenerator.generate_mets_xml(researcher, publications)
mem_file = io.BytesIO()
with zipfile.ZipFile(mem_file, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("sword.xml", xml_bytes)
zf.writestr("manifest.txt", manifest)
zf.writestr("metadata.json", metadata_json)
zf.writestr("mets.xml", mets_xml)
mem_file.seek(0)
return mem_file.read()
+28
View File
@@ -0,0 +1,28 @@
import re
ORCID_REGEX = re.compile(r"^\d{4}-\d{4}-\d{4}-\d{3}[0-9X]$")
def is_valid_orcid(orcid_id: str) -> bool:
"""
Valida un ORCID ID:
- Formato: 0000-0000-0000-0000
- Dígito de control según ISO 7064 Mod 11-2
"""
if not ORCID_REGEX.match(orcid_id):
return False
# Quitar guiones
digits = orcid_id.replace("-", "")
total = 0
# Los primeros 15 dígitos
for char in digits[:-1]:
total = (total + int(char)) * 2
# Resto
remainder = total % 11
result = (12 - remainder) % 11
check_digit = "X" if result == 10 else str(result)
return digits[-1] == check_digit
+14
View File
@@ -0,0 +1,14 @@
fastapi
uvicorn
sqlalchemy
psycopg2-binary
httpx
pydantic
python-dotenv
lxml
apscheduler
authlib
redis
APScheduler==3.10.4
requests
python-jose[cryptography]
+58
View File
@@ -0,0 +1,58 @@
services:
backend:
build: ./backend
container_name: orcid-backend
restart: always
ports:
- "8000:8000"
env_file:
- ./backend/.env
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db
REDIS_URL: redis://redis:6379/0
ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
frontend:
build: ./frontend
container_name: orcid-frontend
restart: always
ports:
- "5173:5173"
depends_on:
- backend
env_file:
- ./frontend/.env
db:
image: postgres:16
container_name: orcid-postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: orcid_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d orcid_db"]
interval: 2s
timeout: 3s
retries: 20
redis:
image: redis:7
container_name: orcid-redis
restart: always
ports:
- "6379:6379"
volumes:
postgres_data:
+31
View File
@@ -0,0 +1,31 @@
# No copiar artefactos del host al contenedor.
#
# El error "sh: vite: not found" al hacer `docker compose up` aparece
# cuando los node_modules del host (Windows / macOS) sobrescriben los
# que `npm install` acaba de instalar dentro del contenedor Linux.
# Excluyéndolos aquí, el `COPY . .` del Dockerfile no los pisa.
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Builds locales / cachés.
dist/
build/
.vite/
*.timestamp-*
# Secretos: docker-compose ya inyecta las variables vía `env_file`,
# no necesitamos copiarlos al filesystem de la imagen.
.env
.env.*
!.env.example
# Editor / OS.
.git/
.gitignore
.DS_Store
Thumbs.db
.idea/
.vscode/
+45
View File
@@ -0,0 +1,45 @@
# URL base del backend FastAPI (sin barra final).
#
# En desarrollo puedes dejarlo en blanco y el proxy de Vite
# (ver vite.config.js) reenviará todo lo que cuelgue de /api al
# destino indicado en VITE_API_PROXY_TARGET. Esto evita problemas
# de CORS sin exponer el host del backend al navegador.
VITE_API_URL=http://localhost:8000/api
# Solo para dev: destino al que el proxy de Vite reenvía las peticiones
# que empiecen por /api. Cambia a http://backend:8000 si ejecutas el
# frontend dentro de docker-compose.
VITE_API_PROXY_TARGET=http://localhost:8000
# Clave compartida con el backend. Se inyecta como header `X-API-Key`
# en TODAS las peticiones salientes (ver src/services/api.js). Debe
# coincidir con `API_KEY_VALUE` del .env del backend.
VITE_API_KEY=12ao.9-8a7b-4c&d-9e,f-?89abc
# Pon "true" SOLO si el backend no está disponible y quieres trabajar
# con los fixtures de src/services/mocks.js. En producción debe estar a "false".
VITE_USE_MOCKS=false
# ── Autenticación OAuth ORCID ────────────────────────────────────────────────
#
# El flujo real es:
# 1. Frontend abre popup → GET /api/auth/orcid/authorize
# 2. Backend redirige a sandbox.orcid.org (o pub.orcid.org en producción)
# 3. Usuario se autentica en ORCID
# 4. ORCID redirige a ORCID_REDIRECT_URI (debe apuntar a esta app)
# 5. /auth/callback extrae el code y llama al backend para obtener el JWT
#
# Para que el callback vuelva al frontend, el backend necesita:
# ORCID_REDIRECT_URI=http://localhost:5173/callback
# (en backend/.env — debe coincidir con el redirect URI del app ORCID sandbox)
# En producción con ngrok u otro túnel, el formato sería:
# ORCID_REDIRECT_URI=https://<tu-dominio>/callback
#
# ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ─────────
# Cuando está a "true", el botón "Iniciar sesión" genera un token simulado
# a partir del ORCID introducido en el campo de texto, sin abrir popup ni
# contactar al backend de auth. Útil para probar la UI autenticada
# (badges "Nuevo", botón "Descargar lo nuevo") sin OAuth real.
# ADVERTENCIA: el token simulado NO es válido en el backend, por lo que
# downloaded_by_me siempre será null (sin datos reales de "novedad").
VITE_AUTH_BYPASS=false
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host"]
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>orcid-system</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2936
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "orcid-system",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.4"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+35
View File
@@ -0,0 +1,35 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { AuthProvider } from "./contexts/AuthContext";
import { LandingPage } from "./pages/LandingPage";
import { DashboardPage } from "./pages/DashboardPage";
import { GroupResultsPage } from "./pages/GroupResultsPage";
import { AuthCallbackPage } from "./pages/AuthCallbackPage";
/**
* App shell. Declares the top-level routes and mounts the global
* notification portal (sonner). Router itself lives in `main.jsx` so tests
* can wrap `<App />` with a `MemoryRouter` if needed.
*/
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard/:orcid" element={<DashboardPage />} />
<Route path="/group" element={<GroupResultsPage />} />
<Route path="/callback" element={<AuthCallbackPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster
position="top-right"
richColors
closeButton
theme="light"
toastOptions={{ duration: 4000 }}
/>
</AuthProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronDownIcon,
DocumentIcon,
DownloadIcon,
PackageIcon,
SparkleIcon,
} from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
const FORMATS = [
{
format: "xml",
icon: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
label: "SWORD XML",
desc: "Metadatos en formato Atom",
},
{
format: "zip",
icon: <PackageIcon size={20} className="shrink-0 text-ink-secondary" />,
label: "Paquete ZIP",
desc: "XML + ficheros adjuntos",
},
];
/**
* SWORD export dropdown. Delegatea the actual download to `onExport(format)`.
*
* Props:
* - `isAuthenticated` → cambia el texto del botón principal.
* - `newPublicationsCount` → cuántas publicaciones tiene downloaded_by_me=false.
* - `selectedCount` → publicaciones seleccionadas manualmente.
* - `exportingFormat` → formato en curso (pone el botón en loading).
*/
export function ExportDropdown({
onExport,
exportingFormat = null,
selectedCount = 0,
isAuthenticated = false,
newPublicationsCount = 0,
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
function handleClick(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0;
function handlePick(format) {
setOpen(false);
onExport(format);
}
// Label logic:
// manual selection → always "Exportar seleccionadas (N)"
// logged in, no selection → "Descargar lo nuevo (N)" or "Todo descargado"
// not logged in, no selection → "Descargar todo"
let idleLabel;
let showSparkle = false;
if (hasSelection) {
idleLabel = `Exportar seleccionadas (${selectedCount})`;
} else if (isAuthenticated) {
if (newPublicationsCount > 0) {
idleLabel = `Descargar lo nuevo (${newPublicationsCount})`;
showSparkle = true;
} else {
idleLabel = "Todo descargado";
}
} else {
idleLabel = "Descargar todo";
}
return (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={isBusy || (isAuthenticated && !hasSelection && newPublicationsCount === 0)}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70"
>
{isBusy ? (
<Spinner size={15} />
) : showSparkle ? (
<SparkleIcon size={15} className="text-brand-accent" />
) : (
<DownloadIcon />
)}
{isBusy
? `Exportando ${exportingFormat.toUpperCase()}...`
: idleLabel}
{!isBusy && <ChevronDownIcon />}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[210px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{FORMATS.map(({ format, icon, label, desc }, idx) => (
<button
key={format}
type="button"
onClick={() => handlePick(format)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-secondary ${
idx < FORMATS.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
{icon}
<div>
<div className="text-sm font-medium text-ink-primary">
{label}
</div>
<div className="text-xs text-ink-tertiary">{desc}</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,614 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge";
const COLUMNS = [
{ key: "title", label: "Título" },
{ key: "journal", label: "Revista / Fuente" },
{ key: "publication_year", label: "Año" },
{ key: "doi", label: "DOI" },
{ key: "type", label: "Tipo" },
];
const PAGE_SIZE = 15;
const EMPTY_SELECTION = new Set();
function SortIcon({ active, direction }) {
const path =
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={`ml-1 ${active ? "opacity-100" : "opacity-30"}`}
aria-hidden
>
<path d={path} fill="currentColor" />
</svg>
);
}
function sortPublications(rows, key, direction) {
const sorted = [...rows].sort((a, b) => {
const va = a[key];
const vb = b[key];
const cmp =
typeof va === "string" ? va.localeCompare(vb) : (va ?? 0) - (vb ?? 0);
return direction === "asc" ? cmp : -cmp;
});
return sorted;
}
/**
* Tri-state checkbox. We can't express `indeterminate` via React props, so
* we set it imperatively on the DOM node whenever the flag changes.
*/
function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate && !checked;
}, [indeterminate, checked]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={onChange}
aria-label={ariaLabel}
className="h-4 w-4 cursor-pointer accent-brand-accent"
/>
);
}
/**
* Publications table. UI-state (filter, sort, pagination) lives here; the
* *selection* set is lifted to the parent so export / bulk actions can see
* it. Data, loading and error states are also driven by the parent so
* retries and toasts can be handled in one place.
*
* Selection semantics:
* - The master checkbox toggles the WHOLE currently-filtered set (not
* just the visible page). This matches the user mental model of
* "filtrar por 2024 → marcar todas de 2024".
* - Selection survives filter changes: the stored IDs remain even if
* those rows are no longer visible.
*/
export function PublicationsTable({
publications,
loading = false,
error = null,
onRetry,
selectedIds = EMPTY_SELECTION,
onSelectedIdsChange,
isAuthenticated = false,
}) {
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year");
const [sortDir, setSortDir] = useState("desc");
const [page, setPage] = useState(1);
const [filtersOpen, setFiltersOpen] = useState(false);
const [yearFrom, setYearFrom] = useState("");
const [yearTo, setYearTo] = useState("");
const availableYears = useMemo(() => {
const years = publications
.map((p) => p.publication_year)
.filter((y) => typeof y === "number" && Number.isFinite(y));
if (years.length === 0) return [];
const min = Math.min(...years);
const max = Math.max(...years, new Date().getFullYear());
const list = [];
for (let y = max; y >= min; y -= 1) list.push(y);
return list;
}, [publications]);
const hasYearFilter = yearFrom !== "" || yearTo !== "";
useEffect(() => {
if (availableYears.length === 0) return;
if (yearFrom && !availableYears.includes(Number(yearFrom))) setYearFrom("");
if (yearTo && !availableYears.includes(Number(yearTo))) setYearTo("");
}, [availableYears, yearFrom, yearTo]);
const filtered = useMemo(() => {
const needle = filter.trim().toLowerCase();
let rows = publications;
if (needle) {
rows = rows.filter(
(p) =>
(p.title ?? "").toLowerCase().includes(needle) ||
(p.journal ?? "").toLowerCase().includes(needle) ||
String(p.publication_year ?? "").includes(needle) ||
(p.doi ?? "").toLowerCase().includes(needle),
);
}
if (hasYearFilter) {
const from = yearFrom ? Number(yearFrom) : null;
const to = yearTo ? Number(yearTo) : null;
rows = rows.filter((p) => {
const year = p.publication_year;
if (typeof year !== "number" || !Number.isFinite(year)) return false;
if (from !== null && year < from) return false;
if (to !== null && year > to) return false;
return true;
});
}
return sortPublications(rows, sortKey, sortDir);
}, [publications, filter, yearFrom, yearTo, hasYearFilter, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]);
const selectionStats = useMemo(() => {
if (filtered.length === 0) {
return { allChecked: false, anyChecked: false, selectedInFiltered: 0 };
}
let count = 0;
for (const pub of filtered) {
if (selectedIds.has(pub.id)) count += 1;
}
return {
allChecked: count === filtered.length,
anyChecked: count > 0,
selectedInFiltered: count,
};
}, [filtered, selectedIds]);
function toggleSort(key) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
setPage(1);
} else {
setSortKey(key);
setSortDir("desc");
setPage(1);
}
}
function emit(nextSet) {
onSelectedIdsChange?.(nextSet);
}
function toggleRow(id) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
emit(next);
}
function toggleAllFiltered() {
const next = new Set(selectedIds);
if (selectionStats.allChecked) {
for (const pub of filtered) next.delete(pub.id);
} else {
for (const pub of filtered) next.add(pub.id);
}
emit(next);
}
function handleYearFromChange(value) {
setYearFrom(value);
// Si el usuario elige un "Desde" mayor que el "Hasta" actual,
// auto-corregimos el "Hasta" para preservar un rango coherente.
if (value && yearTo && Number(value) > Number(yearTo)) {
setYearTo(value);
}
setPage(1);
}
function handleYearToChange(value) {
setYearTo(value);
if (value && yearFrom && Number(value) < Number(yearFrom)) {
setYearFrom(value);
}
setPage(1);
}
function clearYearFilter() {
setYearFrom("");
setYearTo("");
setPage(1);
}
const pageStart =
filtered.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
const pageEnd = Math.min(currentPage * PAGE_SIZE, filtered.length);
const yearFilterSummary = hasYearFilter
? yearFrom && yearTo && yearFrom === yearTo
? `Año: ${yearFrom}`
: `Años: ${yearFrom || "…"} ${yearTo || "…"}`
: null;
return (
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
{/* Toolbar */}
<div className="border-b border-surface-border/60">
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<h3 className="text-base font-medium text-ink-primary">
Publicaciones
</h3>
<p className="mt-0.5 text-xs text-ink-tertiary">
{filtered.length} de {publications.length} resultados
{yearFilterSummary && (
<>
{" · "}
<span className="text-ink-secondary">{yearFilterSummary}</span>
</>
)}
{selectedIds.size > 0 && (
<>
{" · "}
<span className="font-medium text-brand-accent">
{selectedIds.size} seleccionada
{selectedIds.size === 1 ? "" : "s"}
</span>
</>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setFiltersOpen((o) => !o)}
aria-expanded={filtersOpen}
aria-controls="pubs-advanced-filters"
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors ${
filtersOpen || hasYearFilter
? "border-brand-accent/50 bg-brand-accent/10 text-brand-accent"
: "border-surface-border-strong bg-surface-secondary text-ink-secondary hover:bg-surface-primary"
}`}
>
<FilterIcon />
Filtros
{hasYearFilter && (
<span
className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-brand-accent"
aria-hidden
/>
)}
<ChevronDownIcon
className={`transition-transform ${filtersOpen ? "rotate-180" : ""}`}
/>
</button>
<div className="relative">
<input
type="text"
placeholder="Filtrar publicaciones..."
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setPage(1);
}}
className="w-[220px] rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent"
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
<SearchIcon />
</span>
</div>
</div>
</div>
{filtersOpen && (
<div
id="pubs-advanced-filters"
className="flex flex-wrap items-end gap-4 border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3"
>
<div className="flex flex-col gap-1">
<label
htmlFor="year-from"
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
>
Desde año
</label>
<select
id="year-from"
value={yearFrom}
onChange={(e) => handleYearFromChange(e.target.value)}
disabled={availableYears.length === 0}
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Cualquiera</option>
{availableYears.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="year-to"
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
>
Hasta año
</label>
<select
id="year-to"
value={yearTo}
onChange={(e) => handleYearToChange(e.target.value)}
disabled={availableYears.length === 0}
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Cualquiera</option>
{availableYears.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{hasYearFilter && (
<button
type="button"
onClick={clearYearFilter}
className="mb-[2px] rounded-md px-2.5 py-1.5 text-xs font-medium text-ink-tertiary transition-colors hover:bg-surface-primary hover:text-ink-primary"
>
Limpiar rango
</button>
)}
{availableYears.length === 0 && (
<p className="mb-[2px] text-xs text-ink-tertiary">
Aún no hay años disponibles.
</p>
)}
</div>
)}
</div>
{/* Body */}
<div className="overflow-x-auto">
{error ? (
<ErrorState error={error} onRetry={onRetry} />
) : loading ? (
<LoadingState />
) : (
<table className="w-full min-w-[720px] border-collapse">
<thead>
<tr className="bg-surface-secondary">
<th
scope="col"
className="w-10 border-b border-surface-border/60 px-4 py-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<TriStateCheckbox
checked={selectionStats.allChecked}
indeterminate={selectionStats.anyChecked}
onChange={toggleAllFiltered}
ariaLabel="Seleccionar todas las publicaciones del filtro actual"
/>
</th>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className="select-none whitespace-nowrap border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary"
>
<span className="flex cursor-pointer items-center">
{col.label.toUpperCase()}
<SortIcon
active={sortKey === col.key}
direction={sortDir}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={COLUMNS.length + 1}
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con los filtros aplicados.
</td>
</tr>
) : (
pageRows.map((pub, i) => {
const isSelected = selectedIds.has(pub.id);
return (
<tr
key={pub.id}
className={`transition-colors ${
isSelected
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
: "hover:bg-surface-secondary/70"
} ${
i < pageRows.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<td
className="w-10 cursor-pointer px-4 py-3.5"
onClick={(e) => {
e.stopPropagation();
toggleRow(pub.id);
}}
>
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/>
</td>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
<span className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
{pub.title}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal || "—"}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
{pub.publication_year ?? "—"}
</td>
<td className="px-4 py-3.5">
{pub.doi ? (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
</a>
) : (
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
</span>
)}
</td>
<td className="px-4 py-3.5">
<Badge type={pub.type} />
</td>
</tr>
);
})
)}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{!loading && !error && filtered.length > 0 && (
<PaginationBar
page={currentPage}
totalPages={totalPages}
pageStart={pageStart}
pageEnd={pageEnd}
total={filtered.length}
onPrev={() => setPage((p) => Math.max(1, Math.min(p, totalPages) - 1))}
onNext={() => setPage((p) => Math.min(totalPages, Math.min(p, totalPages) + 1))}
/>
)}
</section>
);
}
function PaginationBar({
page,
totalPages,
pageStart,
pageEnd,
total,
onPrev,
onNext,
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
const isSinglePage = totalPages <= 1;
const noun = total === 1 ? "publicación" : "publicaciones";
const visibleCount = pageEnd - pageStart + 1;
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-surface-border/60 px-5 py-3">
{isSinglePage ? (
// Caso "todo entra en una página": evitamos el "del X al Y"
// porque coincide con el total y resulta ruidoso. Un simple
// "Mostrando N de N publicaciones" comunica lo mismo con menos
// palabras.
<p className="text-xs text-ink-tertiary">
Mostrando{" "}
<span className="font-medium text-ink-secondary">{visibleCount}</span>{" "}
de <span className="font-medium text-ink-secondary">{total}</span>{" "}
{noun}
</p>
) : (
<p className="text-xs text-ink-tertiary">
Mostrando del{" "}
<span className="font-medium text-ink-secondary">{pageStart}</span>{" "}
al <span className="font-medium text-ink-secondary">{pageEnd}</span>{" "}
de un total de{" "}
<span className="font-medium text-ink-secondary">{total}</span> {noun}
</p>
)}
{!isSinglePage && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onPrev}
disabled={!hasPrev}
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
>
Anterior
</button>
<span className="text-xs text-ink-tertiary">
Página <span className="font-medium text-ink-primary">{page}</span>{" "}
de <span className="font-medium text-ink-primary">{totalPages}</span>
</span>
<button
type="button"
onClick={onNext}
disabled={!hasNext}
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
>
Siguiente
</button>
</div>
)}
</div>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
<Spinner size={22} />
<p className="text-sm">Cargando publicaciones</p>
</div>
);
}
function ErrorState({ error, onRetry }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<span className="text-ink-danger">
<AlertIcon size={28} />
</span>
<div>
<p className="text-sm font-medium text-ink-primary">
No se pudieron cargar las publicaciones
</p>
<p className="mt-1 text-xs text-ink-tertiary">
{error?.message ?? "Error desconocido."}
</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-primary-hover"
>
Reintentar
</button>
)}
</div>
);
}
@@ -0,0 +1,53 @@
import { ClockIcon } from "../ui/Icons";
import { OrcidLogo } from "../ui/OrcidLogo";
import { formatDate, getInitials } from "../../utils/formatters";
/**
* Header card with avatar + researcher identity + "last sync" timestamp.
* Accepts an optional `actions` slot so the page can inject the Sync /
* Export buttons without coupling this component to API logic.
*/
export function ResearcherCard({ researcher, actions = null }) {
const title = researcher.name || researcher.orcid_id || "Perfil ORCID";
return (
<section className="mb-5 flex flex-wrap items-start gap-5 rounded-2xl border border-surface-border/60 bg-surface-primary px-7 py-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-primary text-xl font-semibold text-white">
{getInitials(title)}
</div>
<div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{title}
</h2>
<div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5">
<OrcidLogo />
<span className="font-mono text-[13px] text-ink-secondary">
{researcher.orcid_id}
</span>
</div>
{researcher.affiliation && (
<>
<span className="text-surface-border-strong">·</span>
<span className="text-[13px] text-ink-secondary">
{researcher.affiliation}
</span>
</>
)}
</div>
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
<ClockIcon />
<span className="text-xs">
Última sincronización: {formatDate(researcher.last_sync_at)}
</span>
</div>
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2.5">
{actions}
</div>
)}
</section>
);
}
@@ -0,0 +1,48 @@
/**
* Derives the summary stats (totals + per-type counts) from the raw
* publications list. Returns a Tailwind class per card so the accent colour
* matches the palette used by `Badge`.
*/
function buildStats(publications) {
const total = publications.length;
const count = (type) => publications.filter((p) => p.type === type).length;
return [
{ label: "Publicaciones", value: total, valueClass: "text-brand-primary" },
{
label: "Artículos",
value: count("journal-article"),
valueClass: "text-tag-article-text",
},
{
label: "Revisiones",
value: count("review"),
valueClass: "text-tag-review-text",
},
{
label: "Conferencias",
value: count("conference-paper"),
valueClass: "text-tag-conference-text",
},
];
}
export function StatsRow({ publications }) {
const stats = buildStats(publications);
return (
<section className="mb-5 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
{stats.map(({ label, value, valueClass }) => (
<div
key={label}
className="rounded-xl border border-surface-border/60 bg-surface-primary px-5 py-4"
>
<div className="mb-1.5 text-xs tracking-wide text-ink-secondary">
{label}
</div>
<div className={`text-[26px] font-semibold ${valueClass}`}>
{value}
</div>
</div>
))}
</section>
);
}
@@ -0,0 +1,39 @@
import { CheckIcon, RefreshIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
/**
* Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash).
*/
export function SyncButton({ onClick, status = "idle" }) {
const isLoading = status === "loading";
const isSuccess = status === "success";
const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
: isLoading
? "bg-surface-secondary text-ink-secondary border border-surface-border"
: "bg-brand-primary text-white border border-transparent hover:bg-brand-primary-hover";
return (
<button
type="button"
onClick={onClick}
disabled={isLoading}
className={`inline-flex items-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette}`}
>
{isLoading ? (
<Spinner size={15} />
) : isSuccess ? (
<CheckIcon />
) : (
<RefreshIcon />
)}
{isLoading
? "Sincronizando..."
: isSuccess
? "Sincronizado"
: "Sincronizar ahora"}
</button>
);
}
@@ -0,0 +1,104 @@
import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { ArrowLeftIcon, LayersIcon, LogoutIcon, UserCheckIcon } from "../ui/Icons";
import { useAuth } from "../../contexts/AuthContext";
/**
* Institutional navy header used across all views.
*
* Variants:
* - `landing` → logo + full product name.
* - `dashboard` → back button to `/` + auth indicator + logout (if logged in).
* - `group` → back button to `/` + group label + auth indicator.
*/
export function AppHeader({ variant = "landing" }) {
const { isAuthenticated, userOrcidId, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
logout();
toast.success("Sesión cerrada", {
description: "Has cerrado sesión correctamente.",
});
navigate("/");
}
if (variant === "dashboard" || variant === "group") {
return (
<header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white">
<Link
to="/"
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<ArrowLeftIcon />
Inicio
</Link>
<div className="flex-1" />
{isAuthenticated && (
<div className="flex items-center gap-3">
{userOrcidId && (
<Link
to={`/dashboard/${userOrcidId}`}
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<UserCheckIcon size={13} />
Mi perfil
</Link>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
<UserCheckIcon size={13} />
Sesión activa
</span>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<LogoutIcon />
Cerrar sesión
</button>
</div>
)}
<span className="text-[13px] text-white/60">
{variant === "group" ? "Búsqueda grupal · ORCID" : "Sistema ORCID · SWORD"}
</span>
</header>
);
}
return (
<header className="flex items-center gap-3 bg-brand-primary px-8 py-3.5">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white/15 text-white">
<LayersIcon />
</div>
<span className="text-sm font-medium tracking-wide text-white">
Sistema de Integración ORCID · SWORD
</span>
{isAuthenticated && (
<div className="ml-auto flex items-center gap-2">
{userOrcidId && (
<Link
to={`/dashboard/${userOrcidId}`}
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<UserCheckIcon size={13} />
Mi perfil
</Link>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
<UserCheckIcon size={13} />
Sesión activa
</span>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
>
<LogoutIcon />
Cerrar sesión
</button>
</div>
)}
</header>
);
}
+22
View File
@@ -0,0 +1,22 @@
import {
DEFAULT_BADGE_CLASSES,
TYPE_BADGE_CLASSES,
TYPE_LABELS,
} from "../../utils/publicationTypes";
/**
* Pill-style badge that colour-codes a publication type (article, review, …).
* Falls back to the neutral palette for unknown types.
*/
export function Badge({ type }) {
const label = TYPE_LABELS[type] ?? type;
const classes = TYPE_BADGE_CLASSES[type] ?? DEFAULT_BADGE_CLASSES;
return (
<span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium tracking-wide ${classes}`}
>
{label}
</span>
);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Centralised collection of inline SVG icons used across the app. Keeping
* them here avoids pulling a full icon library for ~10 glyphs while still
* letting consumers style them via `className` (stroke inherits from
* `currentColor`).
*/
const base = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
};
export function DocumentIcon({ size = 36, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M9 12h6M9 16h6M9 8h2" />
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
<path d="M13 2v5a2 2 0 002 2h5" />
</svg>
);
}
export function LayersIcon({ size = 18, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M12 3L2 8l10 5 10-5-10-5zM2 13l10 5 10-5M2 18l10 5 10-5" />
</svg>
);
}
export function ArrowLeftIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
);
}
export function ClockIcon({ size = 13, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.5} className={className}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 3" />
</svg>
);
}
export function CheckIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M5 13l4 4L19 7" />
</svg>
);
}
export function RefreshIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
);
}
export function DownloadIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
}
export function ChevronDownIcon({ size = 12, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function SearchIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
);
}
export function FilterIcon({ size = 14, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
);
}
export function AlertIcon({ size = 16, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
);
}
export function PackageIcon({ size = 18, className = "" }) {
return (
<svg {...base} width={size} height={size} className={className}>
<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z" />
<path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" />
<path d="M7.5 4.21l9 5.16" />
</svg>
);
}
export function LogoutIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
);
}
export function UsersIcon({ size = 16, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
);
}
export function SparkleIcon({ size = 12, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
);
}
export function UserCheckIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<polyline points="16 11 18 13 22 9" />
</svg>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Official ORCID iD glyph.
*/
export function OrcidLogo({ size = 18, className = "" }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} className={className} xmlns="http://www.w3.org/2000/svg" aria-label="ORCID iD" role="img">
<path d="M256,128c0,70.7-57.3,128-128,128C57.3,256,0,198.7,0,128C0,57.3,57.3,0,128,0C198.7,0,256,57.3,256,128z" fill="#a6ce39"/>
<path d="M86.3,186.2H70.9V79.1h15.4v48.4V186.2z" fill="#fff"/>
<path d="M108.9,79.1h41.6c39.6,0,57,28.3,57,53.6c0,27.5-21.5,53.6-56.8,53.6h-41.8V79.1z M124.3,172.4h24.5c34.9,0,42.9-26.5,42.9-39.7c0-21.5-13.7-39.7-43.7-39.7h-23.7V172.4z" fill="#fff"/>
<path d="M88.7,56.8c0,5.5-4.5,10.1-10.1,10.1c-5.6,0-10.1-4.6-10.1-10.1c0-5.6,4.5-10.1,10.1-10.1C84.2,46.7,88.7,51.3,88.7,56.8z" fill="#fff"/>
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Small inline spinner. Uses Tailwind's `animate-spin` utility, so no custom
* keyframes are required. Inherits colour from its parent via `currentColor`.
*/
export function Spinner({ size = 16, className = "" }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={`animate-spin ${className}`}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="40 20"
strokeLinecap="round"
/>
</svg>
);
}
+98
View File
@@ -0,0 +1,98 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
const STORAGE_KEY = "orcid_auth_token";
// Message type sent by AuthCallbackPage (runs in the OAuth popup)
// to notify the parent window that authentication succeeded.
export const AUTH_MESSAGE_TYPE = "ORCID_AUTH_TOKEN";
export const AUTH_ERROR_TYPE = "ORCID_AUTH_ERROR";
const AuthContext = createContext(null);
function extractOrcidFromToken(token) {
if (!token) return null;
try {
const payloadBase64 = token.split(".")[1];
if (!payloadBase64) return null;
const payloadJson = atob(payloadBase64.replace(/-/g, "+").replace(/_/g, "/"));
const payload = JSON.parse(payloadJson);
return payload?.sub ?? null;
} catch {
return null;
}
}
/**
* Provides JWT-based authentication state throughout the app.
*
* Authentication flow (OAuth 3-legged):
* 1. User clicks "Iniciar sesión" → frontend opens popup at
* GET /api/auth/orcid/authorize.
* 2. Backend redirects to ORCID (sandbox or production).
* 3. User authenticates at orcid.org.
* 4. ORCID redirects to ORCID_REDIRECT_URI (= frontend /auth/callback).
* 5. AuthCallbackPage exchanges the `code` for a JWT via the backend.
* 6. Popup sends postMessage({ type: "ORCID_AUTH_TOKEN", token }) to
* opener and closes itself.
* 7. This provider's message listener stores the token and updates state.
*
*/
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem(STORAGE_KEY));
// Listen for messages from the OAuth popup window.
useEffect(() => {
function handleMessage(event) {
// Only accept messages from the same origin (the React app itself,
// running in the popup after the OAuth redirect lands there).
if (event.origin !== window.location.origin) return;
if (event.data?.type === AUTH_MESSAGE_TYPE && event.data?.token) {
localStorage.setItem(STORAGE_KEY, event.data.token);
setToken(event.data.token);
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
/**
* Stores a JWT directly (used by AuthCallbackPage).
* Does NOT trigger any network request.
*/
const storeToken = useCallback((accessToken) => {
localStorage.setItem(STORAGE_KEY, accessToken);
setToken(accessToken);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setToken(null);
}, []);
const value = useMemo(
() => ({
token,
isAuthenticated: Boolean(token),
userOrcidId: extractOrcidFromToken(token),
storeToken,
logout,
}),
[token, storeToken, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}
+76
View File
@@ -0,0 +1,76 @@
@import "tailwindcss";
/* ───────────────────────────────────────────────────────────────────────────
* Design tokens — Institutional palette (ORCID · UJA)
* ─────────────────────────────────────────────────────────────────────── */
@theme {
/* Brand (institutional navy) */
--color-brand-primary: #0B3D6B;
--color-brand-primary-hover: #0a345c;
--color-brand-accent: #185FA5;
/* ORCID brand */
--color-orcid-green: #A6CE39;
--color-orcid-green-dark: #2A3D00;
--color-orcid-green-soft: #EAF3DE;
--color-orcid-green-border: #C0DD97;
--color-orcid-green-text: #3B6D11;
/* Surfaces (clean neutrals) */
--color-surface-primary: #FFFFFF;
--color-surface-secondary: #F6F5F0;
--color-surface-tertiary: #FAF9F5;
--color-surface-border: #E4E2D8;
--color-surface-border-strong: #CDCAB9;
/* Text */
--color-ink-primary: #1F1F1C;
--color-ink-secondary: #55534B;
--color-ink-tertiary: #8B887C;
--color-ink-danger: #B42318;
--color-border-danger: #F97066;
/* Type colours (publication badges) */
--color-tag-article-bg: #EBF5FF;
--color-tag-article-text: #1A5FA8;
--color-tag-article-border: #B5D4F4;
--color-tag-review-bg: #F0FFF4;
--color-tag-review-text: #1A6B3A;
--color-tag-review-border: #9FE1CB;
--color-tag-conference-bg: #FFF8E6;
--color-tag-conference-text: #7A4A00;
--color-tag-conference-border: #FAC775;
--color-tag-book-bg: #F5F0FF;
--color-tag-book-text: #4B30A8;
--color-tag-book-border: #C5BCEE;
--color-tag-dataset-bg: #FFF0F5;
--color-tag-dataset-text: #8B2252;
--color-tag-dataset-border: #F4C0D1;
--color-tag-default-bg: #F1EFE8;
--color-tag-default-text: #5F5E5A;
--color-tag-default-border: #D3D1C7;
/* Fonts */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--color-ink-primary);
background: var(--color-surface-tertiary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Spinner } from "../components/ui/Spinner";
import { AlertIcon, CheckIcon } from "../components/ui/Icons";
import { exchangeOrcidCode } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext";
/**
* OAuth callback page — mounted at /callback.
*
* ORCID redirects here after the user authenticates. We extract the
* authorization `code`, exchange it for a JWT via the backend, store
* the token and — if running inside a popup — notify the opener and
* close the window. Otherwise we navigate back to the landing page.
*
* For this page to be reached, the backend's ORCID_REDIRECT_URI env var
* must be set to <frontend-origin>/callback, e.g.:
* ORCID_REDIRECT_URI=http://localhost:5173/callback
*/
export function AuthCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { storeToken } = useAuth();
const [status, setStatus] = useState("loading"); // loading | success | error
const [errorMsg, setErrorMsg] = useState("");
const hasHandledCodeRef = useRef(false);
useEffect(() => {
// React StrictMode may remount components in development. OAuth codes
// are single-use, so a second exchange attempt triggers backend errors.
// This in-memory guard handles duplicate effect runs in same mount.
if (hasHandledCodeRef.current) return;
hasHandledCodeRef.current = true;
const code = searchParams.get("code");
const oauthError = searchParams.get("error");
const errorDescription = searchParams.get("error_description");
// User denied access at ORCID
if (oauthError) {
const msg =
errorDescription ??
(oauthError === "access_denied"
? "Acceso denegado en ORCID."
: `Error OAuth: ${oauthError}`);
setStatus("error");
setErrorMsg(msg);
notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg });
return;
}
if (!code) {
const msg = "No se recibió el código de autorización de ORCID.";
setStatus("error");
setErrorMsg(msg);
notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg });
return;
}
// Persistent dedupe across remounts/reloads in the popup.
const consumedKey = `orcid_oauth_code_consumed:${code}`;
if (sessionStorage.getItem(consumedKey) === "1") {
setStatus("success");
notifyAndClose({ type: AUTH_MESSAGE_TYPE });
return;
}
sessionStorage.setItem(consumedKey, "1");
exchangeOrcidCode(code)
.then(({ access_token }) => {
storeToken(access_token);
setStatus("success");
notifyAndClose({ type: AUTH_MESSAGE_TYPE, token: access_token });
})
.catch((err) => {
// Allow re-trying if the first attempt failed before code exchange
// actually happened on the backend (network cut, popup close, etc.).
sessionStorage.removeItem(consumedKey);
const msg = err?.message ?? "No se pudo completar el inicio de sesión.";
setStatus("error");
setErrorMsg(msg);
notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// After a short delay, redirect to home if we're NOT in a popup
// (fallback for browsers that block window.open).
useEffect(() => {
if (status === "success" || status === "error") {
const isPopup = Boolean(window.opener);
if (!isPopup) {
const timer = setTimeout(() => navigate("/"), 2000);
return () => clearTimeout(timer);
}
}
}, [status, navigate]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-5 bg-surface-tertiary p-8 text-center">
{status === "loading" && (
<>
<Spinner size={32} />
<div>
<p className="text-base font-medium text-ink-primary">
Completando inicio de sesión...
</p>
<p className="mt-1 text-sm text-ink-secondary">
Verificando credenciales con ORCID.
</p>
</div>
</>
)}
{status === "success" && (
<>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckIcon size={28} />
</div>
<div>
<p className="text-base font-medium text-ink-primary">
¡Sesión iniciada correctamente!
</p>
<p className="mt-1 text-sm text-ink-secondary">
Cerrando ventana...
</p>
</div>
</>
)}
{status === "error" && (
<>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-100 text-red-500">
<AlertIcon size={28} />
</div>
<div>
<p className="text-base font-medium text-ink-primary">
Error al iniciar sesión
</p>
<p className="mt-1 text-sm text-ink-secondary">{errorMsg}</p>
<p className="mt-2 text-xs text-ink-tertiary">
Cerrando ventana...
</p>
</div>
</>
)}
</div>
);
}
/* ─────────────────────────── Helpers ───────────────────────────── */
/**
* If running in a popup, posts a message to the opener and closes the
* window. If not in a popup (e.g. browser blocked it), the message is
* irrelevant — the useEffect above handles the redirect to "/".
*/
function notifyAndClose(message) {
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage(message, window.location.origin);
} catch {
/* opener may have navigated away */
}
// Small delay so the user sees the success/error state before close.
setTimeout(() => window.close(), 1200);
}
}
export default AuthCallbackPage;
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useParams, Navigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { ResearcherCard } from "../components/dashboard/ResearcherCard";
import { StatsRow } from "../components/dashboard/StatsRow";
import { PublicationsTable } from "../components/dashboard/PublicationsTable";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { SyncButton } from "../components/dashboard/SyncButton";
import {
downloadExport,
searchResearcher,
syncResearcher,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000;
/**
* Researcher detail page. Owns:
* - Carga inicial vía `searchResearcher`. Si llegamos desde la landing
* usamos el bundle ya cargado en `location.state` para evitar
* duplicar la petición.
* - Re-sync manual (POST + actualización de estado in-place + toast).
* - Exportación SWORD/ZIP:
* · Si hay selección manual → exporta esos IDs.
* · Si el usuario está autenticado y sin selección → exporta solo
* los IDs con downloaded_by_me=false ("lo nuevo").
* · Si no está autenticado y sin selección → exporta todo.
*/
export function DashboardPage() {
const { orcid } = useParams();
const location = useLocation();
const { isAuthenticated } = useAuth();
const initialBundleRef = useRef(location.state?.bundle ?? null);
const initialBundle = initialBundleRef.current;
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
const [publications, setPublications] = useState(
initialBundle?.publications ?? [],
);
const [pubsLoading, setPubsLoading] = useState(!initialBundle);
const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [exportingFormat, setExportingFormat] = useState(null);
const [selectedIds, setSelectedIds] = useState(() => new Set());
// IDs de publicaciones que el usuario no ha descargado todavía
const newPublicationIds = useMemo(
() =>
isAuthenticated
? publications
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id)
: [],
[publications, isAuthenticated],
);
const loadBundle = useCallback(
async (signal) => {
setPubsLoading(true);
setPubsError(null);
try {
const bundle = await searchResearcher(orcid, { signal });
if (signal?.aborted) return;
setResearcher(bundle.researcher);
setPublications(bundle.publications);
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id));
const next = new Set();
for (const id of prev) if (alive.has(id)) next.add(id);
return next.size === prev.size ? prev : next;
});
} catch (err) {
if (signal?.aborted) return;
setPubsError(err);
toast.error("No se pudo cargar el investigador", {
description: err?.message ?? "Error desconocido.",
});
} finally {
if (!signal?.aborted) setPubsLoading(false);
}
},
[orcid],
);
useEffect(() => {
if (!isValidOrcid(orcid)) return;
if (initialBundleRef.current) {
initialBundleRef.current = null;
return;
}
const ctrl = new AbortController();
loadBundle(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadBundle]);
if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />;
}
async function handleSync() {
setSyncStatus("loading");
try {
const bundle = await syncResearcher(orcid);
setResearcher(bundle.researcher);
setPublications(bundle.publications);
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id));
const next = new Set();
for (const id of prev) if (alive.has(id)) next.add(id);
return next.size === prev.size ? prev : next;
});
setSyncStatus("success");
const { newRecords, updatedRecords, totalRecords } = bundle;
const hasChanges = newRecords > 0 || updatedRecords > 0;
toast.success("Sincronización completada", {
description: hasChanges
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
: "Sin cambios desde la última sincronización.",
});
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) {
setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", {
description: err?.message ?? "Inténtalo de nuevo más tarde.",
});
}
}
async function handleExport(format) {
setExportingFormat(format);
try {
let ids;
if (selectedIds.size > 0) {
// Manual selection takes priority
ids = Array.from(selectedIds);
} else if (isAuthenticated) {
// Authenticated → only download publications not yet downloaded by me
ids = newPublicationIds;
if (ids.length === 0) {
toast.info("No hay publicaciones nuevas", {
description: "Ya has descargado todas las publicaciones de este investigador.",
});
setExportingFormat(null);
return;
}
} else {
// Anonymous → download everything
ids = undefined;
}
const { blob } = await downloadExport(orcid, format, {
publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
const extension = format === "xml" ? "xml" : format;
anchor.download = `sword-${orcid}.${extension}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
let scope;
if (selectedIds.size > 0) {
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
} else if (isAuthenticated) {
scope = `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`;
} else {
scope = "todo el investigador";
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: scope,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setExportingFormat(null);
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" />
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton onClick={handleSync} status={syncStatus} />
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
/>
</>
}
/>
) : (
<ResearcherSkeleton />
)}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadBundle()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
isAuthenticated={isAuthenticated}
/>
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
<span className="text-xs text-ink-tertiary">
Datos obtenidos vía ORCID Public API v3.0
</span>
<div className="flex gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => (
<span key={t} className="text-xs text-ink-tertiary">
{t}
</span>
))}
</div>
</footer>
</div>
</div>
);
}
function ResearcherSkeleton() {
return (
<div className="mb-5 h-[120px] animate-pulse rounded-2xl border border-surface-border/60 bg-surface-primary" />
);
}
export default DashboardPage;
+496
View File
@@ -0,0 +1,496 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, Link } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { Spinner } from "../components/ui/Spinner";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import {
AlertIcon,
ArrowLeftIcon,
DownloadIcon,
SparkleIcon,
UsersIcon,
} from "../components/ui/Icons";
import { downloadExport, searchResearchersBulk } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
/**
* Group results view: shows one summary card per researcher, plus a global
* "download all new" (or "download everything") action in the header.
*
* Receives `{ orcidIds: string[] }` via `location.state` (set by LandingPage).
* If no state is present the user is redirected back to `/`.
*/
export function GroupResultsPage() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const orcidIds = location.state?.orcidIds;
const [results, setResults] = useState([]);
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [globalExporting, setGlobalExporting] = useState(null); // format | null
// Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({});
const abortRef = useRef(null);
useEffect(() => {
if (!orcidIds || orcidIds.length === 0) {
navigate("/", { replace: true });
return;
}
const ctrl = new AbortController();
abortRef.current = ctrl;
(async () => {
setLoading(true);
try {
const data = await searchResearchersBulk(orcidIds, {
signal: ctrl.signal,
});
if (ctrl.signal.aborted) return;
setResults(data.results ?? []);
setErrors(data.errors ?? []);
const failCount = (data.errors ?? []).length;
const okCount = (data.results ?? []).length;
if (failCount > 0) {
toast.warning(
`${okCount} investigador${okCount !== 1 ? "es" : ""} cargado${okCount !== 1 ? "s" : ""}, ${failCount} con error`,
{
description: "Comprueba los ORCID iDs que fallaron abajo.",
},
);
}
} catch (err) {
if (ctrl.signal.aborted) return;
toast.error("Error al buscar investigadores", {
description: err?.message ?? "Inténtalo de nuevo.",
});
setResults([]);
setErrors([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
})();
return () => ctrl.abort();
}, [orcidIds, navigate]);
// All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => {
if (!isAuthenticated) return [];
return results.flatMap((r) =>
(r.publications ?? [])
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id),
);
}, [results, isAuthenticated]);
const allIds = useMemo(
() => results.flatMap((r) => (r.publications ?? []).map((p) => p.id)),
[results],
);
async function handleGlobalExport(format) {
const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) {
toast.info(
isAuthenticated
? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar",
{ description: "No se encontraron publicaciones en los investigadores cargados." },
);
return;
}
setGlobalExporting(format);
try {
// For bulk export we send all IDs together, passing a placeholder orcid
// since the endpoint is POST /export/{format}/publications (no orcid needed)
const { blob } = await downloadExport(null, format, {
publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-group.${format === "xml" ? "xml" : format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: `${ids.length} publicaciones exportadas.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setGlobalExporting(null);
}
}
async function handleCardExport(orcidId, format, newIds, totalIds) {
const ids = isAuthenticated ? newIds : totalIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar");
return;
}
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try {
const { blob } = await downloadExport(orcidId, format, {
publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-${orcidId}.${format === "xml" ? "xml" : format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: `${ids.length} publicaciones de ${orcidId}.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setCardExporting((prev) => {
const next = { ...prev };
delete next[orcidId];
return next;
});
}
}
const globalLabel = isAuthenticated
? allNewIds.length > 0
? `Descargar lo nuevo de todos (${allNewIds.length})`
: "Todo descargado"
: `Descargar todo (${allIds.length})`;
const globalDisabled =
Boolean(globalExporting) ||
(isAuthenticated ? allNewIds.length === 0 : allIds.length === 0);
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="group" />
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
<UsersIcon size={20} />
</div>
<div>
<h1 className="text-xl font-semibold text-ink-primary">
Búsqueda grupal
</h1>
{!loading && (
<p className="text-xs text-ink-tertiary">
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
{errors.length > 0 && (
<span className="ml-1 text-ink-danger">
· {errors.length} con error
</span>
)}
</p>
)}
</div>
</div>
{/* Global export buttons */}
{!loading && results.length > 0 && (
<div className="flex gap-2">
{["xml", "zip"].map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => handleGlobalExport(fmt)}
disabled={globalDisabled}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{globalExporting === fmt ? (
<Spinner size={14} />
) : isAuthenticated && allNewIds.length > 0 ? (
<SparkleIcon size={13} className="text-brand-accent" />
) : (
<DownloadIcon size={14} />
)}
{globalExporting === fmt
? `Exportando ${fmt.toUpperCase()}...`
: `${fmt.toUpperCase()} · ${globalLabel}`}
</button>
))}
</div>
)}
</div>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
<Spinner size={28} />
<p className="text-sm">
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
</p>
<p className="text-xs text-ink-tertiary/60">
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
</p>
</div>
)}
{/* Results grid */}
{!loading && results.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((bundle) => (
<ResearcherResultCard
key={bundle.researcher?.orcid_id}
bundle={bundle}
isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) =>
handleCardExport(
bundle.researcher?.orcid_id,
fmt,
newIds,
totalIds,
)
}
/>
))}
</div>
)}
{/* Errors */}
{!loading && errors.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
ORCID iDs que no pudieron cargarse
</h2>
<div className="space-y-2">
{errors.map((e) => (
<div
key={e.orcid_id}
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
>
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
<div>
<p className="font-mono text-[13px] font-medium text-red-700">
{e.orcid_id}
</p>
<p className="text-xs text-red-500">
{e.detail ?? "No se pudo obtener información de este ORCID."}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{!loading && results.length === 0 && errors.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
<UsersIcon size={32} className="opacity-30" />
<p className="text-sm">No se encontraron resultados.</p>
<Link
to="/"
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
>
<ArrowLeftIcon />
Volver al inicio
</Link>
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────── Researcher card ─────────────────────────── */
function ResearcherResultCard({ bundle, isAuthenticated, exporting, onExport }) {
const researcher = bundle.researcher ?? {};
const publications = bundle.publications ?? [];
const totalRecords = bundle.totalRecords ?? publications.length;
const newIds = isAuthenticated
? publications.filter((p) => p.downloaded_by_me === false).map((p) => p.id)
: [];
const allPubIds = publications.map((p) => p.id);
const newCount = newIds.length;
const hasNew = isAuthenticated && newCount > 0;
const initials = getInitials(researcher.name);
return (
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-5 shadow-sm">
{/* Researcher identity */}
<div className="mb-4 flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink-primary">
{researcher.name || "Sin nombre"}
</p>
<div className="mt-0.5 flex items-center gap-1">
<OrcidLogo />
<span className="truncate font-mono text-[12px] text-ink-secondary">
{researcher.orcid_id}
</span>
</div>
{researcher.affiliation && (
<p className="mt-0.5 truncate text-[12px] text-ink-tertiary">
{researcher.affiliation}
</p>
)}
</div>
</div>
{/* Stats row */}
<div className="mb-4 flex items-center gap-3 rounded-lg bg-surface-secondary px-3 py-2">
<div className="flex-1 text-center">
<p className="text-lg font-bold text-ink-primary">{totalRecords}</p>
<p className="text-[11px] text-ink-tertiary">publicaciones</p>
</div>
{isAuthenticated && (
<>
<div className="h-8 w-px bg-surface-border" />
<div className="flex-1 text-center">
<p className={`text-lg font-bold ${hasNew ? "text-brand-accent" : "text-ink-tertiary"}`}>
{newCount}
</p>
<p className="text-[11px] text-ink-tertiary">nuevas</p>
</div>
</>
)}
</div>
{/* Actions */}
<div className="mt-auto flex flex-wrap gap-2">
<Link
to={`/dashboard/${researcher.orcid_id}`}
state={{ bundle }}
className="flex-1 rounded-lg border border-surface-border-strong bg-surface-secondary px-3 py-2 text-center text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-primary"
>
Ver detalle
</Link>
<ExportFormatMenu
onExport={(fmt) => onExport(fmt, newIds, allPubIds)}
exporting={exporting}
isAuthenticated={isAuthenticated}
hasNew={hasNew}
newCount={newCount}
totalCount={totalRecords}
/>
</div>
</div>
);
}
/* ─────────────────────── Inline export format picker ─────────────────── */
function ExportFormatMenu({
onExport,
exporting,
isAuthenticated,
hasNew,
newCount,
totalCount,
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exporting);
const disabled =
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
let label;
if (isBusy) {
label = `Exportando ${exporting.toUpperCase()}...`;
} else if (isAuthenticated) {
label = hasNew ? `Nuevo (${newCount})` : "Descargado";
} else {
label = `Todo (${totalCount})`;
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{isBusy ? (
<Spinner size={13} />
) : hasNew ? (
<SparkleIcon size={11} className="text-brand-accent" />
) : (
<DownloadIcon size={13} />
)}
{label}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+4px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{["xml", "zip"].map((fmt, idx) => (
<button
key={fmt}
type="button"
onClick={() => {
setOpen(false);
onExport(fmt);
}}
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-secondary ${
idx === 0 ? "border-b border-surface-border/60" : ""
}`}
>
<DownloadIcon size={13} />
{fmt.toUpperCase()}
</button>
))}
</div>
)}
</div>
);
}
/* ─────────────────────────── Helpers ─────────────────────────────────── */
function getInitials(name) {
if (!name) return "?";
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("");
}
export default GroupResultsPage;
+334
View File
@@ -0,0 +1,334 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { DocumentIcon, UsersIcon } from "../components/ui/Icons";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import { Spinner } from "../components/ui/Spinner";
import { formatOrcidInput, isValidOrcid } from "../utils/orcid";
import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext";
/**
* Entry view: login con ORCID iD + búsqueda individual anónima +
* buscador grupal para múltiples investigadores.
*
* Flujo de login:
* - abre popup OAuth → sandbox.orcid.org → /callback
* - recibe JWT → cierra popup → estado actualizado aquí.
*/
export function LandingPage() {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState("");
const [validating, setValidating] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
// Group search state
const [groupInput, setGroupInput] = useState("");
const [groupError, setGroupError] = useState("");
const [groupLoading, setGroupLoading] = useState(false);
// Cleanup refs for popup polling interval
const popupRef = useRef(null);
const popupTimerRef = useRef(null);
// Clean up popup polling on unmount
useEffect(() => {
return () => {
if (popupTimerRef.current) clearInterval(popupTimerRef.current);
};
}, []);
function handleOrcidChange(event) {
setOrcidInput(formatOrcidInput(event.target.value));
if (error) setError("");
}
async function handleValidate() {
if (!isValidOrcid(orcidInput)) {
setError(
"Formato inválido. El ORCID iD debe tener la estructura: 0000-0002-1234-5678",
);
return;
}
setValidating(true);
try {
const bundle = await searchResearcher(orcidInput);
navigate(`/dashboard/${orcidInput}`, { state: { bundle } });
} catch (err) {
toast.error("No se pudo buscar el ORCID iD", {
description: err?.message ?? "Inténtalo de nuevo en unos segundos.",
});
} finally {
setValidating(false);
}
}
function handleOrcidLogin() {
setLoginLoading(true);
const authorizeUrl = getOrcidAuthorizeUrl();
const popup = window.open(
authorizeUrl,
"orcid_oauth",
"width=600,height=700,scrollbars=yes,resizable=yes",
);
if (!popup || popup.closed) {
// El navegador bloqueó el popup → hacemos redirect completo
setLoginLoading(false);
window.location.href = authorizeUrl;
return;
}
popupRef.current = popup;
// Escuchamos el postMessage que AuthCallbackPage envía al completar
function handleMessage(event) {
if (event.origin !== window.location.origin) return;
if (event.data?.type === AUTH_MESSAGE_TYPE) {
cleanup();
setLoginLoading(false);
toast.success("Sesión iniciada con ORCID", {
description: "Ya puedes ver qué publicaciones son nuevas para ti.",
});
} else if (event.data?.type === AUTH_ERROR_TYPE) {
cleanup();
setLoginLoading(false);
toast.error("No se pudo iniciar sesión", {
description: event.data.error ?? "Inténtalo de nuevo.",
});
}
}
window.addEventListener("message", handleMessage);
// Detectamos si el usuario cierra el popup manualmente antes de autenticar
popupTimerRef.current = setInterval(() => {
if (popup.closed) {
cleanup();
setLoginLoading(false);
}
}, 500);
function cleanup() {
window.removeEventListener("message", handleMessage);
if (popupTimerRef.current) {
clearInterval(popupTimerRef.current);
popupTimerRef.current = null;
}
}
}
function parseGroupOrcids(raw) {
return raw
.split(/[\s,\n]+/)
.map((s) => s.trim())
.filter(Boolean);
}
async function handleGroupSearch() {
const ids = parseGroupOrcids(groupInput);
if (ids.length === 0) {
setGroupError("Introduce al menos un ORCID iD.");
return;
}
const invalid = ids.filter((id) => !isValidOrcid(id));
if (invalid.length > 0) {
setGroupError(`ORCID iDs con formato incorrecto: ${invalid.join(", ")}`);
return;
}
setGroupError("");
setGroupLoading(true);
try {
navigate("/group", { state: { orcidIds: ids } });
} finally {
setGroupLoading(false);
}
}
function handleKeyDown(event) {
if (event.key === "Enter") handleValidate();
}
function handleGroupKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleGroupSearch();
}
}
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" />
<main className="flex flex-1 items-center justify-center p-12 sm:p-6">
<div className="w-full max-w-[520px]">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-[72px] w-[72px] items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
<DocumentIcon size={36} className="text-white" />
</div>
<h1 className="mb-2 text-[28px] font-semibold tracking-tight text-ink-primary">
Repositorio Institucional
</h1>
<p className="text-[15px] leading-relaxed text-ink-secondary">
Conecta tu perfil ORCID y deposita tus publicaciones
automáticamente en el repositorio institucional vía protocolo
SWORD.
</p>
</div>
{/* Main card */}
<div className="rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
{isAuthenticated ? (
<div className="flex items-center justify-between rounded-xl border border-green-200 bg-green-50 px-4 py-2.5 text-sm text-green-800">
<span className="font-medium">Sesión activa</span>
<span className="text-xs text-green-600">
Verás publicaciones nuevas marcadas en el dashboard
</span>
</div>
) : (
<>
<button
type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: "Iniciar sesión con ORCID"}
</button>
</>
)}
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-surface-border" />
<span className="text-xs tracking-widest text-ink-tertiary">
{isAuthenticated ? "TU ORCID iD" : "O INTRODUCE TU ORCID iD"}
</span>
<div className="h-px flex-1 bg-surface-border" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-ink-secondary">
ORCID iD
</label>
<div className="flex gap-2.5">
<div className="relative flex-1">
<input
type="text"
inputMode="numeric"
placeholder="0000-0002-1234-5678"
value={orcidInput}
onChange={handleOrcidChange}
onKeyDown={handleKeyDown}
maxLength={19}
className={`w-full rounded-lg py-2.5 pl-10 pr-3.5 font-mono text-[15px] tracking-wider text-ink-primary outline-none transition-colors ${
error
? "border border-border-danger"
: "border border-surface-border-strong focus:border-brand-accent"
}`}
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<OrcidLogo />
</span>
</div>
<button
type="button"
onClick={handleValidate}
disabled={validating || loginLoading || !orcidInput}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
orcidInput
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
>
{validating && <Spinner size={14} />}
{validating ? "Buscando..." : "Buscar"}
</button>
</div>
{error && (
<p className="mt-2 text-xs leading-relaxed text-ink-danger">
{error}
</p>
)}
<p className="mt-2 text-xs text-ink-tertiary">
{isAuthenticated
? "Busca un investigador o usa «Cerrar sesión» arriba."
: "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."}
</p>
</div>
</div>
{/* Group search card */}
<div className="mt-4 rounded-2xl border border-surface-border/60 bg-surface-primary p-6">
<div className="mb-3 flex items-center gap-2">
<UsersIcon size={17} className="text-brand-accent" />
<h2 className="text-[14px] font-semibold text-ink-primary">
Búsqueda grupal de investigadores
</h2>
</div>
<p className="mb-3 text-xs leading-relaxed text-ink-secondary">
Pega varios ORCID iDs separados por comas, espacios o saltos de
línea para buscar y comparar varios investigadores a la vez.
</p>
<textarea
rows={3}
placeholder={"0000-0002-1825-0097\n0000-0001-5000-0007, 0000-0003-4321-9876"}
value={groupInput}
onChange={(e) => {
setGroupInput(e.target.value);
if (groupError) setGroupError("");
}}
onKeyDown={handleGroupKeyDown}
className={`w-full resize-none rounded-lg border px-3.5 py-2.5 font-mono text-[13px] text-ink-primary outline-none transition-colors ${
groupError
? "border-border-danger"
: "border-surface-border-strong focus:border-brand-accent"
}`}
/>
{groupError && (
<p className="mt-1 text-xs text-ink-danger">{groupError}</p>
)}
<button
type="button"
onClick={handleGroupSearch}
disabled={groupLoading || !groupInput.trim()}
className={`mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
groupInput.trim()
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
>
{groupLoading && <Spinner size={14} />}
<UsersIcon size={14} />
{groupLoading ? "Preparando..." : "Buscar investigadores"}
</button>
</div>
{/* Info chips */}
<div className="mt-6 flex flex-wrap justify-center gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
<span
key={label}
className="rounded-full border border-surface-border/60 bg-surface-secondary px-3 py-1 text-xs text-ink-tertiary"
>
{label}
</span>
))}
</div>
</div>
</main>
</div>
);
}
export default LandingPage;
+469
View File
@@ -0,0 +1,469 @@
/**
* Cliente HTTP del frontend contra la API FastAPI.
*
* Cada función devuelve el JSON ya parseado (o un Blob para descargas)
* y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
* decide cómo mostrarlo (toast, error inline, reintento, …).
*
* Configuración:
* - `VITE_API_URL`: URL base del backend, ya con el prefijo `/api`
* (p. ej. `http://localhost:8000/api`). Si se deja vacío, las
* peticiones se hacen contra `/api` y las redirige el proxy de
* Vite (ver `vite.config.js`).
* - `VITE_API_KEY`: clave compartida con el backend, se manda en el
* header `X-API-Key` de TODAS las peticiones.
*
* Contrato actual del backend (todo bajo `/api`):
* - GET /researchers/search → buscador grupal (todo en uno)
* - GET /researchers/search/{orcid_id} → buscador individual (todo en uno)
* - POST /researchers/{orcid_id}/sync → re-sync manual
* - POST /export/sword/publications body=[ids] → SWORD XML de selección
* - POST /export/zip/publications body=[ids] → ZIP de selección
* - GET /export/sword/researcher/{orcid_id} → SWORD XML de todo el investigador
* - GET /export/zip/researcher/{orcid_id} → ZIP de todo el investigador
*/
import {
mockExport,
mockGetPublications,
mockSyncResearcher,
mockValidateOrcid,
} from "./mocks";
// `VITE_API_URL` puede venir como "" (vacío) en `.env` para usar el proxy
// de Vite. En ese caso no queremos usar string vacío como base, sino `/api`.
const BASE_URL = (import.meta.env.VITE_API_URL
? import.meta.env.VITE_API_URL
: "/api").replace(/\/$/, "");
const API_KEY = import.meta.env.VITE_API_KEY ?? "";
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
const ORCID_PUBLIC_BASE =
import.meta.env.VITE_ORCID_PUBLIC_API_BASE ?? "https://pub.sandbox.orcid.org/v3.0";
const nameCache = new Map();
function extractDisplayNameFromOrcidRecord(record) {
const given = record?.person?.name?.["given-names"]?.value;
const family = record?.person?.name?.["family-name"]?.value;
const full = [given, family].filter(Boolean).join(" ").trim();
return full || null;
}
async function fetchOrcidDisplayName(orcidId, { signal } = {}) {
if (!orcidId) return null;
if (nameCache.has(orcidId)) return nameCache.get(orcidId);
const url = `${ORCID_PUBLIC_BASE.replace(/\/$/, "")}/${encodeURIComponent(orcidId)}/record`;
try {
const res = await fetch(url, { signal, headers: { Accept: "application/json" } });
if (!res.ok) {
nameCache.set(orcidId, null);
return null;
}
const json = await res.json();
const name = extractDisplayNameFromOrcidRecord(json);
nameCache.set(orcidId, name);
return name;
} catch {
return null;
}
}
export class ApiError extends Error {
constructor(message, { status, payload } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
/**
* Construye la cabecera base que llevan TODAS las peticiones (incluidas
* las descargas de blob). Incluye X-API-Key siempre y, si existe un JWT
* en localStorage, también Authorization: Bearer <token>.
*/
function buildAuthHeaders(extra = {}) {
if (!API_KEY && import.meta.env.DEV) {
console.warn(
"[api] VITE_API_KEY no está definida; las peticiones serán rechazadas por el backend.",
);
}
const token = localStorage.getItem("orcid_auth_token");
return {
Accept: "application/json",
...(API_KEY ? { "X-API-Key": API_KEY } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra,
};
}
async function request(path, { method = "GET", body, signal, headers } = {}) {
const url = `${BASE_URL}${path}`;
const init = {
method,
signal,
headers: buildAuthHeaders({
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...headers,
}),
};
if (body !== undefined) init.body = JSON.stringify(body);
let response;
try {
response = await fetch(url, init);
} catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
});
}
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* sin cuerpo JSON */
}
const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
throw new ApiError(typeof detail === "string" ? detail : "Error de API", {
status: response.status,
payload,
});
}
if (response.status === 204) return null;
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) return response.json();
return response;
}
/* ───────────────────────────── Mapeos ────────────────────────────── */
/**
* Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`)
* al que espera la UI (`publication_year`, strings seguras para filtrar).
*
* Mantenemos también los campos crudos relevantes (`put_code`, `subtitle`,
* `citation_value`, …) por si una vista futura los necesita sin tener
* que volver a tocar este mapper.
*/
function normalizePublication(p) {
return {
id: p.id,
put_code: p.put_code ?? null,
title: p.title || "Sin título",
subtitle: p.subtitle ?? null,
journal: p.journal || "",
doi: p.doi || "",
publication_year: p.pub_year ?? null,
publication_month: p.pub_month ?? null,
publication_day: p.pub_day ?? null,
type: p.type || null,
url: p.url ?? null,
short_description: p.short_description ?? null,
citation_type: p.citation_type ?? null,
citation_value: p.citation_value ?? null,
language_code: p.language_code ?? null,
country: p.country ?? null,
external_ids: Array.isArray(p.external_ids) ? p.external_ids : [],
contributors: Array.isArray(p.contributors) ? p.contributors : [],
hash_fingerprint: p.hash_fingerprint ?? null,
last_modified: p.last_modified ?? null,
status: p.status ?? null,
downloaded_by_me: p.downloaded_by_me ?? null,
};
}
/**
* Normaliza la respuesta unificada `{ researcher, publications, … }` que
* devuelven tanto el buscador individual como el endpoint de sync.
* Devuelve siempre la misma forma para que las pantallas no tengan que
* conocer detalles del backend.
*/
function normalizeResearcherBundle(raw) {
if (!raw || typeof raw !== "object") {
return {
researcher: null,
publications: [],
newRecords: 0,
updatedRecords: 0,
unchangedRecords: 0,
totalRecords: 0,
};
}
const publications = Array.isArray(raw.publications)
? raw.publications.map(normalizePublication)
: [];
return {
researcher: raw.researcher ?? null,
publications,
newRecords: raw.new_records ?? 0,
updatedRecords: raw.updated_records ?? 0,
unchangedRecords: raw.unchanged_records ?? 0,
totalRecords: raw.total_records ?? publications.length,
};
}
/* ───────────────────────────── Auth ─────────────────────────────── */
/**
* URL a la que debe redirigirse (o abrirse en popup) para iniciar el
* flujo OAuth 3-legged de ORCID.
*
* Secuencia completa:
* 1. Frontend abre/redirige a GET /api/auth/orcid/authorize
* 2. Backend construye la URL de ORCID y redirige al navegador.
* 3. El usuario se autentica en orcid.org (o sandbox.orcid.org).
* 4. ORCID redirige a ORCID_REDIRECT_URI (debe apuntar a la página
* /auth/callback del frontend).
* 5. El frontend extrae el `code` y llama a exchangeOrcidCode(code).
* 6. El backend intercambia el code → access_token y lo devuelve.
*/
export function getOrcidAuthorizeUrl() {
return `${BASE_URL}/auth/orcid/authorize`;
}
/**
* GET /auth/orcid/callback?code=<code>
*
* Intercambia el authorization code (recibido de ORCID tras el OAuth)
* por un JWT propio del backend. Devuelve `{ access_token, token_type }`.
*/
export async function exchangeOrcidCode(code, { signal } = {}) {
return request(
`/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`,
{ signal },
);
}
/* ───────────────────────────── Endpoints ─────────────────────────────── */
/**
* Búsqueda "todo en uno" para 1 investigador.
*/
export async function searchResearcher(orcidId, { signal } = {}) {
if (USE_MOCKS) {
const researcher = await mockValidateOrcid(orcidId);
const publications = await mockGetPublications(orcidId);
return {
researcher,
publications,
newRecords: 0,
updatedRecords: 0,
unchangedRecords: publications.length,
totalRecords: publications.length,
};
}
const batch = await searchResearchersBulk([orcidId], { signal });
const first = batch.results?.[0] ?? null;
if (first) return first;
const firstError = batch.errors?.[0];
throw new ApiError(firstError?.detail ?? "No se pudo validar el ORCID iD.", {
payload: firstError,
});
}
/**
* Búsqueda grupal: devuelve `results[]` (uno por cada ORCID) junto a
* `errors[]` y contadores.
*
* Contrato backend:
* POST /researchers/search
* body: { "orcid_ids": ["id1", "id2"] }
*/
export async function searchResearchersBulk(orcidIds, { signal } = {}) {
const ids = Array.isArray(orcidIds) ? orcidIds : [orcidIds];
if (USE_MOCKS) {
const results = [];
for (const id of ids) {
const researcher = await mockValidateOrcid(id);
const publications = await mockGetPublications(id);
results.push({
researcher,
publications,
newRecords: 0,
updatedRecords: 0,
unchangedRecords: publications.length,
totalRecords: publications.length,
});
}
return {
results,
errors: [],
totalRequested: ids.length,
totalProcessed: results.length,
};
}
const raw = await request(`/researchers/search`, {
method: "POST",
body: { orcid_ids: ids },
signal,
});
const results = Array.isArray(raw?.results)
? raw.results.map(normalizeResearcherBundle)
: [];
// Frontend enrichment: backend may create researchers with `name=null`
// when discovered via search. We best-effort fill display name from
// ORCID Public API to keep UI consistent with OAuth login cases.
await Promise.all(
results.map(async (bundle) => {
const r = bundle?.researcher;
if (!r || r.name) return;
const name = await fetchOrcidDisplayName(r.orcid_id, { signal });
if (name) bundle.researcher = { ...r, name };
}),
);
return {
results,
errors: Array.isArray(raw?.errors) ? raw.errors : [],
totalRequested: raw?.total_requested ?? ids.length,
totalProcessed: raw?.total_processed ?? results.length,
};
}
/**
* POST /researchers/{orcid}/sync — dispara el re-harvest desde ORCID.
*
* Ahora devuelve el bundle completo `{ researcher, publications,
* new_records, updated_records, unchanged_records, total_records }`,
* así que el caller puede refrescar el dashboard sin volver a pedir
* las publicaciones por separado.
*/
export async function syncResearcher(orcidId, { signal } = {}) {
if (USE_MOCKS) {
const summary = await mockSyncResearcher(orcidId);
const publications = await mockGetPublications(orcidId);
return {
researcher: { orcid_id: orcidId },
publications,
newRecords: summary?.new_records ?? 0,
updatedRecords: summary?.updated_records ?? 0,
unchangedRecords: 0,
totalRecords: summary?.total ?? publications.length,
};
}
const raw = await request(
`/researchers/${encodeURIComponent(orcidId)}/sync`,
{ method: "POST", signal },
);
return normalizeResearcherBundle(raw);
}
/* ───────────────────────────── Exportación ───────────────────────────── */
/**
* Mapa de formatos UI → segmento del path en el backend.
* `xml` mantiene el nombre histórico que ya usaba la UI (`SWORD XML`)
* pero apunta al endpoint nuevo `/export/sword/...`.
*/
const EXPORT_PATH_SEGMENT = {
xml: "sword",
zip: "zip",
};
function exportSegmentFor(format) {
const segment = EXPORT_PATH_SEGMENT[format];
if (!segment) throw new ApiError(`Formato de exportación no soportado: ${format}`);
return segment;
}
/**
* URL pública del endpoint que descarga TODO el investigador
* (`GET /export/{sword|zip}/researcher/{orcid_id}`). La usamos como
* dato meramente informativo en los toasts de éxito; las descargas
* reales se disparan vía blob para poder forzar el download.
*
* Ojo: estas URLs requieren `X-API-Key`, así que NO sirven como link
* directo en una etiqueta `<a href>`; las exponemos para mostrarlas o
* loguearlas, no para navegar.
*/
export function getExportUrl(orcidId, format) {
const segment = exportSegmentFor(format);
return `${BASE_URL}/export/${segment}/researcher/${encodeURIComponent(orcidId)}`;
}
/**
* Descarga una exportación como Blob (para forzar descarga programática).
*
* - Si `publicationIds` viene con un array no vacío usamos el endpoint
* selectivo `POST /export/{sword|zip}/publications` con body
* `["id1", "id2", ...]` (array crudo, tal como espera el backend).
* - Si viene vacío/undefined usamos el endpoint masivo
* `GET /export/{sword|zip}/researcher/{orcid_id}` y descargamos todo.
*
* Lanza `ApiError` en fallo.
*/
export async function downloadExport(
orcidId,
format,
{ signal, publicationIds } = {},
) {
if (USE_MOCKS) {
await mockExport(format);
return { blob: null, url: getExportUrl(orcidId, format) };
}
const segment = exportSegmentFor(format);
const ids =
Array.isArray(publicationIds) && publicationIds.length > 0
? publicationIds
: null;
const url = ids
? `${BASE_URL}/export/${segment}/publications`
: `${BASE_URL}/export/${segment}/researcher/${encodeURIComponent(orcidId)}`;
const init = {
method: ids ? "POST" : "GET",
signal,
headers: buildAuthHeaders({
Accept: "*/*",
...(ids ? { "Content-Type": "application/json" } : {}),
}),
};
if (ids) init.body = JSON.stringify(ids);
let response;
try {
response = await fetch(url, init);
} catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", {
status: 0,
payload: { cause: String(cause) },
});
}
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* sin cuerpo JSON */
}
const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
throw new ApiError(
typeof detail === "string"
? detail
: `No se pudo exportar el fichero ${format.toUpperCase()}.`,
{ status: response.status, payload },
);
}
const blob = await response.blob();
return { blob, url };
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Temporary in-memory fixtures used while el backend está apagado o
* mientras se trabaja sin red. Se activan poniendo
* `VITE_USE_MOCKS=true` en `.env`. Una vez el backend esté siempre
* disponible, este fichero puede borrarse junto a las ramas
* `if (USE_MOCKS) …` de `api.js`.
*
* Los objetos siguen la forma que la UI espera (post-normalización),
* porque las funciones de `api.js` los devuelven directamente sin
* volver a pasar por el mapper. Si en el futuro queremos imitar el
* payload crudo del backend (`pub_year`, etc.), habrá que hacerlas
* pasar por `normalizePublication` en el lado del servicio.
*/
export const MOCK_RESEARCHER = {
orcid_id: "0000-0002-1234-5678",
name: "Dra. María García",
authenticated: false,
affiliation: "Universidad Complutense de Madrid",
last_sync_at: "2026-04-15T10:30:00Z",
};
export const MOCK_PUBLICATIONS = [
{
id: "uuid-1",
put_code: 1000001,
title: "Machine Learning in Quantum Computing",
journal: "Nature Physics",
publication_year: 2025,
doi: "10.1038/s41567-025-xxxx",
type: "journal-article",
last_modified: "2025-09-01T10:00:00Z",
},
{
id: "uuid-2",
put_code: 1000002,
title:
"A review of SWORD protocol integrations in institutional repositories",
journal: "Journal of Digital Repositories",
publication_year: 2024,
doi: "10.1000/jdr.2024.12",
type: "review",
last_modified: "2024-11-12T09:00:00Z",
},
{
id: "uuid-3",
put_code: 1000003,
title: "Open Access Policies and Compliance in European Universities",
journal: "Scientometrics",
publication_year: 2024,
doi: "10.1007/s11192-024-04801-z",
type: "journal-article",
last_modified: "2024-06-20T15:30:00Z",
},
{
id: "uuid-4",
put_code: 1000004,
title: "Automated Metadata Harvesting via OAI-PMH",
journal: "Digital Libraries Conference Proceedings",
publication_year: 2023,
doi: "10.1145/3587-dl.2023.09",
type: "conference-paper",
last_modified: "2023-10-05T11:45:00Z",
},
{
id: "uuid-5",
put_code: 1000005,
title: "Interoperability Standards for Research Information Systems",
journal: "International Journal of Library Science",
publication_year: 2023,
doi: "10.1016/j.ijls.2023.03.011",
type: "journal-article",
last_modified: "2023-04-18T08:15:00Z",
},
];
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
export async function mockValidateOrcid(orcidId) {
await delay(700);
return { ...MOCK_RESEARCHER, orcid_id: orcidId };
}
export async function mockGetPublications(/* orcidId */) {
await delay(600);
return MOCK_PUBLICATIONS;
}
export async function mockSyncResearcher(orcidId) {
await delay(1800);
// Imita el resumen del SyncJob real (`new_records`, `updated_records`,
// `total`). El bundle completo lo reconstruye `api.js` a partir de
// este objeto + las publicaciones mock.
return {
status: "ok",
message: "Sincronización completada correctamente.",
researcher: orcidId,
new_records: 0,
updated_records: MOCK_PUBLICATIONS.length,
total: MOCK_PUBLICATIONS.length,
};
}
export async function mockExport(format) {
await delay(1200);
return { format };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Locale-aware full date + time formatter (used in dashboard headers).
*/
export function formatDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("es-ES", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Builds researcher initials (max 2 chars) from a full name.
* Si el backend aún no conoce el nombre, devolvemos un guion como
* placeholder para no dejar el avatar vacío.
*/
export function getInitials(name) {
if (!name || typeof name !== "string") return "";
const trimmed = name.trim();
if (!trimmed) return "";
return trimmed
.split(/\s+/)
.map((w) => w[0] ?? "")
.slice(0, 2)
.join("")
.toUpperCase();
}
+22
View File
@@ -0,0 +1,22 @@
/**
* ORCID iD regex (16 digits, hyphen every 4, last char may be 'X' checksum).
* @see https://support.orcid.org/hc/en-us/articles/360006897674
*/
export const ORCID_REGEX = /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/;
/**
* Auto-formats a raw user input into the canonical ORCID layout
* `0000-0000-0000-000X`, keeping digits + final 'X' only.
*/
export function formatOrcidInput(raw) {
const digits = raw.replace(/[^0-9X]/gi, "").toUpperCase();
const parts = [];
for (let i = 0; i < digits.length && i < 16; i += 4) {
parts.push(digits.slice(i, i + 4));
}
return parts.join("-");
}
export function isValidOrcid(value) {
return ORCID_REGEX.test(value);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Publication type catalogue — labels + Tailwind class sets per variant.
* Keeping Tailwind classes (instead of inline styles) here lets the Badge
* component stay declarative while still covering every ORCID work-type.
*/
export const TYPE_LABELS = {
"journal-article": "Artículo",
review: "Revisión",
"conference-paper": "Conferencia",
"book-chapter": "Cap. Libro",
dataset: "Dataset",
};
export const TYPE_BADGE_CLASSES = {
"journal-article":
"bg-tag-article-bg text-tag-article-text border border-tag-article-border",
review:
"bg-tag-review-bg text-tag-review-text border border-tag-review-border",
"conference-paper":
"bg-tag-conference-bg text-tag-conference-text border border-tag-conference-border",
"book-chapter":
"bg-tag-book-bg text-tag-book-text border border-tag-book-border",
dataset:
"bg-tag-dataset-bg text-tag-dataset-text border border-tag-dataset-border",
};
export const DEFAULT_BADGE_CLASSES =
"bg-tag-default-bg text-tag-default-text border border-tag-default-border";
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, import.meta.dirname, '')
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
return {
plugins: [react(), tailwindcss()],
server: {
host: true,
// Needed for HTTPS tunnels like ngrok during OAuth callback flows.
// We allow all hosts in dev to avoid host-blocking when ngrok URL rotates.
allowedHosts: true,
port: 5173,
proxy: {
// El backend agrupa todo bajo /api (researchers, export, …).
// Con un único prefijo evitamos tener que mantener una entrada
// por router cada vez que se añada un endpoint nuevo.
'/api': {
target: proxyTarget,
changeOrigin: true,
},
'/health': {
target: proxyTarget,
changeOrigin: true,
},
},
},
}
})