diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cc1173 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..be02114 --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e3f2064 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..205cb95 --- /dev/null +++ b/backend/app/api/auth.py @@ -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) + diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000..2152105 --- /dev/null +++ b/backend/app/api/export.py @@ -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") diff --git a/backend/app/api/researchers.py b/backend/app/api/researchers.py new file mode 100644 index 0000000..927aafa --- /dev/null +++ b/backend/app/api/researchers.py @@ -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, + ) diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..59be703 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..ae61527 --- /dev/null +++ b/backend/app/db/models.py @@ -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) diff --git a/backend/app/db/repositories/publication_repository.py b/backend/app/db/repositories/publication_repository.py new file mode 100644 index 0000000..590010b --- /dev/null +++ b/backend/app/db/repositories/publication_repository.py @@ -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() + ) diff --git a/backend/app/db/repositories/researcher_repository.py b/backend/app/db/repositories/researcher_repository.py new file mode 100644 index 0000000..4aba7af --- /dev/null +++ b/backend/app/db/repositories/researcher_repository.py @@ -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 diff --git a/backend/app/db/repositories/syncjob_repository.py b/backend/app/db/repositories/syncjob_repository.py new file mode 100644 index 0000000..1cb00a1 --- /dev/null +++ b/backend/app/db/repositories/syncjob_repository.py @@ -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 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..37b271e --- /dev/null +++ b/backend/app/db/session.py @@ -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") + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1e5d6c8 --- /dev/null +++ b/backend/app/main.py @@ -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=["*"], +) diff --git a/backend/app/scheduler/sync_scheduler.py b/backend/app/scheduler/sync_scheduler.py new file mode 100644 index 0000000..586e054 --- /dev/null +++ b/backend/app/scheduler/sync_scheduler.py @@ -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() diff --git a/backend/app/schema/auth.py b/backend/app/schema/auth.py new file mode 100644 index 0000000..869fde1 --- /dev/null +++ b/backend/app/schema/auth.py @@ -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" diff --git a/backend/app/schema/publication.py b/backend/app/schema/publication.py new file mode 100644 index 0000000..a36c813 --- /dev/null +++ b/backend/app/schema/publication.py @@ -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} diff --git a/backend/app/schema/researcher.py b/backend/app/schema/researcher.py new file mode 100644 index 0000000..2be69a4 --- /dev/null +++ b/backend/app/schema/researcher.py @@ -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 diff --git a/backend/app/security/api_key.py b/backend/app/security/api_key.py new file mode 100644 index 0000000..7dc9197 --- /dev/null +++ b/backend/app/security/api_key.py @@ -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 diff --git a/backend/app/security/jwt.py b/backend/app/security/jwt.py new file mode 100644 index 0000000..e8a930c --- /dev/null +++ b/backend/app/security/jwt.py @@ -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) diff --git a/backend/app/services/normalizer.py b/backend/app/services/normalizer.py new file mode 100644 index 0000000..fbe41bb --- /dev/null +++ b/backend/app/services/normalizer.py @@ -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, + } diff --git a/backend/app/services/orcid_client.py b/backend/app/services/orcid_client.py new file mode 100644 index 0000000..2e15add --- /dev/null +++ b/backend/app/services/orcid_client.py @@ -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) diff --git a/backend/app/services/sword_generator.py b/backend/app/services/sword_generator.py new file mode 100644 index 0000000..a6a0f58 --- /dev/null +++ b/backend/app/services/sword_generator.py @@ -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) diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..baf4b23 --- /dev/null +++ b/backend/app/services/sync_service.py @@ -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 + } diff --git a/backend/app/services/zip_generator.py b/backend/app/services/zip_generator.py new file mode 100644 index 0000000..f37e8fc --- /dev/null +++ b/backend/app/services/zip_generator.py @@ -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() diff --git a/backend/app/utils/orcid_validator.py b/backend/app/utils/orcid_validator.py new file mode 100644 index 0000000..235a88b --- /dev/null +++ b/backend/app/utils/orcid_validator.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..39dcb09 --- /dev/null +++ b/backend/requirements.txt @@ -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] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da14276 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3e328fd --- /dev/null +++ b/frontend/.dockerignore @@ -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/ diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..159ff5a --- /dev/null +++ b/frontend/.env.example @@ -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:///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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b3e5b32 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -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_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d02d4e4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + orcid-system + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1611bc9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2936 @@ +{ + "name": "orcid-system", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "orcid-system", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.3.tgz", + "integrity": "sha512-dhXFXkW2dGvX4r/fi24gyXM0t1mFMrpykQjqrdA4SuavaMagm4SY1u5G2SCJwu1/0x/5RlZJ2VPjP3mKYQfCkA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.3.tgz", + "integrity": "sha512-YyhwSBcxHLS3CU2Mk3dXDuVm8/Ia0+XvfpT8s9YQoICppkUeoobB3hgyGMYbyQ4vn6VgWH9bdv5UnzhTz2NPTQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.3", + "@tailwindcss/oxide-darwin-arm64": "4.2.3", + "@tailwindcss/oxide-darwin-x64": "4.2.3", + "@tailwindcss/oxide-freebsd-x64": "4.2.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.3", + "@tailwindcss/oxide-linux-x64-musl": "4.2.3", + "@tailwindcss/oxide-wasm32-wasi": "4.2.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.3.tgz", + "integrity": "sha512-0Jmt1U/zPqeKp1+fvgI3qMqrV5b/EcFIbE5Dl5KdPl5Ri6e+95nlYNjfB3w8hJBeASI4IQSnIMz0tdVP1AVO4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.3.tgz", + "integrity": "sha512-c+/Etn/nghKBhd9fh2diG+3SEV1VTTPLlqH209yleofi28H87Cy6g1vsd3W3kf6r/dR5g4G4TEwHxo2Ydn6yFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.3.tgz", + "integrity": "sha512-1DrKKsdJTLuLWVdpaLZ0j/g9YbCZyP9xnwSqEvl3gY4ZHdXmX7TwVAHkoWUljOq7JK5zvzIGhrYmfE/2DJ5qaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.3.tgz", + "integrity": "sha512-HE6HHZYF8k7m80eVQ0RBvRGBdvvLvCpHiT38IRH9JSnBlt1T7gDzWoslWjmpXQFuqlRpzkCpbdKJa3NxWMfgVA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.3.tgz", + "integrity": "sha512-Li2wVd2kkKlKkTdpo7ujHSv6kxD1UYMvulAraikyvVf6AKNZ/VHbm8XoSNimZ+dF7SOFaDD2VAT64SK7WKcbjQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.3.tgz", + "integrity": "sha512-otIiImZaHj9MiDK02ItoWxIVcMTZVAX2F1c32bg9y7ecV0AnN5JHDZqIO8LxWsTuig1d+Bjg0cBWn4A9sGJO9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.3.tgz", + "integrity": "sha512-MmIA32rNEOrjh6wnevlR3OjjlCuwgZ4JMJo7Vrhk4Fk56Vxi7EeF7cekSKwvlrnfcn/ERC1LdcG3sFneU8WdoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.3.tgz", + "integrity": "sha512-BiCy1YV0IKO+xbD7gyZnENU4jdwDygeGQjncJoeIE5Kp4UqWHFsKUSJ3pp7vYURrqVzwJX2xD5gQeGnoXp4xPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.3.tgz", + "integrity": "sha512-venvyAu0AMKdr0c1Oz23IJJdZ72zSwKyHrLvqQV1cn49vPAJk3AuVtDkJ1ayk1sYI4M4j8Jv6ZGflpaP0QVSXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.3.tgz", + "integrity": "sha512-e3kColrZZCdtbwIOc07cNQ2zNf1sTPXTYLjjPlsgsaf+ttzAg/hOlDyEgHoOlBGxM88nPxeVaOGe9ThqVzPncg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.3.tgz", + "integrity": "sha512-qpwoUPzfu71cppxOtcz4LXMR1brljS13yOcAAnVHKIL++NJvSQKZBKlP39pVowd+G6Mq34YAbf4CUUYdLWL9gQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.3.tgz", + "integrity": "sha512-dTRIlLRC5lCRHqO5DLb+A18HCvS394axmzqfnRNLptKVw7WuckpUwo1Z87Yw74mesbeIhnQTA2SZbRcIfVlwxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.3.tgz", + "integrity": "sha512-pEvbC/NoOqxvqjy6IgelSakbzwin865CmOxJxmz3CSEbHJ2aF1B2183ALVasN0o6dOGhYfnVJOKKxVoyag+XeA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.3", + "@tailwindcss/oxide": "4.2.3", + "tailwindcss": "4.2.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.3.tgz", + "integrity": "sha512-fA/NX5gMf0ooCLISgB0wScaWgaj6rjTN2SVAwleURjiya7ITNkV+VMmoHtKkldP6CIZoYCZyxb8zP/e2TWoEtQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b0f14e0 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..17b6d82 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 `` with a `MemoryRouter` if needed. + */ +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx new file mode 100644 index 0000000..d76cdb9 --- /dev/null +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -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: , + label: "SWORD XML", + desc: "Metadatos en formato Atom", + }, + { + format: "zip", + icon: , + 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 ( +
+ + + {open && ( +
+ {FORMATS.map(({ format, icon, label, desc }, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx new file mode 100644 index 0000000..7c0f110 --- /dev/null +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -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 ( + + + + ); +} + +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 ( + + ); +} + +/** + * 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 ( +
+ {/* Toolbar */} +
+
+
+

+ Publicaciones +

+

+ {filtered.length} de {publications.length} resultados + {yearFilterSummary && ( + <> + {" · "} + {yearFilterSummary} + + )} + {selectedIds.size > 0 && ( + <> + {" · "} + + {selectedIds.size} seleccionada + {selectedIds.size === 1 ? "" : "s"} + + + )} +

+
+
+ +
+ { + 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" + /> + + + +
+
+
+ + {filtersOpen && ( +
+
+ + +
+
+ + +
+ {hasYearFilter && ( + + )} + {availableYears.length === 0 && ( +

+ Aún no hay años disponibles. +

+ )} +
+ )} +
+ + {/* Body */} +
+ {error ? ( + + ) : loading ? ( + + ) : ( + + + + + {COLUMNS.map((col) => ( + + ))} + + + + {filtered.length === 0 ? ( + + + + ) : ( + pageRows.map((pub, i) => { + const isSelected = selectedIds.has(pub.id); + return ( + + + + + + + + + ); + }) + )} + +
e.stopPropagation()} + > + + 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" + > + + {col.label.toUpperCase()} + + +
+ No se encontraron publicaciones con los filtros aplicados. +
{ + e.stopPropagation(); + toggleRow(pub.id); + }} + > + toggleRow(pub.id)} + ariaLabel={`Seleccionar publicación ${pub.title}`} + /> + + + {isAuthenticated && pub.downloaded_by_me === false && ( + + + Nuevo + + )} + {pub.title} + + + {pub.journal || "—"} + + {pub.publication_year ?? "—"} + + {pub.doi ? ( + e.stopPropagation()} + className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline" + > + {pub.doi} + + ) : ( + + — + + )} + + +
+ )} +
+ + {/* Pagination */} + {!loading && !error && filtered.length > 0 && ( + setPage((p) => Math.max(1, Math.min(p, totalPages) - 1))} + onNext={() => setPage((p) => Math.min(totalPages, Math.min(p, totalPages) + 1))} + /> + )} +
+ ); +} + +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 ( +
+ {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. +

+ Mostrando{" "} + {visibleCount}{" "} + de {total}{" "} + {noun} +

+ ) : ( +

+ Mostrando del{" "} + {pageStart}{" "} + al {pageEnd}{" "} + de un total de{" "} + {total} {noun} +

+ )} + {!isSinglePage && ( +
+ + + Página {page}{" "} + de {totalPages} + + +
+ )} +
+ ); +} + +function LoadingState() { + return ( +
+ +

Cargando publicaciones…

+
+ ); +} + +function ErrorState({ error, onRetry }) { + return ( +
+ + + +
+

+ No se pudieron cargar las publicaciones +

+

+ {error?.message ?? "Error desconocido."} +

+
+ {onRetry && ( + + )} +
+ ); +} diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx new file mode 100644 index 0000000..7328cfd --- /dev/null +++ b/frontend/src/components/dashboard/ResearcherCard.jsx @@ -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 ( +
+
+ {getInitials(title)} +
+ +
+

+ {title} +

+
+
+ + + {researcher.orcid_id} + +
+ {researcher.affiliation && ( + <> + · + + {researcher.affiliation} + + + )} +
+
+ + + Última sincronización: {formatDate(researcher.last_sync_at)} + +
+
+ + {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/StatsRow.jsx b/frontend/src/components/dashboard/StatsRow.jsx new file mode 100644 index 0000000..bef575a --- /dev/null +++ b/frontend/src/components/dashboard/StatsRow.jsx @@ -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 ( +
+ {stats.map(({ label, value, valueClass }) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/dashboard/SyncButton.jsx b/frontend/src/components/dashboard/SyncButton.jsx new file mode 100644 index 0000000..70a6469 --- /dev/null +++ b/frontend/src/components/dashboard/SyncButton.jsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx new file mode 100644 index 0000000..fd10421 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.jsx @@ -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 ( +
+ + + Inicio + +
+ {isAuthenticated && ( +
+ {userOrcidId && ( + + + Mi perfil + + )} + + + Sesión activa + + +
+ )} + + {variant === "group" ? "Búsqueda grupal · ORCID" : "Sistema ORCID · SWORD"} + +
+ ); + } + + return ( +
+
+ +
+ + Sistema de Integración ORCID · SWORD + + {isAuthenticated && ( +
+ {userOrcidId && ( + + + Mi perfil + + )} + + + Sesión activa + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/Badge.jsx b/frontend/src/components/ui/Badge.jsx new file mode 100644 index 0000000..c3c854f --- /dev/null +++ b/frontend/src/components/ui/Badge.jsx @@ -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 ( + + {label} + + ); +} diff --git a/frontend/src/components/ui/Icons.jsx b/frontend/src/components/ui/Icons.jsx new file mode 100644 index 0000000..1611b5d --- /dev/null +++ b/frontend/src/components/ui/Icons.jsx @@ -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 ( + + + + + + ); +} + +export function LayersIcon({ size = 18, className = "" }) { + return ( + + + + ); +} + +export function ArrowLeftIcon({ size = 14, className = "" }) { + return ( + + + + ); +} + +export function ClockIcon({ size = 13, className = "" }) { + return ( + + + + + ); +} + +export function CheckIcon({ size = 15, className = "" }) { + return ( + + + + ); +} + +export function RefreshIcon({ size = 15, className = "" }) { + return ( + + + + + ); +} + +export function DownloadIcon({ size = 15, className = "" }) { + return ( + + + + ); +} + +export function ChevronDownIcon({ size = 12, className = "" }) { + return ( + + + + ); +} + +export function SearchIcon({ size = 14, className = "" }) { + return ( + + + + + ); +} + +export function FilterIcon({ size = 14, className = "" }) { + return ( + + + + ); +} + +export function AlertIcon({ size = 16, className = "" }) { + return ( + + + + + ); +} + +export function PackageIcon({ size = 18, className = "" }) { + return ( + + + + + + ); +} + +export function LogoutIcon({ size = 15, className = "" }) { + return ( + + + + ); +} + +export function UsersIcon({ size = 16, className = "" }) { + return ( + + + + + + ); +} + +export function SparkleIcon({ size = 12, className = "" }) { + return ( + + + + ); +} + +export function UserCheckIcon({ size = 15, className = "" }) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/ui/OrcidLogo.jsx b/frontend/src/components/ui/OrcidLogo.jsx new file mode 100644 index 0000000..6d44f66 --- /dev/null +++ b/frontend/src/components/ui/OrcidLogo.jsx @@ -0,0 +1,13 @@ +/** + * Official ORCID iD glyph. + */ +export function OrcidLogo({ size = 18, className = "" }) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/ui/Spinner.jsx b/frontend/src/components/ui/Spinner.jsx new file mode 100644 index 0000000..01c7328 --- /dev/null +++ b/frontend/src/components/ui/Spinner.jsx @@ -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 ( + + ); +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..206cba1 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -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 {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside "); + return ctx; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..32818c8 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..d9e87bc --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + , +); diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx new file mode 100644 index 0000000..ae5bb8a --- /dev/null +++ b/frontend/src/pages/AuthCallbackPage.jsx @@ -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 /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 ( +
+ {status === "loading" && ( + <> + +
+

+ Completando inicio de sesión... +

+

+ Verificando credenciales con ORCID. +

+
+ + )} + + {status === "success" && ( + <> +
+ +
+
+

+ ¡Sesión iniciada correctamente! +

+

+ Cerrando ventana... +

+
+ + )} + + {status === "error" && ( + <> +
+ +
+
+

+ Error al iniciar sesión +

+

{errorMsg}

+

+ Cerrando ventana... +

+
+ + )} +
+ ); +} + +/* ─────────────────────────── 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; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..cdd9a17 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ; + } + + 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 ( +
+ + +
+ {researcher ? ( + + + + + } + /> + ) : ( + + )} + + + + loadBundle()} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} + isAuthenticated={isAuthenticated} + /> + +
+ + Datos obtenidos vía ORCID Public API v3.0 + +
+ {["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => ( + + {t} + + ))} +
+
+
+
+ ); +} + +function ResearcherSkeleton() { + return ( +
+ ); +} + +export default DashboardPage; diff --git a/frontend/src/pages/GroupResultsPage.jsx b/frontend/src/pages/GroupResultsPage.jsx new file mode 100644 index 0000000..8a29ad4 --- /dev/null +++ b/frontend/src/pages/GroupResultsPage.jsx @@ -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 ( +
+ + +
+ {/* Page header */} +
+
+
+ +
+
+

+ Búsqueda grupal +

+ {!loading && ( +

+ {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} + {errors.length > 0 && ( + + · {errors.length} con error + + )} +

+ )} +
+
+ + {/* Global export buttons */} + {!loading && results.length > 0 && ( +
+ {["xml", "zip"].map((fmt) => ( + + ))} +
+ )} +
+ + {/* Loading state */} + {loading && ( +
+ +

+ Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID... +

+

+ Esto puede tardar unos segundos si hay muchos perfiles nuevos. +

+
+ )} + + {/* Results grid */} + {!loading && results.length > 0 && ( +
+ {results.map((bundle) => ( + + handleCardExport( + bundle.researcher?.orcid_id, + fmt, + newIds, + totalIds, + ) + } + /> + ))} +
+ )} + + {/* Errors */} + {!loading && errors.length > 0 && ( +
+

+ ORCID iDs que no pudieron cargarse +

+
+ {errors.map((e) => ( +
+ +
+

+ {e.orcid_id} +

+

+ {e.detail ?? "No se pudo obtener información de este ORCID."} +

+
+
+ ))} +
+
+ )} + + {/* Empty state */} + {!loading && results.length === 0 && errors.length === 0 && ( +
+ +

No se encontraron resultados.

+ + + Volver al inicio + +
+ )} +
+
+ ); +} + +/* ─────────────────────────── 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 ( +
+ {/* Researcher identity */} +
+
+ {initials} +
+
+

