feat(export): perfiles DSpace/EPrints/Dublin Core y selector SWORD en UI
Backend: generadores por repositorio, ZIP multi-formato y query profile en /export/sword. Frontend: selector Destino que envia profile al descargar SWORD XML.
This commit is contained in:
@@ -7,13 +7,15 @@ import {
|
||||
SparkleIcon,
|
||||
} from "../ui/Icons";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { SwordProfileSelect } from "./SwordProfileSelect";
|
||||
import { DEFAULT_EXPORT_PROFILE } from "../../utils/exportProfiles";
|
||||
|
||||
const FORMATS = [
|
||||
{
|
||||
format: "xml",
|
||||
icon: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
|
||||
label: "SWORD XML",
|
||||
desc: "Metadatos en formato Atom",
|
||||
desc: "Según destino seleccionado",
|
||||
},
|
||||
{
|
||||
format: "zip",
|
||||
@@ -31,6 +33,8 @@ const FORMATS = [
|
||||
* - `newPublicationsCount` → cuántas publicaciones tiene downloaded_by_me=false.
|
||||
* - `selectedCount` → publicaciones seleccionadas manualmente.
|
||||
* - `exportingFormat` → formato en curso (pone el botón en loading).
|
||||
* - `swordProfile` → perfil SWORD (dublin_core, dspace, eprints…).
|
||||
* - `onSwordProfileChange` → callback al cambiar destino.
|
||||
*/
|
||||
export function ExportDropdown({
|
||||
onExport,
|
||||
@@ -38,6 +42,8 @@ export function ExportDropdown({
|
||||
selectedCount = 0,
|
||||
isAuthenticated = false,
|
||||
newPublicationsCount = 0,
|
||||
swordProfile = DEFAULT_EXPORT_PROFILE,
|
||||
onSwordProfileChange,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
@@ -57,7 +63,7 @@ export function ExportDropdown({
|
||||
|
||||
function handlePick(format) {
|
||||
setOpen(false);
|
||||
onExport(format);
|
||||
onExport(format, format === "xml" ? swordProfile : undefined);
|
||||
}
|
||||
|
||||
// Label logic:
|
||||
@@ -80,7 +86,14 @@ export function ExportDropdown({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<SwordProfileSelect
|
||||
id="dashboard-sword-profile"
|
||||
value={swordProfile}
|
||||
onChange={onSwordProfileChange}
|
||||
/>
|
||||
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
@@ -124,6 +137,7 @@ export function ExportDropdown({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
DEFAULT_EXPORT_PROFILE,
|
||||
EXPORT_PROFILE_OPTIONS,
|
||||
} from "../../utils/exportProfiles";
|
||||
|
||||
/**
|
||||
* Selector de destino para exportación SWORD XML (DSpace, EPrints, Dublin Core…).
|
||||
*/
|
||||
export function SwordProfileSelect({
|
||||
value = DEFAULT_EXPORT_PROFILE,
|
||||
onChange,
|
||||
id = "sword-export-profile",
|
||||
className = "",
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`flex items-center gap-2 text-sm ${className}`.trim()}
|
||||
>
|
||||
<span className="whitespace-nowrap text-ink-tertiary">Destino:</span>
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="min-w-[9.5rem] rounded-lg border border-surface-border-strong bg-surface-primary px-2.5 py-2 text-sm font-medium text-ink-primary transition-colors hover:bg-surface-secondary focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
{EXPORT_PROFILE_OPTIONS.map(({ value: optionValue, label }) => (
|
||||
<option key={optionValue} value={optionValue}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
syncResearcher,
|
||||
} from "../services/api";
|
||||
import { isValidOrcid } from "../utils/orcid";
|
||||
import { DEFAULT_EXPORT_PROFILE, swordXmlFilename } from "../utils/exportProfiles";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const SUCCESS_FLASH_MS = 3000;
|
||||
@@ -49,6 +50,7 @@ export function DashboardPage() {
|
||||
|
||||
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
||||
const [exportingFormat, setExportingFormat] = useState(null);
|
||||
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
|
||||
@@ -138,7 +140,7 @@ export function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format) {
|
||||
async function handleExport(format, profile = DEFAULT_EXPORT_PROFILE) {
|
||||
setExportingFormat(format);
|
||||
try {
|
||||
let ids;
|
||||
@@ -162,13 +164,17 @@ export function DashboardPage() {
|
||||
|
||||
const { blob } = await downloadExport(orcid, format, {
|
||||
publicationIds: ids,
|
||||
profile: format === "xml" ? profile : undefined,
|
||||
});
|
||||
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}`;
|
||||
anchor.download =
|
||||
format === "xml"
|
||||
? swordXmlFilename(orcid, profile)
|
||||
: `sword-${orcid}.${extension}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -220,6 +226,8 @@ export function DashboardPage() {
|
||||
selectedCount={selectedIds.size}
|
||||
isAuthenticated={isAuthenticated}
|
||||
newPublicationsCount={newPublicationIds.length}
|
||||
swordProfile={swordProfile}
|
||||
onSwordProfileChange={setSwordProfile}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
UsersIcon,
|
||||
} from "../components/ui/Icons";
|
||||
import { downloadExport, searchResearchersBulk } from "../services/api";
|
||||
import { DEFAULT_EXPORT_PROFILE, swordXmlFilename } from "../utils/exportProfiles";
|
||||
import { SwordProfileSelect } from "../components/dashboard/SwordProfileSelect";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ export function GroupResultsPage() {
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [globalExporting, setGlobalExporting] = useState(null); // format | null
|
||||
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
|
||||
|
||||
// Track per-researcher export state (format | null)
|
||||
const [cardExporting, setCardExporting] = useState({});
|
||||
@@ -99,7 +102,7 @@ export function GroupResultsPage() {
|
||||
[results],
|
||||
);
|
||||
|
||||
async function handleGlobalExport(format) {
|
||||
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
|
||||
const ids = isAuthenticated ? allNewIds : allIds;
|
||||
if (ids.length === 0) {
|
||||
toast.info(
|
||||
@@ -116,12 +119,16 @@ export function GroupResultsPage() {
|
||||
// since the endpoint is POST /export/{format}/publications (no orcid needed)
|
||||
const { blob } = await downloadExport(null, format, {
|
||||
publicationIds: ids,
|
||||
profile: format === "xml" ? profile : undefined,
|
||||
});
|
||||
if (blob) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `sword-group.${format === "xml" ? "xml" : format}`;
|
||||
anchor.download =
|
||||
format === "xml"
|
||||
? swordXmlFilename("group", profile)
|
||||
: `sword-group.${format}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -139,7 +146,13 @@ export function GroupResultsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardExport(orcidId, format, newIds, totalIds) {
|
||||
async function handleCardExport(
|
||||
orcidId,
|
||||
format,
|
||||
newIds,
|
||||
totalIds,
|
||||
profile = DEFAULT_EXPORT_PROFILE,
|
||||
) {
|
||||
const ids = isAuthenticated ? newIds : totalIds;
|
||||
if (ids.length === 0) {
|
||||
toast.info("No hay publicaciones para exportar");
|
||||
@@ -149,12 +162,16 @@ export function GroupResultsPage() {
|
||||
try {
|
||||
const { blob } = await downloadExport(orcidId, format, {
|
||||
publicationIds: ids,
|
||||
profile: format === "xml" ? profile : undefined,
|
||||
});
|
||||
if (blob) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `sword-${orcidId}.${format === "xml" ? "xml" : format}`;
|
||||
anchor.download =
|
||||
format === "xml"
|
||||
? swordXmlFilename(orcidId, profile)
|
||||
: `sword-${orcidId}.${format}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -216,12 +233,17 @@ export function GroupResultsPage() {
|
||||
|
||||
{/* Global export buttons */}
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<SwordProfileSelect
|
||||
id="group-sword-profile"
|
||||
value={swordProfile}
|
||||
onChange={setSwordProfile}
|
||||
/>
|
||||
{["xml", "zip"].map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
onClick={() => handleGlobalExport(fmt)}
|
||||
onClick={() => handleGlobalExport(fmt, swordProfile)}
|
||||
disabled={globalDisabled}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@@ -269,8 +291,10 @@ export function GroupResultsPage() {
|
||||
fmt,
|
||||
newIds,
|
||||
totalIds,
|
||||
swordProfile,
|
||||
)
|
||||
}
|
||||
swordProfile={swordProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -326,7 +350,13 @@ export function GroupResultsPage() {
|
||||
|
||||
/* ─────────────────────────── Researcher card ─────────────────────────── */
|
||||
|
||||
function ResearcherResultCard({ bundle, isAuthenticated, exporting, onExport }) {
|
||||
function ResearcherResultCard({
|
||||
bundle,
|
||||
isAuthenticated,
|
||||
exporting,
|
||||
onExport,
|
||||
swordProfile,
|
||||
}) {
|
||||
const researcher = bundle.researcher ?? {};
|
||||
const publications = bundle.publications ?? [];
|
||||
const totalRecords = bundle.totalRecords ?? publications.length;
|
||||
|
||||
@@ -402,13 +402,15 @@ export function getExportUrl(orcidId, format) {
|
||||
* `["id1", "id2", ...]` (array crudo, tal como espera el backend).
|
||||
* - Si viene vacío/undefined usamos el endpoint masivo
|
||||
* `GET /export/{sword|zip}/researcher/{orcid_id}` y descargamos todo.
|
||||
* - Para SWORD XML, `profile` añade `?profile=dublin_core|dspace|eprints`
|
||||
* (genérico = sin query).
|
||||
*
|
||||
* Lanza `ApiError` en fallo.
|
||||
*/
|
||||
export async function downloadExport(
|
||||
orcidId,
|
||||
format,
|
||||
{ signal, publicationIds } = {},
|
||||
{ signal, publicationIds, profile } = {},
|
||||
) {
|
||||
if (USE_MOCKS) {
|
||||
await mockExport(format);
|
||||
@@ -421,10 +423,15 @@ export async function downloadExport(
|
||||
? publicationIds
|
||||
: null;
|
||||
|
||||
const url = ids
|
||||
let url = ids
|
||||
? `${BASE_URL}/export/${segment}/publications`
|
||||
: `${BASE_URL}/export/${segment}/researcher/${encodeURIComponent(orcidId)}`;
|
||||
|
||||
if (format === "xml" && profile && profile !== "generic") {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
url += `${separator}profile=${encodeURIComponent(profile)}`;
|
||||
}
|
||||
|
||||
const init = {
|
||||
method: ids ? "POST" : "GET",
|
||||
signal,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Perfiles de exportación SWORD XML (query `profile` en el backend). */
|
||||
export const EXPORT_PROFILE_OPTIONS = [
|
||||
{ value: "generic", label: "Genérico (ORCID)" },
|
||||
{ value: "dublin_core", label: "Dublin Core" },
|
||||
{ value: "dspace", label: "DSpace" },
|
||||
{ value: "eprints", label: "EPrints" },
|
||||
];
|
||||
|
||||
export const DEFAULT_EXPORT_PROFILE = "generic";
|
||||
|
||||
export function swordXmlFilename(baseName, profile = DEFAULT_EXPORT_PROFILE) {
|
||||
const suffix =
|
||||
profile && profile !== DEFAULT_EXPORT_PROFILE ? `-${profile}` : "";
|
||||
return `sword${suffix}-${baseName}.xml`;
|
||||
}
|
||||
Reference in New Issue
Block a user