Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
MergeMarcCommand
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
6 / 6
18
100.00% covered (success)
100.00%
1 / 1
 configure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 recordXmlToString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 findXmlFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 loadXmlContents
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 outputRecordsFromFile
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 execute
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Console command: Merge MARC records.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2020.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Console
25 * @author   Thomas Schwaerzler <thomas.schwaerzler@uibk.ac.at>
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org/wiki/development Wiki
29 */
30
31namespace VuFindConsole\Command\Harvest;
32
33use SimpleXMLElement;
34use Symfony\Component\Console\Attribute\AsCommand;
35use Symfony\Component\Console\Command\Command;
36use Symfony\Component\Console\Input\InputArgument;
37use Symfony\Component\Console\Input\InputInterface;
38use Symfony\Component\Console\Output\OutputInterface;
39
40/**
41 * Console command: Merge MARC records.
42 *
43 * @category VuFind
44 * @package  Console
45 * @author   Thomas Schwaerzler <thomas.schwaerzler@uibk.ac.at>
46 * @author   Demian Katz <demian.katz@villanova.edu>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org/wiki/development Wiki
49 */
50#[AsCommand(
51    name: 'harvest/merge-marc',
52    description: 'MARC merge tool'
53)]
54class MergeMarcCommand extends Command
55{
56    /**
57     * XML namespace for MARC21.
58     */
59    public const MARC21_NAMESPACE = 'http://www.loc.gov/MARC21/slim';
60
61    /**
62     * Configure the command.
63     *
64     * @return void
65     */
66    protected function configure()
67    {
68        $this
69            ->setHelp(
70                'Merges harvested MARCXML files into a single <collection>; '
71                . 'writes to stdout.'
72            )->addArgument(
73                'directory',
74                InputArgument::REQUIRED,
75                'a directory containing MARC XML files to merge'
76            );
77    }
78
79    /**
80     * Convert a SimpleXMLElement into a string, ensuring that namespace declarations
81     * are appropriately included.
82     *
83     * @param SimpleXMLElement $record Record to reformat
84     *
85     * @return string
86     */
87    protected function recordXmlToString(SimpleXMLElement $record): string
88    {
89        // Normalize unprefixed record tags to use marc namespace; remove extraneous
90        // XML headers:
91        return str_replace(
92            ['<record>', '<record ', '</record>', '<?xml version="1.0"?>'],
93            ['<marc:record>', '<marc:record ', '</marc:record>', ''],
94            $record->asXml()
95        );
96    }
97
98    /**
99     * Find all XML files in a directory; return a sorted list.
100     *
101     * @param string $dir Directory to read from
102     *
103     * @return string[]
104     * @throws \Exception
105     */
106    protected function findXmlFiles($dir): array
107    {
108        $handle = @opendir($dir);
109        if (!$handle) {
110            throw new \Exception("Cannot open directory: {$dir}");
111        }
112        $fileList = [];
113        while (false !== ($file = readdir($handle))) {
114            // Only operate on XML files:
115            if (pathinfo($file, PATHINFO_EXTENSION) === 'xml') {
116                // get file content
117                $fileList[] = $dir . '/' . $file;
118            }
119        }
120        // Sort filenames so that we have consistent results:
121        sort($fileList);
122        return $fileList;
123    }
124
125    /**
126     * Load an XML file, and throw an exception if it is invalid.
127     *
128     * @param string $filePath File to load
129     *
130     * @throws \Exception
131     * @return SimpleXMLElement
132     */
133    protected function loadXmlContents(string $filePath): SimpleXMLElement
134    {
135        // Set up user error handling so we can capture XML errors
136        $prev = libxml_use_internal_errors(true);
137        $xml = @simplexml_load_file($filePath);
138        // Capture any errors before we restore previous error behavior (which will
139        // cause them to be lost).
140        $errors = libxml_get_errors();
141        libxml_use_internal_errors($prev);
142        // Build an exception if something has gone wrong
143        if ($xml === false) {
144            $msg = 'Problem loading XML file: ' . realpath($filePath);
145            foreach ($errors as $error) {
146                $msg .= "\n" . trim($error->message)
147                    . ' in ' . realpath($error->file)
148                    . ' line ' . $error->line . ' column ' . $error->column;
149            }
150            throw new \Exception($msg);
151        }
152        return $xml;
153    }
154
155    /**
156     * Given the filename of an XML document, feed any MARC records from the file
157     * to the output stream.
158     *
159     * @param string          $filePath XML filename
160     * @param OutputInterface $output   Output stream
161     *
162     * @return void
163     */
164    protected function outputRecordsFromFile(
165        string $filePath,
166        OutputInterface $output
167    ): void {
168        // We need to find all the possible records; if the top-level tag is a
169        // collection, we will search for namespaced and non-namespaced records
170        // inside it. Otherwise, we'll just check the top-level tag to see if
171        // it's a stand-alone record.
172        $xml = $this->loadXmlContents($filePath);
173        $childSets = (stristr($xml->getName(), 'collection') !== false)
174             ? [$xml->children(self::MARC21_NAMESPACE), $xml->children()]
175             : [[$xml]];
176        foreach ($childSets as $children) {
177            // We'll set a flag to indicate whether or not we found anything in
178            // the most recent set. This allows us to break out of the loop once
179            // a record has been found, which enables us to favor namespaced
180            // matches over non-namespaced matches. This is not ideal (we might
181            // miss records in a weird file containing a mix of namespaced and
182            // non-namespaced records), but the alternative would cause namespaced
183            // but non-prefixed records to get loaded twice.
184            $foundSomething = false;
185            foreach ($children as $record) {
186                if (stristr($record->getName(), 'record') !== false) {
187                    $foundSomething = true;
188                    $output->write(trim($this->recordXmlToString($record)) . "\n");
189                }
190            }
191            if ($foundSomething) {
192                break;
193            }
194        }
195    }
196
197    /**
198     * Run the command.
199     *
200     * @param InputInterface  $input  Input object
201     * @param OutputInterface $output Output object
202     *
203     * @return int 0 for success
204     */
205    protected function execute(InputInterface $input, OutputInterface $output)
206    {
207        $dir = rtrim($input->getArgument('directory'), '/');
208
209        try {
210            $fileList = $this->findXmlFiles($dir);
211        } catch (\Exception $e) {
212            $output->writeln($e->getMessage());
213            return 1;
214        }
215
216        $output->writeln(
217            '<marc:collection xmlns:marc="' . self::MARC21_NAMESPACE . '">'
218        );
219        foreach ($fileList as $filePath) {
220            // Output comment so we know which file the following records came from:
221            $output->writeln("<!-- $filePath -->");
222            $this->outputRecordsFromFile($filePath, $output);
223        }
224        $output->writeln('</marc:collection>');
225        return 0;
226    }
227}