diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index cfb1bbf64ab..d80e878bf03 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -119,17 +119,17 @@ const rawUserTools = ensure>>>()({ }, }, [commonText.export()]: { - makeDwca: { - title: headerText.makeDwca(), - enabled: () => hasPermission('/export/dwca', 'execute'), - url: '/specify/overlay/make-dwca/', - icon: icons.upload, - }, - updateExportFeed: { - title: headerText.updateExportFeed(), - enabled: () => hasPermission('/export/feed', 'force_update'), - url: '/specify/overlay/force-update-feed/', - icon: icons.rss, + schemaMapper: { + title: headerText.schemaMapper(), + enabled: () => hasPermission('/export/schema_mapping', 'read'), + url: '/specify/overlay/schema-mapper/', + icon: icons.documentSearch, + }, + exportPackages: { + title: headerText.exportPackages(), + enabled: () => hasPermission('/export/export_package', 'read'), + url: '/specify/overlay/export-packages/', + icon: icons.archive, }, }, [commonText.import()]: { diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts index 0045e7a884b..3f130cb8780 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts @@ -13,6 +13,8 @@ export const operationPolicies = { '/export/dwca': ['execute'], '/export/feed': ['force_update'], '/export/backup': ['execute'], + '/export/schema_mapping': ['create', 'read', 'update', 'delete'], + '/export/export_package': ['create', 'read', 'update', 'delete', 'execute'], '/permissions/list_admins': ['read'], '/permissions/policies/user': ['read', 'update'], '/permissions/user/roles': ['read', 'update'], @@ -35,10 +37,10 @@ export const operationPolicies = { '/tree/edit/storage': [ 'merge', 'move', + 'bulk_move', 'synonymize', 'desynonymize', 'repair', - 'bulk_move', ], '/tree/edit/geologictimeperiod': [ 'merge', diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 86550dc04d1..86945d8135e 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -201,6 +201,22 @@ export const overlayRoutes: RA = [ ({ ForceUpdateFeedOverlay }) => ForceUpdateFeedOverlay ), }, + { + path: 'schema-mapper', + title: headerText.schemaMapper(), + element: () => + import('../SchemaMapper/index').then( + ({ SchemaMapperOverlay }) => SchemaMapperOverlay + ), + }, + { + path: 'export-packages', + title: headerText.exportPackages(), + element: () => + import('../SchemaMapper/ExportPackages/index').then( + ({ ExportPackagesOverlay }) => ExportPackagesOverlay + ), + }, { path: 'about', title: welcomeText.aboutSpecify(), diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx index dde71819d9f..3ccfc8d9b32 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; import { Input, Label } from '../Atoms/Form'; @@ -103,6 +104,70 @@ export function SchemaConfigField({ schemaData={schemaData} onFormatted={handleFormatted} /> + ); } + +type SchemaTermEntry = { + readonly term: string; + readonly iri: string; + readonly definition: string; + readonly mappingPath: string; +}; + +function DwcTermSection({ + fieldName, +}: { + readonly fieldName: string; +}): JSX.Element { + const [terms, setTerms] = React.useState([]); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let cancelled = false; + fetch('/export/schema_terms/') + .then(async (response) => response.json()) + .then((data: readonly SchemaTermEntry[]) => { + if (!cancelled) { + const matched = data.filter((entry) => { + const pathParts = entry.mappingPath.split('.'); + return pathParts[pathParts.length - 1].toLowerCase() === fieldName.toLowerCase(); + }); + setTerms(matched); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [fieldName]); + + return ( +
+ + {headerText.darwinCore()} + +
+ {loading ? ( +

Loading...

+ ) : terms.length === 0 ? ( +

{headerText.noDwcTerms()}

+ ) : ( +
    + {terms.map((entry) => ( +
  • +
    {entry.term}
    +
    {entry.iri}
    +

    {entry.definition}

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/CloneMapping.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/CloneMapping.tsx new file mode 100644 index 00000000000..5ea93ebcc7c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/CloneMapping.tsx @@ -0,0 +1,13 @@ +import { csrfToken } from '../../utils/ajax/csrfToken'; +import type { MappingRecord } from './types'; + +export async function cloneMapping( + mappingId: number +): Promise { + const response = await fetch(`/export/clone_mapping/${mappingId}/`, { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken }, + }); + if (!response.ok) throw new Error('Failed to clone mapping'); + return response.json(); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/ClonePackage.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/ClonePackage.tsx new file mode 100644 index 00000000000..0c9ea807e7a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/ClonePackage.tsx @@ -0,0 +1,78 @@ +/** + * ClonePackage — Clones an ExportDataSet by calling the backend clone + * endpoint, then opens the new package in the PackageForm for editing. + */ + +import React from 'react'; + +import { ajax } from '../../../utils/ajax'; +import { LoadingScreen } from '../../Molecules/Dialog'; +import { PackageForm } from './PackageForm'; + +export function ClonePackage({ + sourceId, + onClose, +}: { + readonly sourceId: number; + readonly onClose: () => void; +}): JSX.Element { + const [clonedId, setClonedId] = React.useState( + undefined + ); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { + let cancelled = false; + + async function doClone(): Promise { + try { + // Clone the core mapping first (the backend will also clone + // extensions in a future iteration). + const response = await ajax<{ readonly id: number }>( + `/export/clone_dataset/${sourceId}/`, + { + method: 'POST', + headers: { Accept: 'application/json' }, + } + ); + if (!cancelled) { + setClonedId(response.data.id); + } + } catch (caughtError) { + if (!cancelled) { + setError( + caughtError instanceof Error + ? caughtError.message + : 'Clone failed' + ); + } + } + } + + doClone().catch(console.error); + return () => { + cancelled = true; + }; + }, [sourceId]); + + if (error !== undefined) { + return ( +
+

Failed to clone package: {error}

+ +
+ ); + } + + if (clonedId === undefined) { + return ; + } + + return ; +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/CopyRssUrl.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/CopyRssUrl.tsx new file mode 100644 index 00000000000..b66494b6370 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/CopyRssUrl.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { Button } from '../../Atoms/Button'; + +export function CopyRssUrl(): JSX.Element { + const [copied, setCopied] = React.useState(false); + const rssUrl = `${window.location.origin}/export/rss/`; + + const handleCopy = async () => { + await navigator.clipboard.writeText(rssUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + {copied ? 'Copied!' : 'Copy RSS Feed URL'} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/EmlEditor.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/EmlEditor.tsx new file mode 100644 index 00000000000..58d15ed3789 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/EmlEditor.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { Button } from '../../Atoms/Button'; + +const GBIF_EML_GENERATOR = 'https://gbif-norway.github.io/eml-generator-js'; + +export function EmlEditor({ + onImport, +}: { + readonly onImport: (xmlContent: string) => void; +}): JSX.Element { + const fileInputRef = React.useRef(null); + + const handleFileImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + // Basic XML validation + try { + new DOMParser().parseFromString(content, 'text/xml'); + onImport(content); + } catch { + alert('Invalid XML file'); + } + }; + reader.readAsText(file); + }; + + return ( +
+
+ fileInputRef.current?.click()}> + Import EML File + + + window.open(GBIF_EML_GENERATOR, '_blank')} + > + Generate EML on GBIF + +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/GbifValidator.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/GbifValidator.tsx new file mode 100644 index 00000000000..957da7f686f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/GbifValidator.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Button } from '../../Atoms/Button'; + +const GBIF_VALIDATOR_URL = 'https://www.gbif.org/tools/data-validator'; + +export function GbifValidatorLink(): JSX.Element { + return ( +
+

Validate your archive against GBIF standards:

+ window.open(GBIF_VALIDATOR_URL, '_blank')} + > + Open GBIF Data Validator + +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/PackageForm.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/PackageForm.tsx new file mode 100644 index 00000000000..5198c7d5702 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/PackageForm.tsx @@ -0,0 +1,466 @@ +/** + * PackageForm — Create/edit form for an Export Package (ExportDataSet). + * + * Fields: + * - Export Name (text, required) + * - File Name (text, required, must end with .zip) + * - Core Mapping selector (dropdown of Core-type SchemaMappings) + * - Metadata (optional resource picker) + * - Extensions section (list of Extension-type mappings with add/remove) + * - Collection selector + * - "Include in RSS feed?" checkbox + * - Frequency (number, required if RSS checked, > 0) + * - Save / Cancel buttons + * - Download Archive button (for saved packages) + */ + +import React from 'react'; + +import { ajax } from '../../../utils/ajax'; +import { csrfToken } from '../../../utils/ajax/csrfToken'; +import { schema } from '../../DataModel/schema'; +import { userInformation } from '../../InitialContext/userInformation'; +import { Button } from '../../Atoms/Button'; +import { Input, Label } from '../../Atoms/Form'; +import type { MappingSummary } from '../types'; + +type PackageFormData = { + exportname: string; + filename: string; + coremapping: number | null; + metadata: number | null; + includeinfeed: boolean; + frequency: number; + extensions: readonly number[]; +}; + +const emptyForm: PackageFormData = { + exportname: '', + filename: '', + coremapping: null, + metadata: null, + includeinfeed: false, + frequency: 0, + extensions: [], +}; + +export function PackageForm({ + datasetId, + onClose, +}: { + readonly datasetId: number | undefined; + readonly onClose: () => void; +}): JSX.Element { + const [form, setForm] = React.useState(emptyForm); + const [mappings, setMappings] = React.useState< + readonly MappingSummary[] | undefined + >(undefined); + const [saving, setSaving] = React.useState(false); + const [exportSuccess, setExportSuccess] = React.useState(false); + const [downloading, setDownloading] = React.useState(false); + + // EML import state + const [emlFile, setEmlFile] = React.useState<{ + readonly name: string; + readonly content: string; + } | undefined>(undefined); + const [emlError, setEmlError] = React.useState(undefined); + const emlInputRef = React.useRef(null); + + // Fetch available schema mappings + React.useEffect(() => { + ajax('/export/list_mappings/', { + headers: { Accept: 'application/json' }, + }) + .then((response) => setMappings(response.data)) + .catch(console.error); + }, []); + + // Load existing dataset when editing (datasetId is provided) + React.useEffect(() => { + if (datasetId === undefined) return; + let cancelled = false; + ajax<{ + readonly exportname: string; + readonly filename: string; + readonly coremapping_id: number | null; + readonly metadata: number | null; + readonly includeinfeed: boolean; + readonly frequency: number; + readonly extensions: readonly number[]; + }>(`/export/get_dataset/${datasetId}/`, { + headers: { Accept: 'application/json' }, + }) + .then((response) => { + if (cancelled) return; + const data = response.data; + // Clear "Copy of" names so the user must enter unique names (clone flow) + const hasCopyPrefix = data.exportname.startsWith('Copy of'); + setForm({ + exportname: hasCopyPrefix ? '' : data.exportname, + filename: hasCopyPrefix ? '' : data.filename, + coremapping: data.coremapping_id, + metadata: data.metadata, + includeinfeed: data.includeinfeed, + frequency: data.frequency, + extensions: data.extensions, + }); + }) + .catch(console.error); + return () => { + cancelled = true; + }; + }, [datasetId]); + + const coreMappings = React.useMemo( + () => mappings?.filter((m) => m.mappingType === 'Core') ?? [], + [mappings] + ); + + const extensionMappings = React.useMemo( + () => mappings?.filter((m) => m.mappingType === 'Extension') ?? [], + [mappings] + ); + + const isNew = datasetId === undefined; + const filenameValid = form.filename.endsWith('.zip'); + const frequencyValid = !form.includeinfeed || form.frequency > 0; + const canSave = + form.exportname.trim().length > 0 && + filenameValid && + form.coremapping !== null && + frequencyValid; + + const handleSave = React.useCallback(async () => { + if (!canSave) return; + setSaving(true); + try { + const payload = { + exportname: form.exportname, + filename: form.filename, + coremapping_id: form.coremapping, + metadata: form.metadata, + includeinfeed: form.includeinfeed, + frequency: form.frequency, + extensions: [...form.extensions], + ...(emlFile !== undefined ? { eml_xml: emlFile.content } : {}), + }; + + if (isNew) { + await ajax('/export/create_dataset/', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: payload, + }); + } else { + await ajax(`/export/update_dataset/${datasetId}/`, { + method: 'PUT', + headers: { Accept: 'application/json' }, + body: payload, + }); + } + onClose(); + } catch (caughtError) { + console.error('Failed to save export package:', caughtError); + } finally { + setSaving(false); + } + }, [canSave, isNew, datasetId, form, onClose]); + + const addExtension = React.useCallback( + (mappingId: number) => { + if (!form.extensions.includes(mappingId)) { + setForm((prev) => ({ + ...prev, + extensions: [...prev.extensions, mappingId], + })); + } + }, + [form.extensions] + ); + + const removeExtension = React.useCallback((mappingId: number) => { + setForm((prev) => ({ + ...prev, + extensions: prev.extensions.filter((id) => id !== mappingId), + })); + }, []); + + return ( +
+

+ {isNew ? 'New Export Package' : 'Edit Export Package'} +

+ + {/* Export Name */} + + Export Name + + setForm((prev) => ({ ...prev, exportname })) + } + /> + + + {/* File Name */} + + File Name + + setForm((prev) => ({ ...prev, filename })) + } + /> + {form.filename.length > 0 && !filenameValid && ( + + Filename must end with .zip + + )} + + + {/* Core Mapping */} + + Core Mapping + + + + {/* Collection (read-only) */} + + Collection + id === schema.domainLevelIds.collection + )?.collectionName ?? '' + } + readOnly + /> + + + {/* Extensions */} +
+ Extensions + {form.extensions.length === 0 ? ( +

No extensions added.

+ ) : ( +
    + {form.extensions.map((extId) => { + const mapping = extensionMappings.find((m) => m.id === extId); + return ( +
  • + {mapping?.name ?? `Mapping #${extId}`} + removeExtension(extId)}> + Remove + +
  • + ); + })} +
+ )} + {extensionMappings.length > 0 && ( + + )} +
+ + {/* Metadata (EML) */} +
+ Metadata (EML) +
+ + Generate EML on GBIF + +
+ emlInputRef.current?.click()} + > + Import EML + + { + const file = event.target.files?.[0]; + if (file !== undefined) { + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + const doc = new DOMParser().parseFromString( + content, + 'text/xml' + ); + if (doc.querySelector('parsererror') !== null) { + setEmlError( + 'Invalid XML: the imported file is not well-formed.' + ); + setEmlFile(undefined); + return; + } + setEmlError(undefined); + setEmlFile({ + name: file.name, + content, + }); + }; + reader.readAsText(file); + } + }} + /> + {emlFile !== undefined && ( + + {`EML loaded: ${emlFile.name}`} + + )} + {emlError !== undefined && ( + + {emlError} + + )} +
+
+
+ + {/* Include in RSS feed */} + + + setForm((prev) => ({ ...prev, includeinfeed })) + } + /> + Include in RSS feed? + + + {/* Frequency */} + {form.includeinfeed && ( + + Update Frequency (hours) + + setForm((prev) => ({ ...prev, frequency: frequency ?? 0 })) + } + /> + {!frequencyValid && ( + + Frequency must be greater than 0 when RSS is enabled. + + )} + + )} + + {/* Buttons */} +
+ + {saving ? 'Saving...' : 'Save'} + + Cancel + {!isNew && ( + { + setDownloading(true); + fetch(`/export/generate_dwca/${datasetId}/`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'X-CSRFToken': csrfToken ?? '', + }, + }) + .then(async (response) => { + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error( + (err as { error?: string }).error ?? + 'Export failed' + ); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = + response.headers + .get('Content-Disposition') + ?.match(/filename="(.+)"/)?.[1] ?? 'export.zip'; + a.click(); + URL.revokeObjectURL(url); + setExportSuccess(true); + }) + .catch(console.error) + .finally(() => setDownloading(false)); + }} + > + {downloading ? 'Generating...' : 'Download Archive'} + + )} +
+ {downloading && ( +

Building archive...

+ )} + {exportSuccess && ( +

+ {'Export successful! Validate your archive with the '} + + GBIF Data Validator + +

+ )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/index.tsx new file mode 100644 index 00000000000..bf12ed16dd3 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/ExportPackages/index.tsx @@ -0,0 +1,683 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { commonText } from '../../../localization/common'; +import { headerText } from '../../../localization/header'; +import { csrfToken as csrfTokenValue } from '../../../utils/ajax/csrfToken'; +import { Button } from '../../Atoms/Button'; +import { Input, Label } from '../../Atoms/Form'; +import { icons } from '../../Atoms/Icons'; +import { userInformation } from '../../InitialContext/userInformation'; +import { Dialog } from '../../Molecules/Dialog'; +import { OverlayContext } from '../../Router/Router'; +import type { MappingSummary } from '../types'; + +type ExportPackageRecord = { + readonly id: number; + readonly exportName: string; + readonly fileName: string; + readonly isRss: boolean; + readonly frequency: number | undefined; + readonly coreMappingId: number; + readonly lastExported: string | undefined; + readonly hasMetadata: boolean; +}; + +export function ExportPackagesOverlay(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + + if (!userInformation.isadmin) { + return ( + {commonText.close()} + } + header={headerText.exportPackages()} + icon={icons.archive} + onClose={handleClose} + > +

You do not have permission to access this tool.

+
+ ); + } + + return ; +} + +function ExportPackagesInner(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + const [packages, setPackages] = React.useState< + ReadonlyArray + >([]); + const [mappings, setMappings] = React.useState< + ReadonlyArray + >([]); + const [showCreate, setShowCreate] = React.useState(false); + const [downloadingId, setDownloadingId] = React.useState( + undefined + ); + const [downloadResult, setDownloadResult] = React.useState< + { readonly id: number; readonly success: boolean; readonly message: string } | undefined + >(undefined); + const [deletingId, setDeletingId] = React.useState( + undefined + ); + const [elapsed, setElapsed] = React.useState(0); + + // Elapsed time counter while downloading + React.useEffect(() => { + if (downloadingId === undefined) { + setElapsed(0); + return undefined; + } + setElapsed(0); + const interval = setInterval(() => setElapsed((e) => e + 1), 1000); + return () => clearInterval(interval); + }, [downloadingId]); + + const fetchAll = React.useCallback(() => { + fetch('/export/list_export_datasets/', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + .then(async (r) => r.json()) + .then(setPackages) + .catch(() => {}); + fetch('/export/list_mappings/', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + .then(async (r) => r.json()) + .then(setMappings) + .catch(() => {}); + }, []); + + React.useEffect(() => { + fetchAll(); + }, [fetchAll]); + + const csrfToken = csrfTokenValue ?? ''; + + const coreMappings = mappings.filter((m) => m.mappingType === 'Core'); + + const getMappingName = (id: number): string => + mappings.find((m) => m.id === id)?.name ?? `Mapping #${id}`; + + const elapsedRef = React.useRef(0); + elapsedRef.current = elapsed; + + const handleDownload = React.useCallback( + async (pkg: ExportPackageRecord) => { + setDownloadingId(pkg.id); + setDownloadResult(undefined); + try { + const response = await fetch(`/export/generate_dwca/${pkg.id}/`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRFToken': csrfToken }, + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error( + (err as { error?: string }).error ?? 'Export failed' + ); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = + response.headers + .get('Content-Disposition') + ?.match(/filename="(.+)"/)?.[1] ?? pkg.fileName; + a.style.display = 'none'; + document.body.append(a); + a.click(); + // Delay cleanup so the browser can start the download + setTimeout(() => { + a.remove(); + URL.revokeObjectURL(url); + }, 1000); + setDownloadResult({ + id: pkg.id, + success: true, + message: `Downloaded ${pkg.fileName} (${elapsedRef.current}s)`, + }); + fetchAll(); + } catch (error) { + setDownloadResult({ + id: pkg.id, + success: false, + message: + error instanceof Error ? error.message : 'Download failed', + }); + } finally { + setDownloadingId(undefined); + } + }, + [csrfToken, fetchAll] + ); + + const handleDelete = React.useCallback( + async (id: number) => { + await fetch(`/export/delete_dataset/${id}/`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { 'X-CSRFToken': csrfToken }, + }); + setDeletingId(undefined); + fetchAll(); + }, + [csrfToken, fetchAll] + ); + + return ( + <> + {commonText.close()} + } + header={headerText.exportPackages()} + icon={icons.archive} + onClose={handleClose} + > +
+

+ {'Each archive pairs a DwC mapping with a downloadable Darwin Core Archive (.zip) for GBIF or other aggregators.'} +

+ + {/* Package list */} + {packages.length === 0 ? ( +

+ {'No archives configured yet. Click below to create one.'} +

+ ) : ( +
+ {packages.map((pkg) => ( +
+
+
+ {pkg.exportName} +
+ {`Mapping: ${getMappingName(pkg.coreMappingId)}`} +
+ {pkg.lastExported !== undefined && ( +
+ {`Last exported: ${new Date(pkg.lastExported).toLocaleDateString()}`} +
+ )} +
+
+ setDeletingId(pkg.id)} + > + {'Delete'} + +
+
+
+ { + handleDownload(pkg).catch(console.error); + }} + > + {downloadingId === pkg.id + ? `Generating... ${elapsed}s` + : `Download ${pkg.fileName}`} + +
+ + + {downloadResult?.id === pkg.id && ( +

+ {downloadResult.message} + {downloadResult.success && ( + <> + {' — '} + + Validate on GBIF + + + )} +

+ )} +
+ ))} +
+ )} + + {/* Create button */} + setShowCreate(true)}> + {'New DwC Archive'} + + + {/* RSS actions — only shown if any archive is RSS-enabled */} + {packages.some((p) => p.isRss) && ( + + )} +
+
+ + {/* Create dialog */} + {showCreate && ( + { + setShowCreate(false); + fetchAll(); + }} + /> + )} + + {/* Delete confirmation */} + {deletingId !== undefined && ( + + {commonText.cancel()} + { + handleDelete(deletingId).catch(console.error); + }} + > + {commonText.delete()} + + + } + header={'Delete Archive' as LocalizedString} + onClose={() => setDeletingId(undefined)} + > +

+ {`Delete "${packages.find((p) => p.id === deletingId)?.exportName ?? ''}"? This cannot be undone.`} +

+
+ )} + + ); +} + +function CreateArchiveDialog({ + coreMappings, + csrfToken, + onClose, +}: { + readonly coreMappings: ReadonlyArray; + readonly csrfToken: string; + readonly onClose: () => void; +}): JSX.Element { + const [name, setName] = React.useState(''); + const [selectedMapping, setSelectedMapping] = React.useState(''); + const [saving, setSaving] = React.useState(false); + + const fileName = React.useMemo(() => { + if (name.trim().length === 0) return ''; + return `${name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')}.zip`; + }, [name]); + + const canSave = name.trim().length > 0 && selectedMapping !== ''; + + return ( + + {commonText.cancel()} + { + if (!canSave) return; + setSaving(true); + fetch('/export/create_dataset/', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + exportname: name.trim(), + filename: fileName, + coremapping_id: selectedMapping, + includeinfeed: false, + frequency: 0, + extensions: [], + }), + }) + .then(() => onClose()) + .catch(console.error) + .finally(() => setSaving(false)); + }} + > + {saving ? 'Creating...' : 'Create'} + + + } + header={'New DwC Archive' as LocalizedString} + onClose={onClose} + > +
+ + {'Archive Name'} + + + {fileName.length > 0 && ( +

{`File: ${fileName}`}

+ )} + + {'DwC Mapping'} + + +

