Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,17 @@ const rawUserTools = ensure<IR<IR<Omit<MenuItem, 'name'>>>>()({
},
},
[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()]: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -35,10 +37,10 @@ export const operationPolicies = {
'/tree/edit/storage': [
'merge',
'move',
'bulk_move',
'synonymize',
'desynonymize',
'repair',
'bulk_move',
],
'/tree/edit/geologictimeperiod': [
'merge',
Expand Down
16 changes: 16 additions & 0 deletions specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,22 @@ export const overlayRoutes: RA<EnhancedRoute> = [
({ 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(),
Expand Down
65 changes: 65 additions & 0 deletions specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -103,6 +104,70 @@ export function SchemaConfigField({
schemaData={schemaData}
onFormatted={handleFormatted}
/>
<DwcTermSection fieldName={field.name} />
</SchemaConfigColumn>
);
}

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<readonly SchemaTermEntry[]>([]);
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 (
<details className="mt-2">
<summary className="cursor-pointer font-semibold">
{headerText.darwinCore()}
</summary>
<div className="mt-1 pl-2">
{loading ? (
<p className="text-gray-500">Loading...</p>
) : terms.length === 0 ? (
<p className="text-gray-500">{headerText.noDwcTerms()}</p>
) : (
<ul className="space-y-2">
{terms.map((entry) => (
<li key={entry.iri} className="text-sm">
<div className="font-medium">{entry.term}</div>
<div className="font-mono text-xs text-gray-500">{entry.iri}</div>
<p className="text-gray-600 dark:text-gray-400">{entry.definition}</p>
</li>
))}
</ul>
)}
</div>
</details>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { csrfToken } from '../../utils/ajax/csrfToken';
import type { MappingRecord } from './types';

export async function cloneMapping(
mappingId: number
): Promise<MappingRecord> {
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();
}
Original file line number Diff line number Diff line change
@@ -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<number | undefined>(
undefined
);
const [error, setError] = React.useState<string | undefined>(undefined);

React.useEffect(() => {
let cancelled = false;

async function doClone(): Promise<void> {
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 (
<div className="flex flex-col gap-4 p-4">
<p className="text-red-600">Failed to clone package: {error}</p>
<button
className="self-start rounded bg-gray-200 px-3 py-1"
type="button"
onClick={onClose}
>
Close
</button>
</div>
);
}

if (clonedId === undefined) {
return <LoadingScreen />;
}

return <PackageForm datasetId={clonedId} onClose={onClose} />;
}
Original file line number Diff line number Diff line change
@@ -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 (
<Button.Small onClick={handleCopy}>
{copied ? 'Copied!' : 'Copy RSS Feed URL'}
</Button.Small>
);
}
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);

const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button.Small onClick={() => fileInputRef.current?.click()}>
Import EML File
</Button.Small>
<input
ref={fileInputRef}
type="file"
accept=".xml"
className="hidden"
onChange={handleFileImport}
/>
<Button.Small
onClick={() => window.open(GBIF_EML_GENERATOR, '_blank')}
>
Generate EML on GBIF
</Button.Small>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-2 p-2 bg-green-50 border border-green-200 rounded text-sm">
<p className="mb-1">Validate your archive against GBIF standards:</p>
<Button.Small
onClick={() => window.open(GBIF_VALIDATOR_URL, '_blank')}
>
Open GBIF Data Validator
</Button.Small>
</div>
);
}
Loading
Loading