'doi', 'other::urn' => 'urn']; public const USER_GROUP_TO_ORCID_ROLE = ['Author' => 'AUTHOR', 'Translator' => 'CHAIR_OR_TRANSLATOR', 'Journal manager' => 'AUTHOR']; private $currentContextId; /** * @copydoc Plugin::register() * * @param null|mixed $mainContextId */ public function register($category, $path, $mainContextId = null) { $success = parent::register($category, $path, $mainContextId); if (Application::isUnderMaintenance()) { return true; } if ($success && $this->getEnabled($mainContextId)) { $contextId = ($mainContextId === null) ? $this->getCurrentContextId() : $mainContextId; $validator = new OrcidValidator($this); $clientId = $this->getSetting($contextId, 'orcidClientId'); $clientSecret = $this->getSetting($contextId, 'orcidClientSecret'); if (!$validator->validateClientSecret($clientSecret) || !$validator->validateClientId($clientId)) { error_log(new Exception('The ORCID plugin is enabled, but its settings are invalid. In order to fix, access the plugin settings and try to save the form')); return $success; } Hook::add('ArticleHandler::view', [&$this, 'submissionView']); Hook::add('PreprintHandler::view', [&$this, 'submissionView']); // Insert the OrcidProfileHandler to handle ORCID redirects Hook::add('LoadHandler', [$this, 'setupCallbackHandler']); // Register callback for Smarty filters; add CSS Hook::add('TemplateManager::display', [$this, 'handleTemplateDisplay']); // Add "Connect ORCID" button to PublicProfileForm Hook::add('User::PublicProfile::AdditionalItems', [$this, 'handleUserPublicProfileDisplay']); // Display additional ORCID access information and checkbox to send e-mail to authors in the AuthorForm Hook::add('authorform::display', [$this, 'handleFormDisplay']); // Send email to author, if the added checkbox was ticked Hook::add('authorform::execute', [$this, 'handleAuthorFormExecute']); // Handle ORCID on user registration Hook::add('registrationform::execute', [$this, 'collectUserOrcidId']); // Send emails to authors without ORCID id upon submission //TODO Hook::add('submissionsubmitstep3form::execute', [$this, 'handleSubmissionSubmitStep3FormExecute']); // Send emails to authors without authorised ORCID access on promoting a submission to copy editing. Not included in OPS. if ($this->getSetting($contextId, 'sendMailToAuthorsOnPublication')) { Hook::add('EditorAction::recordDecision', [$this, 'handleEditorAction']); } Hook::add('Publication::publish', [$this, 'handlePublicationStatusChange']); Hook::add('ThankReviewerForm::thankReviewer', [$this, 'handleThankReviewer']); // Add more ORCiD fields to author Schema Hook::add('Schema::get::author', function ($hookName, $args) { $schema = &$args[0]; $schema->properties->orcidSandbox = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessToken = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessScope = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidRefreshToken = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessExpiresOn = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessDenied = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidEmailToken = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidWorkPutCode = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; }); // Add more ORCiD fields to user Schema Hook::add('Schema::get::user', function ($hookName, $args) { $schema = &$args[0]; $schema->properties->orcidAccessToken = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessScope = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidRefreshToken = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessExpiresOn = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidAccessDenied = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; $schema->properties->orcidReviewPutCode = (object)[ 'type' => 'string', 'apiSummary' => true, 'validation' => ['nullable'] ]; }); Services::get('schema')->get(PKPSchemaService::SCHEMA_USER, true); Hook::add('Mailer::Mailables', [$this, 'addMailable']); Hook::add('Author::edit', [$this, 'handleAuthorFormExecute']); Hook::add('Form::config::before', [$this, 'addOrcidFormFields']); Hook::add('Installer::postInstall', [$this, 'updateSchema']); Hook::add('Publication::validatePublish', [$this, 'validate']); } return $success; } /** * Load a setting for a specific journal or load it from the config.inc.php if it is specified there. * * @param int $contextId The id of the journal from which the plugin settings should be loaded. * @param string $name Name of the setting. * * @return mixed The setting value, either from the database for this context * or from the global configuration file. */ public function getSetting($contextId, $name) { switch ($name) { case 'orcidProfileAPIPath': $config_value = Config::getVar('orcid', 'api_url'); break; case 'orcidClientId': $config_value = Config::getVar('orcid', 'client_id'); break; case 'orcidClientSecret': $config_value = Config::getVar('orcid', 'client_secret'); break; case 'country': $config_value = Config::getVar('orcid', 'country'); break; case 'city': $config_value = Config::getVar('orcid', 'city'); break; default: return parent::getSetting($contextId, $name); } $config_value = $config_value ?? parent::getSetting($contextId, $name); if ($name == 'orcidProfileAPIPath') { if ($config_value == 'https://orcid.org/') { $config_value = ORCID_API_URL_PUBLIC; } elseif ($config_value == 'https://sandbox.orcid.org/') { $config_value = ORCID_API_URL_PUBLIC_SANDBOX; } } return $config_value; } /** * adds orcid form fields. * * @param string $hookName * @param Form $form */ public function addOrcidFormFields($hookName, $form): bool { if (!$form instanceof ContributorForm) { return Hook::CONTINUE; } $form->removeField('orcid'); $form->addField(new FieldText('orcid', [ 'label' => __('user.orcid'), 'optIntoEdit' => true, 'optIntoEditLabel' => __('common.override'), 'tooltip' => __('plugins.generic.orcidProfile.about.orcidExplanation'), ]), [FIELD_POSITION_AFTER, 'url']); $form->addField(new FieldOptions('requestOrcidAuthorization', [ 'label' => __('plugins.generic.orcidProfile.verify.title'), 'options' => [ [ 'label' => __('plugins.generic.orcidProfile.author.requestAuthorization'), 'value' > false, ] ] ]), [FIELD_POSITION_AFTER, 'orcid']); $form->addField( new FieldOptions('deleteORCID', [ 'label' => __('plugins.generic.orcidProfile.displayName'), 'options' => [ [ 'label' => __('plugins.generic.orcidProfile.author.deleteORCID'), 'value' > false, ] ], 'showWhen' => 'orcid', ]), [FIELD_POSITION_AFTER, 'orcid'] ); return Hook::CONTINUE; } /** * @param string $hookName * @param array $args */ public function handleThankReviewer($hookName, $args) { $request = PKPApplication::get()->getRequest(); $context = $request->getContext(); $newPublication = & $args[0]; if ($this->isMemberApiEnabled($this->currentContextId)) { if ($this->getSetting($context->getId(), 'country') && $this->getSetting($context->getId(), 'city')) { $this->publishReviewerWorkToOrcid($newPublication, $request); } } } /** * @return bool True if the ORCID Member API has been selected in this context. */ public function isMemberApiEnabled($contextId) { $apiUrl = $this->getSetting($contextId, 'orcidProfileAPIPath'); if ($apiUrl === ORCID_API_URL_MEMBER || $apiUrl === ORCID_API_URL_MEMBER_SANDBOX) { return true; } else { return false; } } /** * @return JSONMessage|null */ public function publishReviewerWorkToOrcid(Submission $submission, Request $request) { // Application is set to sandbox mode and will not run the features of plugin if (Config::getVar('general', 'sandbox', false)) { error_log('Application is set to sandbox mode and will not have any interaction with orcid service'); return new JSONMessage(false, __('common.sandbox')); } $context = $request->getContext(); $requestVars = $request->getUserVars(); /** @var ReviewAssignmentDAO */ $reviewAssignmentDao = DAORegistry::getDAO('ReviewAssignmentDAO'); $reviewAssignmentId = $requestVars['reviewAssignmentId']; if (isset($reviewAssignmentId)) { $review = $reviewAssignmentDao->getById($reviewAssignmentId); $reviewer = Repo::user()->get($review->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 = $this->buildOrcidReview($submission, $review, $request); $uri = $this->getSetting($context->getId(), 'orcidProfileAPIPath') . 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, 'allow_redirects' => ['strict' => true], ] ); } catch (ClientException $exception) { $reason = $exception->getResponse()->getBody(); $this->logInfo("Publication fail: {$reason}"); return new JSONMessage(false); } $httpStatus = $response->getStatusCode(); $this->logInfo("Response status: {$httpStatus}"); $responseHeaders = $response->getHeaders(); switch ($httpStatus) { case 200: $this->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']); $this->logInfo("Review added to profile, putCode: {$putCode}"); break; default: $this->logError("Unexpected status {$httpStatus} response, body: {$responseHeaders}"); } } } } } public function buildOrcidReview($submission, $review, $request, $issue = null) { $publicationUrl = $request->getDispatcher()->url($request, PKPApplication::ROUTE_PAGE, null, 'article', 'view', $submission->getId()); $context = $request->getContext(); $publicationLocale = ($submission->getData('locale')) ? $submission->getData('locale') : 'en'; $pubIdPlugins = PluginRegistry::loadCategory('pubIds', true, $context->getId()); // DO not remove $supportedSubmissionLocales = $context->getSupportedSubmissionLocales(); if (!empty($review->getData('dateCompleted')) && $context->getData('onlineIssn')) { $reviewCompletionDate = Carbon::parse($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:' . $context->getData('onlineIssn'), 'convening-organization' => [ 'name' => $context->getData('publisherInstitution'), 'address' => [ 'city' => $this->getSetting($context->getId(), 'city'), 'country' => $this->getSetting($context->getId(), 'country') ] ], 'review-identifiers' => ['external-id' => [ [ 'external-id-type' => 'source-work-id', 'external-id-value' => $review->getData('reviewRoundId'), 'external-id-relationship' => 'part-of'] ]] ]; if ($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' => $submission->getCurrentPublication()->getLocalizedData('title') ?? ''] ]; if (!empty($submission->getData('pub-id::doi'))) { $externalIds = [ 'external-id-type' => 'doi', 'external-id-value' => $submission->getData('pub-id::doi'), 'external-id-url' => [ 'value' => 'https://doi.org/' . $submission->getData('pub-id::doi') ], 'external-id-relationship' => 'self' ]; $orcidReview['subject-external-identifier'] = $externalIds; } } $translatedTitleAvailable = false; foreach ($supportedSubmissionLocales as $defaultLanguage) { if ($defaultLanguage !== $publicationLocale) { $iso2LanguageCode = substr($defaultLanguage, 0, 2); $defaultTitle = $submission->getLocalizedData($iso2LanguageCode); if (strlen($defaultTitle) > 0 && !$translatedTitleAvailable) { $orcidReview['subject-name']['translated-title'] = ['value' => $defaultTitle, 'language-code' => $iso2LanguageCode]; $translatedTitleAvailable = true; } } } return $orcidReview; } } /** * Write info message to log. * * @param string $message Message to write */ public function logInfo($message) { if ($this->getSetting($this->currentContextId, 'logLevel') === 'ERROR') { return; } self::writeLog($message, 'INFO'); } /** * Write error message to log. * * @param string $message Message to write */ public function logError($message) { if ($this->getSetting($this->currentContextId, 'logLevel') === 'ERROR') { return; } self::writeLog($message, 'ERROR'); } /** * Write a message with specified level to log * * @param string $message Message to write * @param string $level Error level to add to message */ private static function writeLog($message, $level) { $fineStamp = date('Y-m-d H:i:s') . substr(microtime(), 1, 4); error_log("{$fineStamp} {$level} {$message}\n", 3, self::logFilePath()); } /** * @return string Path to a custom ORCID log file. */ public static function logFilePath() { return Config::getVar('files', 'files_dir') . '/orcid.log'; } /** * Hook callback: register pages for each sushi-lite method * This URL is of the form: orcidapi/{$orcidrequest} * * @see PKPPageRouter::route() */ public function setupCallbackHandler($hookName, $params) { $page = $params[0]; if ($this->getEnabled() && $page == 'orcidapi') { define('HANDLER_CLASS', OrcidProfileHandler::class); return true; } return false; } /** * Check if there exist a valid orcid configuration section in the global config.inc.php of OJS. * * @return boolean True, if the config file has api_url, client_id and client_secret set in an [orcid] section */ public function isGloballyConfigured() { $apiUrl = Config::getVar('orcid', 'api_url'); $clientId = Config::getVar('orcid', 'client_id'); $clientSecret = Config::getVar('orcid', 'client_secret'); return isset($apiUrl) && trim($apiUrl) && isset($clientId) && trim($clientId) && isset($clientSecret) && trim($clientSecret); } /** * Hook callback to handle form display. * Registers output filter for public user profile and author form. * * @param string $hookName * @param Form[] $args * * @return bool * * @see Form::display() * */ public function handleFormDisplay($hookName, $args) { //TODO $request = Application::get()->getRequest(); $templateMgr = TemplateManager::getManager($request); switch ($hookName) { case 'authorform::display': /** @var AuthorForm */ $authorForm = &$args[0]; $author = $authorForm->getAuthor(); if ($author) { $authenticated = !empty($author->getData('orcidAccessToken')); $templateMgr->assign( [ 'orcidAccessToken' => $author->getData('orcidAccessToken'), 'orcidAccessScope' => $author->getData('orcidAccessScope'), 'orcidAccessExpiresOn' => $author->getData('orcidAccessExpiresOn'), 'orcidAccessDenied' => $author->getData('orcidAccessDenied'), 'orcidAuthenticated' => $authenticated ] ); } $templateMgr->registerFilter('output', [$this, 'authorFormFilter']); break; } return false; } /** * Output filter adds ORCiD interaction to contributors metadata add/edit form. * * @param $output string * @param $templateMgr TemplateManager * @return string */ function authorFormFilter($output, $templateMgr) { if (preg_match('/]+name="submissionId"[^>]*>/', $output, $matches, PREG_OFFSET_CAPTURE)) { $match = $matches[0][0]; $offset = $matches[0][1]; $templateMgr->assign('orcidIcon', $this->getIcon()); $newOutput = substr($output, 0, $offset + strlen($match)); $newOutput .= $templateMgr->fetch($this->getTemplateResource('authorFormOrcid.tpl')); $newOutput .= substr($output, $offset + strlen($match)); $output = $newOutput; $templateMgr->unregisterFilter('output', [$this, 'authorFormFilter']); } return $output; } /** * Hook callback: register output filter for user registration and article display. * * @param string $hookName * @param array $args * * @return bool * * @see TemplateManager::display() * */ public function handleTemplateDisplay($hookName, $args) { //TODO orcid $templateMgr = &$args[0]; $template = &$args[1]; $request = Application::get()->getRequest(); // Assign our private stylesheet, for front and back ends. $templateMgr->addStyleSheet( 'orcidProfile', $request->getBaseUrl() . '/' . $this->getStyleSheet(), [ 'contexts' => ['frontend', 'backend'] ] ); switch ($template) { case 'frontend/pages/userRegister.tpl': $templateMgr->registerFilter('output', [$this, 'registrationFilter']); break; } return false; } /** * Return the location of the plugin's CSS file * * @return string */ public function getStyleSheet() { return $this->getPluginPath() . '/css/orcidProfile.css'; } public function isSandbox() { $apiUrl = $this->getSetting($this->getCurrentContextId(), 'orcidProfileAPIPath'); return ($apiUrl == ORCID_API_URL_MEMBER_SANDBOX); } /** * Output filter adds ORCiD interaction to registration form. * * @param string $output * @param TemplateManager $templateMgr * * @return string */ public function registrationFilter($output, $templateMgr) { if (preg_match('/