+ {'The mapping determines which Specify fields become which DwC columns in the archive. Configure mappings in DwC Mapping.'} +

+
+
+ ); +} + +function EmlButtons({ + csrfToken, + datasetId, + hasMetadata, + onImported, +}: { + readonly csrfToken: string; + readonly datasetId: number; + readonly hasMetadata: boolean; + readonly onImported: () => void; +}): JSX.Element { + const fileRef = React.useRef(null); + const [status, setStatus] = React.useState(undefined); + const [pendingFile, setPendingFile] = React.useState( + undefined + ); + const [preview, setPreview] = React.useState< + { readonly raw?: string } | undefined + >(undefined); + const [showPreview, setShowPreview] = React.useState(false); + + const doImport = React.useCallback( + (content: string) => { + fetch(`/export/update_dataset/${datasetId}/`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ eml_xml: content }), + }) + .then((resp) => { + if (resp.ok) { + setStatus('EML imported'); + setTimeout(() => setStatus(undefined), 3000); + onImported(); + } else { + setStatus('Import failed'); + } + }) + .catch(() => setStatus('Import failed')); + }, + [csrfToken, datasetId, onImported] + ); + + return ( +
+ {'Metadata:'} + {hasMetadata && ( + { + setShowPreview(true); + fetch(`/export/preview_eml/${datasetId}/`, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + .then(async (r) => r.json()) + .then(setPreview) + .catch(() => setPreview(undefined)); + }} + > + {'View EML'} + + )} + {!hasMetadata && ( + {'none'} + )} + + window.open( + 'https://gbif-norway.github.io/eml-generator-js', + '_blank' + ) + } + > + {'Generate EML'} + + fileRef.current?.click()}> + {'Import EML'} + + {status !== undefined && ( + + {status} + + )} + { + const file = event.target.files?.[0]; + if (file === undefined) return; + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + const doc = new DOMParser().parseFromString(content, 'text/xml'); + if (doc.querySelector('parsererror') !== null) { + setStatus('Invalid XML file'); + return; + } + if (hasMetadata) { + setPendingFile(content); + } else { + doImport(content); + } + }; + reader.readAsText(file); + event.target.value = ''; + }} + /> + {/* Overwrite confirmation */} + {pendingFile !== undefined && ( + + {commonText.cancel()} + { + doImport(pendingFile); + setPendingFile(undefined); + }} + > + {'Overwrite'} + + + } + header={'Replace EML?' as LocalizedString} + onClose={() => setPendingFile(undefined)} + > +

