diff --git a/api/v1/orcid/index.php b/api/v1/orcid/index.php new file mode 100644 index 00000000000..9e3bc82e2d3 --- /dev/null +++ b/api/v1/orcid/index.php @@ -0,0 +1,20 @@ +data = $this->buildOrcidReview(); + } + + public function toArray(): array + { + return $this->data; + } + + private function buildOrcidReview(): array + { + $publicationUrl = Application::get()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_PAGE, + $this->context->getPath(), + 'article', + 'view', + $this->submission->getId(), + urlLocaleForPage: '', + ); + + $submissionLocale = $this->submission->getData('locale'); + $currentPublication = $this->submission->getCurrentPublication(); + + if (!empty($this->review->getData('dateCompleted')) && $this->context->getData('onlineIssn')) { + $reviewCompletionDate = Carbon::parse($this->review->getData('dateCompleted')); + + $orcidReview = [ + 'reviewer-role' => 'reviewer', + 'review-type' => 'review', + 'review-completion-date' => [ + 'year' => [ + 'value' => $reviewCompletionDate->format('Y') + ], + 'month' => [ + 'value' => $reviewCompletionDate->format('m') + ], + 'day' => [ + 'value' => $reviewCompletionDate->format('d') + ] + ], + 'review-group-id' => 'issn:' . $this->context->getData('onlineIssn'), + + 'convening-organization' => [ + 'name' => $this->context->getData('publisherInstitution'), + 'address' => [ + 'city' => OrcidManager::getCity($this->context), + 'country' => OrcidManager::getCountry($this->context), + + ] + ], + 'review-identifiers' => ['external-id' => [ + [ + 'external-id-type' => 'source-work-id', + 'external-id-value' => $this->review->getData('reviewRoundId'), + 'external-id-relationship' => 'part-of'] + ]] + ]; + if ($this->review->getReviewMethod() == ReviewAssignment::SUBMISSION_REVIEW_METHOD_OPEN) { + $orcidReview['subject-url'] = ['value' => $publicationUrl]; + $orcidReview['review-url'] = ['value' => $publicationUrl]; + $orcidReview['subject-type'] = 'journal-article'; + $orcidReview['subject-name'] = [ + 'title' => ['value' => $this->submission->getCurrentPublication()->getLocalizedData('title') ?? ''] + ]; + + if (!empty($currentPublication->getDoi())) { + /** @var Doi $doiObject */ + $doiObject = $currentPublication->getData('doiObject'); + $externalIds = [ + 'external-id-type' => 'doi', + 'external-id-value' => $currentPublication->getDoi(), + 'external-id-url' => [ + 'value' => $doiObject->getResolvingUrl(), + ], + 'external-id-relationship' => 'self' + + ]; + $orcidReview['subject-external-identifier'] = $externalIds; + } + } + + $allTitles = $currentPublication->getData('title'); + foreach ($allTitles as $locale => $title) { + if ($locale !== $submissionLocale) { + $orcidReview['subject-name']['translated-title'] = ['value' => $title, 'language-code' => LocaleConversion::getIso1FromLocale($locale)]; + } + } + + return $orcidReview; + } else { + // TODO: Check how this should be handled. + // It seems like this should be blocked earlier rather than letting it get to this point. + return []; + } + } +} diff --git a/classes/orcid/OrcidWork.php b/classes/orcid/OrcidWork.php new file mode 100644 index 00000000000..accc2cdbeca --- /dev/null +++ b/classes/orcid/OrcidWork.php @@ -0,0 +1,307 @@ + 'doi', 'other::urn' => 'urn']; + public const USER_GROUP_TO_ORCID_ROLE = ['Author' => 'AUTHOR', 'Translator' => 'CHAIR_OR_TRANSLATOR', 'Journal manager' => 'AUTHOR']; + + private array $data = []; + + public function __construct( + private Publication $publication, + private Context $context, + private array $authors, + private ?Issue $issue = null + ) { + $this->data = $this->build(); + } + + public function toArray(): array + { + return $this->data; + } + + private function build(): array + { + $submission = Repo::submission()->get($this->publication->getData('submissionId')); + + $applicationName = Application::get()->getName(); + $bibtexCitation = ''; + + $publicationLocale = $this->publication->getData('locale'); + + $publicationUrl = Application::get()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_PAGE, + $this->context->getPath(), + 'article', + 'view', + $submission->getId(), + urlLocaleForPage: '', + ); + + $orcidWork = [ + 'title' => [ + 'title' => [ + 'value' => trim(strip_tags($this->publication->getLocalizedTitle($publicationLocale))) ?? '' + ], + 'subtitle' => [ + 'value' => trim(strip_tags($this->publication->getLocalizedData('subtitle', $publicationLocale))) ?? '' + ] + ], + 'journal-title' => [ + 'value' => $this->context->getName($publicationLocale) ?? $this->context->getName($this->context->getPrimaryLocale()), + ], + 'short-description' => trim(strip_tags($this->publication->getLocalizedData('abstract', $publicationLocale))) ?? '', + + 'external-ids' => [ + 'external-id' => $this->buildOrcidExternalIds($submission, $this->publication, $this->context, $this->issue, $publicationUrl) + ], + 'publication-date' => $this->buildOrcidPublicationDate($this->publication, $this->issue), + 'url' => $publicationUrl, + 'language-code' => LocaleConversion::getIso1FromLocale($publicationLocale), + 'contributors' => [ + 'contributor' => $this->buildOrcidContributors($this->authors, $this->context, $this->publication) + ] + ]; + + if ($applicationName == 'ojs2') { + PluginRegistry::loadCategory('generic'); + $citationPlugin = PluginRegistry::getPlugin('generic', 'citationstylelanguageplugin'); + /** @var CitationStyleLanguagePlugin $citationPlugin */ + $bibtexCitation = trim(strip_tags($citationPlugin->getCitation($this->request, $submission, 'bibtex', $this->issue, $this->publication))); + $orcidWork['citation'] = [ + 'citation-type' => 'bibtex', + 'citation-value' => $bibtexCitation, + ]; + $orcidWork['type'] = 'journal-article'; + } elseif ($applicationName == 'ops') { + $orcidWork['type'] = 'preprint'; + } + + foreach ($this->publication->getData('title') as $locale => $title) { + if ($locale !== $publicationLocale) { + $orcidWork['title']['translated-title'] = ['value' => $title, 'language-code' => LocaleConversion::getIso1FromLocale($locale)]; + } + } + + return $orcidWork; + } + + /** + * Build the external identifiers ORCID JSON structure from article, journal and issue meta data. + * + * @see https://pub.orcid.org/v2.0/identifiers Table of valid ORCID identifier types. + * + * @param Submission $submission The Article object for which the external identifiers should be build. + * @param Publication $publication The Article object for which the external identifiers should be build. + * @param Journal $context Context the Submission is part of. + * @param Issue $issue The Issue object the Article object belongs to. + * + * @return array An associative array corresponding to ORCID external-id JSON. + */ + private function buildOrcidExternalIds(Submission $submission, Publication $publication, Context $context, Issue $issue, string $articleUrl): array + { + $contextId = $context->getId(); + + $externalIds = []; + $pubIdPlugins = PluginRegistry::loadCategory('pubIds', true, $contextId); + // Add doi, urn, etc. for article + $articleHasStoredPubId = false; + + // Handle non-DOI pubIds + if (!empty($pubIdPlugins)) { + foreach ($pubIdPlugins as $plugin) { + if (!$plugin->getEnabled()) { + continue; + } + + $pubIdType = $plugin->getPubIdType(); + + # Add article ids + $pubId = $publication->getStoredPubId($pubIdType); + + if ($pubId) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID[$pubIdType], + 'external-id-value' => $pubId, + 'external-id-url' => [ + 'value' => $plugin->getResolvingURL($contextId, $pubId) + ], + 'external-id-relationship' => 'self' + ]; + + $articleHasStoredPubId = true; + } + + # Add issue ids if they exist + $pubId = $issue->getStoredPubId($pubIdType); + if ($pubId) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID[$pubIdType], + 'external-id-value' => $pubId, + 'external-id-url' => [ + 'value' => $plugin->getResolvingURL($contextId, $pubId) + ], + 'external-id-relationship' => 'part-of' + ]; + } + } + } + + // Handle DOIs + if ($context->areDoisEnabled()) { + # Add article ids + $publicationDoiObject = $publication->getData('doiObject'); + + if ($publicationDoiObject) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID['doi'], + 'external-id-value' => $publicationDoiObject->getData('doi'), + 'external-id-url' => [ + 'value' => $publicationDoiObject->getResolvingUrl() + ], + 'external-id-relationship' => 'self' + ]; + + $articleHasStoredPubId = true; + } + + // Add issue ids if they exist + $issueDoiObject = $issue->getData('doiObject'); + if ($issueDoiObject) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID['doi'], + 'external-id-value' => $issueDoiObject->getData('doi'), + 'external-id-url' => [ + 'value' => $issueDoiObject->getResolvingUrl() + ], + 'external-id-relationship' => 'part-of' + ]; + } + } + + if (!$articleHasStoredPubId) { + // No pubidplugins available or article does not have any stored pubid + // Use URL as an external-id + $externalIds[] = [ + 'external-id-type' => 'uri', + 'external-id-value' => $articleUrl, + 'external-id-relationship' => 'self' + ]; + } + + // Add journal online ISSN + // TODO What about print ISSN? + if ($context->getData('onlineIssn')) { + $externalIds[] = [ + 'external-id-type' => 'issn', + 'external-id-value' => $context->getData('onlineIssn'), + 'external-id-relationship' => 'part-of' + ]; + } + + return $externalIds; + } + + /** + * Parse issue year and publication date and use the older one of the two as + * the publication date of the ORCID work. + * + * @param null|mixed $issue + * + * @return array Associative array with year, month and day or only year + */ + private function buildOrcidPublicationDate(Publication $publication, ?Issue $issue = null): array + { + $publicationPublishDate = Carbon::parse($publication->getData('datePublished')); + + return [ + 'year' => ['value' => $publicationPublishDate->format('Y')], + 'month' => ['value' => $publicationPublishDate->format('m')], + 'day' => ['value' => $publicationPublishDate->format('d')] + ]; + } + + /** + * Build associative array fitting for ORCID contributor mentions in an + * ORCID work from the supplied Authors array. + * + * @param Author[] $authors Array of Author objects + * + * @return array[] Array of associative arrays, + * one for each contributor + */ + private function buildOrcidContributors(array $authors, Context $context, Publication $publication): array + { + $contributors = []; + $first = true; + + foreach ($authors as $author) { + $contributor = [ + 'credit-name' => $author->getFullName(), + 'contributor-attributes' => [ + 'contributor-sequence' => $first ? 'first' : 'additional' + ] + ]; + + $userGroup = $author->getUserGroup(); + $role = self::USER_GROUP_TO_ORCID_ROLE[$userGroup->getName('en')]; + + if ($role) { + $contributor['contributor-attributes']['contributor-role'] = $role; + } + + if ($author->getOrcid()) { + $orcid = basename(parse_url($author->getOrcid(), PHP_URL_PATH)); + + if ($author->getData('orcidSandbox')) { + $uri = ORCID_URL_SANDBOX . $orcid; + $host = 'sandbox.orcid.org'; + } else { + $uri = $author->getOrcid(); + $host = 'orcid.org'; + } + + $contributor['contributor-orcid'] = [ + 'uri' => $uri, + 'path' => $orcid, + 'host' => $host + ]; + } + + $first = false; + + $contributors[] = $contributor; + } + + return $contributors; + } +} diff --git a/classes/orcid/actions/SendSubmissionToOrcid.php b/classes/orcid/actions/SendSubmissionToOrcid.php new file mode 100644 index 00000000000..38bb22937bb --- /dev/null +++ b/classes/orcid/actions/SendSubmissionToOrcid.php @@ -0,0 +1,79 @@ +context)) { + // Sending to ORCID only works with the member API + return; + } + + $issueId = $this->publication->getData('issueId'); + if (isset($issueId)) { + $issue = Repo::issue()->get($issueId); + } + + $authors = Repo::author() + ->getCollector() + ->filterByPublicationIds([$this->publication->getId()]) + ->getMany(); + + // Collect valid ORCID ids and access tokens from submission contributors + $authorsWithOrcid = []; + foreach ($authors as $author) { + if ($author->getOrcid() && $author->getData('orcidAccessToken')) { + $orcidAccessExpiresOn = Carbon::parse($author->getData('orcidAccessExpiresOn')); + if ($orcidAccessExpiresOn->isFuture()) { + # Extract only the ORCID from the stored ORCID uri + $orcid = basename(parse_url($author->getOrcid(), PHP_URL_PATH)); + $authorsWithOrcid[$orcid] = $author; + } else { + OrcidManager::logError("Token expired on {$orcidAccessExpiresOn} for author " . $author->getId() . ', deleting orcidAccessToken!'); + OrcidManager::removeOrcidAccessToken($author); + } + } + } + + if (empty($authorsWithOrcid)) { + OrcidManager::logInfo('No contributor with ORICD id or valid access token for submission ' . $this->publication->getData('submissionId')); + return; + } + + $orcidWork = new OrcidWork($this->publication, $this->context, $authors->toArray(), $issue ?? null); + OrcidManager::logInfo('Request body (without put-code): ' . json_encode($orcidWork->toArray())); + + foreach ($authorsWithOrcid as $orcid => $author) { + dispatch(new DepositOrcidSubmission($author, $this->context, $orcidWork->toArray(), $orcid)); + } + + } +} diff --git a/dbscripts/xml/upgrade.xml b/dbscripts/xml/upgrade.xml index 8684989c5f1..35c9e2b03b0 100644 --- a/dbscripts/xml/upgrade.xml +++ b/dbscripts/xml/upgrade.xml @@ -131,6 +131,7 @@ + diff --git a/jobs/orcid/DepositOrcidSubmission.php b/jobs/orcid/DepositOrcidSubmission.php new file mode 100644 index 00000000000..b731b749652 --- /dev/null +++ b/jobs/orcid/DepositOrcidSubmission.php @@ -0,0 +1,142 @@ +fail('Application is set to sandbox mode and will not interact with the ORCID service'); + return; + } + + $uri = OrcidManager::getApiPath($this->context) . ORCID_API_VERSION_URL . $this->authorOrcid . '/' . ORCID_WORK_URL; + $method = 'POST'; + + if ($putCode = $this->author->getData('orcidWorkPutCode')) { + // Submission has already been sent to ORCID. Use PUT to update meta data + $uri .= '/' . $putCode; + $method = 'PUT'; + $orcidWork['put-code'] = $putCode; + } else { + // Remove put-code from body because the work has not yet been sent + unset($this->orcidWork['put-code']); + } + + $headers = [ + 'Content-type: application/vnd.orcid+json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $this->author->getData('orcidAccessToken') + ]; + + OrcidManager::logInfo("{$method} {$uri}"); + OrcidManager::logInfo('Header: ' . var_export($headers, true)); + + $httpClient = Application::get()->getHttpClient(); + try { + $response = $httpClient->request( + $method, + $uri, + [ + 'headers' => $headers, + 'json' => $this->orcidWork, + ] + ); + } catch (ClientException $exception) { + $reason = $exception->getResponse()->getBody(); + OrcidManager::logError("Publication fail: {$reason}"); + + $this->fail($exception); + } + $httpStatus = $response->getStatusCode(); + OrcidManager::logInfo("Response status: {$httpStatus}"); + $responseHeaders = $response->getHeaders(); + + switch ($httpStatus) { + case 200: + // Work updated + OrcidManager::logInfo("Work updated in profile, putCode: {$putCode}"); + // TODO: See what to do with request success variable. Won't be handled by job in anyway by default + $requestSuccess = true; + break; + case 201: + $location = $responseHeaders['Location'][0]; + // Extract the ORCID work put code for updates/deletion. + $putCode = intval(basename(parse_url($location, PHP_URL_PATH))); + OrcidManager::logInfo("Work added to profile, putCode: {$putCode}"); + $this->author->setData('orcidWorkPutCode', $putCode); + Repo::author()->dao->update($this->author); + $requestSuccess = true; + break; + case 401: + // invalid access token, token was revoked + $error = json_decode($response->getBody(), true); + if ($error['error'] === 'invalid_token') { + OrcidManager::logError($error['error_description'] . ', deleting orcidAccessToken from author'); + OrcidManager::removeOrcidAccessToken($this->author); + } + $requestSuccess = false; + break; + case 403: + OrcidManager::logError('Work update forbidden: ' . $response->getBody()); + $requestSuccess = false; + break; + case 404: + // a work has been deleted from a ORCID record. putCode is no longer valid. + if ($method === 'PUT') { + OrcidManager::logError('Work deleted from ORCID record, deleting putCode form author'); + $this->author->setData('orcidWorkPutCode', null); + Repo::author()->dao->update($this->author); + $requestSuccess = false; + } else { + OrcidManager::logError("Unexpected status {$httpStatus} response, body: " . $response->getBody()); + $requestSuccess = false; + } + break; + case 409: + OrcidManager::logError('Work already added to profile, response body: ' . $response->getBody()); + $requestSuccess = false; + break; + default: + OrcidManager::logError("Unexpected status {$httpStatus} response, body: " . $response->getBody()); + $requestSuccess = false; + } + } +} diff --git a/jobs/orcid/PublishReviewerWorkToOrcid.php b/jobs/orcid/PublishReviewerWorkToOrcid.php new file mode 100644 index 00000000000..e6d61f1a5b6 --- /dev/null +++ b/jobs/orcid/PublishReviewerWorkToOrcid.php @@ -0,0 +1,120 @@ +fail('Application is set to sandbox mode and will not interact with the ORCID service'); + return; + } + + if (!OrcidManager::isMemberApiEnabled($this->context)) { + return; + } + + if (!OrcidManager::getCity($this->context) || !OrcidManager::getCountry($this->context)) { + return; + } + + $reviewer = Repo::user()->get($this->reviewAssignment->getData('reviewerId')); + + if ($reviewer->getOrcid() && $reviewer->getData('orcidAccessToken')) { + $orcidAccessExpiresOn = Carbon::parse($reviewer->getData('orcidAccessExpiresOn')); + if ($orcidAccessExpiresOn->isFuture()) { + # Extract only the ORCID from the stored ORCID uri + $orcid = basename(parse_url($reviewer->getOrcid(), PHP_URL_PATH)); + + $orcidReview = new OrcidReview($this->submission, $this->reviewAssignment, $this->context); + + $uri = OrcidManager::getApiPath($this->context) . ORCID_API_VERSION_URL . $orcid . '/' . ORCID_REVIEW_URL; + $method = 'POST'; + if ($putCode = $reviewer->getData('orcidReviewPutCode')) { + $uri .= '/' . $putCode; + $method = 'PUT'; + $orcidReview['put-code'] = $putCode; + } + $headers = [ + 'Content-Type' => ' application/vnd.orcid+json; qs=4', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $reviewer->getData('orcidAccessToken') + ]; + $httpClient = Application::get()->getHttpClient(); + + try { + $response = $httpClient->request( + $method, + $uri, + [ + 'headers' => $headers, + 'json' => $orcidReview->toArray(), + ] + ); + } catch (ClientException $exception) { + $reason = $exception->getResponse()->getBody(); + OrcidManager::logInfo("Publication fail: {$reason}"); + + $this->fail($exception); + } + $httpStatus = $response->getStatusCode(); + OrcidManager::logInfo("Response status: {$httpStatus}"); + $responseHeaders = $response->getHeaders(); + switch ($httpStatus) { + case 200: + OrcidManager::logInfo("Review updated in profile, putCode: {$putCode}"); + break; + case 201: + $location = $responseHeaders['Location'][0]; + // Extract the ORCID work put code for updates/deletion. + $putCode = basename(parse_url($location, PHP_URL_PATH)); + $reviewer->setData('orcidReviewPutCode', $putCode); + Repo::user()->edit($reviewer, ['orcidReviewPutCode']); + OrcidManager::logInfo("Review added to profile, putCode: {$putCode}"); + break; + default: + OrcidManager::logError("Unexpected status {$httpStatus} response, body: {$responseHeaders}"); + } + } + } + } +} diff --git a/pages/article/ArticleHandler.php b/pages/article/ArticleHandler.php index 18de5d32962..57ae0b93020 100644 --- a/pages/article/ArticleHandler.php +++ b/pages/article/ArticleHandler.php @@ -34,6 +34,7 @@ use PKP\core\PKPApplication; use PKP\core\PKPJwt as JWT; use PKP\db\DAORegistry; +use PKP\orcid\OrcidManager; use PKP\plugins\Hook; use PKP\plugins\PluginRegistry; use PKP\security\authorization\ContextRequiredPolicy; @@ -307,6 +308,7 @@ public function view($args, $request) 'copyrightYear' => $publication->getData('copyrightYear'), 'pubIdPlugins' => PluginRegistry::loadCategory('pubIds', true), 'keywords' => $publication->getData('keywords'), + 'orcidIcon' => OrcidManager::getIcon(), ]); // Fetch and assign the galley to the template diff --git a/plugins/themes/default/styles/index.less b/plugins/themes/default/styles/index.less index 52eea2223f7..5b82741f955 100644 --- a/plugins/themes/default/styles/index.less +++ b/plugins/themes/default/styles/index.less @@ -20,6 +20,8 @@ @import "../../../../lib/pkp/styles/variables.less"; @import "../../../../lib/pkp/styles/utils.less"; @import "../../../../lib/pkp/styles/helpers.less"; +// General ORCID styles +@import '../../../../lib/pkp/styles/orcid.less'; // Styles unique to this theme @import "variables.less"; diff --git a/registry/emailTemplates.xml b/registry/emailTemplates.xml index 33a38623f5c..5fac37ce923 100644 --- a/registry/emailTemplates.xml +++ b/registry/emailTemplates.xml @@ -76,4 +76,6 @@ + +