+ {researcher.name || "Sin nombre"} +

+
+ + + {researcher.orcid_id} + +
+ {researcher.affiliation && ( +

+ {researcher.affiliation} +

+ )} +
+
+ + {/* Stats row */} +
+
+

{totalRecords}

+

publicaciones

+
+ {isAuthenticated && ( + <> +
+
+

+ {newCount} +

+

nuevas

+
+ + )} +
+ + {/* Actions */} +
+ + Ver detalle + + onExport(fmt, newIds, allPubIds)} + exporting={exporting} + isAuthenticated={isAuthenticated} + hasNew={hasNew} + newCount={newCount} + totalCount={totalRecords} + /> +
+
+ ); +} + +/* ─────────────────────── 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 ( +
+ + + {open && ( +
+ {["xml", "zip"].map((fmt, idx) => ( + + ))} +
+ )} +
+ ); +} + +/* ─────────────────────────── 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; diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx new file mode 100644 index 0000000..cd0a219 --- /dev/null +++ b/frontend/src/pages/LandingPage.jsx @@ -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 ( +
+ + +
+
+
+
+ +
+

+ Repositorio Institucional +

+

+ Conecta tu perfil ORCID y deposita tus publicaciones + automáticamente en el repositorio institucional vía protocolo + SWORD. +

+
+ + {/* Main card */} +
+ {isAuthenticated ? ( +
+ Sesión activa + + Verás publicaciones nuevas marcadas en el dashboard + +
+ ) : ( + <> + + + )} + +
+
+ + {isAuthenticated ? "TU ORCID iD" : "O INTRODUCE TU ORCID iD"} + +
+
+ +
+ +
+
+ + + + +
+ +
+ {error && ( +

+ {error} +

+ )} +

+ {isAuthenticated + ? "Busca un investigador o usa «Cerrar sesión» arriba." + : "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."} +

+
+
+ + {/* Group search card */} +
+
+ +

+ Búsqueda grupal de investigadores +

+
+

+ Pega varios ORCID iDs separados por comas, espacios o saltos de + línea para buscar y comparar varios investigadores a la vez. +

+