+ { + 'This archive already has EML metadata attached. Importing a new file will replace it.' + } +

+
+ )} + {/* Preview dialog */} + {showPreview && ( + {commonText.close()} + } + header={'EML Metadata' as LocalizedString} + onClose={() => { + setShowPreview(false); + setPreview(undefined); + }} + > + {preview === undefined ? ( +

{'Loading...'}

+ ) : ( +
+              {(() => {
+                try {
+                  const raw = (preview as { raw?: string }).raw ?? '';
+                  const doc = new DOMParser().parseFromString(raw, 'text/xml');
+                  const serializer = new XMLSerializer();
+                  const xml = serializer.serializeToString(doc);
+                  // Simple pretty-print
+                  let indent = 0;
+                  return xml
+                    .replace(/>\n<')
+                    .split('\n')
+                    .map((line) => {
+                      if (line.startsWith('') &&
+                        !line.includes('
+          )}
+        
+ )} +
+ ); +} + +function RssSection({ + csrfToken, +}: { + readonly csrfToken: string; +}): JSX.Element { + const [updating, setUpdating] = React.useState(false); + const [message, setMessage] = React.useState(undefined); + const [copied, setCopied] = React.useState(false); + + return ( +
+

+ {'Archives marked with RSS are published to an RSS feed that GBIF can harvest automatically.'} +

+
+ { + const url = `${window.location.origin}/export/rss/`; + navigator.clipboard + .writeText(url) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(console.error); + }} + > + {copied ? 'Copied!' : 'Copy RSS URL'} + + { + setUpdating(true); + setMessage(undefined); + fetch('/export/force_update_packages/', { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRFToken': csrfToken }, + }) + .then(() => setMessage('RSS feed update started.')) + .catch(() => setMessage('Failed to update RSS feed.')) + .finally(() => setUpdating(false)); + }} + > + {updating ? 'Updating...' : 'Rebuild RSS Feed'} + +
+ {message !== undefined && ( +

{message}

+ )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingEditor.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingEditor.tsx new file mode 100644 index 00000000000..e2c39a1abcf --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingEditor.tsx @@ -0,0 +1,425 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { ajax } from '../../utils/ajax'; +import { csrfToken } from '../../utils/ajax/csrfToken'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { Dialog, LoadingScreen } from '../Molecules/Dialog'; +import { autoMapFields } from './autoMap'; +import { MappingRow } from './MappingList'; +import { findDuplicateTerms, MappingToolbar } from './Toolbar'; +import { TermDropdown } from './TermDropdown'; +import { OCCURRENCE_ID_IRI } from './types'; +import type { DwcTerm, MappingField, MappingRecord } from './types'; +import { fetchSchemaTerms } from './vocabulary'; +import type { SchemaTerms, Vocabulary } from './vocabulary'; + +type ApiMappingDetail = { + readonly id: number; + readonly name: string; + readonly mappingType: string; + readonly isDefault: boolean; + readonly queryId: number; + readonly vocabulary: string; + readonly totalFields: number; + readonly unmappedFields: number; +}; + +type ApiQueryField = { + readonly id: number; + readonly position: number; + readonly stringid: string; + readonly fieldname: string; +}; + +type ApiMappingFieldAssignment = { + readonly fieldid: number; + readonly term: string | undefined; + readonly isstatic: boolean; + readonly staticvalue: string | undefined; +}; + +export function MappingEditor({ + mappingId, + onClose: handleClose, +}: { + readonly mappingId: number; + readonly onClose: () => void; +}): JSX.Element | null { + const [mapping, setMapping] = React.useState( + undefined + ); + const [fields, setFields] = React.useState< + ReadonlyArray | undefined + >(undefined); + const [schemaTerms, setSchemaTerms] = React.useState< + SchemaTerms | undefined + >(undefined); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [saveWarning, setSaveWarning] = React.useState( + undefined + ); + const [saveSuccess, setSaveSuccess] = React.useState(false); + const saveSuccessTimerRef = React.useRef>(); + React.useEffect(() => () => clearTimeout(saveSuccessTimerRef.current), []); + const [editingName, setEditingName] = React.useState(false); + const [nameDraft, setNameDraft] = React.useState(''); + + const commitRename = React.useCallback(() => { + const trimmed = nameDraft.trim(); + if ( + trimmed.length > 0 && + mapping !== undefined && + trimmed !== mapping.name && + !mapping.isDefault + ) { + fetch(`/export/update_mapping/${mapping.id}/`, { + credentials: 'same-origin', + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken ?? '', + }, + body: JSON.stringify({ name: trimmed }), + }) + .then(() => + setMapping((prev) => + prev === undefined ? prev : { ...prev, name: trimmed } + ) + ) + .catch(console.error); + } + setEditingName(false); + }, [nameDraft, mapping]); + + // Fetch the mapping detail, its query fields, and the schema terms + React.useEffect(() => { + let cancelled = false; + + async function load(): Promise { + const [mappingsResponse, termsData] = await Promise.all([ + ajax>('/export/list_mappings/', { + headers: { Accept: 'application/json' }, + }), + fetchSchemaTerms(), + ]); + + if (cancelled) return; + + const raw = mappingsResponse.data.find((m) => m.id === mappingId); + if (raw === undefined) { + setError('Mapping not found'); + return; + } + const record: MappingRecord = { + id: raw.id, + name: raw.name, + mappingType: raw.mappingType === 'Core' ? 'Core' : 'Extension', + isDefault: raw.isDefault, + queryId: raw.queryId, + vocabulary: raw.vocabulary ?? 'dwc', + totalFields: raw.totalFields ?? 0, + unmappedFields: raw.unmappedFields ?? 0, + }; + setMapping(record); + setSchemaTerms(termsData); + + // Fetch query fields + const qid = raw.queryId; + const fieldsResponse = await ajax<{ + readonly objects: ReadonlyArray; + }>(`/api/specify/spqueryfield/?query=${qid}&limit=0`, { + headers: { Accept: 'application/json' }, + }); + + if (cancelled) return; + + // Read term assignments directly from the SpQueryField records + // (term, isstatic, staticvalue are columns on spqueryfield) + const mappedFields: ReadonlyArray = + fieldsResponse.data.objects.map((qf) => { + const rec = qf as Record; + return { + id: qf.id, + position: qf.position, + stringId: qf.stringid, + fieldName: qf.fieldname, + term: (rec.term as string | null) ?? undefined, + isStatic: (rec.isstatic as boolean) ?? false, + staticValue: (rec.staticvalue as string | null) ?? undefined, + }; + }); + setFields(mappedFields); + } + + load().catch((caughtError) => + setError( + caughtError instanceof Error ? caughtError.message : 'Load failed' + ) + ); + + return () => { + cancelled = true; + }; + }, [mappingId]); + + // Filter terms to the mapping's vocabulary (stored on SchemaMapping) + const allTerms: Readonly> = React.useMemo(() => { + if (schemaTerms === undefined || mapping === undefined) return {}; + const vocabKey = mapping.vocabulary ?? 'dwc'; + const vocab = schemaTerms.vocabularies[vocabKey] + ?? Object.values(schemaTerms.vocabularies)[0]; + if (vocab === undefined) return {}; + const result: Record = {}; + for (const [iri, term] of Object.entries(vocab.terms)) { + // #7722: Filter out occurrenceID — it's always on the locked row + if (iri === OCCURRENCE_ID_IRI) continue; + result[iri] = { + iri, + label: term.name, + definition: term.description, + comments: '', + examples: '', + }; + } + return result; + }, [schemaTerms]); + + const firstVocabulary: Vocabulary | undefined = React.useMemo(() => { + if (schemaTerms === undefined) return undefined; + const vocabs = Object.values(schemaTerms.vocabularies); + return vocabs.length > 0 ? vocabs[0] : undefined; + }, [schemaTerms]); + + // Compute duplicate term IRIs for visual highlighting (#7731) + const duplicateTerms: ReadonlyArray = React.useMemo( + () => (fields === undefined ? [] : findDuplicateTerms(fields)), + [fields] + ); + + const handleTermChange = React.useCallback( + (fieldId: number, newTerm: string | undefined) => { + // #7722: Prevent adding a second occurrenceID term + if ( + newTerm === OCCURRENCE_ID_IRI && + fields?.some((f) => f.id !== fieldId && f.term === OCCURRENCE_ID_IRI) + ) { + return; + } + setFields((prev) => + prev?.map((f) => (f.id === fieldId ? { ...f, term: newTerm } : f)) + ); + }, + [fields] + ); + + const handleAutoMap = React.useCallback(() => { + if (fields === undefined || firstVocabulary === undefined) return; + setFields(autoMapFields(fields, firstVocabulary)); + }, [fields, firstVocabulary]); + + const handleSave = React.useCallback(async () => { + if (fields === undefined) return; + setSaving(true); + setError(undefined); + setSaveWarning(undefined); + try { + // Validate occurrenceID uniqueness before saving (Core mappings only) + if (mapping?.mappingType === 'Core') { + const validationResponse = await ajax<{ + readonly valid: boolean; + readonly duplicates: ReadonlyArray; + readonly totalDuplicates: number; + }>(`/export/validate_occurrence_ids/${mappingId}/`, { + headers: { Accept: 'application/json' }, + }).catch(() => undefined); + + if (validationResponse?.data !== undefined && !validationResponse.data.valid) { + const count = validationResponse.data.totalDuplicates; + setSaveWarning( + `Warning: ${count} duplicate occurrenceID values found. You may need to add a condition for current determination or use an aggregator for preparations.` + ); + setSaving(false); + return; + } + } + + await ajax(`/export/save_mapping_fields/${mappingId}/`, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + fields: fields.map((f) => ({ + fieldid: f.id, + term: f.term ?? null, + isstatic: f.isStatic, + staticvalue: f.staticValue ?? null, + })), + }, + }); + setSaveSuccess(true); + clearTimeout(saveSuccessTimerRef.current); + saveSuccessTimerRef.current = setTimeout(() => setSaveSuccess(false), 3000); + } catch (caughtError) { + setError( + caughtError instanceof Error ? caughtError.message : 'Save failed' + ); + } finally { + setSaving(false); + } + }, [fields, mappingId, mapping]); + + if (error !== undefined) { + return ( + {commonText.close()} + } + header={headerText.mappingEditor()} + icon={icons.documentSearch} + onClose={handleClose} + > +

{error}

+
+ ); + } + + if (mapping === undefined || fields === undefined) { + return ; + } + + const isCore = mapping.mappingType === 'Core'; + + return ( + + + {headerText.backToList()} + + {commonText.close()} + + } + header={ + ( + + {`${headerText.mappingEditor()} — `} + {editingName ? ( + setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + setNameDraft(mapping.name); + setEditingName(false); + } + }} + /> + ) : ( + { + setNameDraft(mapping.name); + setEditingName(true); + }} + > + {mapping.name} + {'✎'} + + )} + + ) as unknown as LocalizedString + } + icon={icons.documentSearch} + onClose={handleClose} + > +
+ { + handleSave().catch(console.error); + }} + /> +
+ + {headerText.autoMapFields()} + +
+ {saveSuccess && ( +

+ {'Saved successfully.'} +

+ )} + {saveWarning !== undefined && ( +

+ {saveWarning} +

+ )} + + {'Edit backing query in Query Builder'} + + {fields.length === 0 ? ( +

+ {'No fields in this mapping yet. Click the link above to open the Query Builder and add the Specify fields you want to export, then re-open this editor to assign DwC terms.'} +

+ ) : ( +
+ {fields.map((field) => { + const isLocked = isCore && field.term === OCCURRENCE_ID_IRI; + const isDuplicate = + field.term !== undefined && + duplicateTerms.includes(field.term); + // Terms used by OTHER fields — exclude from this field's dropdown + const usedByOthers = fields + .filter((f) => f.id !== field.id && f.term !== undefined) + .map((f) => f.term!); + return ( + + {!isLocked && ( + handleTermChange(field.id, iri)} + /> + )} + {isDuplicate && ( + + {'⚠'} + + )} + + ); + })} +
+ )} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingList.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingList.tsx new file mode 100644 index 00000000000..77d002b5bbd --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/MappingList.tsx @@ -0,0 +1,197 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { OCCURRENCE_ID_IRI } from './types'; +import type { MappingRecord } from './types'; + +export function MappingList({ + mappings, + onEdit: handleEdit, + onClone: handleClone, + onDelete: handleDelete, + onRename: handleRename, +}: { + readonly mappings: ReadonlyArray; + readonly onEdit: (id: number) => void; + readonly onClone: (id: number) => void; + readonly onDelete: (id: number) => void; + readonly onRename: (id: number, newName: string) => void; +}): JSX.Element { + return ( +
    + {mappings.map((mapping) => ( + + ))} +
+ ); +} + +function MappingListItem({ + mapping, + onEdit, + onClone, + onDelete, + onRename, +}: { + readonly mapping: MappingRecord; + readonly onEdit: (id: number) => void; + readonly onClone: (id: number) => void; + readonly onDelete: (id: number) => void; + readonly onRename: (id: number, newName: string) => void; +}): JSX.Element { + const [editing, setEditing] = React.useState(false); + const [draft, setDraft] = React.useState(mapping.name); + + const commitRename = React.useCallback(() => { + const trimmed = draft.trim(); + if (trimmed.length > 0 && trimmed !== mapping.name) { + onRename(mapping.id, trimmed); + } + setEditing(false); + }, [draft, mapping.id, mapping.name, onRename]); + + return ( +
  • + + {editing ? ( + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + setDraft(mapping.name); + setEditing(false); + } + }} + /> + ) : ( + { + setDraft(mapping.name); + setEditing(true); + }} + > + {mapping.name} + + )} + {mapping.unmappedFields > 0 && ( + + {'⚠'} + + )} + {mapping.totalFields === 0 && ( + + {'(empty)'} + + )} + + + onEdit(mapping.id)} + /> + onClone(mapping.id)} + /> + onDelete(mapping.id)} + /> + +
  • + ); +} + +/** + * Renders a single mapping field row. For Core mappings, the first row + * is always the locked occurrenceID row. + */ +export function MappingRow({ + field, + isLocked, + isDuplicate = false, + onRemove: handleRemove, + children, +}: { + readonly field: MappingField; + readonly isLocked: boolean; + readonly isDuplicate?: boolean; + readonly onRemove: (() => void) | undefined; + readonly children: React.ReactNode; +}): JSX.Element { + return ( +
    + + {field.fieldName} + {field.term !== undefined && ( + {field.term} + )} + + {/* + * Static value toggle — V2 feature, currently disabled. + * When field.isStatic is true, a text input would replace the field + * picker, allowing the user to enter a literal value. Uncomment and + * enable when the backend supports static values. + * + * {field.isStatic && ( + * handleStaticValueChange(value)} + * /> + * )} + */} + {children} + {isLocked && ( + + {'required'} + + )} + {!isLocked && handleRemove !== undefined && ( + + )} +
    + ); +} + diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/NewMappingDialog.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/NewMappingDialog.tsx new file mode 100644 index 00000000000..43e9311e7f5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/NewMappingDialog.tsx @@ -0,0 +1,198 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import { Input, Label } from '../Atoms/Form'; +import { Dialog } from '../Molecules/Dialog'; +import type { MappingRecord } from './types'; +import { VocabularyDialog } from './VocabularyDialog'; + +type AvailableQuery = { + readonly id: number; + readonly name: string; + readonly contextTableId: number; +}; + +export function NewMappingDialog({ + existingMappings, + onClose: handleClose, + onCreateFromScratch: handleCreateFromScratch, + onCreateFromQuery: handleCreateFromQuery, + onCloneExisting: handleCloneExisting, +}: { + readonly existingMappings: ReadonlyArray; + readonly onClose: () => void; + readonly onCreateFromScratch: ( + type: 'Core' | 'Extension', + vocabularyKey: string, + name: string + ) => void; + readonly onCreateFromQuery: ( + type: 'Core' | 'Extension', + name: string, + queryId: number + ) => void; + readonly onCloneExisting: (mappingId: number) => void; +}): JSX.Element { + const [mappingName, setMappingName] = React.useState(''); + const [mappingType, setMappingType] = React.useState< + 'Core' | 'Extension' | undefined + >(undefined); + const [showVocabulary, setShowVocabulary] = React.useState(false); + const [availableQueries, setAvailableQueries] = React.useState< + ReadonlyArray | undefined + >(undefined); + + // Fetch available queries when user reaches the type selection step + React.useEffect(() => { + if (mappingType === undefined) return; + let cancelled = false; + fetch('/export/list_queries/', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + .then(async (res) => { + if (!res.ok || cancelled) return; + const data = (await res.json()) as ReadonlyArray; + if (!cancelled) setAvailableQueries(data); + }) + .catch(() => { + if (!cancelled) setAvailableQueries([]); + }); + return () => { + cancelled = true; + }; + }, [mappingType]); + + return ( + <> + {commonText.cancel()} + } + header={headerText.newMapping()} + onClose={handleClose} + > + {mappingType === undefined ? ( +
    + + {'Mapping Name'} + + +

    + {headerText.selectMappingType()} +

    +
    + setMappingType('Core')} + > + {headerText.coreOccurrence()} + + setMappingType('Extension')} + > + {headerText.extension()} + +
    +
    + ) : ( +
    +

    + {mappingType === 'Core' + ? headerText.coreMappings() + : headerText.extensionMappings()} +

    + setShowVocabulary(true)}> + {headerText.createFromScratch()} + + + {/* Use Existing Query */} + {availableQueries !== undefined && availableQueries.length > 0 && ( +
    +

    + {'Use Existing Query'} +

    +

    + {'Pick a Collection Object query you already built. Its fields will become the columns in your DwC export.'} +

    +
    + {availableQueries.map((query) => ( +
    + {query.name} + + handleCreateFromQuery( + mappingType, + mappingName.trim(), + query.id + ) + } + > + {'Use'} + +
    + ))} +
    +
    + )} + + {/* Clone Existing Mapping */} +
    +

    + {headerText.cloneExistingMapping()} +

    + {existingMappings.filter( + (mapping) => mapping.mappingType === mappingType + ).length === 0 ? ( +

    + No existing mappings available. Create one from scratch to get + started. +

    + ) : ( + existingMappings + .filter((mapping) => mapping.mappingType === mappingType) + .map((mapping) => ( +
    + {mapping.name} + {mapping.isDefault && ( + + ({headerText.defaultMapping()}) + + )} + handleCloneExisting(mapping.id)} + > + {headerText.clone()} + +
    + )) + )} +
    +
    + )} +
    + {showVocabulary && mappingType !== undefined && ( + setShowVocabulary(false)} + onSelected={(vocabularyKey) => { + setShowVocabulary(false); + handleCreateFromScratch(mappingType, vocabularyKey, mappingName.trim()); + }} + /> + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermDropdown.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermDropdown.tsx new file mode 100644 index 00000000000..912143b0f5d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermDropdown.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import { Input } from '../Atoms/Form'; +import type { DwcTerm } from './types'; +import { TermTooltip } from './TermTooltip'; + +export function TermDropdown({ + selectedIri, + vocabularyTerms, + usedTerms = [], + onChange: handleChange, +}: { + readonly selectedIri: string | undefined; + readonly vocabularyTerms: Readonly>; + readonly usedTerms?: ReadonlyArray; + readonly onChange: (iri: string | undefined) => void; +}): JSX.Element { + const [search, setSearch] = React.useState(''); + const [isOpen, setIsOpen] = React.useState(false); + const [showCustomIri, setShowCustomIri] = React.useState(false); + const [customIri, setCustomIri] = React.useState(''); + const [showTooltip, setShowTooltip] = React.useState(false); + + const filteredTerms = React.useMemo(() => { + const lowerSearch = search.toLowerCase(); + return Object.entries(vocabularyTerms).filter( + ([iri, term]) => + // Hide terms already used by other fields + !usedTerms.includes(iri) && + (term.label.toLowerCase().includes(lowerSearch) || + iri.toLowerCase().includes(lowerSearch)) + ); + }, [vocabularyTerms, search, usedTerms]); + + const selectedTerm = + selectedIri === undefined ? undefined : vocabularyTerms[selectedIri]; + + const isCustomIriValid = + customIri.startsWith('http://') || customIri.startsWith('https://'); + + return ( +
    +
    + setIsOpen(true)} + onValueChange={(value) => { + setSearch(value); + setIsOpen(true); + }} + /> + {selectedIri !== undefined && selectedTerm !== undefined && ( + + )} + {selectedIri !== undefined && ( + { + handleChange(undefined); + setSearch(''); + setShowTooltip(false); + }} + /> + )} +
    + {showTooltip && selectedIri !== undefined && selectedTerm !== undefined && ( + setShowTooltip(false)} + /> + )} + {isOpen && ( +
      + {filteredTerms.length === 0 && !showCustomIri ? ( +
    • + {commonText.noResults()} +
    • + ) : ( + filteredTerms.map(([iri, term]) => ( +
    • + +
    • + )) + )} +
    • + {showCustomIri ? ( +
      + + {customIri.length > 0 && !isCustomIriValid && ( + + {headerText.customIriWarning()} + + )} + { + if (customIri.length > 0) { + handleChange(customIri); + setCustomIri(''); + setShowCustomIri(false); + setSearch(''); + setIsOpen(false); + } + }} + > + {commonText.apply()} + +
      + ) : ( + + )} +
    • +
    + )} +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermTooltip.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermTooltip.tsx new file mode 100644 index 00000000000..e3badbc63fb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/TermTooltip.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { headerText } from '../../localization/header'; +import type { DwcTerm } from './types'; + +export function TermTooltip({ + iri, + term, + onClose: handleClose, +}: { + readonly iri: string; + readonly term: DwcTerm; + readonly onClose: () => void; +}): JSX.Element { + const containerRef = React.useRef(null); + + React.useEffect(() => { + function handleClickOutside(event: MouseEvent): void { + if ( + containerRef.current !== null && + !containerRef.current.contains(event.target as Node) + ) { + handleClose(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [handleClose]); + + const termName = iri.split('/').pop() ?? iri; + + return ( +
    +
    {term.label}
    +
    + {iri} +
    +

    {term.definition}

    + + {headerText.viewOnTdwg()} + +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/Toolbar.tsx new file mode 100644 index 00000000000..d2aac9357f9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/Toolbar.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import type { MappingField } from './types'; + +export function findDuplicateTerms( + fields: ReadonlyArray +): ReadonlyArray { + const terms = fields + .map((field) => field.term) + .filter((term): term is string => term !== undefined); + const seen = new Set(); + const duplicates = new Set(); + for (const term of terms) { + if (seen.has(term)) duplicates.add(term); + seen.add(term); + } + return [...duplicates]; +} + +export function MappingToolbar({ + onSave: handleSave, + isRunning, + fields, +}: { + readonly onSave: () => void; + readonly isRunning: boolean; + readonly fields: ReadonlyArray; +}): JSX.Element { + const [error, setError] = React.useState(undefined); + + function handleSaveClick(): void { + const duplicateTerms = findDuplicateTerms(fields); + if (duplicateTerms.length > 0) { + setError( + headerText.duplicateTermsError({ + terms: duplicateTerms.join(', '), + }) + ); + return; + } + setError(undefined); + handleSave(); + } + + return ( +
    + + {headerText.saveMapping()} + + {error !== undefined && ( + + {error as LocalizedString} + + )} +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/VocabularyDialog.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/VocabularyDialog.tsx new file mode 100644 index 00000000000..868c8ca94a2 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/VocabularyDialog.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { Dialog } from '../Molecules/Dialog'; +import type { DwcVocabulary } from './types'; + +const vocabularies: ReadonlyArray = [ + { + key: 'dwc', + name: 'Darwin Core', + abbreviation: 'dwc', + description: + 'Core terms for sharing biodiversity occurrence data and related information.', + uri: 'http://rs.tdwg.org/dwc/terms/', + terms: {}, + }, + { + key: 'dc', + name: 'Dublin Core', + abbreviation: 'dc', + description: + 'General-purpose metadata terms for describing resources.', + uri: 'http://purl.org/dc/terms/', + terms: {}, + }, + { + key: 'ac', + name: 'Audubon Core', + abbreviation: 'ac', + description: + 'Terms for describing biodiversity-related multimedia resources.', + uri: 'http://rs.tdwg.org/ac/terms/', + terms: {}, + }, +]; + +export function VocabularyDialog({ + onClose: handleClose, + onSelected: handleSelected, +}: { + readonly onClose: () => void; + readonly onSelected: (vocabularyKey: string) => void; +}): JSX.Element { + return ( + {commonText.cancel()} + } + header={headerText.selectVocabulary()} + icon={icons.documentSearch} + onClose={handleClose} + > +
      + {vocabularies.map((vocabulary) => ( +
    • + +
    • + ))} +
    +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/Toolbar.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/Toolbar.test.ts new file mode 100644 index 00000000000..8ff17d00e3f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/Toolbar.test.ts @@ -0,0 +1,57 @@ +import { findDuplicateTerms } from '../Toolbar'; +import type { MappingField } from '../types'; + +function makeField( + term: string | undefined, + id: number = 1 +): MappingField { + return { + id, + position: id, + stringId: `field.${id}`, + fieldName: `field${id}`, + term, + isStatic: false, + staticValue: undefined, + }; +} + +test('returns empty array when there are no duplicate terms', () => { + const fields = [ + makeField('http://rs.tdwg.org/dwc/terms/catalogNumber', 1), + makeField('http://rs.tdwg.org/dwc/terms/occurrenceID', 2), + makeField('http://rs.tdwg.org/dwc/terms/scientificName', 3), + ]; + expect(findDuplicateTerms(fields)).toEqual([]); +}); + +test('returns duplicate IRIs when duplicates exist', () => { + const duplicateIri = 'http://rs.tdwg.org/dwc/terms/catalogNumber'; + const fields = [ + makeField(duplicateIri, 1), + makeField('http://rs.tdwg.org/dwc/terms/occurrenceID', 2), + makeField(duplicateIri, 3), + ]; + expect(findDuplicateTerms(fields)).toEqual([duplicateIri]); +}); + +test('ignores fields with undefined terms', () => { + const fields = [ + makeField(undefined, 1), + makeField(undefined, 2), + makeField('http://rs.tdwg.org/dwc/terms/catalogNumber', 3), + ]; + expect(findDuplicateTerms(fields)).toEqual([]); +}); + +test('handles all fields being undefined', () => { + const fields = [ + makeField(undefined, 1), + makeField(undefined, 2), + ]; + expect(findDuplicateTerms(fields)).toEqual([]); +}); + +test('handles empty fields array', () => { + expect(findDuplicateTerms([])).toEqual([]); +}); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/autoMap.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/autoMap.test.ts new file mode 100644 index 00000000000..374f5e6a470 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/autoMap.test.ts @@ -0,0 +1,97 @@ +import { autoMapFields } from '../autoMap'; +import type { MappingField } from '../types'; +import type { Vocabulary } from '../vocabulary'; + +const mockVocabulary: Vocabulary = { + name: 'Darwin Core', + abbreviation: 'dwc', + vocabularyURI: 'http://rs.tdwg.org/dwc/terms/', + description: 'Darwin Core standard terms', + terms: { + 'http://rs.tdwg.org/dwc/terms/catalogNumber': { + name: 'catalogNumber', + description: 'An identifier for the record within the collection', + group: 'Occurrence', + mappingPaths: [['CollectionObject', 'catalogNumber']], + }, + 'http://rs.tdwg.org/dwc/terms/occurrenceID': { + name: 'occurrenceID', + description: 'An identifier for the Occurrence', + group: 'Occurrence', + mappingPaths: [['CollectionObject', 'guid']], + }, + }, +}; + +test('auto-maps field with matching stringId to correct term', () => { + const fields: ReadonlyArray = [ + { + id: 1, + position: 0, + stringId: 'CollectionObject.catalogNumber', + fieldName: 'catalogNumber', + term: undefined, + isStatic: false, + staticValue: undefined, + }, + ]; + + const result = autoMapFields(fields, mockVocabulary); + expect(result[0].term).toBe( + 'http://rs.tdwg.org/dwc/terms/catalogNumber' + ); +}); + +test('does not overwrite fields that already have a term assigned', () => { + const existingTerm = 'http://example.org/custom/term'; + const fields: ReadonlyArray = [ + { + id: 1, + position: 0, + stringId: 'CollectionObject.catalogNumber', + fieldName: 'catalogNumber', + term: existingTerm, + isStatic: false, + staticValue: undefined, + }, + ]; + + const result = autoMapFields(fields, mockVocabulary); + expect(result[0].term).toBe(existingTerm); +}); + +test('fields with no matching path remain unmapped', () => { + const fields: ReadonlyArray = [ + { + id: 1, + position: 0, + stringId: 'Agent.lastName', + fieldName: 'lastName', + term: undefined, + isStatic: false, + staticValue: undefined, + }, + ]; + + const result = autoMapFields(fields, mockVocabulary); + expect(result[0].term).toBeUndefined(); +}); + +test('auto-maps by fieldName match against last element of mappingPath', () => { + const fields: ReadonlyArray = [ + { + id: 1, + position: 0, + stringId: 'SomeOther.path.guid', + fieldName: 'guid', + term: undefined, + isStatic: false, + staticValue: undefined, + }, + ]; + + const result = autoMapFields(fields, mockVocabulary); + expect(result[0].term).toBe( + 'http://rs.tdwg.org/dwc/terms/occurrenceID' + ); +}); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/vocabulary.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/vocabulary.test.ts new file mode 100644 index 00000000000..29e50de00a6 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/__tests__/vocabulary.test.ts @@ -0,0 +1,72 @@ +import { findTermByIri } from '../vocabulary'; +import type { SchemaTerms } from '../vocabulary'; + +const mockVocabularies: SchemaTerms['vocabularies'] = { + dwc: { + name: 'Darwin Core', + abbreviation: 'dwc', + vocabularyURI: 'http://rs.tdwg.org/dwc/terms/', + description: 'Darwin Core standard terms for biodiversity data', + terms: { + 'http://rs.tdwg.org/dwc/terms/occurrenceID': { + name: 'occurrenceID', + description: 'An identifier for the Occurrence', + group: 'Occurrence', + mappingPaths: [['CollectionObject', 'guid']], + }, + 'http://rs.tdwg.org/dwc/terms/catalogNumber': { + name: 'catalogNumber', + description: + 'An identifier for the record within the data set or collection', + group: 'Occurrence', + mappingPaths: [['CollectionObject', 'catalogNumber']], + }, + }, + }, + dc: { + name: 'Dublin Core', + abbreviation: 'dc', + vocabularyURI: 'http://purl.org/dc/terms/', + description: 'Dublin Core metadata terms', + terms: { + 'http://purl.org/dc/terms/modified': { + name: 'modified', + description: + 'The most recent date-time on which the resource was changed', + group: 'Record', + mappingPaths: [['CollectionObject', 'timestampModified']], + }, + }, + }, +}; + +describe('findTermByIri', () => { + test('returns correct term and vocabulary for a known DwC IRI', () => { + const result = findTermByIri( + mockVocabularies, + 'http://rs.tdwg.org/dwc/terms/occurrenceID' + ); + expect(result).toBeDefined(); + expect(result!.term.name).toBe('occurrenceID'); + expect(result!.term.group).toBe('Occurrence'); + expect(result!.vocabulary.abbreviation).toBe('dwc'); + }); + + test('returns correct term for a Dublin Core IRI', () => { + const result = findTermByIri( + mockVocabularies, + 'http://purl.org/dc/terms/modified' + ); + expect(result).toBeDefined(); + expect(result!.term.name).toBe('modified'); + expect(result!.vocabulary.abbreviation).toBe('dc'); + }); + + test('returns undefined for an unknown IRI', () => { + const result = findTermByIri( + mockVocabularies, + 'http://example.org/unknown/term' + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/autoMap.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/autoMap.ts new file mode 100644 index 00000000000..5c9266e3944 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/autoMap.ts @@ -0,0 +1,33 @@ +import type { MappingField } from './types'; +import type { Vocabulary } from './vocabulary'; + +/** + * Auto-assign DwC terms to query fields based on field name matching. + * Matches by comparing the field's fieldName (case-insensitive) against + * the last element of each term's mappingPath. + * + * Does not overwrite fields that already have a term assigned. + */ +export function autoMapFields( + fields: ReadonlyArray, + vocabulary: Vocabulary +): ReadonlyArray { + return fields.map((field) => { + if (field.term !== undefined) return field; + + const fieldNameLower = field.fieldName.toLowerCase(); + + for (const [iri, term] of Object.entries(vocabulary.terms)) { + for (const mappingPath of term.mappingPaths) { + if (mappingPath.length === 0) continue; + + // Match by last element of mappingPath (the actual Specify field name) + const lastPathElement = mappingPath[mappingPath.length - 1]; + if (lastPathElement.toLowerCase() === fieldNameLower) { + return { ...field, term: iri }; + } + } + } + return field; + }); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaMapper/index.tsx new file mode 100644 index 00000000000..100a7d51067 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/index.tsx @@ -0,0 +1,353 @@ +import React from 'react'; + +import { csrfToken } from '../../utils/ajax/csrfToken'; +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { LoadingContext } from '../Core/Contexts'; +import { userInformation } from '../InitialContext/userInformation'; +import { Dialog, LoadingScreen } from '../Molecules/Dialog'; +import { OverlayContext } from '../Router/Router'; +import { cloneMapping } from './CloneMapping'; +import { MappingEditor } from './MappingEditor'; +import { MappingList } from './MappingList'; +import { NewMappingDialog } from './NewMappingDialog'; +import type { MappingRecord } from './types'; + +type ApiMappingRecord = { + readonly id: number; + readonly name: string; + readonly mappingType: string; + readonly isDefault: boolean; + readonly queryId: number; + readonly vocabulary: string; + readonly totalFields: number; + readonly unmappedFields: number; +}; + +function toMappingRecord(raw: ApiMappingRecord): MappingRecord { + return { + id: raw.id, + name: raw.name, + mappingType: raw.mappingType === "Core" ? 'Core' : 'Extension', + isDefault: raw.isDefault, + queryId: raw.queryId, + vocabulary: raw.vocabulary ?? 'dwc', + totalFields: raw.totalFields ?? 0, + unmappedFields: raw.unmappedFields ?? 0, + }; +} + +export function SchemaMapperOverlay(): JSX.Element | null { + const handleClose = React.useContext(OverlayContext); + + if (!userInformation.isadmin) { + return ( + {commonText.close()} + } + header={headerText.schemaMapper()} + icon={icons.documentSearch} + onClose={handleClose} + > +

    You do not have permission to access this tool.

    +
    + ); + } + + return ; +} + +function SchemaMapperOverlayInner(): JSX.Element | null { + const handleClose = React.useContext(OverlayContext); + const loading = React.useContext(LoadingContext); + + const [mappings, setMappings] = React.useState< + ReadonlyArray | undefined + >(undefined); + const [showNewDialog, setShowNewDialog] = React.useState(false); + const [editingMappingId, setEditingMappingId] = React.useState< + number | undefined + >(undefined); + const [deletingMappingId, setDeletingMappingId] = React.useState< + number | undefined + >(undefined); + + const fetchMappings = React.useCallback(async () => { + const response = await fetch(`/export/list_mappings/?_=${Date.now()}`, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + setMappings([]); + return; + } + const data = (await response.json()) as ReadonlyArray; + setMappings(data.map(toMappingRecord)); + }, []); + + React.useEffect(() => { + fetchMappings().catch(console.error); + }, [fetchMappings]); + + const coreMappings = React.useMemo( + () => mappings?.filter((m) => m.mappingType === 'Core') ?? [], + [mappings] + ); + + const extensionMappings = React.useMemo( + () => mappings?.filter((m) => m.mappingType === 'Extension') ?? [], + [mappings] + ); + + const handleClone = React.useCallback( + async (id: number) => { + const cloned = await cloneMapping(id); + await fetchMappings(); + setEditingMappingId(cloned.id); + }, + [fetchMappings] + ); + + const [deleteError, setDeleteError] = React.useState( + undefined + ); + + const handleDelete = React.useCallback( + async (id: number) => { + setDeleteError(undefined); + const response = await fetch(`/export/delete_mapping/${id}/`, { + credentials: 'same-origin', + method: 'DELETE', + headers: { + 'X-CSRFToken': csrfToken ?? '', + }, + }); + if (response.ok) { + await fetchMappings(); + setDeletingMappingId(undefined); + } else { + const data = await response.json().catch(() => ({})); + setDeleteError( + data.message ?? 'Failed to delete mapping.' + ); + } + }, + [fetchMappings] + ); + + const handleCreateFromScratch = React.useCallback( + async (type: 'Core' | 'Extension', _vocabularyKey: string, name: string) => { + const response = await fetch('/export/create_mapping/', { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken ?? '', + }, + body: JSON.stringify({ + name, + mappingtype: type, query_context_table_id: 1, + }), + }); + if (response.ok) { + const created = (await response.json()) as ApiMappingRecord; + await fetchMappings(); + setShowNewDialog(false); + setEditingMappingId(created.id); + } + }, + [fetchMappings] + ); + + const handleCreateFromQuery = React.useCallback( + async ( + type: 'Core' | 'Extension', + name: string, + queryId: number + ) => { + const response = await fetch('/export/create_mapping_from_query/', { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken ?? '', + }, + body: JSON.stringify({ + name, + mappingtype: type, + query_id: queryId, + }), + }); + if (response.ok) { + const created = (await response.json()) as ApiMappingRecord; + await fetchMappings(); + setShowNewDialog(false); + setEditingMappingId(created.id); + } + }, + [fetchMappings] + ); + + const handleCloneExisting = React.useCallback( + async (mappingId: number) => { + const cloned = await cloneMapping(mappingId); + await fetchMappings(); + setShowNewDialog(false); + setEditingMappingId(cloned.id); + }, + [fetchMappings] + ); + + const handleRename = React.useCallback( + async (id: number, newName: string) => { + await fetch(`/export/update_mapping/${id}/`, { + credentials: 'same-origin', + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken ?? '', + }, + body: JSON.stringify({ name: newName }), + }); + await fetchMappings(); + }, + [fetchMappings] + ); + + if (editingMappingId !== undefined) { + return ( + { + setEditingMappingId(undefined); + fetchMappings().catch(console.error); + }} + /> + ); + } + + if (mappings === undefined) { + return ; + } + + return ( + <> + {commonText.close()} + } + header={headerText.schemaMapper()} + icon={icons.documentSearch} + onClose={handleClose} + > +
    +
    +

    + {headerText.coreMappings()} +

    + {coreMappings.length === 0 ? ( +

    {headerText.noCoreMappings()}

    + ) : ( + { + loading(handleClone(id)); + }} + onDelete={(id) => setDeletingMappingId(id)} + onEdit={(id) => setEditingMappingId(id)} + onRename={(id, name) => { + loading(handleRename(id, name)); + }} + /> + )} +
    +
    +

    + {headerText.extensionMappings()} +

    + {extensionMappings.length === 0 ? ( +

    + {headerText.noExtensionMappings()} +

    + ) : ( + { + loading(handleClone(id)); + }} + onDelete={(id) => setDeletingMappingId(id)} + onEdit={(id) => setEditingMappingId(id)} + onRename={(id, name) => { + loading(handleRename(id, name)); + }} + /> + )} +
    + setShowNewDialog(true)}> + {headerText.newMapping()} + +
    +
    + {showNewDialog && ( + setShowNewDialog(false)} + onCloneExisting={(id) => { + loading(handleCloneExisting(id)); + }} + onCreateFromQuery={(type, name, queryId) => { + loading(handleCreateFromQuery(type, name, queryId)); + }} + onCreateFromScratch={(type, vocabularyKey, name) => { + loading(handleCreateFromScratch(type, vocabularyKey, name)); + }} + /> + )} + {deletingMappingId !== undefined && ( + + + {commonText.cancel()} + + {deleteError === undefined && ( + { + loading(handleDelete(deletingMappingId)); + }} + > + {commonText.delete()} + + )} + + } + header={commonText.delete()} + onClose={() => { + setDeletingMappingId(undefined); + setDeleteError(undefined); + }} + > + {deleteError !== undefined ? ( + + ) : ( +

    + {`Are you sure you want to delete the mapping "${mappings.find((m) => m.id === deletingMappingId)?.name ?? ''}"? This will also delete the backing query and cannot be undone.`} +

    + )} +
    + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/types.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/types.ts new file mode 100644 index 00000000000..59ebf5221a0 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/types.ts @@ -0,0 +1,45 @@ +export const OCCURRENCE_ID_IRI = 'http://rs.tdwg.org/dwc/terms/occurrenceID'; + +export type MappingSummary = { + readonly id: number; + readonly name: string; + readonly mappingType: string; +}; + +export type MappingRecord = { + readonly id: number; + readonly name: string; + readonly mappingType: 'Core' | 'Extension'; + readonly isDefault: boolean; + readonly queryId: number; + readonly vocabulary: string; + readonly totalFields: number; + readonly unmappedFields: number; +}; + +export type MappingField = { + readonly id: number; + readonly position: number; + readonly stringId: string; + readonly fieldName: string; + readonly term: string | undefined; + readonly isStatic: boolean; + readonly staticValue: string | undefined; +}; + +export type DwcTerm = { + readonly iri: string; + readonly label: string; + readonly definition: string; + readonly comments: string; + readonly examples: string; +}; + +export type DwcVocabulary = { + readonly key: string; + readonly name: string; + readonly abbreviation: string; + readonly description: string; + readonly uri: string; + readonly terms: Readonly>; +}; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaMapper/vocabulary.ts b/specifyweb/frontend/js_src/lib/components/SchemaMapper/vocabulary.ts new file mode 100644 index 00000000000..0aa501ff195 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaMapper/vocabulary.ts @@ -0,0 +1,40 @@ +// Types and fetch function for DwC schema terms vocabulary + +export type DwcTerm = { + readonly name: string; + readonly description: string; + readonly group: string; + readonly mappingPaths: ReadonlyArray>; +}; + +export type Vocabulary = { + readonly name: string; + readonly abbreviation: string; + readonly vocabularyURI: string; + readonly description: string; + readonly terms: Readonly>; +}; + +export type SchemaTerms = { + readonly vocabularies: Readonly>; +}; + +let cachedTerms: SchemaTerms | undefined; + +export async function fetchSchemaTerms(): Promise { + if (cachedTerms !== undefined) return cachedTerms; + const response = await fetch('/export/schema_terms/'); + cachedTerms = await response.json(); + return cachedTerms!; +} + +export function findTermByIri( + vocabularies: SchemaTerms['vocabularies'], + iri: string +): { vocabulary: Vocabulary; term: DwcTerm } | undefined { + for (const vocab of Object.values(vocabularies)) { + const term = vocab.terms[iri]; + if (term !== undefined) return { vocabulary: vocab, term }; + } + return undefined; +} diff --git a/specifyweb/frontend/js_src/lib/localization/header.ts b/specifyweb/frontend/js_src/lib/localization/header.ts index 152298d4370..6f18d0ce3f0 100644 --- a/specifyweb/frontend/js_src/lib/localization/header.ts +++ b/specifyweb/frontend/js_src/lib/localization/header.ts @@ -497,6 +497,256 @@ export const headerText = createDictionary({ 'pt-br': 'Documentação', 'hr-hr': 'Dokumentacija', }, + schemaMapper: { + 'en-us': 'DwC Mapping', + 'de-ch': 'Schema-Zuordnung', + 'es-es': 'Mapeador de esquema', + 'fr-fr': 'Mappeur de schéma', + 'pt-br': 'Mapeador de esquema', + 'ru-ru': 'Сопоставление схемы', + 'uk-ua': 'Зіставлення схеми', + 'hr-hr': 'Mapiranje sheme', + }, + exportPackages: { + 'en-us': 'DwC Archive Export', + 'de-ch': 'Export-Pakete', + 'es-es': 'Paquetes de exportación', + 'fr-fr': "Paquets d'exportation", + 'pt-br': 'Pacotes de exportação', + 'ru-ru': 'Пакеты экспорта', + 'uk-ua': 'Пакети експорту', + 'hr-hr': 'Paketi za izvoz', + }, + coreMappings: { + 'en-us': 'Darwin Core Mappings', + 'de-ch': 'Darwin-Core-Zuordnungen', + 'es-es': 'Mapeos Darwin Core', + 'fr-fr': 'Mappages Darwin Core', + 'pt-br': 'Mapeamentos Darwin Core', + 'ru-ru': 'Сопоставления Darwin Core', + 'uk-ua': 'Зіставлення Darwin Core', + 'hr-hr': 'Darwin Core mapiranja', + }, + extensionMappings: { + 'en-us': 'Darwin Core Extension Mappings', + 'de-ch': 'Darwin-Core-Erweiterungs-Zuordnungen', + 'es-es': 'Mapeos de extensión Darwin Core', + 'fr-fr': "Mappages d'extension Darwin Core", + 'pt-br': 'Mapeamentos de extensão Darwin Core', + 'ru-ru': 'Сопоставления расширений Darwin Core', + 'uk-ua': 'Зіставлення розширень Darwin Core', + 'hr-hr': 'Darwin Core mapiranja proširenja', + }, + noCoreMappings: { + 'en-us': 'No Darwin Core mappings configured', + 'de-ch': 'Keine Darwin-Core-Zuordnungen konfiguriert', + 'es-es': 'No hay mapeos Darwin Core configurados', + 'fr-fr': 'Aucun mappage Darwin Core configuré', + 'pt-br': 'Nenhum mapeamento Darwin Core configurado', + 'ru-ru': 'Сопоставления Darwin Core не настроены', + 'uk-ua': 'Зіставлення Darwin Core не налаштовані', + 'hr-hr': 'Nema konfiguriranih Darwin Core mapiranja', + }, + noExtensionMappings: { + 'en-us': 'No Darwin Core extension mappings configured', + 'de-ch': 'Keine Darwin-Core-Erweiterungs-Zuordnungen konfiguriert', + 'es-es': 'No hay mapeos de extensión Darwin Core configurados', + 'fr-fr': "Aucun mappage d'extension Darwin Core configuré", + 'pt-br': 'Nenhum mapeamento de extensão Darwin Core configurado', + 'ru-ru': 'Сопоставления расширений Darwin Core не настроены', + 'uk-ua': 'Зіставлення розширень Darwin Core не налаштовані', + 'hr-hr': 'Nema konfiguriranih Darwin Core mapiranja proširenja', + }, + newMapping: { + 'en-us': 'New Mapping', + 'de-ch': 'Neue Zuordnung', + 'es-es': 'Nuevo mapeo', + 'fr-fr': 'Nouveau mappage', + 'pt-br': 'Novo mapeamento', + 'ru-ru': 'Новое сопоставление', + 'uk-ua': 'Нове зіставлення', + 'hr-hr': 'Novo mapiranje', + }, + selectVocabulary: { + 'en-us': 'Select Vocabulary', + 'de-ch': 'Vokabular auswählen', + 'es-es': 'Seleccionar vocabulario', + 'fr-fr': 'Sélectionner le vocabulaire', + 'pt-br': 'Selecionar vocabulário', + 'ru-ru': 'Выбрать словарь', + 'uk-ua': 'Вибрати словник', + 'hr-hr': 'Odaberite rječnik', + }, + darwinCore: { + 'en-us': 'Darwin Core', + 'de-ch': 'Darwin Core', + 'es-es': 'Darwin Core', + 'fr-fr': 'Darwin Core', + 'pt-br': 'Darwin Core', + 'ru-ru': 'Darwin Core', + 'uk-ua': 'Darwin Core', + 'hr-hr': 'Darwin Core', + }, + selectMappingType: { + 'en-us': 'Select Mapping Type', + 'de-ch': 'Zuordnungstyp auswählen', + 'es-es': 'Seleccionar tipo de mapeo', + 'fr-fr': 'Sélectionner le type de mappage', + 'pt-br': 'Selecionar tipo de mapeamento', + 'ru-ru': 'Выбрать тип сопоставления', + 'uk-ua': 'Вибрати тип зіставлення', + 'hr-hr': 'Odaberite vrstu mapiranja', + }, + coreOccurrence: { + 'en-us': 'Core (Occurrence)', + 'de-ch': 'Kern (Vorkommen)', + 'es-es': 'Principal (Ocurrencia)', + 'fr-fr': 'Principal (Occurrence)', + 'pt-br': 'Principal (Ocorrência)', + 'ru-ru': 'Основной (Находка)', + 'uk-ua': 'Основний (Знахідка)', + 'hr-hr': 'Osnovno (Pojava)', + }, + extension: { + 'en-us': 'Extension', + 'de-ch': 'Erweiterung', + 'es-es': 'Extensión', + 'fr-fr': 'Extension', + 'pt-br': 'Extensão', + 'ru-ru': 'Расширение', + 'uk-ua': 'Розширення', + 'hr-hr': 'Proširenje', + }, + createFromScratch: { + 'en-us': 'Create From Scratch', + 'de-ch': 'Neu erstellen', + 'es-es': 'Crear desde cero', + 'fr-fr': 'Créer à partir de zéro', + 'pt-br': 'Criar do zero', + 'ru-ru': 'Создать с нуля', + 'uk-ua': 'Створити з нуля', + 'hr-hr': 'Stvori od nule', + }, + cloneExistingMapping: { + 'en-us': 'Or clone an existing mapping:', + 'de-ch': 'Oder eine bestehende Zuordnung klonen:', + 'es-es': 'O clonar un mapeo existente:', + 'fr-fr': 'Ou cloner un mappage existant :', + 'pt-br': 'Ou clonar um mapeamento existente:', + 'ru-ru': 'Или клонировать существующее сопоставление:', + 'uk-ua': 'Або клонувати наявне зіставлення:', + 'hr-hr': 'Ili kloniraj postojeće mapiranje:', + }, + clone: { + 'en-us': 'Clone', + 'de-ch': 'Klonen', + 'es-es': 'Clonar', + 'fr-fr': 'Cloner', + 'pt-br': 'Clonar', + 'ru-ru': 'Клонировать', + 'uk-ua': 'Клонувати', + 'hr-hr': 'Kloniraj', + }, + defaultMapping: { + 'en-us': 'default', + 'de-ch': 'Standard', + 'es-es': 'predeterminado', + 'fr-fr': 'par défaut', + 'pt-br': 'padrão', + 'ru-ru': 'по умолчанию', + 'uk-ua': 'за замовчуванням', + 'hr-hr': 'zadano', + }, + runAndPreview: { + 'en-us': 'Run & Preview', + 'de-ch': 'Ausführen & Vorschau', + 'es-es': 'Ejecutar y vista previa', + 'fr-fr': 'Exécuter et prévisualiser', + 'pt-br': 'Executar e visualizar', + 'ru-ru': 'Запустить и просмотреть', + 'uk-ua': 'Запустити і переглянути', + 'hr-hr': 'Pokreni i pregledaj', + }, + saveMapping: { + 'en-us': 'Save Mapping', + 'de-ch': 'Zuordnung speichern', + 'es-es': 'Guardar mapeo', + 'fr-fr': 'Enregistrer le mappage', + 'pt-br': 'Salvar mapeamento', + 'ru-ru': 'Сохранить сопоставление', + 'uk-ua': 'Зберегти зіставлення', + 'hr-hr': 'Spremi mapiranje', + }, + duplicateTermsError: { + 'en-us': 'Duplicate terms found: {terms:string}', + 'de-ch': 'Doppelte Begriffe gefunden: {terms:string}', + 'es-es': 'Términos duplicados encontrados: {terms:string}', + 'fr-fr': 'Termes en double trouvés : {terms:string}', + 'pt-br': 'Termos duplicados encontrados: {terms:string}', + 'ru-ru': 'Найдены дублирующиеся термины: {terms:string}', + 'uk-ua': 'Знайдено дублікати термінів: {terms:string}', + 'hr-hr': 'Pronađeni su duplicirani pojmovi: {terms:string}', + }, + termDetails: { + 'en-us': 'Term Details', + 'de-ch': 'Begriffsdetails', + 'es-es': 'Detalles del término', + 'fr-fr': 'Détails du terme', + 'pt-br': 'Detalhes do termo', + 'ru-ru': 'Подробности термина', + 'uk-ua': 'Деталі терміна', + 'hr-hr': 'Detalji pojma', + }, + viewOnTdwg: { + 'en-us': 'View on TDWG', + 'de-ch': 'Auf TDWG ansehen', + 'es-es': 'Ver en TDWG', + 'fr-fr': 'Voir sur TDWG', + 'pt-br': 'Ver no TDWG', + 'ru-ru': 'Посмотреть на TDWG', + 'uk-ua': 'Переглянути на TDWG', + 'hr-hr': 'Pogledaj na TDWG', + }, + enterCustomIri: { + 'en-us': 'Enter custom IRI...', + 'de-ch': 'Benutzerdefinierte IRI eingeben...', + 'es-es': 'Ingrese IRI personalizado...', + 'fr-fr': 'Entrer un IRI personnalisé...', + 'pt-br': 'Insira IRI personalizado...', + 'ru-ru': 'Введите пользовательский IRI...', + 'uk-ua': 'Введіть довільний IRI...', + 'hr-hr': 'Unesite prilagođeni IRI...', + }, + customIriWarning: { + 'en-us': 'IRI should start with http:// or https://', + 'de-ch': 'IRI sollte mit http:// oder https:// beginnen', + 'es-es': 'El IRI debe comenzar con http:// o https://', + 'fr-fr': "L'IRI doit commencer par http:// ou https://", + 'pt-br': 'O IRI deve começar com http:// ou https://', + 'ru-ru': 'IRI должен начинаться с http:// или https://', + 'uk-ua': 'IRI має починатися з http:// або https://', + 'hr-hr': 'IRI bi trebao počinjati s http:// ili https://', + }, + lockedOccurrenceId: { + 'en-us': 'Required occurrenceID mapping (locked)', + 'de-ch': 'Erforderliche occurrenceID-Zuordnung (gesperrt)', + 'es-es': 'Mapeo de occurrenceID requerido (bloqueado)', + 'fr-fr': "Mappage occurrenceID requis (verrouillé)", + 'pt-br': 'Mapeamento occurrenceID obrigatório (bloqueado)', + 'ru-ru': 'Обязательное сопоставление occurrenceID (заблокировано)', + 'uk-ua': "Обов'язкове зіставлення occurrenceID (заблоковано)", + 'hr-hr': 'Obavezno mapiranje occurrenceID (zaključano)', + }, + noDwcTerms: { + 'en-us': 'No Darwin Core terms mapped to this field', + 'de-ch': 'Keine Darwin-Core-Begriffe diesem Feld zugeordnet', + 'es-es': 'No hay términos de Darwin Core mapeados a este campo', + 'fr-fr': 'Aucun terme Darwin Core mappé à ce champ', + 'pt-br': 'Nenhum termo Darwin Core mapeado para este campo', + 'ru-ru': 'Нет терминов Darwin Core, сопоставленных с этим полем', + 'uk-ua': 'Немає термінів Darwin Core, зіставлених із цим полем', + 'hr-hr': 'Nema Darwin Core pojmova mapiranih na ovo polje', + }, chronostratigraphicChart: { 'en-us': 'Chronostratigraphic Chart', 'de-ch': 'Chronostratigraphische Tabelle', @@ -507,4 +757,22 @@ export const headerText = createDictionary({ 'uk-ua': 'Хроностратиграфічна діаграма', 'hr-hr': 'Kronostratigrafski grafikon', }, + autoMapFields: { + 'en-us': 'Auto-Map Fields', + }, + mappingEditor: { + 'en-us': 'Mapping Editor', + }, + loadingMapping: { + 'en-us': 'Loading mapping...', + }, + loadingFields: { + 'en-us': 'Loading fields...', + }, + noFieldsFound: { + 'en-us': 'No fields found for this mapping', + }, + backToList: { + 'en-us': 'Back to List', + }, } as const);