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
9 changes: 6 additions & 3 deletions client/components/ContributorsList/Contributor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';

import { Avatar, Icon } from 'components';
import { normalizeOrcid } from 'utils/orcid';

import './contributor.scss';

Expand Down Expand Up @@ -38,20 +39,22 @@ const Contributor = function (props) {
return curr;
}, '');

const orcid = normalizeOrcid(user.orcid);

return (
<div className="contributors-list_contributor-component">
<div className="avatar-wrapper">{avatarElement}</div>
<div className="details-wrapper">
<div className="name">{nameElement}</div>
{user.orcid && (
{orcid && (
<div className="pub-header-themed-secondary orcid">
<Icon icon="orcid" />
<a
href={`https://orcid.org/${user.orcid}`}
href={`https://orcid.org/${orcid}`}
target="_blank"
rel="noopener noreferrer"
>
{user.orcid}
{orcid}
</a>
</div>
)}
Expand Down
7 changes: 5 additions & 2 deletions client/containers/User/UserHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Avatar from 'components/Avatar/Avatar';
import Icon from 'components/Icon/Icon';
import SpamStatusMenu from 'components/SpamStatusMenu';
import { usePageContext } from 'utils/hooks';
import { normalizeOrcid } from 'utils/orcid';

import './userHeader.scss';

Expand Down Expand Up @@ -43,6 +44,8 @@ const UserHeader = function (props) {
setSpamStatus(status);
}, []);

const orcid = normalizeOrcid(props.userData.orcid);

const links = [
{ value: props.userData.location, icon: 'map-marker' as const, url: '' },
{
Expand All @@ -51,9 +54,9 @@ const UserHeader = function (props) {
url: props.userData.website,
},
{
value: props.userData.orcid,
value: orcid as string,
icon: 'orcid' as const,
url: `https://www.orcid.org/${props.userData.orcid}`,
url: orcid ? `https://orcid.org/${orcid}` : '',
},
{
value: props.userData.github,
Expand Down
3 changes: 2 additions & 1 deletion deposit/transform/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fetchFacetsForScope } from 'server/facets';
import { expect } from 'utils/assert';
import { collectionUrl } from 'utils/canonicalUrls';
import { licenseDetailsByKind } from 'utils/licenses';
import { normalizeOrcid } from 'utils/orcid';

const attributionRoleToResourceContributorRole: Record<string, ResourceContributorRole> = {
'Writing – Review & Editing': 'Editor',
Expand Down Expand Up @@ -46,7 +47,7 @@ function transformCollectionAttributionToResourceContribution(
return {
contributor: {
name: attribution.user?.fullName ?? expect(attribution.name),
orcid: attribution.orcid,
orcid: normalizeOrcid(attribution.orcid),
},
contributorAffiliation: attribution.affiliation,
contributorRole: transformAttributionRoleToResourceContributorRole(role),
Expand Down
3 changes: 2 additions & 1 deletion deposit/transform/pub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { exists, expect } from 'utils/assert';
import { pubUrl } from 'utils/canonicalUrls';
import { getPrimaryCollection } from 'utils/collections/primary';
import { licenseDetailsByKind } from 'utils/licenses';
import { normalizeOrcid } from 'utils/orcid';
import { getWordAndCharacterCountsFromDoc } from 'utils/pub/metadata';
import { RelationType, type relationTypeDefinitions } from 'utils/pubEdge';
import { sortByRank } from 'utils/rank';
Expand Down Expand Up @@ -63,7 +64,7 @@ function transformPubAttributionToResourceContribution(
return {
contributor: {
name: attribution.user?.fullName ?? expect(attribution.name),
orcid: attribution.user?.orcid ?? attribution.orcid,
orcid: normalizeOrcid(attribution.user?.orcid ?? attribution.orcid),
},
contributorAffiliation: attribution.affiliation,
contributorRole: transformAttributionRoleToResourceContributorRole(role),
Expand Down
14 changes: 10 additions & 4 deletions server/user/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { promisify } from 'util';
import { Signup, User } from 'server/models';
import { subscribeUser } from 'server/utils/mailchimp';
import { expect } from 'utils/assert';
import { ORCID_PATTERN } from 'utils/orcid';
import { normalizeOrcid } from 'utils/orcid';
import { slugifyString } from 'utils/strings';

type InputValues = CreationAttributes<User> & {
Expand Down Expand Up @@ -35,7 +35,7 @@ export const createUser = async (inputValues: InputValues) => {
bio: inputValues.bio,
location: inputValues.location,
website: inputValues.website,
orcid: inputValues.orcid,
orcid: normalizeOrcid(inputValues.orcid),
github: inputValues.github,
twitter: inputValues.twitter,
facebook: inputValues.facebook,
Expand Down Expand Up @@ -93,8 +93,14 @@ export const updateUser = (
filteredValues.initials = `${filteredValues.firstName[0]}${filteredValues.lastName[0]}`;
}

if (filteredValues.orcid && (filteredValues.orcid as string).match(ORCID_PATTERN) === null) {
throw new Error('Invalid ORCID');
if (filteredValues.orcid) {
const normalized = normalizeOrcid(filteredValues.orcid as string);

if (!normalized) {
throw new Error('Invalid ORCID');
}

filteredValues.orcid = normalized;
}

// A bit of extra paranoia
Expand Down
23 changes: 23 additions & 0 deletions tools/migrations/2026_05_13_normalizeOrcids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const up = async ({ sequelize }) => {
// strip orcid.org URL prefixes from all orcid columns, leaving just the bare identifier.
// handles http/https and optional www prefix.
const tables = ['Users', 'PubAttributions', 'CollectionAttributions'];

for (const table of tables) {
const [, meta] = await sequelize.query(
`UPDATE "${table}"
SET orcid = regexp_replace(orcid, '^https?://(?:www\\.)?orcid\\.org/', '')
WHERE orcid LIKE '%orcid.org/%'`,
);

const count = meta?.rowCount ?? meta;
if (count > 0) {
console.info(`${table}: normalized ${count} orcid(s)`);
}
}
};

export const down = async () => {
// not reversible -- the bare identifiers are strictly more correct than the URLs
throw new Error('Irreversible migration: orcid normalization cannot be undone');
};
9 changes: 8 additions & 1 deletion utils/api/schemas/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { MinimalUser, User, UserWithPrivateFields } from 'types';

import { z } from 'zod';

import { ORCID_ID_OR_URL_PATTERN, ORCID_PATTERN } from 'utils/orcid';

export const privateUserSchema = z.object({
id: z.string().uuid(),
slug: z.string(),
Expand All @@ -20,7 +22,12 @@ export const privateUserSchema = z.object({
facebook: z.string().nullable(),
twitter: z.string().nullable(),
github: z.string().nullable(),
orcid: z.string().nullable(),
orcid: z
.string()
.regex(ORCID_ID_OR_URL_PATTERN)
.transform((orcid) => orcid.match(ORCID_PATTERN)?.[0]!)
.nullable()
.or(z.literal('')),
googleScholar: z.string().nullable(),
resetHashExpiration: z.coerce
.date()
Expand Down
13 changes: 11 additions & 2 deletions utils/crossref/schema/contributors.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** Renders a list of contributors */

import { normalizeOrcid } from 'utils/orcid';

const roleList = {
'Writing – Review & Editing': 'editor',
Editor: 'editor',
Expand All @@ -16,9 +18,12 @@ export default (attributions) => {
if (attributions.length === 0) {
return {};
}

return {
contributors: {
person_name: attributions.map((attribution, attributionIndex) => {
const orcid = normalizeOrcid(attribution.user.orcid);

const personNameOutput = {
'@contributor_role': attribution.isAuthor ? checkRole(attribution) : 'reader',
'@sequence': attributionIndex === 0 ? 'first' : 'additional',
Expand All @@ -27,17 +32,21 @@ export default (attributions) => {
? attribution.user.lastName
: attribution.user.firstName,
affiliation: attribution.affiliation,
ORCID: `https://orcid.org/${attribution.user.orcid}`,
ORCID: orcid ? `https://orcid.org/${orcid}` : undefined,
};

if (!personNameOutput.affiliation) {
delete personNameOutput.affiliation;
}

if (!personNameOutput.given_name) {
delete personNameOutput.given_name;
}
if (!attribution.user.orcid) {

if (!personNameOutput.ORCID) {
delete personNameOutput.ORCID;
}

return personNameOutput;
}),
},
Expand Down
12 changes: 12 additions & 0 deletions utils/orcid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,15 @@ export const ORCID_PATTERN = /(\d{4}-){3}\d{3}(\d|X)/g;

export const ORCID_ID_OR_URL_PATTERN =
/^(?:(?:https?:\/\/)?(?:www\.)?orcid\.org\/)?(\d{4}-){3}\d{3}(\d|X)$/g;

/**
* extracts the bare ORCID identifier (e.g. 0000-0001-2345-6789) from a string
* that may be a full URL, a bare ID, or anything in between. returns null if no
* valid ORCID can be found.
*/
export const normalizeOrcid = (value: string | null | undefined): string | null => {
if (!value) return null;

const match = value.match(/(\d{4}-){3}\d{3}(\d|X)/);
return match?.[0] ?? null;
Comment on lines +11 to +15
};
59 changes: 34 additions & 25 deletions workers/tasks/communityExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import ensureUserForAttribution from 'utils/ensureUserForAttribution';
import { isProd } from 'utils/environment';
import { getAssetUrlFromResizedUrl } from 'utils/images';
import { licenseDetailsByKind } from 'utils/licenses';
import { normalizeOrcid } from 'utils/orcid';
import { getTextAbstract } from 'utils/pub/metadata';

// for some reason when imported from utils/notes, it tries to import the client/utils/notes.ts file instead
Expand Down Expand Up @@ -96,38 +97,46 @@ const renderPubFooter = (metadata: PubMetadata) => {
<section className="pub-attributions">
<h2>Authors</h2>
<ul>
{authors.map((a: any) => (
<li key={a.id}>
{a.user?.fullName || a.name}
{a.affiliation && <span> ({a.affiliation})</span>}
{a.orcid && (
<span>
{' — '}
<a href={`https://orcid.org/${a.orcid}`}>ORCID</a>
</span>
)}
</li>
))}
{authors.map((a: any) => {
const orcid = normalizeOrcid(a.orcid);

return (
<li key={a.id}>
{a.user?.fullName || a.name}
{a.affiliation && <span> ({a.affiliation})</span>}
{orcid && (
<span>
{' — '}
<a href={`https://orcid.org/${orcid}`}>ORCID</a>
</span>
)}
</li>
);
})}
</ul>
</section>
)}
{contributors.length > 0 && (
<section className="pub-attributions">
<h2>Contributors</h2>
<ul>
{contributors.map((a: any) => (
<li key={a.id}>
{a.user?.fullName || a.name}
{a.roles?.length > 0 && <span> — {a.roles.join(', ')}</span>}
{a.affiliation && <span> ({a.affiliation})</span>}
{a.orcid && (
<span>
{' — '}
<a href={`https://orcid.org/${a.orcid}`}>ORCID</a>
</span>
)}
</li>
))}
{contributors.map((a: any) => {
const orcid = normalizeOrcid(a.orcid);

return (
<li key={a.id}>
{a.user?.fullName || a.name}
{a.roles?.length > 0 && <span> — {a.roles.join(', ')}</span>}
{a.affiliation && <span> ({a.affiliation})</span>}
{orcid && (
<span>
{' — '}
<a href={`https://orcid.org/${orcid}`}>ORCID</a>
</span>
)}
</li>
);
})}
</ul>
</section>
)}
Expand Down
5 changes: 4 additions & 1 deletion workers/tasks/export/pandoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import YAML from 'yaml';

import { editorSchema, getReactedDocFromJson } from 'components/Editor';
import { getPathToCslFileForCitationStyleKind } from 'server/utils/citations';
import { normalizeOrcid } from 'utils/orcid';

import { rules } from '../import/rules';
import {
Expand Down Expand Up @@ -94,11 +95,13 @@ const createYamlMetadataFile = async (pubMetadata: PubMetadata, pandocTarget: Pa
const affiliationIds = getAffiliations(attr).map((aff) => {
return dedupedAffiliations.indexOf(aff);
});
const orcid = normalizeOrcid(attr.user.orcid);

return {
...(attr.user.lastName && { surname: attr.user.lastName }),
...(attr.user.firstName && { 'given-names': attr.user.firstName }),
...(publicEmail && { email: publicEmail }),
...(attr.user.orcid && { orcid: attr.user.orcid }),
...(orcid && { orcid }),
...(attr.affiliation && { affiliation: affiliationIds }),
};
}
Expand Down
Loading