Merge branch 'develop'
This commit is contained in:
+51
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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=["*"],
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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:
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
Generated
+2936
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user