diff --git a/.gitignore b/.gitignore index 987e2a2..e96516b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ composer.lock vendor +.phpunit.result.cache diff --git a/README.md b/README.md index e5aee53..a6dfd25 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ +## Fork (redcuillin) + +Maintained for PHP 8.4 support. Upstream: [scriptotek/php-marc](https://github.com/scriptotek/php-marc). + +### Using this fork with Composer + +Add the following to your `composer.json` `repositories` and `require` sections (merge with existing entries as needed): + +```json +"repositories": [ + { + "type": "vcs", + "url": "https://github.com/redcuillin/php-marc" + } +], +"require": { + "scriptotek/marc": "dev-main as 4.999.0" +} +``` + +----- + +## Upstream readme: + [![Coverage](https://img.shields.io/codecov/c/github/scriptotek/php-marc)](https://codecov.io/gh/scriptotek/php-marc) [![StyleCI](https://github.styleci.io/repos/41363199/shield?branch=main)](https://styleci.io/repos/41363199) [![Code Climate](https://img.shields.io/codeclimate/maintainability/scriptotek/php-marc)](https://codeclimate.com/github/scriptotek/php-marc) @@ -84,6 +108,19 @@ Records can be edited using the editing capabilities of File_MARC See [an example](https://github.com/scriptotek/php-marc/issues/13#issuecomment-522036879) to get started. +### Sorting field order (`Record::sortFieldsByTag`) + +To reorder the fields inside a single record by MARC tag (so `020` comes before `264`, +for example), call `sortFieldsByTag()` on the record. Any field whose tag is `LDR` +(case-insensitive) is moved to the front; all other fields are sorted by their +three-digit tag (`001`–`999`). When several fields share the same tag, their +relative order is preserved. The leader string from `getLeader()` is not part of +the field list and is left unchanged. + +```php +$record->sortFieldsByTag(); // returns $record for chaining +``` + ## Querying with MARCspec Use the `Record::query()` method to query a record using the diff --git a/composer.json b/composer.json index 7b19b3d..3464151 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,9 @@ "name": "scriptotek/marc", "type": "library", "description": "Simple interface to parsing MARC records using File_MARC", - "keywords": ["marc"], + "keywords": [ + "marc" + ], "license": "MIT", "authors": [ { @@ -11,7 +13,7 @@ } ], "require": { - "php": ">=8.0", + "php": ">=8.4", "ext-xml": "*", "ext-json": "*", "ext-simplexml": "*", @@ -31,4 +33,4 @@ "scripts": { "test": "phpunit" } -} +} \ No newline at end of file diff --git a/src/BibliographicRecord.php b/src/BibliographicRecord.php index a31bf9e..ebe28b6 100644 --- a/src/BibliographicRecord.php +++ b/src/BibliographicRecord.php @@ -18,8 +18,18 @@ class BibliographicRecord extends Record * @var string[] List of properties to be included when serializing the record using the `toArray()` method. */ public array $properties = [ - 'id', 'isbns', 'title', 'publisher', 'pub_year', 'edition', 'creators', - 'subjects', 'classifications', 'toc', 'summary', 'part_of' + 'id', + 'isbns', + 'title', + 'publisher', + 'pub_year', + 'edition', + 'creators', + 'subjects', + 'classifications', + 'toc', + 'summary', + 'part_of' ]; /** @@ -114,7 +124,7 @@ public function getToc(): ?array } else { // Basic return $field->mapSubFields([ - 'a' => 'text', + 'a' => 'text', ]); } } @@ -131,8 +141,8 @@ public function getSummary(): array|null $field = $this->getField('520'); if ($field) { return $field->mapSubFields([ - 'a' => 'text', - 'c' => 'assigning_source', + 'a' => 'text', + 'c' => 'assigning_source', ]); } return null; @@ -146,7 +156,7 @@ public function getSummary(): array|null * @param string|string[]|null $tag * @return SubjectInterface[] */ - public function getSubjects(string $vocabulary = null, array|string $tag = null): array + public function getSubjects(?string $vocabulary = null, array|string|null $tag = null): array { $tag = is_null($tag) ? [] : (is_array($tag) ? $tag : [$tag]); @@ -165,7 +175,7 @@ public function getSubjects(string $vocabulary = null, array|string $tag = null) * @param string|null $scheme * @return Classification[] */ - public function getClassifications(string $scheme = null): array + public function getClassifications(?string $scheme = null): array { return array_values(array_filter(Classification::get($this), function ($classifications) use ($scheme) { $a = is_null($scheme) || $scheme == $classifications->getScheme(); @@ -181,7 +191,7 @@ public function getClassifications(string $scheme = null): array * @param string|string[]|null $tag * @return Person[] */ - public function getCreators(array|string $tag = null): array + public function getCreators(array|string|null $tag = null): array { $tag = is_null($tag) ? [] : (is_array($tag) ? $tag : [$tag]); diff --git a/src/Collection.php b/src/Collection.php index b1b4e58..122b1ed 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -24,7 +24,7 @@ class Collection implements \Iterator * * @param File_MARCXML|File_MARC|null $parser */ - public function __construct(File_MARCXML|File_MARC $parser = null) + public function __construct(File_MARCXML|File_MARC|null $parser = null) { $this->parser = $parser; } @@ -206,7 +206,7 @@ public function rewind(): void * Magic *********************************************************/ - public function __call($name, $arguments) + public function __call(string $name, array $arguments): mixed { return call_user_func_array([$this->parser, $name], $arguments); } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index d92d54d..d466066 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -115,7 +115,7 @@ public function getField(): File_MARC_Field * * @return mixed */ - public function __call(string $name, array $args) + public function __call(string $name, array $args): mixed { return call_user_func_array([$this->field, $name], $args); } @@ -140,7 +140,7 @@ public function __toString(): string * * @return string */ - protected function clean(string $value = null, array $options = []): string + protected function clean(?string $value = null, array $options = []): string { if (is_null($value)) { return ""; @@ -223,7 +223,7 @@ public function asLineMarc(string $sep = '$', string $blank = ' '): ?string $ind2 = $blank; } - return "${tag} ${ind1}${ind2} " . implode(' ', $subfields); + return "{$tag} {$ind1}{$ind2} " . implode(' ', $subfields); } /** @@ -235,7 +235,7 @@ public function asLineMarc(string $sep = '$', string $blank = ' '): ?string * The fallback value to return if the subfield does not exist. * @return string|null */ - public function sf(string $code, string $default = null): ?string + public function sf(string $code, ?string $default = null): ?string { // In PHP, ("a" == 0) will evaluate to TRUE, so it's actually very important that we ensure type here! $code = (string) $code; diff --git a/src/Fields/SerializableField.php b/src/Fields/SerializableField.php index 529985e..0324a03 100644 --- a/src/Fields/SerializableField.php +++ b/src/Fields/SerializableField.php @@ -4,7 +4,7 @@ trait SerializableField { - public function jsonSerialize(): array|string + public function jsonSerialize(): mixed { if (count($this->properties)) { $o = []; diff --git a/src/Fields/Subfield.php b/src/Fields/Subfield.php index 94fffb6..1854822 100644 --- a/src/Fields/Subfield.php +++ b/src/Fields/Subfield.php @@ -28,7 +28,7 @@ public function delete() $this->__destruct(); } - public function jsonSerialize(): string|array + public function jsonSerialize(): mixed { return (string) $this; } @@ -38,12 +38,12 @@ public function __toString(): string return $this->subfield->getData(); } - public function __call($name, $args) + public function __call(string $name, array $args): mixed { return call_user_func_array([$this->subfield, $name], $args); } - public function __get($key) + public function __get(string $key): mixed { $method = 'get' . ucfirst($key); if (method_exists($this, $method)) { diff --git a/src/Importers/Importer.php b/src/Importers/Importer.php index 67abbff..fa85c7d 100644 --- a/src/Importers/Importer.php +++ b/src/Importers/Importer.php @@ -11,7 +11,7 @@ class Importer { private Factory $factory; - public function __construct(Factory $factory = null) + public function __construct(?Factory $factory = null) { $this->factory = $factory ?? new Factory(); } diff --git a/src/Importers/XmlImporter.php b/src/Importers/XmlImporter.php index ded3c40..e641591 100644 --- a/src/Importers/XmlImporter.php +++ b/src/Importers/XmlImporter.php @@ -11,10 +11,9 @@ class XmlImporter { - protected $factory; + protected Factory $factory; - /* var SimpleXMLElement */ - protected $source; + protected SimpleXMLElement $source; /** * XmlImporter constructor. @@ -22,11 +21,11 @@ class XmlImporter * @param string|SimpleXMLElement $data Filename, XML string or SimpleXMLElement object * @param string $ns URI or prefix of the namespace * @param bool $isPrefix TRUE if $ns is a prefix, FALSE if it's a URI; defaults to FALSE - * @param string $factory (optional) Object factory, probably no need to set this outside testing. + * @param Factory|null $factory (optional) Object factory, probably no need to set this outside testing. */ - public function __construct($data, $ns = '', $isPrefix = false, $factory = null) + public function __construct($data, string $ns = '', bool $isPrefix = false, ?Factory $factory = null) { - $this->factory = isset($factory) ? $factory : new Factory(); + $this->factory = $factory ?? new Factory(); if (is_a($data, SimpleXMLElement::class)) { $this->source = $data; @@ -40,10 +39,11 @@ public function __construct($data, $ns = '', $isPrefix = false, $factory = null) // Store errors internally so that we can fetch them with libxml_get_errors() later libxml_use_internal_errors(true); - $this->source = simplexml_load_string($data, 'SimpleXMLElement', 0, $ns, $isPrefix); - if (false === $this->source) { + $source = simplexml_load_string($data, 'SimpleXMLElement', 0, $ns, $isPrefix); + if (false === $source) { throw new XmlException(libxml_get_errors()); } + $this->source = $source; } public function getMarcNamespace($namespaces) @@ -98,7 +98,7 @@ public function getFirstRecord() $parser = $this->factory->make('File_MARCXML', $record, File_MARCXML::SOURCE_SIMPLEXMLELEMENT, $ns); - return (new Collection($parser))->$this->getFirstRecord(); + return (new Collection($parser))->first(); } public function getCollection(): Collection diff --git a/src/MagicAccess.php b/src/MagicAccess.php index 16a08e1..4047177 100644 --- a/src/MagicAccess.php +++ b/src/MagicAccess.php @@ -9,7 +9,7 @@ */ trait MagicAccess { - public function __get($key) + public function __get(string $key): mixed { // Convert key from underscore_case to camelCase. $key_uc = preg_replace_callback( @@ -24,5 +24,7 @@ function ($matches) { if (method_exists($this, $method)) { return call_user_func([$this, $method]); } + + return null; } } diff --git a/src/QueryResult.php b/src/QueryResult.php index b6ae713..b73cffd 100644 --- a/src/QueryResult.php +++ b/src/QueryResult.php @@ -29,7 +29,7 @@ public function __construct(File_MARC_Reference $ref) $this->data = $ref->data; $this->content = $ref->content; - for ($i=0; $i < count($this->data); $i++) { + for ($i = 0; $i < count($this->data); $i++) { if (is_a($this->data[$i], File_MARC_Field::class)) { $this->data[$i] = new Field($this->data[$i]); } @@ -77,7 +77,7 @@ public function getIterator(): Traversable|ArrayIterator * @param mixed $offset An offset to check for. * @return boolean true on success or false on failure. */ - public function offsetExists($offset): bool + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } @@ -88,7 +88,7 @@ public function offsetExists($offset): bool * @param mixed $offset The offset to retrieve. * @return Field|File_MARC_Subfield|null */ - public function offsetGet($offset): Field|File_MARC_Subfield|null + public function offsetGet(mixed $offset): Field|File_MARC_Subfield|null { return $this->data[$offset]; } @@ -99,7 +99,7 @@ public function offsetGet($offset): Field|File_MARC_Subfield|null * @param mixed $offset The offset to assign the value to. * @param mixed $value The value to set. */ - public function offsetSet($offset, $value): void + public function offsetSet(mixed $offset, mixed $value): void { $this->data[$offset] = $value; } @@ -109,7 +109,7 @@ public function offsetSet($offset, $value): void * @link http://php.net/manual/en/arrayaccess.offsetunset.php * @param mixed $offset The offset to unset. */ - public function offsetUnset($offset): void + public function offsetUnset(mixed $offset): void { unset($this->data[$offset]); } diff --git a/src/Record.php b/src/Record.php index f324c28..73ae6f7 100644 --- a/src/Record.php +++ b/src/Record.php @@ -115,6 +115,54 @@ public function getFields($spec = null, $pcre = null) }, $this->record->getFields($spec, $pcre))); } + /** + * Reorder all fields in this record by tag ascending. + * + * Field tags are either `LDR` (leader, when stored as a field) or three-digit + * strings `001` through `999`. Any `LDR` fields are + * moved to the front; remaining fields are sorted by tag string (numeric order + * for standard digit tags). Duplicate tags keep their relative order. The + * record leader from {@see getLeader()} is separate from the field list and is + * not modified. + * + * @return $this + */ + public function sortFieldsByTag(): self + { + $list = $this->record->getFields(); + $ldr = []; + $rest = []; + $i = 0; + foreach ($list as $field) { + $item = [$i++, $field]; + if (strtoupper($field->getTag()) === 'LDR') { + $ldr[] = $item; + } else { + $rest[] = $item; + } + } + usort( + $rest, + static function (array $a, array $b): int { + $cmp = strcmp($a[1]->getTag(), $b[1]->getTag()); + if ($cmp !== 0) { + return $cmp; + } + + return $a[0] <=> $b[0]; + } + ); + $ordered = array_merge($ldr, $rest); + while ($list->count() > 0) { + $list->shift(); + } + foreach ($ordered as [, $field]) { + $this->record->appendField($field); + } + + return $this; + } + /************************************************************************* * Data loading *************************************************************************/ @@ -219,7 +267,7 @@ public function getId() * * @return array */ - public function jsonSerialize(): array|string + public function jsonSerialize(): mixed { $o = []; foreach ($this->properties as $prop) { @@ -255,7 +303,7 @@ public function jsonSerialize(): array|string * * @return mixed */ - public function __call($name, $args) + public function __call(string $name, array $args): mixed { return call_user_func_array([$this->record, $name], $args); } @@ -265,7 +313,7 @@ public function __call($name, $args) * * @return string */ - public function __toString() + public function __toString(): string { return strval($this->record); } diff --git a/tests/RecordTest.php b/tests/RecordTest.php index 5649756..b409f79 100644 --- a/tests/RecordTest.php +++ b/tests/RecordTest.php @@ -3,6 +3,7 @@ namespace Tests; use File_MARC; +use File_MARC_Control_Field; use File_MARC_Record; use Scriptotek\Marc\AuthorityRecord; use Scriptotek\Marc\BibliographicRecord; @@ -159,4 +160,91 @@ public function testInitializeFromSimpleXmlElement() $this->assertInstanceOf(Record::class, $record); $this->assertInstanceOf(BibliographicRecord::class, $record); } + + public function testSortFieldsByTagOrdersDataFieldsNumericallyByTag() + { + $source = ' + + 00000nam a2200277 a 4500 + trs_sho_id_308 + 260417s2026 can ob 001 0 eng d + + Edinburgh : + + + 9780748616275 + + '; + + $record = Record::fromString($source); + $record->sortFieldsByTag(); + + $tags = []; + foreach ($record->getRecord()->getFields() as $field) { + $tags[] = $field->getTag(); + } + + $this->assertSame(['001', '008', '020', '264'], $tags); + } + + public function testSortFieldsByTagPreservesOrderAmongDuplicateTags() + { + $source = ' + + 00000nam a2200277 a 4500 + id1 + + Topic A + + + Topic B + + '; + + $record = Record::fromString($source); + $record->sortFieldsByTag(); + + $sixFifties = $record->getFields('650'); + $this->assertCount(2, $sixFifties); + $this->assertStringContainsString('Topic A', (string) $sixFifties[0]); + $this->assertStringContainsString('Topic B', (string) $sixFifties[1]); + } + + public function testSortFieldsByTagPutsLdrFieldBeforeNumericTags() + { + $source = ' + + 00000nam a2200277 a 4500 + 9780123456789 + 00000nam a2200277 a 4500 + '; + + $record = Record::fromString($source); + $record->sortFieldsByTag(); + + $tags = []; + foreach ($record->getRecord()->getFields() as $field) { + $tags[] = $field->getTag(); + } + + $this->assertSame(['LDR', '020'], $tags); + } + + public function testSortFieldsByTagRecognizesLdrTagCaseInsensitively() + { + $record = Record::fromString(' + + 00000nam a2200277 a 4500 + id + '); + $record->getRecord()->appendField(new File_MARC_Control_Field('ldr', '00000nam a2200277 a 4500')); + $record->sortFieldsByTag(); + + $tags = []; + foreach ($record->getRecord()->getFields() as $field) { + $tags[] = $field->getTag(); + } + + $this->assertSame(['ldr', '001'], $tags); + } }