Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,
Comment on lines 34 to 40
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