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')
+ );
}
}
}