From 03b9d0a231f8efa99dfdfab1764a89ac7fce465d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:38:18 +0000 Subject: [PATCH 1/3] Add CSV export feature for citation data (per-journal and all-journals) Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- CitationsPlugin.php | 74 ++++++++---- classes/CitationsHandler.php | 219 +++++++++++++++++++++++++++++++++++ locale/en/locale.po | 12 ++ 3 files changed, 284 insertions(+), 21 deletions(-) diff --git a/CitationsPlugin.php b/CitationsPlugin.php index 04b73a1..beab02d 100644 --- a/CitationsPlugin.php +++ b/CitationsPlugin.php @@ -10,8 +10,10 @@ use PKP\core\JSONMessage; use PKP\linkAction\LinkAction; use PKP\linkAction\request\AjaxModal; +use PKP\linkAction\request\RedirectAction; use PKP\plugins\GenericPlugin; use PKP\plugins\Hook; +use PKP\security\Role; class CitationsPlugin extends GenericPlugin @@ -96,29 +98,59 @@ public function getActions($request, $actionArgs): array { $router = $request->getRouter(); import('lib.pkp.classes.linkAction.request.AjaxModal'); - return array_merge( - $this->getEnabled() ? array( - new LinkAction( - 'settings', - new AjaxModal( - $router->url( - $request, - null, - null, - 'manage', - null, - array('verb' => 'settings', 'plugin' => $this->getName(), - 'category' => 'generic' - ) - ), - $this->getDisplayName() + $actions = []; + if ($this->getEnabled()) { + $actions[] = new LinkAction( + 'settings', + new AjaxModal( + $router->url( + $request, + null, + null, + 'manage', + null, + array('verb' => 'settings', 'plugin' => $this->getName(), + 'category' => 'generic' + ) ), - __('manager.plugins.settings'), - null + $this->getDisplayName() ), - ) : array(), - parent::getActions($request, $actionArgs) - ); + __('manager.plugins.settings'), + null + ); + + $context = $request->getContext(); + $user = $request->getUser(); + + if ($context && $user) { + $contextId = $context->getId(); + $isManager = $user->hasRole([Role::ROLE_ID_MANAGER], $contextId); + $isSiteAdmin = $user->hasRole([Role::ROLE_ID_SITE_ADMIN], \PKP\core\PKPApplication::SITE_CONTEXT_ID); + + if ($isManager || $isSiteAdmin) { + $actions[] = new LinkAction( + 'exportCitations', + new RedirectAction( + $router->url($request, null, 'citations', 'export') + ), + __('plugins.generic.citations.export'), + null + ); + } + + if ($isSiteAdmin) { + $actions[] = new LinkAction( + 'exportAllCitations', + new RedirectAction( + $router->url($request, null, 'citations', 'export', null, ['scope' => 'all']) + ), + __('plugins.generic.citations.export.all'), + null + ); + } + } + } + return array_merge($actions, parent::getActions($request, $actionArgs)); } diff --git a/classes/CitationsHandler.php b/classes/CitationsHandler.php index 0a5c5a9..7e6f1a1 100644 --- a/classes/CitationsHandler.php +++ b/classes/CitationsHandler.php @@ -2,14 +2,19 @@ namespace APP\plugins\generic\citations\classes; +use APP\core\Application; +use APP\facades\Repo; use APP\handler\Handler; use APP\plugins\generic\citations\classes\processor\CrossrefProcessor; use APP\plugins\generic\citations\classes\processor\EuropePmcProcessor; use APP\plugins\generic\citations\classes\processor\ScopusProcessor; use PKP\core\JSONMessage; +use PKP\core\PKPApplication; use PKP\core\PKPRequest; use PKP\plugins\PluginRegistry; +use PKP\security\Role; +use PKP\submission\PKPSubmission; class CitationsHandler extends Handler { @@ -49,6 +54,204 @@ public function get(array $args, PKPRequest $request): JSONMessage return new JSONMessage(!empty($result), !empty($result) ? $result : null); } + /** + * Export citation data for a journal (or all journals) as a CSV download. + * + * Accessible at /index.php//citations/export + * + * Site admins may append ?scope=all to export across every journal in the + * installation. Journal managers (and site admins) without that parameter + * receive a report for the current journal only. + * + * @param array $args + * @param PKPRequest $request + */ + public function export(array $args, PKPRequest $request): void + { + $user = $request->getUser(); + if ($user === null) { + header('HTTP/1.0 403 Forbidden'); + exit; + } + + $context = $request->getContext(); + $contextId = $context ? $context->getId() : null; + + $isSiteAdmin = $user->hasRole([Role::ROLE_ID_SITE_ADMIN], PKPApplication::SITE_CONTEXT_ID); + $isManager = $contextId && $user->hasRole([Role::ROLE_ID_MANAGER], $contextId); + + if (!$isSiteAdmin && !$isManager) { + header('HTTP/1.0 403 Forbidden'); + exit; + } + + $exportAll = $isSiteAdmin && $request->getUserVar('scope') === 'all'; + + if ($exportAll) { + $contextMap = $this->getAllContextMap(); + } else { + if ($contextId === null) { + header('HTTP/1.0 400 Bad Request'); + exit; + } + $journalName = $context ? ($context->getLocalizedName() ?: $context->getPath()) : (string) $contextId; + $contextMap = [$contextId => $journalName]; + } + + $rawPath = $exportAll ? 'all-journals' : ($context ? $context->getPath() : 'journal'); + $safePath = preg_replace('/[^A-Za-z0-9_\-]/', '_', $rawPath); + $filename = 'citations-' . $safePath . '.csv'; + + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + + $output = fopen('php://output', 'w'); + + // UTF-8 BOM for Excel compatibility + fputs($output, "\xEF\xBB\xBF"); + + fputcsv($output, [ + 'Journal', + 'Article DOI', + 'Article Title', + 'Article Year', + 'Citation Source', + 'Citation DOI', + 'Citation Title', + 'Citation Authors', + 'Citation Journal', + 'Citation Year', + 'Citation Volume', + 'Citation Issue', + 'Citation Pages', + 'Citation Type', + ]); + + foreach ($contextMap as $ctxId => $journalName) { + $settings = $this->loadSettingsForContext($ctxId); + if (empty($settings)) { + continue; + } + $this->writeContextRows($output, $ctxId, $journalName, $settings); + } + + fclose($output); + exit; + } + + /** + * Write CSV rows for all published submissions in one journal context. + * + * @param resource $output + * @param int $contextId + * @param string $journalName + * @param array $settings + */ + private function writeContextRows($output, int $contextId, string $journalName, array $settings): void + { + $submissions = Repo::submission()->getCollector() + ->filterByContextIds([$contextId]) + ->filterByStatus([PKPSubmission::STATUS_PUBLISHED]) + ->getMany(); + + foreach ($submissions as $submission) { + $doi = $submission->getStoredPubId('doi'); + if (empty($doi)) { + continue; + } + + $publication = $submission->getCurrentPublication(); + $articleTitle = $publication ? $publication->getLocalizedTitle() : ''; + $articleYear = $publication + ? substr((string) ($publication->getData('datePublished') ?? ''), 0, 4) + : ''; + + // Force showList=true so we always retrieve citation details for the export + $exportSettings = array_merge($settings, ['showList' => true]); + $citations = $this->fetchAllCitations($doi, $exportSettings); + + if (empty($citations)) { + fputcsv($output, [ + $journalName, $doi, $articleTitle, $articleYear, + '', '', '', '', '', '', '', '', '', '', + ]); + continue; + } + + foreach ($citations as $citation) { + fputcsv($output, [ + $journalName, + $doi, + $articleTitle, + $articleYear, + $citation['source'] ?? '', + $citation['doi'] ?? '', + $citation['title'] ?? '', + $citation['authors'] ?? '', + $citation['journal'] ?? '', + $citation['year'] ?? '', + $citation['volume'] ?? '', + $citation['issue'] ?? '', + $citation['pages'] ?? '', + $citation['type'] ?? '', + ]); + } + } + } + + /** + * Collect citations from all configured providers for a given DOI. + * + * @param string $doi + * @param array $settings Plugin settings (showList must already be true) + * @return array Flat list of citation arrays + */ + private function fetchAllCitations(string $doi, array $settings): array + { + $citations = []; + + if ('all' === ($settings['provider'] ?? '') || 'crossref' === ($settings['provider'] ?? '')) { + $result = (new CrossrefProcessor())->process($doi, $settings); + if (!empty($result['citations'])) { + $citations = array_merge($citations, $result['citations']); + } + } + + if ('all' === ($settings['provider'] ?? '') || 'scopus' === ($settings['provider'] ?? '')) { + $result = (new ScopusProcessor())->process($doi, $settings); + if (!empty($result['citations'])) { + $citations = array_merge($citations, $result['citations']); + } + } + + if (!empty($settings['showPmc'])) { + $result = (new EuropePmcProcessor())->process($doi, $settings); + if (!empty($result['citations'])) { + $citations = array_merge($citations, $result['citations']); + } + } + + return $citations; + } + + /** + * Return a map of context ID => display name for all enabled contexts. + * + * @return array + */ + private function getAllContextMap(): array + { + $contextService = Application::get()->getContextService(); + $map = []; + foreach ($contextService->getMany(['isEnabled' => true]) as $ctx) { + $map[$ctx->getId()] = $ctx->getLocalizedName() ?: $ctx->getPath(); + } + return $map; + } + /** Loads the plugin settings * @param PKPRequest $request The request * @return array The settings @@ -64,6 +267,22 @@ private function loadSettings(PKPRequest $request): array } } + /** + * Load plugin settings for a specific context ID. + * + * @param int $contextId + * @return array + */ + private function loadSettingsForContext(int $contextId): array + { + $plugin = PluginRegistry::getPlugin('generic', 'citationsplugin'); + if ($plugin === null) { + return []; + } + $raw = $plugin->getSetting($contextId, 'settings'); + return $raw ? (json_decode($raw, true) ?? []) : []; + } + /** checks if the doi of a scopus citation is already in the crossref citations and removes it if so * @param array $crossrefCitations The Crossref citations * @param array $scopusCitations The Scopus citations diff --git a/locale/en/locale.po b/locale/en/locale.po index bed950a..bedf104 100644 --- a/locale/en/locale.po +++ b/locale/en/locale.po @@ -100,3 +100,15 @@ msgstr "The link to the Europe PMC search can be de-/activated with this option. msgid "plugins.generic.citations.show.pmc.check" msgstr "Enable Europe PMC" + +msgid "plugins.generic.citations.export" +msgstr "Export Citations (CSV)" + +msgid "plugins.generic.citations.export.all" +msgstr "Export All Journals (CSV)" + +msgid "plugins.generic.citations.export.description" +msgstr "Download a CSV report of all citation data for published articles in this journal." + +msgid "plugins.generic.citations.export.all.description" +msgstr "Download a CSV report of all citation data for published articles across all journals in this installation. Only available to site administrators." From c3f114ef0530eb534d436e22d7bfedba7f964502 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:52:42 +0000 Subject: [PATCH 2/3] Fix OJS 3.5 compat: use Repo::journal() for context map, dispatcher for page URLs Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- CitationsPlugin.php | 5 +++-- classes/CitationsHandler.php | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CitationsPlugin.php b/CitationsPlugin.php index ee805a7..f121f88 100644 --- a/CitationsPlugin.php +++ b/CitationsPlugin.php @@ -107,11 +107,12 @@ public function getActions($request, $actionArgs): array $isManager = $user->hasRole([Role::ROLE_ID_MANAGER], $contextId); $isSiteAdmin = $user->hasRole([Role::ROLE_ID_SITE_ADMIN], \PKP\core\PKPApplication::SITE_CONTEXT_ID); + $dispatcher = $request->getDispatcher(); if ($isManager || $isSiteAdmin) { $exportActions[] = new LinkAction( 'exportCitations', new RedirectAction( - $router->url($request, null, 'citations', 'export') + $dispatcher->url($request, Application::ROUTE_PAGE, null, 'citations', 'export') ), __('plugins.generic.citations.export'), null @@ -122,7 +123,7 @@ public function getActions($request, $actionArgs): array $exportActions[] = new LinkAction( 'exportAllCitations', new RedirectAction( - $router->url($request, null, 'citations', 'export', null, ['scope' => 'all']) + $dispatcher->url($request, Application::ROUTE_PAGE, null, 'citations', 'export', null, ['scope' => 'all']) ), __('plugins.generic.citations.export.all'), null diff --git a/classes/CitationsHandler.php b/classes/CitationsHandler.php index 7e6f1a1..55ee2c3 100644 --- a/classes/CitationsHandler.php +++ b/classes/CitationsHandler.php @@ -2,7 +2,6 @@ namespace APP\plugins\generic\citations\classes; -use APP\core\Application; use APP\facades\Repo; use APP\handler\Handler; @@ -244,9 +243,8 @@ private function fetchAllCitations(string $doi, array $settings): array */ private function getAllContextMap(): array { - $contextService = Application::get()->getContextService(); $map = []; - foreach ($contextService->getMany(['isEnabled' => true]) as $ctx) { + foreach (Repo::journal()->getCollector()->filterByEnabled(true)->getMany() as $ctx) { $map[$ctx->getId()] = $ctx->getLocalizedName() ?: $ctx->getPath(); } return $map; From 6a8c647a42bd9479e1ef75ea7cfb9b2fb0fdeda3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:58:26 +0000 Subject: [PATCH 3/3] Fix getAllContextMap: use DAORegistry::getDAO('JournalDAO') for OJS 3.5 compatibility Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- classes/CitationsHandler.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/classes/CitationsHandler.php b/classes/CitationsHandler.php index 55ee2c3..0c7ca00 100644 --- a/classes/CitationsHandler.php +++ b/classes/CitationsHandler.php @@ -4,6 +4,7 @@ use APP\facades\Repo; use APP\handler\Handler; +use PKP\db\DAORegistry; use APP\plugins\generic\citations\classes\processor\CrossrefProcessor; use APP\plugins\generic\citations\classes\processor\EuropePmcProcessor; @@ -243,9 +244,12 @@ private function fetchAllCitations(string $doi, array $settings): array */ private function getAllContextMap(): array { + /** @var \APP\journal\JournalDAO $journalDao */ + $journalDao = DAORegistry::getDAO('JournalDAO'); $map = []; - foreach (Repo::journal()->getCollector()->filterByEnabled(true)->getMany() as $ctx) { - $map[$ctx->getId()] = $ctx->getLocalizedName() ?: $ctx->getPath(); + $journalIterator = $journalDao->getAll(true); + while ($journal = $journalIterator->next()) { + $map[$journal->getId()] = $journal->getLocalizedName() ?: $journal->getPath(); } return $map; }