<?php
/**
* @file plugins/generic/crossref/CrossrefExportPlugin.php
*
* Copyright (c) 2014-2022 Simon Fraser University
* Copyright (c) 2003-2022 John Willinsky
* Distributed under The MIT License. For full terms see the file LICENSE.
*
* @class CrossrefExportPlugin
*
* @brief Crossref/MEDLINE XML metadata export plugin
*/
namespace APP\plugins\generic\crossref;
use APP\core\Application;
use PKP\config\Config;
use APP\facades\Repo;
use APP\issue\Issue;
use APP\journal\Journal;
use APP\plugins\DOIPubIdExportPlugin;
use APP\plugins\IDoiRegistrationAgency;
use APP\submission\Submission;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use PKP\core\DataObject;
use PKP\doi\Doi;
use PKP\file\FileManager;
use PKP\file\TemporaryFileManager;
use PKP\plugins\Hook;
use PKP\plugins\Plugin;
class CrossrefExportPlugin extends DOIPubIdExportPlugin
{
// The status of the Crossref DOI.
// any, notDeposited, and markedRegistered are reserved
public const CROSSREF_STATUS_FAILED = 'failed';
public const CROSSREF_API_DEPOSIT_OK = 200;
public const CROSSREF_API_DEPOSIT_ERROR_FROM_CROSSREF = 403;
public const CROSSREF_API_URL = 'https://api.crossref.org/v2/deposits';
//TESTING
public const CROSSREF_API_URL_DEV = 'https://test.crossref.org/v2/deposits';
public const CROSSREF_API_STATUS_URL = 'https://doi.crossref.org/servlet/submissionDownload';
//TESTING
public const CROSSREF_API_STATUS_URL_DEV = 'https://test.crossref.org/servlet/submissionDownload';
// The name of the setting used to save the registered DOI and the URL with the deposit status.
public const CROSSREF_DEPOSIT_STATUS = 'depositStatus';
public function __construct(protected IDoiRegistrationAgency|Plugin $agencyPlugin)
{
parent::__construct();
}
public function register($category, $path, $mainContextId = null)
{
$success = parent::register($category, $path, $mainContextId);
if ($success) {
// register hooks. This will prevent DB access attempts before the
// schema is installed.
if (Application::isUnderMaintenance()) {
return true;
}
}
return $success;
}
/**
* @copydoc Plugin::getName()
*/
public function getName()
{
return 'CrossrefExportPlugin';
}
/**
* @copydoc Plugin::getDisplayName()
*/
public function getDisplayName()
{
return __('plugins.importexport.crossref.displayName');
}
/**
* @copydoc Plugin::getDescription()
*/
public function getDescription()
{
return __('plugins.importexport.crossref.description');
}
/**
* @copydoc PubObjectsExportPlugin::getSubmissionFilter()
*/
public function getSubmissionFilter()
{
return 'article=>crossref-xml';
}
/**
* @copydoc PubObjectsExportPlugin::getIssueFilter()
*/
public function getIssueFilter()
{
return 'issue=>crossref-xml';
}
/** Proxy to main plugin class's `getSetting` method */
public function getSetting($contextId, $name)
{
return $this->agencyPlugin->getSetting($contextId, $name);
}
/**
* @copydoc PubObjectsExportPlugin::getStatusMessage()
*/
public function getStatusMessage($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 crossref external service');
return __('common.sandbox');
}
// if the failure occurred on request and the message was saved
// return that message
$articleId = $request->getUserVar('articleId');
$article = Repo::submission()->get((int)$articleId);
$failedMsg = $article->getData('doiObject')->getData($this->getFailedMsgSettingName());
if (!empty($failedMsg)) {
return $failedMsg;
}
$context = $request->getContext();
$httpClient = Application::get()->getHttpClient();
try {
$response = $httpClient->request(
'POST',
$this->isTestMode($context) ? static::CROSSREF_API_STATUS_URL_DEV : static::CROSSREF_API_STATUS_URL,
[
'form_params' => [
'doi_batch_id' => $request->getUserVar('batchId'),
'type' => 'result',
'usr' => $this->getSetting($context->getId(), 'username'),
'pwd' => $this->getSetting($context->getId(), 'password'),
]
]
);
} catch (RequestException $e) {
$returnMessage = $e->getMessage();
if ($e->hasResponse()) {
$returnMessage = $e->getResponse()->getBody() . ' (' . $e->getResponse()->getStatusCode() . ' ' . $e->getResponse()->getReasonPhrase() . ')';
}
return __('plugins.importexport.common.register.error.mdsError', ['param' => $returnMessage]);
}
return (string) $response->getBody();
}
/**
* Get a list of additional setting names that should be stored with the objects.
*
* @return array
*/
protected function _getObjectAdditionalSettings()
{
return array_merge(parent::_getObjectAdditionalSettings(), [
$this->getDepositBatchIdSettingName(),
$this->getFailedMsgSettingName(),
$this->getSuccessMsgSettingName(),
]);
}
/**
* @copydoc ImportExportPlugin::getPluginSettingsPrefix()
*/
public function getPluginSettingsPrefix()
{
return 'crossrefplugin';
}
/**
* @copydoc PubObjectsExportPlugin::getSettingsFormClassName()
*/
public function getSettingsFormClassName()
{
throw new Exception('DOI settings no longer managed via plugin settings form.');
}
/**
* @copydoc PubObjectsExportPlugin::getExportDeploymentClassName()
*/
public function getExportDeploymentClassName()
{
return (string) \APP\plugins\generic\crossref\CrossrefExportDeployment::class;
}
public function exportAndDeposit($context, $objects, $filter, string &$responseMessage, $noValidation = null): bool
{
$fileManager = new FileManager();
$resultErrors = [];
assert($filter != null);
// Errors occurred will be accessible via the status link
// thus do not display all errors notifications (for every article),
// just one general.
// Warnings occurred when the registration was successful will however be
// displayed for each article.
$errorsOccurred = false;
// The new Crossref deposit API expects one request per object.
// On contrary the export supports bulk/batch object export, thus
// also the filter expects an array of objects.
// Thus the foreach loop, but every object will be in an one item array for
// the export and filter to work.
foreach ($objects as $object) {
// Get the XML
// Supply an exportErrors array because otherwise exportXML() will echo out export errors
$exportErrors = [];
$exportXml = $this->exportXML([$object], $filter, $context, $noValidation, $exportErrors);
// Write the XML to a file.
// export file name example: crossref-20160723-160036-articles-1-1.xml
$objectFileNamePart = $this->_getObjectFileNamePart($object);
$exportFileName = $this->getExportFileName($this->getExportPath(), $objectFileNamePart, $context, '.xml');
$fileManager->writeFile($exportFileName, $exportXml);
// Deposit the XML file.
$result = $this->depositXML($object, $context, $exportFileName);
if (!$result) {
$errorsOccurred = true;
}
if (is_array($result)) {
$resultErrors[] = $result;
}
// Remove all temporary files.
$fileManager->deleteByPath($exportFileName);
}
// Prepare response message and return status
if (empty($resultErrors)) {
if ($errorsOccurred) {
$responseMessage = 'plugins.importexport.crossref.register.error.mdsError';
return false;
} else {
$responseMessage = $this->getDepositSuccessNotificationMessageKey();
return true;
}
} else {
$responseMessage = 'api.dois.400.depositFailed';
return false;
}
}
/**
* Exports and stores XML as a TemporaryFile
*
*
* @throws Exception
*/
public function exportAsDownload(\PKP\context\Context $context, array $objects, string $filter, string $objectsFileNamePart, ?bool $noValidation = null, ?array &$exportErrors = null): ?int
{
$fileManager = new TemporaryFileManager();
$exportErrors = [];
$exportXml = $this->exportXML($objects, $filter, $context, $noValidation, $exportErrors);
$exportFileName = $this->getExportFileName($this->getExportPath(), $objectsFileNamePart, $context, '.xml');
$fileManager->writeFile($exportFileName, $exportXml);
$user = Application::get()->getRequest()->getUser();
return $fileManager->createTempFileFromExisting($exportFileName, $user->getId());
}
/**
* @param Submission $objects
* @param Journal $context
* @param string $filename Export XML filename
*
* @throws GuzzleException
*
* @see PubObjectsExportPlugin::depositXML()
*
*/
public function depositXML($objects, $context, $filename)
{
// 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 crossref external service');
return false;
}
$status = null;
$msgSave = null;
$httpClient = Application::get()->getHttpClient();
assert(is_readable($filename));
try {
$response = $httpClient->request(
'POST',
$this->isTestMode($context) ? static::CROSSREF_API_URL_DEV : static::CROSSREF_API_URL,
[
'multipart' => [
[
'name' => 'usr',
'contents' => $this->getSetting($context->getId(), 'username'),
],
[
'name' => 'pwd',
'contents' => $this->getSetting($context->getId(), 'password'),
],
[
'name' => 'operation',
'contents' => 'doMDUpload',
],
[
'name' => 'mdFile',
'contents' => fopen($filename, 'r'),
],
]
]
);
} catch (RequestException $e) {
$returnMessage = $e->getMessage();
if ($e->hasResponse()) {
$eResponseBody = $e->getResponse()->getBody();
$eStatusCode = $e->getResponse()->getStatusCode();
if ($eStatusCode == static::CROSSREF_API_DEPOSIT_ERROR_FROM_CROSSREF) {
$xmlDoc = new \DOMDocument('1.0', 'utf-8');
$xmlDoc->loadXML($eResponseBody);
$batchIdNode = $xmlDoc->getElementsByTagName('batch_id')->item(0);
$msg = $xmlDoc->getElementsByTagName('msg')->item(0)->nodeValue;
$msgSave = $msg . PHP_EOL . $eResponseBody;
$status = Doi::STATUS_ERROR;
$this->updateDepositStatus($context, $objects, $status, $batchIdNode->nodeValue, $msgSave);
$returnMessage = $msg . ' (' . $eStatusCode . ' ' . $e->getResponse()->getReasonPhrase() . ')';
} else {
$returnMessage = $eResponseBody . ' (' . $eStatusCode . ' ' . $e->getResponse()->getReasonPhrase() . ')';
$this->updateDepositStatus($context, $objects, Doi::STATUS_ERROR, null, $returnMessage);
}
}
return [['plugins.importexport.common.register.error.mdsError', $returnMessage]];
}
// Get DOMDocument from the response XML string
$xmlDoc = new \DOMDocument('1.0', 'utf-8');
$xmlDoc->loadXML($response->getBody());
$batchIdNode = $xmlDoc->getElementsByTagName('batch_id')->item(0);
$submissionIdNode = $xmlDoc->getElementsByTagName('submission_id')->item(0);
$successMessage = __('plugins.generic.crossref.successMessage', ['submissionId' => $submissionIdNode->nodeValue]);
// Get the DOI deposit status
// If the deposit failed
$failureCountNode = $xmlDoc->getElementsByTagName('failure_count')->item(0);
$failureCount = (int) $failureCountNode->nodeValue;
if ($failureCount > 0) {
$status = Doi::STATUS_ERROR;
$result = false;
} else {
// Deposit was received
$status = Doi::STATUS_REGISTERED;
$result = true;
// If there were some warnings, display them
$warningCountNode = $xmlDoc->getElementsByTagName('warning_count')->item(0);
$warningCount = (int) $warningCountNode->nodeValue;
if ($warningCount > 0) {
$result = [['plugins.importexport.crossref.register.success.warning', htmlspecialchars($response->getBody())]];
}
// A possibility for other plugins (e.g. reference linking) to work with the response
Hook::run('crossrefexportplugin::deposited', [[$this, $response->getBody(), $objects]]);
}
// Update the status
if ($status) {
$this->updateDepositStatus($context, $objects, $status, $batchIdNode->nodeValue, $msgSave, $successMessage);
}
return $result;
}
/**
* Check the Crossref APIs, if deposits and registration have been successful
*
* @param Journal $context
* @param DataObject $object The object getting deposited
* @param int $status
* @param string $batchId
* @param string $failedMsg (optional)
* @param null|mixed $successMsg
*/
public function updateDepositStatus($context, $object, $status, $batchId = null, $failedMsg = null, $successMsg = null)
{
assert($object instanceof Submission || $object instanceof Issue);
if ($object instanceof Submission) {
$doiIds = Repo::doi()->getDoisForSubmission($object->getId());
} else {
$doiIds = Repo::doi()->getDoisForIssue($object->getId(), true);
}
foreach ($doiIds as $doiId) {
$doi = Repo::doi()->get($doiId);
$editParams = [
'status' => $status,
// Sets new failedMsg or resets to null for removal of previous message
$this->getFailedMsgSettingName() => $failedMsg,
$this->getDepositBatchIdSettingName() => $batchId,
$this->getSuccessMsgSettingName() => $successMsg,
];
if ($status === Doi::STATUS_REGISTERED) {
$editParams['registrationAgency'] = $this->getName();
}
Repo::doi()->edit($doi, $editParams);
}
}
/**
* @copydoc DOIPubIdExportPlugin::markRegistered()
*/
public function markRegistered($context, $objects)
{
foreach ($objects as $object) {
// Get all DOIs for each object
// Check if submission or issue
if ($object instanceof Submission) {
$doiIds = Repo::doi()->getDoisForSubmission($object->getId());
} else {
$doiIds = Repo::doi()->getDoisForIssue($object->getId, true);
}
foreach ($doiIds as $doiId) {
Repo::doi()->markRegistered($doiId);
}
}
}
/**
* Get request failed message setting name.
* NB: Changed as of 3.4
*
* @return string
*/
public function getFailedMsgSettingName()
{
return $this->getPluginSettingsPrefix() . '_failedMsg';
}
/**
* Get deposit batch ID setting name.
* NB Changed as of 3.4
*
* @return string
*/
public function getDepositBatchIdSettingName()
{
return $this->getPluginSettingsPrefix() . '_batchId';
}
public function getSuccessMsgSettingName(): string
{
return $this->getPluginSettingsPrefix() . '_successMsg';
}
/**
* @copydoc PubObjectsExportPlugin::getDepositSuccessNotificationMessageKey()
*/
public function getDepositSuccessNotificationMessageKey()
{
return 'plugins.importexport.common.register.success';
}
/**
* @param Submission|Issue $object
*
*/
private function _getObjectFileNamePart(DataObject $object): string
{
if ($object instanceof Submission) {
return 'articles-' . $object->getId();
} elseif ($object instanceof Issue) {
return 'issues-' . $object->getId();
} else {
return '';
}
}
}
|