diff --git a/api/v1/citations/PKPCitationController.php b/api/v1/citations/PKPCitationController.php new file mode 100644 index 00000000000..63ae7cf1da1 --- /dev/null +++ b/api/v1/citations/PKPCitationController.php @@ -0,0 +1,220 @@ +get(...)) + ->name('citation.getCitation') + ->whereNumber('citationId'); + + Route::get('', $this->getMany(...)) + ->name('citation.getMany'); + + Route::post('', $this->edit(...)) + ->name('citation.edit'); + + Route::get('{citationId}/_components/citationForm', $this->getCitationForm(...)) + ->name('citation._components.citationForm'); + } + + /** + * @copydoc \PKP\core\PKPBaseController::authorize() + */ + public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool + { + $this->addPolicy(new UserRolesRequiredPolicy($request), true); + + $rolePolicy = new PolicySet(PolicySet::COMBINING_PERMIT_OVERRIDES); + + $this->addPolicy(new ContextRequiredPolicy($request)); + + foreach ($roleAssignments as $role => $operations) { + $rolePolicy->addPolicy(new RoleBasedHandlerOperationPolicy($request, $role, $operations)); + } + + $this->addPolicy($rolePolicy); + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Get a single citation + */ + public function get(Request $illuminateRequest): JsonResponse + { + if (!Repo::citation()->exists((int)$illuminateRequest->route('citationId'))) { + return response()->json([ + 'error' => __('api.citations.404.citationNotFound') + ], Response::HTTP_OK); + } + + $citation = Repo::citation()->get((int)$illuminateRequest->route('citationId')); + + return response()->json(Repo::citation()->getSchemaMap()->map($citation), Response::HTTP_OK); + } + + /** + * Get a collection of citations + * + * @hook API::citations::params [[$collector, $illuminateRequest]] + */ + public function getMany(Request $illuminateRequest): JsonResponse + { + $collector = Repo::citation()->getCollector() + ->limit(self::DEFAULT_COUNT) + ->offset(0); + + foreach ($illuminateRequest->query() as $param => $val) { + switch ($param) { + case 'count': + $collector->limit(min((int)$val, self::MAX_COUNT)); + break; + case 'offset': + $collector->offset((int)$val); + break; + } + } + + Hook::call('API::citations::params', [$collector, $illuminateRequest]); + + $citations = $collector->getMany(); + + return response()->json([ + 'itemsMax' => $collector->getCount(), + 'items' => Repo::citation()->getSchemaMap()->summarizeMany($citations->values())->values(), + ], Response::HTTP_OK); + } + + + /** + * Add or edit a citation + */ + public function edit(Request $illuminateRequest): JsonResponse + { + $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_CITATION, $illuminateRequest->input()); + + $errors = Repo::citation()->validate(null, $params); + + if (!empty($errors)) { + return response()->json($errors, Response::HTTP_BAD_REQUEST); + } + + $citation = Repo::citation()->newDataObject($params); + $id = Repo::citation()->updateOrInsert($citation); + $citation = Repo::citation()->get($id); + + return response()->json( + Repo::citation()->getSchemaMap()->map($citation), Response::HTTP_OK + ); + } + + /** + * Get Publication Reference/Citation Form component + */ + protected function getCitationForm(Request $illuminateRequest): JsonResponse + { + $citation = Repo::citation()->get((int)$illuminateRequest->route('citationId')); + $publication = Repo::publication()->get($citation->getData('publicationId')); + + if (!$citation) { + return response()->json( + [ + 'error' => __('api.404.resourceNotFound') + ], + Response::HTTP_NOT_FOUND + ); + } + + $publicationApiUrl = $this->getCitationApiUrl( + $this->getRequest(), + (int)$illuminateRequest->route('citationId')); + + $citationForm = new PKPCitationEditForm($publicationApiUrl, (int)$illuminateRequest->route('citationId')); + + return response()->json($citationForm->getConfig(), Response::HTTP_OK); + } + + /** + * Get the url to the citation's API endpoint + */ + protected function getCitationApiUrl(PKPRequest $request, int $citationId): string + { + return $request + ->getDispatcher() + ->url( + $request, + Application::ROUTE_API, + $request->getContext()->getPath(), + 'citations/' . $citationId + ); + } +} diff --git a/api/v1/submissions/PKPSubmissionController.php b/api/v1/submissions/PKPSubmissionController.php index 77ed324b023..7fd8e4ba1eb 100644 --- a/api/v1/submissions/PKPSubmissionController.php +++ b/api/v1/submissions/PKPSubmissionController.php @@ -34,8 +34,8 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\LazyCollection; use PKP\affiliation\Affiliation; +use PKP\components\forms\citation\PKPCitationsForm; use PKP\components\forms\FormComponent; -use PKP\components\forms\publication\PKPCitationsForm; use PKP\components\forms\publication\PKPMetadataForm; use PKP\components\forms\publication\PKPPublicationIdentifiersForm; use PKP\components\forms\publication\PKPPublicationLicenseForm; diff --git a/classes/citation/Citation.php b/classes/citation/Citation.php index 1b1cf80db81..81de9998f72 100644 --- a/classes/citation/Citation.php +++ b/classes/citation/Citation.php @@ -1,14 +1,10 @@ setRawCitation($rawCitation); - } - - // - // Getters and Setters - // - - /** - * Replace URLs through HTML links, if the citation does not already contain HTML links - * - * @return string - */ - public function getCitationWithLinks() - { - $citation = $this->getRawCitation(); - if (stripos($citation, ']*/?#', - function ($matches) { - $trailingDot = in_array($char = substr($matches[0], -1), ['.', ',']); - $url = rtrim($matches[0], '.,'); - return "{$url}" . ($trailingDot ? $char : ''); - }, - $citation - ); - } - return $citation; - } +use PKP\core\DataObject; +class Citation extends DataObject +{ /** - * Get the rawCitation - * - * @return string + * Get the rawCitation. */ - public function getRawCitation() + public function getRawCitation(): string { return $this->getData('rawCitation'); } /** - * Set the rawCitation + * Set the rawCitation. */ - public function setRawCitation(?string $rawCitation) + public function setRawCitation(?string $rawCitation): void { - $rawCitation = $this->_cleanCitationString($rawCitation ?? ''); + $rawCitation = $this->cleanCitationString($rawCitation ?? ''); $this->setData('rawCitation', $rawCitation); } /** * Get the sequence number - * - * @return int */ - public function getSequence() + public function getSequence(): int { return $this->getData('seq'); } /** * Set the sequence number - * - * @param int $seq */ - public function setSequence($seq) + public function setSequence(int $seq): void { $this->setData('seq', $seq); } - // - // Private methods - // + /** + * Replace URLs through HTML links, if the citation does not already contain HTML links. + */ + public function getRawCitationWithLinks(): string + { + $rawCitationWithLinks = $this->getRawCitation(); + if (stripos($rawCitationWithLinks, ']*/?#', + function ($matches) { + $trailingDot = in_array($char = substr($matches[0], -1), ['.', ',']); + $url = rtrim($matches[0], '.,'); + return "{$url}" . ($trailingDot ? $char : ''); + }, + $rawCitationWithLinks + ); + } + return $rawCitationWithLinks; + } + /** * Take a citation string and clean/normalize it */ - public function _cleanCitationString(string $citationString) : string + public function cleanCitationString(?string $citationString = null): string|null { - // 1) Strip slashes and whitespace $citationString = trim(stripslashes($citationString)); - - // 2) Normalize whitespace - $citationString = preg_replace('/[\s]+/u', ' ', $citationString); - - return $citationString; + return preg_replace('/[\s]+/u', ' ', $citationString); } } diff --git a/classes/citation/CitationDAO.php b/classes/citation/CitationDAO.php deleted file mode 100644 index a534344296f..00000000000 --- a/classes/citation/CitationDAO.php +++ /dev/null @@ -1,266 +0,0 @@ -getSequence(); - if (!(is_numeric($seq) && $seq > 0)) { - // Find the latest sequence number - $result = $this->retrieve( - 'SELECT MAX(seq) AS lastseq FROM citations - WHERE publication_id = ?', - [(int)$citation->getData('publicationId')] - ); - $row = $result->current(); - $citation->setSequence($row ? $row->lastseq + 1 : 1); - } - - $this->update( - sprintf('INSERT INTO citations - (publication_id, raw_citation, seq) - VALUES - (?, ?, ?)'), - [ - (int) $citation->getData('publicationId'), - $citation->getRawCitation(), - (int) $seq - ] - ); - $citation->setId($this->getInsertId()); - $this->_updateObjectMetadata($citation); - return $citation->getId(); - } - - /** - * Retrieve a citation by id. - * - * @param int $citationId - * - * @return ?Citation - */ - public function getById($citationId) - { - $result = $this->retrieve( - 'SELECT * FROM citations WHERE citation_id = ?', - [$citationId] - ); - $row = $result->current(); - return $row ? $this->_fromRow((array) $row) : null; - } - - /** - * Import citations from a raw citation list of the particular publication. - * - * @param int $publicationId - * @param string $rawCitationList - * - * @hook Citation::importCitations::after [$publicationId, $existingCitations, $importedCitations] - */ - public function importCitations($publicationId, $rawCitationList) - { - assert(is_numeric($publicationId)); - $publicationId = (int) $publicationId; - - $existingCitations = $this->getByPublicationId($publicationId)->toAssociativeArray(); - - // Remove existing citations. - $this->deleteByPublicationId($publicationId); - - // Tokenize raw citations - $citationTokenizer = new CitationListTokenizerFilter(); - $citationStrings = $rawCitationList ? $citationTokenizer->execute($rawCitationList) : []; - - // Instantiate and persist citations - $importedCitations = []; - if (is_array($citationStrings)) { - foreach ($citationStrings as $seq => $citationString) { - if (!empty(trim($citationString))) { - $citation = new Citation($citationString); - // Set the publication - $citation->setData('publicationId', $publicationId); - // Set the counter - $citation->setSequence($seq + 1); - - $this->insertObject($citation); - - $importedCitations[] = $citation; - } - } - } - - Hook::run('Citation::importCitations::after', [$publicationId, $existingCitations, $importedCitations]); - } - - /** - * Retrieve an array of citations matching a particular publication id. - * - * @param int $publicationId - * @param ?\PKP\db\DBResultRange $rangeInfo - * - * @return DAOResultFactory containing matching Citations - */ - public function getByPublicationId($publicationId, $rangeInfo = null) - { - $result = $this->retrieveRange( - 'SELECT * - FROM citations - WHERE publication_id = ? - ORDER BY seq, citation_id', - [(int)$publicationId], - $rangeInfo - ); - return new DAOResultFactory($result, $this, '_fromRow', ['id']); - } - - /** - * Retrieve raw citations for the given publication. - */ - public function getRawCitationsByPublicationId(int $publicationId): Collection - { - return DB::table('citations') - ->select(['raw_citation']) - ->where('publication_id', '=', $publicationId) - ->orderBy('seq') - ->pluck('raw_citation'); - } - - /** - * Update an existing citation. - * - * @param Citation $citation - */ - public function updateObject($citation) - { - $returner = $this->update( - 'UPDATE citations - SET publication_id = ?, - raw_citation = ?, - seq = ? - WHERE citation_id = ?', - [ - (int) $citation->getData('publicationId'), - $citation->getRawCitation(), - (int) $citation->getSequence(), - (int) $citation->getId() - ] - ); - $this->_updateObjectMetadata($citation); - } - - /** - * Delete a citation. - * - * @param Citation $citation - * - * @return bool - */ - public function deleteObject($citation) - { - return $this->deleteById($citation->getId()); - } - - /** - * Delete a citation by id. - */ - public function deleteById(int $citationId): int - { - return DB::table('citations') - ->where('citation_id', '=', $citationId) - ->delete(); - } - - /** - * Delete all citations matching a particular publication id. - * - * @param int $publicationId - * - * @return bool - */ - public function deleteByPublicationId($publicationId) - { - $citations = $this->getByPublicationId($publicationId); - while ($citation = $citations->next()) { - $this->deleteById($citation->getId()); - } - return true; - } - - // - // Private helper methods - // - /** - * Construct a new citation object. - * - * @return Citation - */ - public function _newDataObject() - { - return new Citation(); - } - - /** - * Internal function to return a citation object from a - * row. - * - * @param array $row - * - * @return Citation - */ - public function _fromRow($row) - { - $citation = $this->_newDataObject(); - $citation->setId((int)$row['citation_id']); - $citation->setData('publicationId', (int)$row['publication_id']); - $citation->setRawCitation($row['raw_citation']); - $citation->setSequence((int)$row['seq']); - - $this->getDataObjectSettings('citation_settings', 'citation_id', $row['citation_id'], $citation); - - return $citation; - } - - /** - * Update the citation meta-data - * - * @param Citation $citation - */ - public function _updateObjectMetadata($citation) - { - $this->updateDataObjectSettings('citation_settings', $citation, ['citation_id' => $citation->getId()]); - } -} - -if (!PKP_STRICT_MODE) { - class_alias('\PKP\citation\CitationDAO', '\CitationDAO'); -} diff --git a/classes/citation/Collector.php b/classes/citation/Collector.php new file mode 100644 index 00000000000..567fab8bb5d --- /dev/null +++ b/classes/citation/Collector.php @@ -0,0 +1,132 @@ +dao = $dao; + } + + public function getCount(): int + { + return $this->dao->getCount($this); + } + + /** + * @copydoc DAO::getMany() + * + * @return LazyCollection + */ + public function getMany(): LazyCollection + { + return $this->dao->getMany($this); + } + + /** + * Filter by single publication + */ + public function filterByPublicationId(?int $publicationId): self + { + $this->publicationId = $publicationId; + return $this; + } + + /** + * Include orderBy columns to the collector query + */ + public function orderBy(?string $orderBy): self + { + $this->orderBy = $orderBy; + return $this; + } + + /** + * Limit the number of objects retrieved + */ + public function limit(?int $count): self + { + $this->count = $count; + return $this; + } + + /** + * Offset the number of objects retrieved, for example to + * retrieve the second page of contents + */ + public function offset(?int $offset): self + { + $this->offset = $offset; + return $this; + } + + /** + * @copydoc CollectorInterface::getQueryBuilder() + */ + public function getQueryBuilder(): Builder + { + $qb = DB::table($this->dao->table . ' as c') + ->select('c.*'); + + if (!is_null($this->count)) { + $qb->limit($this->count); + } + + if (!is_null($this->offset)) { + $qb->offset($this->offset); + } + + if (!is_null($this->publicationId)) { + $qb->where('c.publication_id', $this->publicationId); + } + + switch ($this->orderBy) { + case self::ORDERBY_SEQUENCE: + default: + $qb->orderBy('c.seq', 'asc'); + break; + } + + // Add app-specific query statements + Hook::call('Citation::Collector', [&$qb, $this]); + + return $qb; + } +} diff --git a/classes/citation/DAO.php b/classes/citation/DAO.php new file mode 100644 index 00000000000..2485b7ca520 --- /dev/null +++ b/classes/citation/DAO.php @@ -0,0 +1,139 @@ + + */ +class DAO extends EntityDAO +{ + use EntityWithParent; + + /** @copydoc EntityDAO::$schema */ + public $schema = PKPSchemaService::SCHEMA_CITATION; + + /** @copydoc EntityDAO::$table */ + public $table = 'citations'; + + /** @copydoc EntityDAO::$settingsTable */ + public $settingsTable = 'citation_settings'; + + /** @copydoc EntityDAO::$primaryKeyColumn */ + public $primaryKeyColumn = 'citation_id'; + + /** @copydoc EntityDAO::$primaryTableColumns */ + public $primaryTableColumns = [ + 'id' => 'citation_id', + 'publicationId' => 'publication_id', + 'rawCitation' => 'raw_citation', + 'seq' => 'seq' + ]; + + /** + * Get the parent object ID column name + */ + public function getParentColumn(): string + { + return 'publication_id'; + } + + /** + * Instantiate a new DataObject + */ + public function newDataObject(): Citation + { + return App::make(Citation::class); + } + + /** + * Get the number of Citation's matching the configured query + */ + public function getCount(Collector $query): int + { + return $query + ->getQueryBuilder() + ->getCountForPagination(); + } + + /** + * Get a collection of citations matching the configured query + * + * @return LazyCollection + */ + public function getMany(Collector $query): LazyCollection + { + $rows = $query + ->getQueryBuilder() + ->get(); + + return LazyCollection::make(function () use ($rows) { + foreach ($rows as $row) { + yield $row->citation_id => $this->fromRow($row); + } + }); + } + + /** @copydoc EntityDAO::insert() */ + public function insert(Citation $citation): int + { + return parent::_insert($citation); + } + + /** @copydoc EntityDAO::update() */ + public function update(Citation $citation): void + { + parent::_update($citation); + } + + /** @copydoc EntityDAO::delete() */ + public function delete(Citation $citation): void + { + parent::_delete($citation); + } + + /** + * Retrieve raw citations for the given publication. + */ + public function getRawCitationsByPublicationId(int $publicationId): Collection + { + return DB::table('citations') + ->select(['raw_citation']) + ->where('publication_id', '=', $publicationId) + ->orderBy('seq') + ->pluck('raw_citation'); + } + + /** + * Delete publication's citations. + */ + public function deleteByPublicationId(int $publicationId): void + { + DB::table($this->table) + ->where($this->getParentColumn(), '=', $publicationId) + ->delete(); + } +} diff --git a/classes/citation/Repository.php b/classes/citation/Repository.php new file mode 100644 index 00000000000..e62ba6ffb42 --- /dev/null +++ b/classes/citation/Repository.php @@ -0,0 +1,225 @@ + */ + protected PKPSchemaService $schemaService; + + public function __construct(DAO $dao, Request $request, PKPSchemaService $schemaService) + { + $this->dao = $dao; + $this->request = $request; + $this->schemaService = $schemaService; + } + + /** @copydoc DAO::newDataObject() */ + public function newDataObject(array $params = []): Citation + { + $object = $this->dao->newDataObject(); + if (!empty($params)) { + $object->setAllData($params); + } + return $object; + } + + /** @copydoc DAO::exists() */ + public function exists(int $id, ?int $publicationId = null): bool + { + return $this->dao->exists($id, $publicationId); + } + + /** @copydoc DAO::get() */ + public function get(int $id, ?int $publicationId = null): ?Citation + { + return $this->dao->get($id, $publicationId); + } + + /** @copydoc DAO::getCollector() */ + public function getCollector(): Collector + { + return App::make(Collector::class); + } + + /** + * Get an instance of the map class for mapping citations to their schema. + */ + public function getSchemaMap(): maps\Schema + { + return app('maps')->withExtensions($this->schemaMap); + } + + /** + * Validate properties for a citation + * + * Perform validation checks on data used to add or edit a citation. + * + * @param Citation|null $citation Citation being edited. Pass `null` if creating a new citation + * @param array $props A key/value array with the new data to validate + * + * @return array A key/value array with validation errors. Empty if no errors + * + * @hook Citation::validate [[&$errors, $citation, $props]] + */ + public function validate(?Citation $citation, array $props): array + { + $errors = []; + + Hook::call('Citation::validate', [&$errors, $citation, $props]); + + return $errors; + } + + /** @copydoc DAO::insert() */ + public function add(Citation $citation): int + { + $id = $this->dao->insert($citation); + Hook::call('Citation::add', [$citation]); + return $id; + } + + /** @copydoc DAO::update() */ + public function edit(Citation $citation, array $params): void + { + $newCitation = clone $citation; + $newCitation->setAllData(array_merge($newCitation->_data, $params)); + Hook::call('Citation::edit', [$newCitation, $citation, $params]); + $this->dao->update($newCitation); + } + + /** @copydoc DAO::delete() */ + public function delete(Citation $citation): void + { + Hook::call('Citation::delete::before', [$citation]); + $this->dao->delete($citation); + Hook::call('Citation::delete', [$citation]); + } + + /** + * Delete a collection of citations + */ + public function deleteMany(Collector $collector): void + { + foreach ($collector->getMany() as $citation) { + $this->delete($citation); + } + } + + /** + * Get all citations for a given publication. + * + * @return array + */ + public function getByPublicationId(int $publicationId): array + { + return $this->getCollector() + ->filterByPublicationId($publicationId) + ->orderBy(Collector::ORDERBY_SEQUENCE) + ->getMany() + ->all(); + } + + /** + * Get all raw citations for a given publication. + */ + public function getRawCitationsByPublicationId(int $publicationId): Collection + { + return $this->dao->getRawCitationsByPublicationId($publicationId); + } + + /** + * Delete a publication's citations. + */ + public function deleteByPublicationId(int $publicationId): void + { + $this->dao->deleteByPublicationId($publicationId); + } + + /** + * Import citations from a raw citation list of the particular publication. + * + * @hook Citation::importCitations::before [[$publicationId, $existingCitations, $rawCitations]] + * @hook Citation::importCitations::after [[$publicationId, $existingCitations, $importedCitations]] + */ + public function importCitations(int $publicationId, ?string $rawCitationList): void + { + $existingCitations = $this->getByPublicationId($publicationId); + + Hook::call('Citation::importCitations::before', [$publicationId, $existingCitations, $rawCitationList]); + + // Tokenize raw citations + $citationTokenizer = new CitationListTokenizerFilter(); + $citationStrings = $rawCitationList ? $citationTokenizer->execute($rawCitationList) : []; + + $existingRawCitations = collect($this->getByPublicationId($publicationId)) + ->map(fn (Citation $citation) => $citation->getData('rawCitation')) + ->all(); + + if ($existingRawCitations !== $citationStrings) { + $importedCitations = []; + $this->deleteByPublicationId($publicationId); + if (is_array($citationStrings) && !empty($citationStrings)) { + foreach ($citationStrings as $seq => $rawCitationString) { + if (!empty(trim($rawCitationString))) { + $citation = new Citation(); + $citation->setRawCitation($rawCitationString); + $citation->setData('isStructured', false); + $citation->setData('publicationId', $publicationId); + $citation->setData('lastModified', Core::getCurrentDate()); + + $citation->setSequence($seq + 1); + $this->dao->insert($citation); + $importedCitations[] = $citation; + } + } + } + + //todo: check if submission / publication is set to use structured citations + // Bus::chain([ + // new ExtractPids($publicationId), + // new RetrieveStructured($publicationId) + // ]) + // ->catch(function (Throwable $e) { + // error_log($e->getMessage()); + // }) + // ->dispatch() + // ->delay(now()->addSeconds(300)); + dispatch(new ExtractPids($publicationId))->delay(now()->addSeconds(60)); + dispatch(new RetrieveStructured($publicationId))->delay(now()->addSeconds(300)); + + Hook::call('Citation::importCitations::after', [$publicationId, $existingCitations, $importedCitations]); + } + } +} diff --git a/classes/citation/CitationListTokenizerFilter.php b/classes/citation/filter/CitationListTokenizerFilter.php similarity index 63% rename from classes/citation/CitationListTokenizerFilter.php rename to classes/citation/filter/CitationListTokenizerFilter.php index 0bb560fd401..a0607cedb2f 100644 --- a/classes/citation/CitationListTokenizerFilter.php +++ b/classes/citation/filter/CitationListTokenizerFilter.php @@ -1,29 +1,25 @@ setDisplayName('Split a reference list into separate citations'); @@ -31,17 +27,8 @@ public function __construct() parent::__construct('primitive::string', 'primitive::string[]'); } - // - // Implement template methods from Filter - // - /** - * @see Filter::process() - * - * @param string $input - * - * @return mixed array - */ - public function &process(&$input) + /** @copy Filter::process() */ + public function &process(&$input): array { // The default implementation assumes that raw citations are // separated with line endings. @@ -63,5 +50,5 @@ public function &process(&$input) } if (!PKP_STRICT_MODE) { - class_alias('\PKP\citation\CitationListTokenizerFilter', '\CitationListTokenizerFilter'); + class_alias('\PKP\citation\filter\CitationListTokenizerFilter', '\CitationListTokenizerFilter'); } diff --git a/classes/citation/job/externalServices/ExternalServicesHelper.php b/classes/citation/job/externalServices/ExternalServicesHelper.php new file mode 100644 index 00000000000..d72db29ef47 --- /dev/null +++ b/classes/citation/job/externalServices/ExternalServicesHelper.php @@ -0,0 +1,88 @@ + [ + 'User-Agent' => Application::get()->getName(), + 'Accept' => 'application/json' + ], + 'verify' => false + ] + ); + + try { + $response = $httpClient->request($method, $url, $options); + + if (!str_contains('200,201,202', (string)$response->getStatusCode())) { + return []; + } + + $result = json_decode($response->getBody(), true); + + if (empty($result) || json_last_error() !== JSON_ERROR_NONE) return []; + + return $result; + + } catch (GuzzleException $e) { + error_log(__METHOD__ . ' ' . $e->getMessage()); + } + + return []; + } +} diff --git a/classes/citation/job/externalServices/crossref/Inbound.php b/classes/citation/job/externalServices/crossref/Inbound.php new file mode 100644 index 00000000000..e4c94dd1a19 --- /dev/null +++ b/classes/citation/job/externalServices/crossref/Inbound.php @@ -0,0 +1,128 @@ +publicationId = $publicationId; + } + + /** + * Executes this external service + */ + public function execute(): bool + { + $citations = Repo::citation()->getByPublicationId($this->publicationId); + + if (empty($citations)) { + return true; + } + + foreach ($citations as $citation) { + if (!empty($citation->getData('doi')) || empty($citation->getData('rawCitation'))) { + continue; + } + + Repo::citation()->edit($this->getWork($citation), []); + } + + return true; + } + + /** + * Get citation (work) from external service + */ + public function getWork(Citation $citation): Citation + { + $response = ExternalServicesHelper::apiRequest( + 'GET', + $this->url . '/works/?query.bibliographic=' . $citation->getData('rawCitation'), + [] + ); + + if (empty($response)) { + return $citation; + } + + if (!$this->isMatched($citation->getData('rawCitation'), $response)) { + return $citation; + } + + foreach (Mapping::getWork() as $key => $value) { + switch ($key) { + case 'doi': + $citation->setData( + $key, + Doi::addPrefix(ExternalServicesHelper::getValueFromArrayPath($response, $value)) + ); + break; + default: + if (is_array($value)) { + $citation->setData($key, ExternalServicesHelper::getValueFromArrayPath($response, $value)); + } else { + $citation->setData($key, $response[$value]); + } + break; + } + } + + return $citation; + } + + /** + * Check if there is a match. + * - Is score higher than given threshold + * - Is title found in raw citation + * - Are family names of authors in raw citation + */ + private function isMatched(string $rawCitation, array $response): bool + { + $score = (float)ExternalServicesHelper::getValueFromArrayPath($response, ['message', 'items', 0, 'score']); + if (empty($score) || $score < $this->scoreTreshold) { + return false; + } + + $title = ExternalServicesHelper::getValueFromArrayPath($response, ['message', 'items', 0, 'title', 0]); + if(empty($title) || !str_contains(strtolower($rawCitation), strtolower($title))) { + return false; + } + + $authors = ExternalServicesHelper::getValueFromArrayPath($response, ['message', 'items', 0, 'author']); + foreach($authors as $author) { + if(empty($author['family']) || !str_contains(strtolower($rawCitation), strtolower($author['family']))) { + return false; + } + } + + return true; + } +} diff --git a/classes/citation/job/externalServices/crossref/Mapping.php b/classes/citation/job/externalServices/crossref/Mapping.php new file mode 100644 index 00000000000..6329aaaa0a7 --- /dev/null +++ b/classes/citation/job/externalServices/crossref/Mapping.php @@ -0,0 +1,29 @@ + ['message', 'items', 0, 'DOI'] + ]; + } +} diff --git a/classes/citation/job/externalServices/openAlex/Inbound.php b/classes/citation/job/externalServices/openAlex/Inbound.php new file mode 100644 index 00000000000..c4f64fd218b --- /dev/null +++ b/classes/citation/job/externalServices/openAlex/Inbound.php @@ -0,0 +1,123 @@ +publicationId = $publicationId; + } + + /** + * Executes this external service + */ + public function execute(): bool + { + $citations = Repo::citation()->getByPublicationId($this->publicationId); + + if (empty($citations)) { + return true; + } + + foreach ($citations as $citation) { + if (empty($citation->getData('doi')) || !empty($citation->getData('openAlex'))) { + continue; + } + + Repo::citation()->edit($this->getWork($citation), []); + } + + return true; + } + + /** + * Get citation (work) from external service + */ + public function getWork(Citation $citation): Citation + { + $response = ExternalServicesHelper::apiRequest( + 'GET', $this->url . + '/works/doi:' . $citation->getData('doi'), + [] + ); + + if (empty($response)) { + return $citation; + } + + foreach (Mapping::getWork() as $key => $value) { + switch ($key) { + case 'authors': + $authors = []; + foreach (ExternalServicesHelper::getValueFromArrayPath($response, $value) as $index => $author) { + $authors[] = $this->getAuthor($author); + } + $citation->setData('authors', $authors); + break; + default: + if (is_array($value)) { + $citation->setData($key, ExternalServicesHelper::getValueFromArrayPath($response, $value)); + } else { + $citation->setData($key, $response[$value]); + } + break; + } + } + + return $citation; + } + + /** + * Convert to Author with mappings + */ + private function getAuthor(array $authorShip): array + { + $author = []; + $mapping = Mapping::getAuthor(); + + foreach ($mapping as $key => $val) { + if (is_array($val)) { + $author[$key] = ExternalServicesHelper::getValueFromArrayPath($authorShip, $val); + } else { + $author[$key] = $authorShip[$key]; + } + } + + $author['displayName'] = trim(str_replace('null', '', $author['displayName'])); + if (empty($author['displayName'])) { + $author['displayName'] = $authorShip['raw_author_name']; + } + + $authorDisplayNameParts = explode(' ', trim($author['displayName'])); + if (count($authorDisplayNameParts) > 1) { + $author['familyName'] = array_pop($authorDisplayNameParts); + $author['givenName'] = implode(' ', $authorDisplayNameParts); + } + + return $author; + } +} diff --git a/classes/citation/job/externalServices/openAlex/Mapping.php b/classes/citation/job/externalServices/openAlex/Mapping.php new file mode 100644 index 00000000000..488ccc885d3 --- /dev/null +++ b/classes/citation/job/externalServices/openAlex/Mapping.php @@ -0,0 +1,67 @@ + 'title', + 'date' => 'publication_date', + 'type' => 'type_crossref', + 'volume' => ['biblio', 'volume'], + 'issue' => ['biblio', 'issue'], + 'firstPage' => ['biblio', 'first_page'], + 'lastPage' => ['biblio', 'last_page'], + 'sourceName' => ['locations', 0, 'source', 'display_name'], + 'sourceIssn' => ['locations', 0, 'source', 'issn_l'], + 'sourceHost' => ['locations', 0, 'source', 'host_organization_name'], + 'sourceType' => ['locations', 0, 'source', 'type'], + 'authors' => ['authorships'], // see getAuthor() + 'wikidata' => ['ids', 'wikidata'], + 'openAlex' => 'id', + ]; + } + + /** + * Authors are people who create works. + * + * @see https://docs.openalex.org/api-entities/authors + */ + public static function getAuthor(): array + { + return [ + 'displayName' => ['author', 'display_name'], + 'givenName' => ['author', 'display_name'], + 'familyName' => ['author', 'display_name'], + 'orcid' => ['author', 'orcid'], + 'openAlex' => ['author', 'id'] + ]; + } +} diff --git a/classes/citation/job/externalServices/orcid/Inbound.php b/classes/citation/job/externalServices/orcid/Inbound.php new file mode 100644 index 00000000000..50118adbdaf --- /dev/null +++ b/classes/citation/job/externalServices/orcid/Inbound.php @@ -0,0 +1,95 @@ +publicationId = $publicationId; + } + + /** + * Executes this external service + */ + public function execute(): bool + { + $citations = Repo::citation()->getByPublicationId($this->publicationId); + + if (empty($citations)) { + return true; + } + + foreach ($citations as $citation) { + $authors = []; + if (empty($citation->getData('authors'))) { + continue; + } + + foreach ($citation->getData('authors') as $author) { + if (empty($author['orcid'])) { + continue; + } + + $authors[] = $this->getAuthor($author); + } + + $citation->setData('authors', $authors); + + Repo::citation()->edit($citation, []); + } + + return true; + } + + /** + * Convert to Author with mappings + */ + public function getAuthor(array $author): array + { + $response = ExternalServicesHelper::apiRequest( + 'GET', + $this->url . '/' . Orcid::removePrefix($author['orcid']), + [] + ); + + if (empty($response)) { + return $author; + } + + foreach (Mapping::getAuthor() as $key => $value) { + if (is_array($value)) { + $author[$key] = ExternalServicesHelper::getValueFromArrayPath($response, $value); + } else { + $author[$key] = $response[$value]; + } + + if (str_contains(strtolower($author[$key]), 'deactivated')) + $author[$key] = ''; + } + return $author; + } +} diff --git a/classes/citation/job/externalServices/orcid/Mapping.php b/classes/citation/job/externalServices/orcid/Mapping.php new file mode 100644 index 00000000000..96f82734cd4 --- /dev/null +++ b/classes/citation/job/externalServices/orcid/Mapping.php @@ -0,0 +1,36 @@ + orcid, ... ] + */ + public static function getAuthor(): array + { + return [ + 'givenName' => ['person', 'name', 'given-names', 'value'], + 'familyName' => ['person', 'name', 'family-name', 'value'] + ]; + } +} diff --git a/classes/citation/job/pid/Arxiv.php b/classes/citation/job/pid/Arxiv.php new file mode 100644 index 00000000000..51d9852e343 --- /dev/null +++ b/classes/citation/job/pid/Arxiv.php @@ -0,0 +1,49 @@ +]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s'; + + /** @copydoc AbstractPid::prefix */ + public const prefix = 'https://arxiv.org/abs'; + + /** @copydoc AbstractPid::prefixInCorrect */ + public const prefixInCorrect = [ + 'arxiv:' + ]; + + /** @copydoc AbstractPid::extractFromString() */ + public static function extractFromString(?string $string): string + { + $string = parent::extractFromString($string); + + $class = get_called_class(); + + // check if prefix found in extracted string + $prefixes = $class::prefixInCorrect; + $prefixes[] = $class::prefix; + + foreach($prefixes as $prefix){ + if(str_contains($string, $prefix)) return $string; + } + + return ''; + } +} diff --git a/classes/citation/job/pid/BasePid.php b/classes/citation/job/pid/BasePid.php new file mode 100644 index 00000000000..b3b8ce2b142 --- /dev/null +++ b/classes/citation/job/pid/BasePid.php @@ -0,0 +1,148 @@ +]*/[^\s"<>]+)'; + + /** @copydoc AbstractPid::prefix */ + public const prefix = 'https://doi.org'; + + /** @copydoc AbstractPid::prefixInCorrect */ + public const prefixInCorrect = [ + 'doi:', + 'dx.doi.org' + ]; +} diff --git a/classes/citation/job/pid/Handle.php b/classes/citation/job/pid/Handle.php new file mode 100644 index 00000000000..365480c63ce --- /dev/null +++ b/classes/citation/job/pid/Handle.php @@ -0,0 +1,49 @@ +]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s'; + + /** @copydoc AbstractPid::prefix */ + public const prefix = 'https://hdl.handle.net'; + + /** @copydoc AbstractPid::prefixInCorrect */ + public const prefixInCorrect = [ + 'handle:' + ]; + + /** @copydoc AbstractPid::extractFromString() */ + public static function extractFromString(?string $string): string + { + $string = parent::extractFromString($string); + + $class = get_called_class(); + + // check if prefix found in extracted string + $prefixes = $class::prefixInCorrect; + $prefixes[] = $class::prefix; + + foreach($prefixes as $prefix){ + if(str_contains($string, $prefix)) return $string; + } + + return ''; + } +} diff --git a/classes/citation/job/pid/OpenAlex.php b/classes/citation/job/pid/OpenAlex.php new file mode 100644 index 00000000000..35ceefc5f81 --- /dev/null +++ b/classes/citation/job/pid/OpenAlex.php @@ -0,0 +1,30 @@ +]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s'; +} diff --git a/classes/citation/job/pid/Urn.php b/classes/citation/job/pid/Urn.php new file mode 100644 index 00000000000..bd9c9cf8d72 --- /dev/null +++ b/classes/citation/job/pid/Urn.php @@ -0,0 +1,23 @@ +mapByProperties($this->getProps(), $item); + } + + /** + * Summarize a citation + * + * Includes properties with the apiSummary flag in the citation schema. + */ + public function summarize(Citation $item): array + { + return $this->mapByProperties($this->getSummaryProps(), $item); + } + + /** + * Map a collection of Citations + * + * @see self::map + */ + public function mapMany(Enumerable $collection): Enumerable + { + $this->collection = $collection; + return $collection->map(function ($item) { + return $this->map($item); + }); + } + + /** + * Summarize a collection of Citations + * + * @see self::summarize + */ + public function summarizeMany(Enumerable $collection): Enumerable + { + $this->collection = $collection; + return $collection->map(function ($item) { + return $this->summarize($item); + }); + } + + /** + * Map schema properties of a Citation to an assoc array + */ + protected function mapByProperties(array $props, Citation $item): array + { + $output = []; + foreach ($props as $prop) { + switch ($prop) { + default: + $output[$prop] = $item->getData($prop); + break; + } + } + $output = $this->schemaService->addMissingMultilingualValues($this->schema, $output, $this->context->getSupportedFormLocales()); + ksort($output); + return $this->withExtensions($output, $item); + } +} diff --git a/classes/components/forms/citation/FieldCitationAuthors.php b/classes/components/forms/citation/FieldCitationAuthors.php new file mode 100644 index 00000000000..db749e7d4ca --- /dev/null +++ b/classes/components/forms/citation/FieldCitationAuthors.php @@ -0,0 +1,40 @@ +value ?? $this->default ?? null; + + return $config; + } +} diff --git a/classes/components/forms/citation/FieldCitations.php b/classes/components/forms/citation/FieldCitations.php new file mode 100644 index 00000000000..cef982333c3 --- /dev/null +++ b/classes/components/forms/citation/FieldCitations.php @@ -0,0 +1,40 @@ +value ?? $this->default ?? null; + + return $config; + } +} diff --git a/classes/components/forms/citation/PKPCitationEditForm.php b/classes/components/forms/citation/PKPCitationEditForm.php new file mode 100644 index 00000000000..616bfe0eb4a --- /dev/null +++ b/classes/components/forms/citation/PKPCitationEditForm.php @@ -0,0 +1,126 @@ +action = $action; + $this->isRequired = $isRequired; + + // Article Information + foreach (['doi', 'url', 'urn', 'arxiv', 'handle'] as $key) { + $this->addField(new FieldText($key, [ + 'label' => __('submission.citations.structured.label.' . $key), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + } + + $this->addField(new FieldText('title', [ + 'label' => __('submission.citations.structured.label.title'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + + // Author Information + $this->addField(new FieldCitationAuthors('authors', [ + 'label' => __('submission.citations.structured.label.authors'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + + // Journal Information + $this->addField(new FieldText('sourceName', [ + 'label' => __('submission.citations.structured.label.sourceName'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('sourceIssn', [ + 'label' => __('submission.citations.structured.label.sourceIssn'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('sourceHost', [ + 'label' => __('submission.citations.structured.label.sourceHost'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('sourceType', [ + 'label' => __('submission.citations.structured.label.sourceType'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('date', [ + 'label' => __('submission.citations.structured.label.date'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('type', [ + 'label' => __('submission.citations.structured.label.type'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('volume', [ + 'label' => __('submission.citations.structured.label.volume'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('issue', [ + 'label' => __('submission.citations.structured.label.issue'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('firstPage', [ + 'label' => __('submission.citations.structured.label.firstPage'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + $this->addField(new FieldText('lastPage', [ + 'label' => __('submission.citations.structured.label.lastPage'), + 'description' => '', + 'value' => null, + 'isRequired' => $isRequired + ])); + } +} diff --git a/classes/components/forms/citation/PKPCitationsForm.php b/classes/components/forms/citation/PKPCitationsForm.php new file mode 100644 index 00000000000..53368934246 --- /dev/null +++ b/classes/components/forms/citation/PKPCitationsForm.php @@ -0,0 +1,76 @@ +action = $action; + $this->isRequired = $isRequired; + + $this->addField(new FieldTextarea('citationsRaw', [ + 'label' => __('submission.citations'), + 'description' => __('submission.citations.description'), + 'value' => $publication->getData('citationsRaw'), + 'isRequired' => $isRequired + ])); + + $useStructuredCitations = $publication->getData('useStructuredCitations'); + $citations = Repo::citation()->getByPublicationId($publication->getId()); + $citations = array_map(function ($citation) { + return Repo::citation()->getSchemaMap()->map($citation); + }, $citations); + + $this->addField(new FieldOptions('useStructuredCitations', [ + 'label' => __('submission.citations.structured'), + 'description' => '', + 'type' => 'checkbox', + 'value' => $useStructuredCitations, + 'options' => [ + [ + 'value' => $useStructuredCitations, + 'label' => __('submission.citations.structured.useStructuredReferences'), + ] + ], + 'isRequired' => false + ])); + + $this->addField(new FieldCitations('citations', [ + 'label' => '', + 'description' => __('submission.citations.structured.description'), + 'value' => $citations, + 'isRequired' => $isRequired + ])); + } +} diff --git a/classes/components/forms/publication/PKPCitationsForm.php b/classes/components/forms/publication/PKPCitationsForm.php deleted file mode 100644 index 747b7837f59..00000000000 --- a/classes/components/forms/publication/PKPCitationsForm.php +++ /dev/null @@ -1,46 +0,0 @@ -action = $action; - $this->isRequired = $isRequired; - - $this->addField(new FieldTextarea('citationsRaw', [ - 'label' => __('submission.citations'), - 'description' => __('submission.citations.description'), - 'value' => $publication->getData('citationsRaw'), - 'isRequired' => $isRequired - ])); - } -} diff --git a/classes/core/PKPApplication.php b/classes/core/PKPApplication.php index a90356ecbc5..faf7502d72e 100644 --- a/classes/core/PKPApplication.php +++ b/classes/core/PKPApplication.php @@ -3,8 +3,8 @@ /** * @file classes/core/PKPApplication.php * - * Copyright (c) 2014-2024 Simon Fraser University - * Copyright (c) 2000-2024 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class PKPApplication @@ -173,6 +173,7 @@ class_alias('\PKP\payment\QueuedPayment', '\QueuedPayment'); // QueuedPayment in } // Ensure that nobody registers for hooks that are no longer supported + Hook::addUnsupportedHooks('CitationDAO::afterImportCitations'); // pkp/pkp-lib#10692 Removed with introduction of structured citations Hook::addUnsupportedHooks('API::_submissions::params', 'Template::Workflow::Publication', 'Template::Workflow', 'Workflow::Recommendations'); // pkp/pkp-lib#10766 Removed with new submission lists for 3.5.0 Hook::addUnsupportedHooks('APIHandler::endpoints'); // pkp/pkp-lib#9434 Unavailable since stable-3_4_0; remove for 3.6.0 development branch Hook::addUnsupportedHooks('Mail::send', 'EditorAction::modifyDecisionOptions', 'EditorAction::recordDecision', 'Announcement::getProperties', 'Author::getProperties::values', 'EmailTemplate::getProperties', 'Galley::getProperties::values', 'Issue::getProperties::fullProperties', 'Issue::getProperties::summaryProperties', 'Issue::getProperties::values', 'Publication::getProperties', 'Section::getProperties::fullProperties', 'Section::getProperties::summaryProperties', 'Section::getProperties::values', 'Submission::getProperties::values', 'SubmissionFile::getProperties', 'User::getProperties::fullProperties', 'User::getProperties::reviewerSummaryProperties', 'User::getProperties::summaryProperties', 'User::getProperties::values', 'Announcement::getMany::queryBuilder', 'Announcement::getMany::queryObject', 'Author::getMany::queryBuilder', 'Author::getMany::queryObject', 'EmailTemplate::getMany::queryBuilder', 'EmailTemplate::getMany::queryObject::custom', 'EmailTemplate::getMany::queryObject::default', 'Galley::getMany::queryBuilder', 'Issue::getMany::queryBuilder', 'Publication::getMany::queryBuilder', 'Publication::getMany::queryObject', 'Stats::getOrderedObjects::queryBuilder', 'Stats::getRecords::queryBuilder', 'Stats::queryBuilder', 'Stats::queryObject', 'Submission::getMany::queryBuilder', 'Submission::getMany::queryObject', 'SubmissionFile::getMany::queryBuilder', 'SubmissionFile::getMany::queryObject', 'User::getMany::queryBuilder', 'User::getMany::queryObject', 'User::getReviewers::queryBuilder', 'CategoryDAO::_fromRow', 'IssueDAO::_fromRow', 'IssueDAO::_returnIssueFromRow', 'SectionDAO::_fromRow', 'UserDAO::_returnUserFromRow', 'UserDAO::_returnUserFromRowWithData', 'UserDAO::_returnUserFromRowWithReviewerStats', 'UserGroupDAO::_returnFromRow', 'ReviewerSubmissionDAO::_fromRow', 'API::stats::publication::abstract::params', 'API::stats::publication::galley::params', 'API::stats::publications::abstract::params', 'API::stats::publications::galley::params', 'PKPLocale::installLocale', 'PKPLocale::registerLocaleFile', 'PKPLocale::registerLocaleFile::isValidLocaleFile', 'PKPLocale::translate', 'API::submissions::files::params', 'ArticleGalleyDAO::getLocalizedGalleysByArticle', 'PluginGridHandler::plugin', 'PluginGridHandler::plugin', 'SubmissionFile::assignedFileStages', 'SubmissionHandler::saveSubmit'); // From the 3.4.0 Release Notebook; remove for 3.6.0 development branch @@ -522,7 +523,6 @@ public function getDAOMap(): array { return [ 'AnnouncementTypeDAO' => 'PKP\announcement\AnnouncementTypeDAO', - 'CitationDAO' => 'PKP\citation\CitationDAO', 'DataObjectTombstoneDAO' => 'PKP\tombstone\DataObjectTombstoneDAO', 'DataObjectTombstoneSettingsDAO' => 'PKP\tombstone\DataObjectTombstoneSettingsDAO', 'FilterDAO' => 'PKP\filter\FilterDAO', diff --git a/classes/facades/Repo.php b/classes/facades/Repo.php index 797b13d8433..5ed1c1c3219 100644 --- a/classes/facades/Repo.php +++ b/classes/facades/Repo.php @@ -28,6 +28,7 @@ use PKP\announcement\Repository as AnnouncementRepository; use PKP\author\Repository as AuthorRepository; use PKP\category\Repository as CategoryRepository; +use PKP\citation\Repository as CitationRepository; use PKP\controlledVocab\Repository as ControlledVocabRepository; use PKP\decision\Repository as DecisionRepository; use PKP\emailTemplate\Repository as EmailTemplateRepository; @@ -65,6 +66,11 @@ public static function author(): AuthorRepository return app(AuthorRepository::class); } + public static function citation(): CitationRepository + { + return app(CitationRepository::class); + } + public static function decision(): DecisionRepository { return app()->make(DecisionRepository::class); diff --git a/classes/publication/DAO.php b/classes/publication/DAO.php index 61c25c5ad12..630c0940a93 100644 --- a/classes/publication/DAO.php +++ b/classes/publication/DAO.php @@ -3,8 +3,8 @@ /** * @file classes/publication/DAO.php * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2000-2021 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class DAO @@ -21,11 +21,9 @@ use Illuminate\Support\Enumerable; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; -use PKP\citation\CitationDAO; use PKP\controlledVocab\ControlledVocab; use PKP\core\EntityDAO; use PKP\core\traits\EntityWithParent; -use PKP\db\DAORegistry; use PKP\services\PKPSchemaService; /** @@ -49,19 +47,6 @@ class DAO extends EntityDAO /** @copydoc EntityDAO::$primaryKeyColumn */ public $primaryKeyColumn = 'publication_id'; - /** @var CitationDAO */ - public $citationDao; - - /** - * Constructor - */ - public function __construct(CitationDAO $citationDao, PKPSchemaService $schemaService) - { - parent::__construct($schemaService); - - $this->citationDao = $citationDao; - } - /** * Get the parent object ID column name */ @@ -176,11 +161,10 @@ public function fromRow(object $row): Publication ->value('locale'); $publication->setData('locale', $locale); - $citationDao = DAORegistry::getDAO('CitationDAO'); /** @var CitationDAO $citationDao */ - $citations = $citationDao->getByPublicationId($publication->getId())->toArray(); - $citationsRaw = $citationDao->getRawCitationsByPublicationId($publication->getId())->implode(PHP_EOL); - $publication->setData('citations', $citations); - $publication->setData('citationsRaw', $citationsRaw); + $publication->setData('citations', + Repo::citation()->getByPublicationId($publication->getId())); + $publication->setData('citationsRaw', + Repo::citation()->getRawCitationsByPublicationId($publication->getId())->implode(PHP_EOL)); $this->setAuthors($publication); $this->setCategories($publication); @@ -201,10 +185,10 @@ public function insert(Publication $publication): int $this->saveControlledVocab($vocabs, $id); $this->saveCategories($publication); - // Parse the citations - if ($publication->getData('citationsRaw')) { - $this->saveCitations($publication); - } + Repo::citation()->importCitations( + $publication->getId(), + $publication->getData('citationsRaw') + ); return $id; } @@ -221,8 +205,11 @@ public function update(Publication $publication, ?Publication $oldPublication = $this->saveControlledVocab($vocabs, $publication->getId()); $this->saveCategories($publication); - if ($oldPublication && $oldPublication->getData('citationsRaw') != $publication->getData('citationsRaw')) { - $this->saveCitations($publication); + if ($oldPublication) { + Repo::citation()->importCitations( + $publication->getId(), + $publication->getData('citationsRaw') + ); } } @@ -244,7 +231,7 @@ public function deleteById(int $publicationId): int $this->deleteAuthors($publicationId); $this->deleteCategories($publicationId); $this->deleteControlledVocab($publicationId); - $this->deleteCitations($publicationId); + Repo::citation()->deleteByPublicationId($publicationId); return $affectedRows; } @@ -472,22 +459,6 @@ protected function deleteCategories(int $publicationId): void PublicationCategory::where('publication_id', $publicationId)->delete(); } - /** - * Save the citations - */ - protected function saveCitations(Publication $publication) - { - $this->citationDao->importCitations($publication->getId(), $publication->getData('citationsRaw')); - } - - /** - * Delete the citations - */ - protected function deleteCitations(int $publicationId) - { - $this->citationDao->deleteByPublicationId($publicationId); - } - /** * Set the DOI object * diff --git a/classes/publication/Repository.php b/classes/publication/Repository.php index d693e476a8c..4e345119b31 100644 --- a/classes/publication/Repository.php +++ b/classes/publication/Repository.php @@ -1,9 +1,10 @@ getData('citationsRaw'))) { - $citationDao = DAORegistry::getDAO('CitationDAO'); /** @var \PKP\citation\CitationDAO $citationDao */ - $citationDao->importCitations($newPublication->getId(), $newPublication->getData('citationsRaw')); - } + Repo::citation()->importCitations( + $newPublication->getId(), + $newpublication->getData('citationsRaw') + ); $genreDao = DAORegistry::getDAO('GenreDAO'); /** @var \PKP\submission\GenreDAO $genreDao */ $genres = $genreDao->getEnabledByContextId($context->getId()); diff --git a/classes/publication/maps/Schema.php b/classes/publication/maps/Schema.php index 726ebac09b0..f73ed522b3e 100644 --- a/classes/publication/maps/Schema.php +++ b/classes/publication/maps/Schema.php @@ -3,8 +3,8 @@ /** * @file classes/publication/maps/Schema.php * - * Copyright (c) 2014-2020 Simon Fraser University - * Copyright (c) 2000-2020 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class Schema @@ -19,9 +19,8 @@ use APP\publication\Publication; use APP\submission\Submission; use Illuminate\Support\Enumerable; -use PKP\citation\CitationDAO; +use PKP\citation\Citation; use PKP\context\Context; -use PKP\db\DAORegistry; use PKP\services\PKPSchemaService; use PKP\submission\Genre; @@ -108,8 +107,6 @@ protected function mapByProperties(array $props, Publication $publication, bool $output = []; - $citationDao = DAORegistry::getDAO('CitationDAO'); /** @var CitationDAO $citationDao */ - $rawCitationList = $citationDao->getRawCitationsByPublicationId($publication->getId()); foreach ($props as $prop) { switch ($prop) { case '_href': @@ -138,10 +135,14 @@ protected function mapByProperties(array $props, Publication $publication, bool $output[$prop] = $publication->getData('categoryIds'); break; case 'citations': - $output[$prop] = $rawCitationList->toArray(); + $data = []; + foreach ($publication->getData('citations') as $citation) { + $data[] = Repo::citation()->getSchemaMap()->map($citation); + } + $output[$prop] = $data; break; case 'citationsRaw': - $output[$prop] = $rawCitationList->implode(PHP_EOL); + $output[$prop] = Repo::citation()->getRawCitationsByPublicationId($publication->getId())->implode(PHP_EOL); break; case 'doiObject': if ($publication->getData('doiObject')) { diff --git a/classes/services/PKPSchemaService.php b/classes/services/PKPSchemaService.php index 5b40d103dc4..9c2d6d50797 100644 --- a/classes/services/PKPSchemaService.php +++ b/classes/services/PKPSchemaService.php @@ -33,6 +33,7 @@ class PKPSchemaService public const SCHEMA_ANNOUNCEMENT = 'announcement'; public const SCHEMA_AUTHOR = 'author'; public const SCHEMA_CATEGORY = 'category'; + public const SCHEMA_CITATION = 'citation'; public const SCHEMA_CONTEXT = 'context'; public const SCHEMA_DOI = 'doi'; public const SCHEMA_DECISION = 'decision'; diff --git a/jobs/citation/ExtractPids.php b/jobs/citation/ExtractPids.php new file mode 100644 index 00000000000..945fb91a6cf --- /dev/null +++ b/jobs/citation/ExtractPids.php @@ -0,0 +1,96 @@ +publicationId = $publicationId; + } + + public function handle(): void + { + $publication = Repo::publication()->get($this->publicationId); + + if (!$this->publicationId || !$publication) { + throw new JobException(JobException::INVALID_PAYLOAD); + } + + $citations = Repo::citation()->getByPublicationId($this->publicationId); + + if (empty($citations)) { + return; + } + + foreach ($citations as $citation) { + Repo::citation()->edit($this->extractPIDs($citation), []); + } + } + + /** + * Extract PIDs + */ + private function extractPIDs(Citation $citation): Citation + { + $rowRaw = $citation->cleanCitationString($citation->getRawCitation()); + + // extract doi + $doi = Doi::extractFromString($rowRaw); + if (!empty($doi)) { + $citation->setData('doi', Doi::addPrefix($doi)); + } + + // remove doi from raw + $rowRaw = str_replace(Doi::addPrefix($doi), '', Doi::normalize($rowRaw)); + + // parse url (after parsing doi) + $url = Url::extractFromString($rowRaw); + $handle = Handle::extractFromString($rowRaw); + $arxiv = Arxiv::extractFromString($rowRaw); + + if (!empty($handle)) { + $citation->setData('handle', $handle); + } else if (!empty($arxiv)) { + $citation->setData('arxiv', $arxiv); + } else if (!empty($url)) { + $citation->setData('url', $url); + } + + // urn + $urn = Urn::extractFromString($rowRaw); + if (!empty($urn)) { + $citation->setData('urn', Urn::extractFromString($rowRaw)); + } + + return $citation; + } +} diff --git a/jobs/citation/RetrieveStructured.php b/jobs/citation/RetrieveStructured.php new file mode 100644 index 00000000000..9ac270e6268 --- /dev/null +++ b/jobs/citation/RetrieveStructured.php @@ -0,0 +1,54 @@ +publicationId = $publicationId; + } + + public function handle(): void + { + $publication = Repo::publication()->get($this->publicationId); + + if (!$this->publicationId || !$publication) { + throw new JobException(JobException::INVALID_PAYLOAD); + } + + $crossref = new CrossrefInbound($this->publicationId); + $crossref->execute(); + + $openAlex = new OpenAlexInbound($this->publicationId); + $openAlex->execute(); + + $orcid = new OrcidInbound($this->publicationId); + $orcid->execute(); + } +} diff --git a/locale/en/api.po b/locale/en/api.po index 5eae48a4f11..62b65108b7e 100644 --- a/locale/en/api.po +++ b/locale/en/api.po @@ -361,3 +361,6 @@ msgstr "The ror you requested was not found." msgid "api.409.resourceActionConflict" msgstr "Unable to complete the intended action on resource." + +msgid "api.citations.404.citationNotFound" +msgstr "The citation you requested was not found." diff --git a/locale/en/submission.po b/locale/en/submission.po index 00c99c072f6..9e84c05dca6 100644 --- a/locale/en/submission.po +++ b/locale/en/submission.po @@ -468,11 +468,96 @@ msgstr "Change to" msgid "submission.changeFile" msgstr "Change File" +msgid "submission.citations.structured" +msgstr "Structured References" + +msgid "submission.citations.structured.description" +msgstr "" +"This section helps you structure your references. " +"Clicking \"Enable Metadata Lookup\" will allow the system to process your references and retrieve DOIs and other metadata from external sources. " +"This may take some time, but you can continue working on your submission and return to this page later to view the updated structured citations. " + +msgid "submission.citations.structured.descriptionTable" +msgstr "The above references have been organised here in a structured format." + +msgid "submission.citations.structured.useStructuredReferences" +msgstr "Enable Metadata Lookup" + msgid "submission.citations" msgstr "References" msgid "submission.citations.description" -msgstr "Enter each reference on a new line so that they can be extracted and recorded separately." +msgstr "" +"Enter each reference on a new line so that they can be individually processed. " +"You can add one or multiple references at a time. " +"Click \"Add\" button to process and move them into the table below. " +"To edit existing entries, please use the \"Edit\" option in the table to modify individual entries." + +msgid "submission.citations.structured.deleteAll" +msgstr "Delete All Structured References" + +msgid "submission.citations.structured.collapseAll" +msgstr "Collapse All" + +msgid "submission.citations.structured.expandAll" +msgstr "Expand All" + +msgid "submission.citations.structured.search.placeholder" +msgstr "Search references here" + +msgid "submission.citations.structured.header.article" +msgstr "Article Information" + +msgid "submission.citations.structured.header.authors" +msgstr "Author Information" + +msgid "submission.citations.structured.header.journal" +msgstr "Journal Information" + +msgid "submission.citations.structured.label.doi" +msgstr "DOI" + +msgid "submission.citations.structured.label.title" +msgstr "Article Name" + +msgid "submission.citations.structured.label.date" +msgstr "Date" + +msgid "submission.citations.structured.label.type" +msgstr "Type" + +msgid "submission.citations.structured.label.volume" +msgstr "Volume" + +msgid "submission.citations.structured.label.issue" +msgstr "Issue" + +msgid "submission.citations.structured.label.pages" +msgstr "Pages" + +msgid "submission.citations.structured.label.sourceName" +msgstr "Journal / Venue" + +msgid "submission.citations.structured.label.sourceHost" +msgstr "Publisher or Host" + +msgid "submission.citations.structured.label.url" +msgstr "URL" + +msgid "submission.citations.structured.label.urn" +msgstr "URN" + +msgid "submission.citations.structured.label.arxiv" +msgstr "Arxiv" + +msgid "submission.citations.structured.label.handle" +msgstr "Handle" + +msgid "submission.citations.structured.label.openAlex" +msgstr "OpenAlex" + +msgid "submission.citations.structured.label.wikidata" +msgstr "Wikidata" msgid "submission.parsedCitations" msgstr "Extracted References" diff --git a/pages/authorDashboard/PKPAuthorDashboardHandler.php b/pages/authorDashboard/PKPAuthorDashboardHandler.php index d02ca907624..2172bbdc5d9 100644 --- a/pages/authorDashboard/PKPAuthorDashboardHandler.php +++ b/pages/authorDashboard/PKPAuthorDashboardHandler.php @@ -25,11 +25,10 @@ use APP\submission\Submission; use APP\template\TemplateManager; use Illuminate\Support\Enumerable; -use PKP\components\forms\publication\PKPCitationsForm; +use PKP\components\forms\citation\PKPCitationsForm; use PKP\components\forms\publication\PKPMetadataForm; use PKP\components\forms\publication\TitleAbstractForm; use PKP\components\listPanels\ContributorsListPanel; -use PKP\config\Config; use PKP\context\Context; use PKP\core\JSONMessage; use PKP\core\PKPApplication; diff --git a/pages/dashboard/PKPDashboardHandler.php b/pages/dashboard/PKPDashboardHandler.php index 83c977dc7ce..83138423178 100644 --- a/pages/dashboard/PKPDashboardHandler.php +++ b/pages/dashboard/PKPDashboardHandler.php @@ -1,10 +1,11 @@ getSupportedFormLocales(), $context); + $citationEditForm = new PKPCitationEditForm('emit', null); $templateMgr->setState([ 'pageInitConfig' => [ 'selectRevisionDecisionForm' => $selectRevisionDecisionForm->getConfig(), @@ -172,6 +175,7 @@ public function index($args, $request) 'componentForms' => [ 'contributorForm' => $contributorForm->getConfig(), 'logResponseForm' => $logResponseForm->getConfig(), + 'citationEditForm' => $citationEditForm->getConfig() ] ] ]); diff --git a/pages/submission/PKPSubmissionHandler.php b/pages/submission/PKPSubmissionHandler.php index 0bacf786e43..b840a3f9e3c 100644 --- a/pages/submission/PKPSubmissionHandler.php +++ b/pages/submission/PKPSubmissionHandler.php @@ -27,8 +27,8 @@ use APP\template\TemplateManager; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; +use PKP\components\forms\citation\PKPCitationsForm; use PKP\components\forms\FormComponent; -use PKP\components\forms\publication\PKPCitationsForm; use PKP\components\forms\publication\TitleAbstractForm; use PKP\components\forms\submission\CommentsForTheEditors; use PKP\components\forms\submission\ConfirmSubmission; @@ -232,7 +232,7 @@ protected function showWizard(array $args, Request $request, Submission $submiss } $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); - + $templateMgr = TemplateManager::getManager($request); $templateMgr->setState([ 'categories' => Repo::category()->getBreadcrumbs($categories), diff --git a/pages/workflow/PKPWorkflowHandler.php b/pages/workflow/PKPWorkflowHandler.php index 2a24f5d3c27..601b0ef04f0 100644 --- a/pages/workflow/PKPWorkflowHandler.php +++ b/pages/workflow/PKPWorkflowHandler.php @@ -16,7 +16,6 @@ namespace PKP\pages\workflow; -use APP\components\forms\publication\PublishForm; use APP\core\Application; use APP\core\PageRouter; use APP\core\Request; @@ -24,38 +23,18 @@ use APP\handler\Handler; use APP\publication\Publication; use APP\submission\Submission; -use APP\template\TemplateManager; use Exception; -use Illuminate\Support\Enumerable; use PKP\components\forms\FormComponent; -use PKP\components\forms\publication\PKPCitationsForm; -use PKP\components\forms\publication\PKPMetadataForm; -use PKP\components\forms\publication\PKPPublicationLicenseForm; use PKP\components\forms\publication\TitleAbstractForm; -use PKP\components\forms\submission\ChangeSubmissionLanguageMetadataForm; use PKP\components\listPanels\ContributorsListPanel; use PKP\components\PublicationSectionJats; -use PKP\config\Config; use PKP\context\Context; -use PKP\core\JSONMessage; use PKP\core\PKPApplication; use PKP\core\PKPRequest; -use PKP\db\DAORegistry; -use PKP\decision\Decision; -use PKP\facades\Locale; -use PKP\notification\Notification; -use PKP\plugins\PluginRegistry; use PKP\security\authorization\internal\SubmissionCompletePolicy; use PKP\security\authorization\internal\SubmissionRequiredPolicy; use PKP\security\authorization\internal\UserAccessibleWorkflowStageRequiredPolicy; use PKP\security\authorization\WorkflowStageAccessPolicy; -use PKP\security\Role; -use PKP\stageAssignment\StageAssignment; -use PKP\submission\GenreDAO; -use PKP\submission\PKPSubmission; -use PKP\submission\reviewRound\ReviewRoundDAO; -use PKP\user\User; -use PKP\userGroup\UserGroup; use PKP\workflow\WorkflowStageDAO; abstract class PKPWorkflowHandler extends Handler diff --git a/plugins/importexport/native/filter/NativeXmlPKPPublicationFilter.php b/plugins/importexport/native/filter/NativeXmlPKPPublicationFilter.php index f8ac26684dc..a14c8d9c245 100644 --- a/plugins/importexport/native/filter/NativeXmlPKPPublicationFilter.php +++ b/plugins/importexport/native/filter/NativeXmlPKPPublicationFilter.php @@ -3,8 +3,8 @@ /** * @file plugins/importexport/native/filter/NativeXmlPKPPublicationFilter.php * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2000-2021 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class NativeXmlPKPPublicationFilter @@ -19,9 +19,7 @@ use APP\core\Application; use APP\facades\Repo; use APP\publication\Publication; -use PKP\citation\CitationDAO; use PKP\controlledVocab\ControlledVocab; -use PKP\db\DAORegistry; use PKP\filter\Filter; use PKP\filter\FilterGroup; use PKP\plugins\PluginRegistry; @@ -277,17 +275,16 @@ public function parseAuthor($n, $publication) public function parseCitations($n, $publication) { $publicationId = $publication->getId(); - $citationsString = ''; + $citationsRaw = ''; foreach ($n->childNodes as $citNode) { $nodeText = trim($citNode->textContent); if (empty($nodeText)) { continue; } - $citationsString .= $nodeText . "\n"; + $citationsRaw .= $nodeText . "\n"; } - $publication->setData('citationsRaw', $citationsString); - $citationDao = DAORegistry::getDAO('CitationDAO'); /** @var CitationDAO $citationDao */ - $citationDao->importCitations($publicationId, $citationsString); + $publication->setData('citationsRaw', $citationsRaw); + Repo::citation()->importCitations($publicationId, $citationsRaw); } // diff --git a/plugins/importexport/native/filter/PKPPublicationNativeXmlFilter.php b/plugins/importexport/native/filter/PKPPublicationNativeXmlFilter.php index 1cce9c7007f..e66559f64ef 100644 --- a/plugins/importexport/native/filter/PKPPublicationNativeXmlFilter.php +++ b/plugins/importexport/native/filter/PKPPublicationNativeXmlFilter.php @@ -3,8 +3,8 @@ /** * @file plugins/importexport/native/filter/PKPPublicationNativeXmlFilter.php * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2000-2021 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class PKPPublicationNativeXmlFilter diff --git a/schemas/citation.json b/schemas/citation.json new file mode 100644 index 00000000000..f529f31a76e --- /dev/null +++ b/schemas/citation.json @@ -0,0 +1,267 @@ +{ + "title": "Citation", + "description": "Citation is a reference in a publication to another publication.", + "required": [ + "publicationId", + "seq" + ], + "properties": { + "id": { + "type": "integer", + "description": "The unique id of citation in the database.", + "readOnly": true, + "apiSummary": true + }, + "publicationId": { + "type": "integer", + "description": "The publication to which this citation is associated with.", + "writeDisabledInApi": true, + "apiSummary": true + }, + "seq": { + "type": "integer", + "description": "The sequence number of citation.", + "default": 0, + "validation": [ + "nullable" + ] + }, + "authors": { + "type": "array", + "description": "A list of the authors for this work.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ], + "items": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "givenName": { + "type": "string" + }, + "familyName": { + "type": "string" + }, + "orcid": { + "type": "string" + }, + "wikidata": { + "type": "string" + }, + "openAlex": { + "type": "string" + } + } + } + }, + "doi": { + "type": "string", + "description": "The DOI itself, such as `10.1234/5a6b-7c8d`.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "regex:/^\\d+(.\\d+)+\\//" + ] + }, + "rawCitation": { + "type": "string", + "description": "Optional metadata that contains references for works cited in this submission as raw text.", + "multilingual": false, + "validation": [ + "nullable" + ] + }, + "rawCitationWithLinks": { + "type": "string", + "description": "Optional metadata that contains references for works cited in this submission as raw text.", + "multilingual": false, + "readOnly": true, + "validation": [ + "nullable" + ] + }, + "title": { + "type": "string", + "description": "Title of work.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "date": { + "type": "string", + "description": "The publication date", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "date_format:Y-m-d" + ] + }, + "type": { + "type": "string", + "description": "The type or genre of the work, e.g. journal-article.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "in:book,book-chapter,book-part,book-section,book-series,book-set,book-track,component,database,dataset,dissertation,edited-book,grant,journal,journal-article,journal-issue,journal-volume,monograph,other,peer-review,posted-content,proceedings,proceedings-article,proceedings-series,reference-book,reference-entry,report,report-component,report-series,standard" + ] + }, + "volume": { + "type": "string", + "description": "The volume of the issue of the journal, e.g. 495.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "issue": { + "type": "string", + "description": "The issue of the journal, e.g. 7442.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "firstPage": { + "type": "string", + "description": "The first page of the work/article, e.g. 49.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "lastPage": { + "type": "string", + "description": "The last page of the work/article, e.g. 59.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "sourceName": { + "type": "string", + "description": "Name of the source, e.g. PeerJ", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "sourceIssn": { + "type": "string", + "description": "Issn_l of the source, e.g. 2167-8359", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "sourceHost": { + "type": "string", + "description": "Host of the source, e.g. Pensoft Publishers", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "sourceType": { + "type": "string", + "description": "Type of the source, e.g. journal, repository", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "in:journal,repository,conference,ebookplatform,bookseries,metadata,other" + ] + }, + "url": { + "type": "string", + "description": "URL for the work.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "url" + ] + }, + "urn": { + "type": "string", + "description": "URN for the work.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "arxiv": { + "type": "string", + "description": "Arxiv id.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "url" + ] + }, + "handle": { + "type": "string", + "description": "Handle id.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "url" + ] + }, + "openAlex": { + "type": "string", + "description": "OpenAlex id.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "url" + ] + }, + "wikidata": { + "type": "string", + "description": "Wikidata qid.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable", + "url" + ] + }, + "isStructured": { + "type": "boolean", + "description": "Whether this citation is structured or not.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "lastModified": { + "type": "string", + "description": "The last time a modification was made to this record.", + "apiSummary": true, + "validation": [ + "date_format:Y-m-d H:i:s" + ] + } + } +} diff --git a/schemas/publication.json b/schemas/publication.json index c5c7799278e..7ed6a700dbb 100644 --- a/schemas/publication.json +++ b/schemas/publication.json @@ -54,12 +54,18 @@ "type": "integer" } }, + "useStructuredCitations": { + "type": "boolean", + "description": "Whether or not to use the structured citations.", + "default": true + }, "citations": { "type": "array", "description": "Optional metadata that contains an array of references for works cited in this submission. References have been split and parsed from the raw text.", "readOnly": true, "items": { - "type": "string" + "type": "object", + "$ref": "#/definitions/Citation" } }, "citationsRaw": { @@ -403,4 +409,4 @@ ] } } -} \ No newline at end of file +} diff --git a/templates/submission/review-details.tpl b/templates/submission/review-details.tpl index 9beca76120f..d14a814d724 100644 --- a/templates/submission/review-details.tpl +++ b/templates/submission/review-details.tpl @@ -71,4 +71,4 @@ {call_hook name="Template::SubmissionWizard::Section::Review::Details" submission=$submission step=$step.id} -{/foreach} \ No newline at end of file +{/foreach} diff --git a/tests/classes/citation/CitationListTokenizerFilterTest.php b/tests/classes/citation/CitationListTokenizerFilterTest.php index dd3e802f5c5..2063b334f1e 100644 --- a/tests/classes/citation/CitationListTokenizerFilterTest.php +++ b/tests/classes/citation/CitationListTokenizerFilterTest.php @@ -3,22 +3,22 @@ /** * @file tests/classes/citation/CitationListTokenizerFilterTest.php * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2000-2021 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2000-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class CitationListTokenizerFilterTest * * @ingroup tests_classes_citation * - * @see CitationListTokenizerFilter + * @see \PKP\citation\filter\CitationListTokenizerFilter * * @brief Test class for CitationListTokenizerFilter. */ namespace PKP\tests\classes\citation; -use PKP\citation\CitationListTokenizerFilter; +use PKP\citation\filter\CitationListTokenizerFilter; use PKP\tests\PKPTestCase; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/classes/publication/PublicationTest.php b/tests/classes/publication/PublicationTest.php index 7045dedcb60..70377b7aeaa 100644 --- a/tests/classes/publication/PublicationTest.php +++ b/tests/classes/publication/PublicationTest.php @@ -1,14 +1,15 @@ publication = (new DAO( - new CitationDAO(), new PKPSchemaService() ))->newDataObject(); } + /** * @see PKPTestCase::tearDown() */ @@ -48,7 +48,7 @@ protected function tearDown(): void unset($this->publication); parent::tearDown(); } - + public function testPageArray() { $expected = [['i', 'ix'], ['6', '11'], ['19'], ['21']]; diff --git a/tools/parseCitations.php b/tools/parseCitations.php index bed3cf6e4ee..718edae8ad2 100644 --- a/tools/parseCitations.php +++ b/tools/parseCitations.php @@ -3,8 +3,8 @@ /** * @file tools/parseCitations.php * - * Copyright (c) 2014-2021 Simon Fraser University - * Copyright (c) 2003-2021 John Willinsky + * Copyright (c) 2014-2025 Simon Fraser University + * Copyright (c) 2003-2025 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. * * @class CitationsParsingTool @@ -16,13 +16,13 @@ use APP\core\Application; use APP\facades\Repo; -use PKP\db\DAORegistry; require(dirname(__FILE__, 4) . '/tools/bootstrap.php'); class CitationsParsingTool extends \PKP\cliTool\CommandLineTool { - public $parameters; + public array $parameters; + /** * Constructor. * @@ -43,7 +43,7 @@ public function __construct($argv = []) /** * Print command usage information. */ - public function usage() + public function usage(): void { echo "Parse and save submission(s) citations.\n" . "Usage:\n" @@ -55,7 +55,7 @@ public function usage() /** * Parse citations */ - public function execute() + public function execute(): void { $contextDao = Application::getContextDAO(); @@ -65,7 +65,7 @@ public function execute() while ($context = $contexts->next()) { $submissions = Repo::submission()->getCollector()->filterByContextIds([$context->getId()])->getMany(); foreach ($submissions as $submission) { - $this->_parseSubmission($submission); + $this->parseSubmission($submission); } } break; @@ -78,7 +78,7 @@ public function execute() } $submissions = Repo::submission()->getCollector()->filterByContextIds([$context->getId()])->getMany(); foreach ($submissions as $submission) { - $this->_parseSubmission($submission); + $this->parseSubmission($submission); } } break; @@ -89,7 +89,7 @@ public function execute() printf("Error: Skipping {$submissionId}. Unknown submission.\n"); continue; } - $this->_parseSubmission($submission); + $this->parseSubmission($submission); } break; default: @@ -99,17 +99,16 @@ public function execute() } /** - * Parse the citations of one submission - * - * @param Submission $submission + * Parse the citations of one submission. */ - private function _parseSubmission($submission) + private function parseSubmission(Submission $submission): void { - /** @var CitationDAO */ - $citationDao = DAORegistry::getDAO('CitationDAO'); foreach ($submission->getData('publications') as $publication) { if (!empty($publication->getData('citationsRaw'))) { - $citationDao->importCitations($publication->getId(), $publication->getData('citationsRaw')); + Repo::citation()->importCitations( + $publication->getId(), + $publication->getData('citationsRaw') + ); } } }