From 19b5b9b514ce795adfc72cb2745482ef81814ce6 Mon Sep 17 00:00:00 2001 From: taslangraham Date: Mon, 20 Apr 2026 10:19:18 -0500 Subject: [PATCH 1/2] pkp/pkp-lib#12509 Include Peer Review DOIs in crossref metadata export --- CrossrefExportPlugin.php | 5 + CrossrefPlugin.php | 11 +- filter/ArticleCrossrefXmlFilter.php | 13 +- filter/IssueCrossrefXmlFilter.php | 73 +---- filter/PeerReviewCrossrefXmlFilter.php | 387 +++++++++++++++++++++++++ filter/trait/CrossrefFilterBuilder.php | 94 ++++++ 6 files changed, 511 insertions(+), 72 deletions(-) create mode 100644 filter/PeerReviewCrossrefXmlFilter.php create mode 100644 filter/trait/CrossrefFilterBuilder.php diff --git a/CrossrefExportPlugin.php b/CrossrefExportPlugin.php index 4c34838..b630fbc 100644 --- a/CrossrefExportPlugin.php +++ b/CrossrefExportPlugin.php @@ -83,6 +83,11 @@ public function getSubmissionFilter() return 'article=>crossref-xml'; } + public function getPeerReviewFilter(): string + { + return 'peerReview=>crossref-xml'; + + } /** * @copydoc PubObjectsExportPlugin::getIssueFilter() */ diff --git a/CrossrefPlugin.php b/CrossrefPlugin.php index 30f3fb0..95c7744 100644 --- a/CrossrefPlugin.php +++ b/CrossrefPlugin.php @@ -346,6 +346,15 @@ public function exportSubmissions(array $submissions, Context $context): array return ['temporaryFileId' => $temporaryFileId, 'xmlErrors' => $xmlErrors]; } + + public function exportPeerReviews(array $reviews, Context $context): array{ + $filterName = $this->_exportPlugin->getPeerReviewFilter(); + $xmlErrors = []; + + $temporaryFileId = $this->_exportPlugin->exportAsDownload($context, $reviews, $filterName, 'peerReviews', null, $xmlErrors); + + return ['temporaryFileId' => $temporaryFileId, 'xmlErrors' => $xmlErrors]; + } /** * @param Submission[] $submissions */ @@ -436,7 +445,7 @@ public function getSettingsObject(): RegistrationAgencySettings */ public function getAllowedDoiTypes(): array { - return [Repo::doi()::TYPE_PUBLICATION, Repo::doi()::TYPE_ISSUE]; + return [Repo::doi()::TYPE_PUBLICATION, Repo::doi()::TYPE_ISSUE, Repo::doi()::TYPE_PEER_REVIEW]; } /** diff --git a/filter/ArticleCrossrefXmlFilter.php b/filter/ArticleCrossrefXmlFilter.php index 7ecb3d9..aa5fb03 100644 --- a/filter/ArticleCrossrefXmlFilter.php +++ b/filter/ArticleCrossrefXmlFilter.php @@ -672,7 +672,18 @@ public function createComponentListNode(DOMDocument $doc, Publication $publicati $componentNode->appendChild($titlesNode); } // DOI data node - $resourceURL = $dispatcher->url($request, PKPApplication::ROUTE_PAGE, $context->getPath(), 'article', 'download', [$publication->getData('urlPath') ?? $submission->getId(), $componentGalley->getBestGalleyId()], null, null, true, ''); + $resourceURL = $dispatcher->url( + $request, + PKPApplication::ROUTE_PAGE, + $context->getPath(), + 'article', + 'download', + [$publication->getData('urlPath') ?? $submission->getId(), $componentGalley->getBestGalleyId()], + null, + null, + true, + '' + ); $componentNode->appendChild($this->createDOIDataNode($doc, $componentGalley->getStoredPubId('doi'), $resourceURL)); $componentListNode->appendChild($componentNode); } diff --git a/filter/IssueCrossrefXmlFilter.php b/filter/IssueCrossrefXmlFilter.php index 3df04e5..8d572d4 100644 --- a/filter/IssueCrossrefXmlFilter.php +++ b/filter/IssueCrossrefXmlFilter.php @@ -18,6 +18,7 @@ use APP\core\Request; use APP\issue\Issue; use APP\plugins\generic\crossref\CrossrefExportDeployment; +use APP\plugins\generic\crossref\filter\trait\CrossrefFilterBuilder; use DOMDocument; use DOMElement; use PKP\core\Dispatcher; @@ -25,6 +26,8 @@ class IssueCrossrefXmlFilter extends \PKP\plugins\importexport\native\filter\NativeExportFilter { + use CrossrefFilterBuilder; + /** * Constructor * @@ -76,52 +79,6 @@ public function &process(&$pubObjects) // // Issue conversion functions // - /** - * Create and return the root node 'doi_batch'. - */ - public function createRootNode(DOMDocument $doc): DOMElement - { - /** @var CrossrefExportDeployment $deployment */ - $deployment = $this->getDeployment(); - $rootNode = $doc->createElementNS($deployment->getNamespace(), $deployment->getRootElementName()); - $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', $deployment->getXmlSchemaInstance()); - $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:jats', $deployment->getJATSNamespace()); - $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ai', $deployment->getAINamespace()); - $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:rel', $deployment->getRelNamespace()); - $rootNode->setAttribute('version', $deployment->getXmlSchemaVersion()); - $rootNode->setAttribute('xsi:schemaLocation', $deployment->getNamespace() . ' ' . $deployment->getSchemaFilename()); - return $rootNode; - } - - /** - * Create and return the head node 'head'. - */ - public function createHeadNode(DOMDocument $doc): DOMElement - { - /** @var CrossrefExportDeployment $deployment */ - $deployment = $this->getDeployment(); - $context = $deployment->getContext(); - $plugin = $deployment->getPlugin(); - $headNode = $doc->createElementNS($deployment->getNamespace(), 'head'); - $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'doi_batch_id', htmlspecialchars($context->getData('acronym', $context->getPrimaryLocale()) . '_' . time(), ENT_COMPAT, 'UTF-8'))); - $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'timestamp', date('YmdHisv'))); - $depositorNode = $doc->createElementNS($deployment->getNamespace(), 'depositor'); - $depositorName = $plugin->getSetting($context->getId(), 'depositorName'); - if (empty($depositorName)) { - $depositorName = $context->getData('supportName'); - } - $depositorEmail = $plugin->getSetting($context->getId(), 'depositorEmail'); - if (empty($depositorEmail)) { - $depositorEmail = $context->getData('supportEmail'); - } - $depositorNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'depositor_name', htmlspecialchars($depositorName, ENT_COMPAT, 'UTF-8'))); - $depositorNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'email_address', htmlspecialchars($depositorEmail, ENT_COMPAT, 'UTF-8'))); - $headNode->appendChild($depositorNode); - $publisherInstitution = $context->getData('publisherInstitution'); - $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'registrant', htmlspecialchars($publisherInstitution, ENT_COMPAT, 'UTF-8'))); - return $headNode; - } - /** * Create and return the journal node 'journal'. */ @@ -217,28 +174,4 @@ public function createDateNode(DOMDocument $doc, string $objectPublicationDate, $publicationDateNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'year', date('Y', $publicationDate))); return $publicationDateNode; } - - /** - * Create and return the DOI data node 'doi_data'. - */ - public function createDOIDataNode(DOMDocument $doc, string $doi, string $url): DOMElement - { - $deployment = $this->getDeployment(); - $doiDataNode = $doc->createElementNS($deployment->getNamespace(), 'doi_data'); - $doiDataNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'doi', htmlspecialchars($doi, ENT_COMPAT, 'UTF-8'))); - $doiDataNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'resource', $url)); - return $doiDataNode; - } - - /** - * Helper to ensure dispatcher is available even when called from CLI tools - */ - protected function _getDispatcher(Request $request): Dispatcher - { - $dispatcher = $request->getDispatcher(); - if ($dispatcher === null) { - $dispatcher = Application::get()->getDispatcher(); - } - return $dispatcher; - } } diff --git a/filter/PeerReviewCrossrefXmlFilter.php b/filter/PeerReviewCrossrefXmlFilter.php new file mode 100644 index 0000000..d376dd7 --- /dev/null +++ b/filter/PeerReviewCrossrefXmlFilter.php @@ -0,0 +1,387 @@ +setDisplayName('Crossref XML peer review export'); + parent::__construct($filterGroup); + } + + /** + * @param ReviewAssignment[] $pubObjects Array of Review Assignments + * + * @return \DOMDocument + * @throws \Exception + * @see \PKP\filter\Filter::process() + * + */ + public function &process(&$pubObjects) + { + $this->reviewAssignments = $pubObjects; + $this->loadReviewRounds(); + $this->loadPublications(); + + // Create the XML document + $doc = new \DOMDocument('1.0', 'utf-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + /** @var CrossrefExportDeployment $deployment */ + $deployment = $this->getDeployment(); + $context = $deployment->getContext(); + $plugin = $deployment->getPlugin(); + + // Create the root node + $rootNode = $this->createRootNode($doc); + $doc->appendChild($rootNode); + + $rootNode->appendChild($this->createHeadNode($doc)); + + $bodyNode = $doc->createElementNS($deployment->getNamespace(), 'body'); + $rootNode->appendChild($bodyNode); + + foreach ($pubObjects as $reviewAssignment) { + $peerReviewNode = $doc->createElementNS($deployment->getNamespace(), 'peer_review'); + $publication = $this->getPublication($reviewAssignment); + $locale = $publication->getData('locale'); + + /** + * Set Review round attribute. See https://www.crossref.org/documentation/schema-library/markup-guide-record-types/peer-reviews/#:~:text=Revision%20round%20number%2C%20first%20submission%20is%20defined%20as%20revision%20round%200 + */ + $peerReviewNode->setAttribute('revision-round', $reviewAssignment->getRound() - 1); + $peerReviewNode->setAttribute('language', \Locale::getPrimaryLanguage($locale)); + $bodyNode->appendChild($peerReviewNode); + + $peerReviewNode->appendChild($this->createContributorsNode($doc, $reviewAssignment)); + $peerReviewNode->appendChild($this->createTitlesNode($doc, $reviewAssignment)); + $peerReviewNode->appendChild($this->createReviewDateNode($doc, $reviewAssignment)); + + if ($reviewAssignment->getCompetingInterests()) { + $peerReviewNode->appendChild($this->createCompetingInterestNode($doc, $reviewAssignment)); + } + + $peerReviewNode->appendChild($this->createRunningNumberNode($doc, $reviewAssignment)); + $peerReviewNode->appendChild($this->createProgramNode($doc, $reviewAssignment)); + + $request = Application::get()->getRequest(); + $dispatcher = $this->_getDispatcher($request); + + $resourceURL = $dispatcher->url( + $request, + PKPApplication::ROUTE_PAGE, + $context->getPath(), + 'article', + 'view', + [ + $publication->getData('urlPath') ?? + Repo::submission()->get($publication->getData('submissionId'))->getId(), + ], + [ + 'tab' =>'peer-review-record', + 'reviewId' => $reviewAssignment->getId(), + ], + null, + true, + '' + ); + + $peerReviewNode->appendChild($this->createDoiDataNode($doc, $reviewAssignment->getDoi(), $resourceURL)); + } + + return $doc; + } + + /** + * Create the contributor node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createContributorsNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + /** @var CrossrefExportDeployment $deployment */ + $deployment = $this->getDeployment(); + $contributorsNode = $doc->createElementNS($deployment->getNamespace(), 'contributors'); + + // move getting remover, round, and publication details to methods + // add cache for these data too + /** @var User $reviewer */ + $reviewer = Repo::user()->get($reviewAssignment->getReviewerId()); + + $isOpenReview = $reviewAssignment->getReviewMethod() === ReviewAssignment::SUBMISSION_REVIEW_METHOD_OPEN; + $publication = $this->getPublication($reviewAssignment); + + $locale = $publication->getData('locale'); + + if ($isOpenReview) { + $familyNames = $reviewer->getFamilyName(null); + $givenNames = $reviewer->getGivenName(null); + $personNameNode = $doc->createElementNS($deployment->getNamespace(), 'person_name'); + $personNameNode->setAttribute('contributor_role', 'reviewer-external'); + $personNameNode->setAttribute('sequence', 'first'); + + // Check if both givenName and familyName are set for the submission language. + if (!empty($familyNames[$locale]) && !empty($givenNames[$locale])) { + $personNameNode->setAttribute('language', \Locale::getPrimaryLanguage($locale)); + $personNameNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'given_name', htmlspecialchars($givenNames[$locale], ENT_COMPAT, 'UTF-8'))); + $personNameNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'surname', htmlspecialchars($familyNames[$locale], ENT_COMPAT, 'UTF-8'))); + } else { + $personNameNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'surname', htmlspecialchars($givenNames[$locale], ENT_COMPAT, 'UTF-8'))); + } + + $affiliation = $reviewer->getAffiliation($locale); + if ($affiliation) { + $affiliationsNode = $doc->createElementNS($deployment->getNamespace(), 'affiliations'); + $institutionNode = $doc->createElementNS($deployment->getNamespace(), 'institution'); + $institutionNameNode = $doc->createElementNS($deployment->getNamespace(), 'institution_name', htmlspecialchars($affiliation, ENT_COMPAT, 'UTF-8')); + $institutionNode->appendChild($institutionNameNode); + $affiliationsNode->appendChild($institutionNode); + $personNameNode->appendChild($affiliationsNode); + } + + $contributorsNode->appendChild($personNameNode); + } else { + $anonymousReviewerNode = $doc->createElementNS($deployment->getNamespace(), 'anonymous'); + $anonymousReviewerNode->setAttribute('contributor_role', 'reviewer-external'); + $anonymousReviewerNode->setAttribute('sequence', 'first'); + $contributorsNode->appendChild($anonymousReviewerNode); + } + + + return $contributorsNode; + } + + /** + * Create the titles node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createTitlesNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + $deployment = $this->getDeployment(); + + $publication = $this->getPublication($reviewAssignment); + + $titlesNode = $doc->createElementNS($deployment->getNamespace(), 'titles'); + $titleText = 'Review: ' . $publication->getLocalizedTitle($publication->getData('locale')); + + $titleNode = $doc->createElementNS( + $deployment->getNamespace(), + 'title', + htmlspecialchars($titleText, ENT_COMPAT, 'UTF-8') + ); + + $titlesNode->appendChild($titleNode); + + return $titlesNode; + } + + /** + * Create the review date node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createReviewDateNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + $deployment = $this->getDeployment(); + + $reviewDateNode = $doc->createElementNS($deployment->getNamespace(), 'review_date'); + $dateParsed = Carbon::parse($reviewAssignment->getDateCompleted()); + + $reviewDateNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'month', $dateParsed->month)); + $reviewDateNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'day', $dateParsed->day)); + $reviewDateNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'year', $dateParsed->year)); + + return $reviewDateNode; + } + + /** + * Create the competing interest node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createCompetingInterestNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + $deployment = $this->getDeployment(); + return $doc->createElementNS($deployment->getNamespace(), 'competing_interest_statement', htmlspecialchars($reviewAssignment->getCompetingInterests(), ENT_COMPAT, 'UTF-8')); + } + + /** + * Create the running number node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createRunningNumberNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + $deployment = $this->getDeployment(); + return $doc->createElementNS($deployment->getNamespace(), 'running_number', $reviewAssignment->getId()); + } + + /** + * Create the program node. + * @param DOMDocument $doc + * @param ReviewAssignment $reviewAssignment + * @return DOMElement + * @throws \DOMException + */ + private function createProgramNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + { + $relationsNs = 'http://www.crossref.org/relations.xsd'; + $programNode = $doc->createElementNS($relationsNs, 'program'); + $relatedItemNode = $doc->createElementNS($relationsNs, 'related_item'); + $publication = $this->getPublication($reviewAssignment); + + $relationNode = $doc->createElementNS($relationsNs, 'inter_work_relation', $publication->getDoi()); + $relationNode->setAttribute('relationship-type', 'isReviewOf'); + $relationNode->setAttribute('identifier-type', 'doi'); + + $relatedItemNode->appendChild($relationNode); + $programNode->appendChild($relatedItemNode); + + return $programNode; + } + + /** + * Get the publication for a review assignment. + */ + private function getPublication(ReviewAssignment $reviewAssignment): Publication + { + $reviewRound = $this->getReviewRound($reviewAssignment->getReviewRoundId()); + + if (empty($this->publications)) { + $this->loadPublications(); + } + + /** @var Publication $publication */ + $publication = $this->publications->get($reviewRound->getpublicationId()); + return $publication; + } + + /** + * Get the review round for a review assignment. + */ + private function getReviewRound($id): ReviewRound + { + + if (empty($this->reviewRounds)) { + $this->loadReviewRounds($this->reviewAssignments); + } + + /** @var ReviewRound $reviewRound */ + $reviewRound = $this->reviewRounds->get($id); + + return $reviewRound; + } + + /** + * Load the review rounds for all review assignments. + * @return void + * @throws \Exception + */ + private function loadReviewRounds(): void + { + if (!empty($this->reviewRounds)) { + return; + } + + /** @var ReviewRoundDAO $reviewRoundDao */ + $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); + $this->reviewRounds = collect(); + + foreach ($this->reviewAssignments as $reviewAssignment) { + $this->reviewRounds->put( + $reviewAssignment->getReviewRoundId(), + $reviewRoundDao->getById($reviewAssignment->getReviewRoundId()) + ); + } + } + + /** + * Load the publications for all review assignments. + * @return void + */ + private function loadPublications(): void + { + if (!empty($this->publications)) { + return; + } + + $this->publications = collect(); + $publicationIds = []; + + foreach ($this->reviewAssignments as $reviewAssignment) { + $reviewRound = $this->getReviewRound($reviewAssignment->getReviewRoundId()); + $publicationIds[] = $reviewRound->getPublicationId(); + } + + $publications = Repo::publication()->getCollector() + ->filterByPublicationIds($publicationIds) + ->getMany() + ->keyBy(fn(Publication $publication) => $publication->getId()); + + $this->publications = $publications; + } +} diff --git a/filter/trait/CrossrefFilterBuilder.php b/filter/trait/CrossrefFilterBuilder.php new file mode 100644 index 0000000..2eaa4ce --- /dev/null +++ b/filter/trait/CrossrefFilterBuilder.php @@ -0,0 +1,94 @@ +getDeployment(); + $context = $deployment->getContext(); + $plugin = $deployment->getPlugin(); + $headNode = $doc->createElementNS($deployment->getNamespace(), 'head'); + $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'doi_batch_id', htmlspecialchars($context->getData('acronym', $context->getPrimaryLocale()) . '_' . time(), ENT_COMPAT, 'UTF-8'))); + $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'timestamp', date('YmdHisv'))); + $depositorNode = $doc->createElementNS($deployment->getNamespace(), 'depositor'); + $depositorName = $plugin->getSetting($context->getId(), 'depositorName'); + if (empty($depositorName)) { + $depositorName = $context->getData('supportName'); + } + $depositorEmail = $plugin->getSetting($context->getId(), 'depositorEmail'); + if (empty($depositorEmail)) { + $depositorEmail = $context->getData('supportEmail'); + } + $depositorNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'depositor_name', htmlspecialchars($depositorName, ENT_COMPAT, 'UTF-8'))); + $depositorNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'email_address', htmlspecialchars($depositorEmail, ENT_COMPAT, 'UTF-8'))); + $headNode->appendChild($depositorNode); + $publisherInstitution = $context->getData('publisherInstitution'); + $headNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'registrant', htmlspecialchars($publisherInstitution, ENT_COMPAT, 'UTF-8'))); + return $headNode; + } + + /** + * Create and return the DOI data node 'doi_data'. + */ + public function createDOIDataNode(DOMDocument $doc, string $doi, string $url): DOMElement + { + $deployment = $this->getDeployment(); + $doiDataNode = $doc->createElementNS($deployment->getNamespace(), 'doi_data'); + $doiDataNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'doi', htmlspecialchars($doi, ENT_COMPAT, 'UTF-8'))); + $doiDataNode->appendChild($node = $doc->createElementNS($deployment->getNamespace(), 'resource', $url)); + return $doiDataNode; + } + + /** + * Helper to ensure dispatcher is available even when called from CLI tools + */ + protected function _getDispatcher(Request $request): Dispatcher + { + $dispatcher = $request?->getDispatcher(); + if ($dispatcher === null) { + $dispatcher = Application::get()->getDispatcher(); + } + return $dispatcher; + } + + /** + * Create and return the root node 'doi_batch'. + */ + public function createRootNode(DOMDocument $doc): DOMElement + { + /** @var CrossrefExportDeployment $deployment */ + $deployment = $this->getDeployment(); + $rootNode = $doc->createElementNS($deployment->getNamespace(), $deployment->getRootElementName()); + $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', $deployment->getXmlSchemaInstance()); + $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:jats', $deployment->getJATSNamespace()); + $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ai', $deployment->getAINamespace()); + $rootNode->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:rel', $deployment->getRelNamespace()); + $rootNode->setAttribute('version', $deployment->getXmlSchemaVersion()); + $rootNode->setAttribute('xsi:schemaLocation', $deployment->getNamespace() . ' ' . $deployment->getSchemaFilename()); + return $rootNode; + } +} From faaf5f8c01e8f8dc6ccd32929d067c7f5ca7c2da Mon Sep 17 00:00:00 2001 From: taslangraham Date: Fri, 24 Apr 2026 14:43:16 -0500 Subject: [PATCH 2/2] pkp/pkp-lib#12509 Enable Peer Review Crossref Deposits --- CrossrefExportPlugin.php | 18 +++- CrossrefPlugin.php | 24 ++++- filter/PeerReviewCrossrefXmlFilter.php | 132 ++++++++++++++++++------- filter/filterConfig.xml | 12 +++ locale/en/locale.po | 3 + 5 files changed, 149 insertions(+), 40 deletions(-) diff --git a/CrossrefExportPlugin.php b/CrossrefExportPlugin.php index b630fbc..6baaebb 100644 --- a/CrossrefExportPlugin.php +++ b/CrossrefExportPlugin.php @@ -30,6 +30,7 @@ use PKP\file\FileManager; use PKP\file\TemporaryFileManager; use PKP\plugins\Hook; +use PKP\submission\reviewAssignment\ReviewAssignment; class CrossrefExportPlugin extends DOIPubIdExportPlugin { @@ -83,6 +84,9 @@ public function getSubmissionFilter() return 'article=>crossref-xml'; } + /** + * @copydoc PubObjectsExportPlugin::getPeerReviewFilter() + */ public function getPeerReviewFilter(): string { return 'peerReview=>crossref-xml'; @@ -288,7 +292,7 @@ public function exportAsDownload(Journal $context, array $objects, string $filte } /** - * @param Submission $objects + * @param mixed $objects * @param Journal $context * @param string $filename Export XML filename * @@ -412,12 +416,14 @@ public function depositXML($objects, $context, $filename) /** * Update the local DOI deposit status and related metadata for the given object. */ - public function updateDepositStatus(Journal $context, Issue|Submission $object, int $status, ?string $batchId = null, ?string $failedMsg = null, ?string $successMsg = null) + public function updateDepositStatus(Journal $context, Issue|Submission|ReviewAssignment $object, int $status, ?string $batchId = null, ?string $failedMsg = null, ?string $successMsg = null) { if ($object instanceof Submission) { $doiIds = Repo::doi()->getDoisForSubmission($object->getId()); - } else { + } else if ($object instanceof Issue) { $doiIds = Repo::doi()->getDoisForIssue($object->getId(), true); + } else { + $doiIds = Repo::doi()->getDoisForReviewAssignment($object->getId(), true); } foreach ($doiIds as $doiId) { @@ -442,11 +448,15 @@ public function updateDepositStatus(Journal $context, Issue|Submission $object, /** * Get part of the file name based on the object that is being exported */ - private function _getObjectFileNamePart(Submission|Issue $object): string + private function _getObjectFileNamePart(Submission|Issue|ReviewAssignment $object): string { if ($object instanceof Submission) { return 'articles-' . $object->getId(); } + + if ($object instanceof ReviewAssignment) { + return 'peerReviews-' . $object->getId(); + } return 'issues-' . $object->getId(); } } diff --git a/CrossrefPlugin.php b/CrossrefPlugin.php index 95c7744..c5ab3e6 100644 --- a/CrossrefPlugin.php +++ b/CrossrefPlugin.php @@ -347,13 +347,17 @@ public function exportSubmissions(array $submissions, Context $context): array } - public function exportPeerReviews(array $reviews, Context $context): array{ + /* + * @copyDoc IDoiRegistrationAgency::exportPeerReviews() + */ + public function exportPeerReviews(array $reviewAssignments, Context $context): array + { $filterName = $this->_exportPlugin->getPeerReviewFilter(); $xmlErrors = []; - $temporaryFileId = $this->_exportPlugin->exportAsDownload($context, $reviews, $filterName, 'peerReviews', null, $xmlErrors); + $temporaryFileId = $this->_exportPlugin->exportAsDownload($context, $reviewAssignments, $filterName, 'peerReviews', null, $xmlErrors); - return ['temporaryFileId' => $temporaryFileId, 'xmlErrors' => $xmlErrors]; + return ['temporaryFileId' => $temporaryFileId, 'xmlErrors' => $xmlErrors]; } /** * @param Submission[] $submissions @@ -370,6 +374,20 @@ public function depositSubmissions(array $submissions, Context $context): array ]; } + /* + * @copyDoc IDoiRegistrationAgency::depositPeerReviews() + */ + public function depositPeerReviews(array $peerReviews, Context $context): array + { + $filterName = $this->_exportPlugin->getPeerReviewFilter(); + $responseMessage = ''; + $status = $this->_exportPlugin->exportAndDeposit($context, $peerReviews, $filterName, $responseMessage); + + return [ + 'hasErrors' => !$status, + 'responseMessage' => $responseMessage + ]; + } /** * @param Issue[] $issues */ diff --git a/filter/PeerReviewCrossrefXmlFilter.php b/filter/PeerReviewCrossrefXmlFilter.php index d376dd7..bc4f078 100644 --- a/filter/PeerReviewCrossrefXmlFilter.php +++ b/filter/PeerReviewCrossrefXmlFilter.php @@ -11,34 +11,25 @@ * * @ingroup plugins_generic_crossref * - * @brief Class that converts an Article to a Crossref XML document. + * @brief Class that converts a Review Assignment to a Crossref XML document. */ namespace APP\plugins\generic\crossref\filter; -use APP\author\Author; use APP\core\Application; use APP\facades\Repo; use APP\plugins\generic\crossref\CrossrefExportDeployment; use APP\plugins\generic\crossref\filter\trait\CrossrefFilterBuilder; use APP\publication\Publication; -use APP\submission\Submission; use DOMDocument; use DOMElement; use Illuminate\Support\Carbon; use Illuminate\Support\Enumerable; -use PKP\citation\Citation; -use PKP\citation\enum\CitationSourceType; -use PKP\citation\enum\CitationType; use PKP\context\Context; use PKP\core\PKPApplication; use PKP\db\DAORegistry; -use PKP\filter\FilterGroup; -use PKP\i18n\LocaleConversion; use PKP\plugins\importexport\native\filter\NativeExportFilter; -use PKP\submission\GenreDAO; use PKP\submission\reviewAssignment\ReviewAssignment; -use PKP\submission\reviewRound\authorResponse\AuthorResponse; use PKP\submission\reviewRound\ReviewRound; use PKP\submission\reviewRound\ReviewRoundDAO; use PKP\user\User; @@ -49,7 +40,7 @@ class PeerReviewCrossrefXmlFilter extends NativeExportFilter private Enumerable $publications; private Enumerable $reviewRounds; - private Enumerable $authorResponses; + private Enumerable $reviewers; private array $reviewAssignments = []; @@ -73,7 +64,10 @@ public function __construct($filterGroup) public function &process(&$pubObjects) { $this->reviewAssignments = $pubObjects; + + // cache necessary data before processing reviews $this->loadReviewRounds(); + $this->loadReviewers(); $this->loadPublications(); // Create the XML document @@ -83,7 +77,6 @@ public function &process(&$pubObjects) /** @var CrossrefExportDeployment $deployment */ $deployment = $this->getDeployment(); $context = $deployment->getContext(); - $plugin = $deployment->getPlugin(); // Create the root node $rootNode = $this->createRootNode($doc); @@ -115,7 +108,7 @@ public function &process(&$pubObjects) } $peerReviewNode->appendChild($this->createRunningNumberNode($doc, $reviewAssignment)); - $peerReviewNode->appendChild($this->createProgramNode($doc, $reviewAssignment)); + $peerReviewNode->appendChild($this->createRelationshipNode($doc, $reviewAssignment)); $request = Application::get()->getRequest(); $dispatcher = $this->_getDispatcher($request); @@ -158,11 +151,7 @@ private function createContributorsNode(DOMDocument $doc, ReviewAssignment $revi $deployment = $this->getDeployment(); $contributorsNode = $doc->createElementNS($deployment->getNamespace(), 'contributors'); - // move getting remover, round, and publication details to methods - // add cache for these data too - /** @var User $reviewer */ - $reviewer = Repo::user()->get($reviewAssignment->getReviewerId()); - + $reviewer = $this->getReviewer($reviewAssignment); $isOpenReview = $reviewAssignment->getReviewMethod() === ReviewAssignment::SUBMISSION_REVIEW_METHOD_OPEN; $publication = $this->getPublication($reviewAssignment); @@ -194,6 +183,36 @@ private function createContributorsNode(DOMDocument $doc, ReviewAssignment $revi $personNameNode->appendChild($affiliationsNode); } + if ($reviewer->getData('orcid')) { + $orcidNode = $doc->createElementNS($deployment->getNamespace(), 'ORCID', $reviewer->getData('orcid')); + $orcidAuthenticated = $reviewer->getData('orcidIsVerified') ? 'true' : 'false'; + $orcidNode->setAttribute('authenticated', $orcidAuthenticated); + $personNameNode->appendChild($orcidNode); + } + + if (!empty($familyNames[$locale]) && !empty($givenNames[$locale])) { + $hasAltName = false; + foreach ($familyNames as $otherLocal => $familyName) { + if ($otherLocal != $locale && isset($familyName) && !empty($familyName)) { + if (!$hasAltName) { + $altNameNode = $doc->createElementNS($deployment->getNamespace(), 'alt-name'); + $personNameNode->appendChild($altNameNode); + $hasAltName = true; + } + + $nameNode = $doc->createElementNS($deployment->getNamespace(), 'name'); + $nameNode->setAttribute('language', \Locale::getPrimaryLanguage($otherLocal)); + + $nameNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'surname', htmlspecialchars($familyName, ENT_COMPAT, 'UTF-8'))); + if (!empty($givenNames[$otherLocal])) { + $nameNode->appendChild($doc->createElementNS($deployment->getNamespace(), 'given_name', htmlspecialchars($givenNames[$otherLocal], ENT_COMPAT, 'UTF-8'))); + } + + $altNameNode->appendChild($nameNode); + } + } + } + $contributorsNode->appendChild($personNameNode); } else { $anonymousReviewerNode = $doc->createElementNS($deployment->getNamespace(), 'anonymous'); @@ -202,7 +221,6 @@ private function createContributorsNode(DOMDocument $doc, ReviewAssignment $revi $contributorsNode->appendChild($anonymousReviewerNode); } - return $contributorsNode; } @@ -216,11 +234,12 @@ private function createContributorsNode(DOMDocument $doc, ReviewAssignment $revi private function createTitlesNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement { $deployment = $this->getDeployment(); - $publication = $this->getPublication($reviewAssignment); $titlesNode = $doc->createElementNS($deployment->getNamespace(), 'titles'); - $titleText = 'Review: ' . $publication->getLocalizedTitle($publication->getData('locale')); + $titleText = __('plugins.importexport.crossref.reviewTitle', [ + 'publicationTitle' => $publication->getLocalizedTitle($publication->getData('locale')), + ]); $titleNode = $doc->createElementNS( $deployment->getNamespace(), @@ -281,24 +300,39 @@ private function createRunningNumberNode(DOMDocument $doc, ReviewAssignment $rev } /** - * Create the program node. + * Create the relationship node to associate a review with a publication. * @param DOMDocument $doc * @param ReviewAssignment $reviewAssignment * @return DOMElement * @throws \DOMException */ - private function createProgramNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement + private function createRelationshipNode(DOMDocument $doc, ReviewAssignment $reviewAssignment): DOMElement { - $relationsNs = 'http://www.crossref.org/relations.xsd'; - $programNode = $doc->createElementNS($relationsNs, 'program'); - $relatedItemNode = $doc->createElementNS($relationsNs, 'related_item'); + /** @var CrossrefExportDeployment $deployment */ + $deployment = $this->getDeployment(); + + $relationsNs = $deployment->getRelNamespace(); + $programNode = $doc->createElementNS($relationsNs, 'rel:program'); + $relatedItemNode = $doc->createElementNS($relationsNs, 'rel:related_item'); $publication = $this->getPublication($reviewAssignment); - $relationNode = $doc->createElementNS($relationsNs, 'inter_work_relation', $publication->getDoi()); - $relationNode->setAttribute('relationship-type', 'isReviewOf'); - $relationNode->setAttribute('identifier-type', 'doi'); - $relatedItemNode->appendChild($relationNode); + $doiVersioning =$deployment->getContext()->getData(Context::SETTING_DOI_VERSIONING); + // If same DOI is used for all publication versions then link the peer review to the current publication as that publication would be the one deposited to crossref + $publicationDoi = null; + if (!$doiVersioning) { + $submission = Repo::submission()->get($publication->getData('submissionId')); + $currentPublication = Repo::publication()->get($submission->getCurrentPublication()->getId()); + $publicationDoi = $currentPublication->getDoi(); + } else { + $publicationDoi = $publication->getDoi(); + } + + $InterRelationNode = $doc->createElementNS($relationsNs, 'rel:inter_work_relation', $publicationDoi); + $InterRelationNode->setAttribute('relationship-type', 'isReviewOf'); + $InterRelationNode->setAttribute('identifier-type', 'doi'); + + $relatedItemNode->appendChild($InterRelationNode); $programNode->appendChild($relatedItemNode); return $programNode; @@ -320,14 +354,27 @@ private function getPublication(ReviewAssignment $reviewAssignment): Publication return $publication; } + /** + * Get the reviewer for a review assignment. + */ + private function getReviewer(ReviewAssignment $reviewAssignment): User + { + if (empty($this->reviewers)) { + $this->loadReviewers(); + } + + /** @var User $reviewer */ + $reviewer = $this->reviewers->get($reviewAssignment->getReviewerId()); + return $reviewer; + } + /** * Get the review round for a review assignment. */ private function getReviewRound($id): ReviewRound { - if (empty($this->reviewRounds)) { - $this->loadReviewRounds($this->reviewAssignments); + $this->loadReviewRounds(); } /** @var ReviewRound $reviewRound */ @@ -359,6 +406,25 @@ private function loadReviewRounds(): void } } + + /** + * Load the reviewers for all review assignments + * @return void + */ + private function loadReviewers(): void{ + if (!empty($this->reviewers)) { + return; + } + + $reviewerIds = array_unique( + array_map(fn(ReviewAssignment $review) => $review->getReviewerId(), $this->reviewAssignments) + ); + + $this->reviewers = Repo::user()->getCollector() + ->filterByUserIds($reviewerIds) + ->getMany() + ->keyBy(fn(User $user)=> $user->getId()); + } /** * Load the publications for all review assignments. * @return void @@ -378,7 +444,7 @@ private function loadPublications(): void } $publications = Repo::publication()->getCollector() - ->filterByPublicationIds($publicationIds) + ->filterByPublicationIds(array_unique($publicationIds)) ->getMany() ->keyBy(fn(Publication $publication) => $publication->getId()); diff --git a/filter/filterConfig.xml b/filter/filterConfig.xml index eca5d26..38d255c 100644 --- a/filter/filterConfig.xml +++ b/filter/filterConfig.xml @@ -26,6 +26,13 @@ description="plugins.importexport.crossref.description" inputType="class::APP\submission\Submission[]" outputType="xml::schema(https://www.crossref.org/schemas/crossref5.4.0.xsd)" /> + + @@ -38,5 +45,10 @@ inGroup="article=>crossref-xml" class="APP\plugins\generic\crossref\filter\ArticleCrossrefXmlFilter" isTemplate="0" /> + + diff --git a/locale/en/locale.po b/locale/en/locale.po index d9c1ff3..342034f 100644 --- a/locale/en/locale.po +++ b/locale/en/locale.po @@ -193,3 +193,6 @@ msgstr "The submission \"{$publicationTitle}\" is not associated with a valid DO msgid "plugins.generic.crossref.publisherInstitution.required" msgstr "Journal publisher must be provided before submissions can be deposited with Crossref." + +msgid "plugins.importexport.crossref.reviewTitle" +msgstr "Review: {publicationTitle}"